✨ (preview) Add preview runtime dropdown
User can select between Web and API previews Closes #247
This commit is contained in:
@@ -37,6 +37,12 @@ export const ChevronRightIcon = (props: IconProps) => (
|
|||||||
</Icon>
|
</Icon>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const ChevronDownIcon = (props: IconProps) => (
|
||||||
|
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
|
||||||
export const PlusIcon = (props: IconProps) => (
|
export const PlusIcon = (props: IconProps) => (
|
||||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
|||||||
@@ -90,8 +90,16 @@ test('Variable buttons should work', async ({ page }) => {
|
|||||||
await page.click('[data-testid="block1-icon"]')
|
await page.click('[data-testid="block1-icon"]')
|
||||||
await page.click('text=Multiple choice?')
|
await page.click('text=Multiple choice?')
|
||||||
await page.click('text="Restart"')
|
await page.click('text="Restart"')
|
||||||
await page.getByTestId('button').first().click()
|
await page
|
||||||
await page.getByTestId('button').nth(1).click()
|
.locator('typebot-standard')
|
||||||
|
.getByRole('checkbox', { name: 'Variable item' })
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
await page
|
||||||
|
.locator('typebot-standard')
|
||||||
|
.getByRole('checkbox', { name: 'Variable item' })
|
||||||
|
.nth(1)
|
||||||
|
.click()
|
||||||
await page.locator('text="Send"').click()
|
await page.locator('text="Send"').click()
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('text="Variable item, Variable item"')
|
page.locator('text="Variable item, Variable item"')
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ test.describe('Builder', () => {
|
|||||||
`${process.env.NEXTAUTH_URL}/api/mock/webhook`
|
`${process.env.NEXTAUTH_URL}/api/mock/webhook`
|
||||||
)
|
)
|
||||||
await page.click('text=Advanced configuration')
|
await page.click('text=Advanced configuration')
|
||||||
await page.click('text=GET')
|
await page.getByRole('button', { name: 'GET' }).click()
|
||||||
await page.click('text=POST')
|
await page.click('text=POST')
|
||||||
|
|
||||||
await page.click('text=Query params')
|
await page.click('text=Query params')
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useTypebot } from '../providers/TypebotProvider'
|
|||||||
import { BlocksSideBar } from './BlocksSideBar'
|
import { BlocksSideBar } from './BlocksSideBar'
|
||||||
import { BoardMenuButton } from './BoardMenuButton'
|
import { BoardMenuButton } from './BoardMenuButton'
|
||||||
import { GettingStartedModal } from './GettingStartedModal'
|
import { GettingStartedModal } from './GettingStartedModal'
|
||||||
import { PreviewDrawer } from './PreviewDrawer'
|
import { PreviewDrawer } from '@/features/preview/components/PreviewDrawer'
|
||||||
import { TypebotHeader } from './TypebotHeader'
|
import { TypebotHeader } from './TypebotHeader'
|
||||||
|
|
||||||
export const EditorPage = () => {
|
export const EditorPage = () => {
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
CloseButton,
|
|
||||||
Fade,
|
|
||||||
Flex,
|
|
||||||
FlexProps,
|
|
||||||
useColorMode,
|
|
||||||
useColorModeValue,
|
|
||||||
useEventListener,
|
|
||||||
UseToastOptions,
|
|
||||||
VStack,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { useEditor } from '../providers/EditorProvider'
|
|
||||||
import { useGraph } from '@/features/graph'
|
|
||||||
import { useTypebot } from '../providers/TypebotProvider'
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { headerHeight } from '../constants'
|
|
||||||
import { Standard } from '@typebot.io/react'
|
|
||||||
import { ChatReply } from 'models'
|
|
||||||
import { useToast } from '@/hooks/useToast'
|
|
||||||
|
|
||||||
export const PreviewDrawer = () => {
|
|
||||||
const isDark = useColorMode().colorMode === 'dark'
|
|
||||||
const { typebot, save, isSavingLoading } = useTypebot()
|
|
||||||
const { setRightPanel, startPreviewAtGroup } = useEditor()
|
|
||||||
const { setPreviewingBlock } = useGraph()
|
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
|
||||||
const [width, setWidth] = useState(500)
|
|
||||||
const [isResizeHandleVisible, setIsResizeHandleVisible] = useState(false)
|
|
||||||
const [restartKey, setRestartKey] = useState(0)
|
|
||||||
|
|
||||||
const { showToast } = useToast()
|
|
||||||
|
|
||||||
const handleMouseDown = () => {
|
|
||||||
setIsResizing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!isResizing) return
|
|
||||||
setWidth(width - e.movementX)
|
|
||||||
}
|
|
||||||
useEventListener('mousemove', handleMouseMove)
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsResizing(false)
|
|
||||||
}
|
|
||||||
useEventListener('mouseup', handleMouseUp)
|
|
||||||
|
|
||||||
const handleRestartClick = async () => {
|
|
||||||
await save()
|
|
||||||
setRestartKey((key) => key + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloseClick = () => {
|
|
||||||
setPreviewingBlock(undefined)
|
|
||||||
setRightPanel(undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNewLogs = (logs: ChatReply['logs']) => {
|
|
||||||
logs?.forEach((log) => showToast(log as UseToastOptions))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
pos="absolute"
|
|
||||||
right="0"
|
|
||||||
top={`0`}
|
|
||||||
h={`100%`}
|
|
||||||
w={`${width}px`}
|
|
||||||
bgColor={useColorModeValue('white', 'gray.900')}
|
|
||||||
borderLeftWidth={'1px'}
|
|
||||||
shadow="lg"
|
|
||||||
borderLeftRadius={'lg'}
|
|
||||||
onMouseOver={() => setIsResizeHandleVisible(true)}
|
|
||||||
onMouseLeave={() => setIsResizeHandleVisible(false)}
|
|
||||||
p="6"
|
|
||||||
zIndex={10}
|
|
||||||
>
|
|
||||||
<Fade in={isResizeHandleVisible}>
|
|
||||||
<ResizeHandle
|
|
||||||
isDark={isDark}
|
|
||||||
pos="absolute"
|
|
||||||
left="-7.5px"
|
|
||||||
top={`calc(50% - ${headerHeight}px)`}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
/>
|
|
||||||
</Fade>
|
|
||||||
|
|
||||||
<VStack w="full" spacing={4}>
|
|
||||||
<Flex justifyContent={'space-between'} w="full">
|
|
||||||
<Button onClick={handleRestartClick} isLoading={isSavingLoading}>
|
|
||||||
Restart
|
|
||||||
</Button>
|
|
||||||
<CloseButton onClick={handleCloseClick} />
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{typebot && (
|
|
||||||
<Standard
|
|
||||||
key={restartKey + (startPreviewAtGroup ?? '')}
|
|
||||||
typebot={typebot}
|
|
||||||
startGroupId={startPreviewAtGroup}
|
|
||||||
onNewInputBlock={setPreviewingBlock}
|
|
||||||
onNewLogs={handleNewLogs}
|
|
||||||
style={{
|
|
||||||
borderWidth: '1px',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
pointerEvents: isResizing ? 'none' : 'auto',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResizeHandle = (props: FlexProps & { isDark: boolean }) => {
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
w="15px"
|
|
||||||
h="50px"
|
|
||||||
borderWidth={'1px'}
|
|
||||||
bgColor={useColorModeValue('white', 'gray.800')}
|
|
||||||
cursor={'col-resize'}
|
|
||||||
justifyContent={'center'}
|
|
||||||
align={'center'}
|
|
||||||
borderRadius={'sm'}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
w="2px"
|
|
||||||
bgColor={useColorModeValue('gray.300', 'gray.600')}
|
|
||||||
h="70%"
|
|
||||||
mr="0.5"
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
w="2px"
|
|
||||||
bgColor={useColorModeValue('gray.300', 'gray.600')}
|
|
||||||
h="70%"
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { CodeEditor } from '@/components/CodeEditor'
|
||||||
|
import { TextLink } from '@/components/TextLink'
|
||||||
|
import { useEditor } from '@/features/editor/providers/EditorProvider'
|
||||||
|
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||||
|
import { Code, ListItem, OrderedList, Stack, Text } from '@chakra-ui/react'
|
||||||
|
import { env, getViewerUrl } from 'utils'
|
||||||
|
|
||||||
|
export const ApiPreviewInstructions = () => {
|
||||||
|
const { typebot } = useTypebot()
|
||||||
|
const { startPreviewAtGroup } = useEditor()
|
||||||
|
|
||||||
|
const startParamsBody = startPreviewAtGroup
|
||||||
|
? `{
|
||||||
|
"startParams": {
|
||||||
|
"typebot": "${typebot?.id}",
|
||||||
|
"isPreview": true,
|
||||||
|
"startGroupId": "${startPreviewAtGroup}"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
: `{
|
||||||
|
"startParams": {
|
||||||
|
"typebot": "${typebot?.id}",
|
||||||
|
"isPreview": true
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const replyBody = `{
|
||||||
|
"message": "This is my reply",
|
||||||
|
"sessionId": "<ID_FROM_FIRST_RESPONSE>"
|
||||||
|
}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OrderedList
|
||||||
|
p="4"
|
||||||
|
spacing={6}
|
||||||
|
w="full"
|
||||||
|
overflowY="scroll"
|
||||||
|
className="hide-scrollbar"
|
||||||
|
>
|
||||||
|
<ListItem>
|
||||||
|
All your requests need to be authenticated with an API token.{' '}
|
||||||
|
<TextLink href="https://docs.typebot.io/api/builder/authenticate">
|
||||||
|
See instructions
|
||||||
|
</TextLink>
|
||||||
|
.
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<Stack>
|
||||||
|
<Text>
|
||||||
|
To start the chat, send a <Code>POST</Code> request to
|
||||||
|
</Text>
|
||||||
|
<CodeEditor
|
||||||
|
isReadOnly
|
||||||
|
lang={'shell'}
|
||||||
|
value={`${
|
||||||
|
env('VIEWER_INTERNAL_URL') ?? getViewerUrl()
|
||||||
|
}/api/v1/sendMessage`}
|
||||||
|
/>
|
||||||
|
<Text>with the following JSON body:</Text>
|
||||||
|
<CodeEditor isReadOnly lang={'json'} value={startParamsBody} />
|
||||||
|
</Stack>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
The first response will contain a <Code>sessionId</Code> that you will
|
||||||
|
need for subsequent requests.
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<Stack>
|
||||||
|
<Text>
|
||||||
|
To send replies, send <Code>POST</Code> requests to
|
||||||
|
</Text>
|
||||||
|
<CodeEditor
|
||||||
|
isReadOnly
|
||||||
|
lang={'shell'}
|
||||||
|
value={`${
|
||||||
|
env('VIEWER_INTERNAL_URL') ?? getViewerUrl()
|
||||||
|
}/api/v1/sendMessage`}
|
||||||
|
/>
|
||||||
|
<Text>With the following JSON body:</Text>
|
||||||
|
<CodeEditor isReadOnly lang={'json'} value={replyBody} />
|
||||||
|
<Text>
|
||||||
|
Replace <Code>{'<ID_FROM_FIRST_RESPONSE>'}</Code> with{' '}
|
||||||
|
<Code>sessionId</Code>.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</ListItem>
|
||||||
|
</OrderedList>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
apps/builder/src/features/preview/components/PreviewDrawer.tsx
Normal file
103
apps/builder/src/features/preview/components/PreviewDrawer.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
CloseButton,
|
||||||
|
Fade,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
useColorMode,
|
||||||
|
useColorModeValue,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { useEditor } from '../../editor/providers/EditorProvider'
|
||||||
|
import { useGraph } from '@/features/graph'
|
||||||
|
import { useTypebot } from '../../editor/providers/TypebotProvider'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { headerHeight } from '../../editor/constants'
|
||||||
|
import { RuntimeMenu } from './RuntimeMenu'
|
||||||
|
import { runtimes } from '../data'
|
||||||
|
import { PreviewDrawerBody } from './PreviewDrawerBody'
|
||||||
|
import { useDrag } from '@use-gesture/react'
|
||||||
|
import { ResizeHandle } from './ResizeHandle'
|
||||||
|
|
||||||
|
export const PreviewDrawer = () => {
|
||||||
|
const isDark = useColorMode().colorMode === 'dark'
|
||||||
|
const { save, isSavingLoading } = useTypebot()
|
||||||
|
const { setRightPanel } = useEditor()
|
||||||
|
const { setPreviewingBlock } = useGraph()
|
||||||
|
const [width, setWidth] = useState(500)
|
||||||
|
const [isResizeHandleVisible, setIsResizeHandleVisible] = useState(false)
|
||||||
|
const [restartKey, setRestartKey] = useState(0)
|
||||||
|
const [selectedRuntime, setSelectedRuntime] = useState<
|
||||||
|
(typeof runtimes)[number]
|
||||||
|
>(runtimes[0])
|
||||||
|
|
||||||
|
const handleRestartClick = async () => {
|
||||||
|
await save()
|
||||||
|
setRestartKey((key) => key + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseClick = () => {
|
||||||
|
setPreviewingBlock(undefined)
|
||||||
|
setRightPanel(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useResizeHandleDrag = useDrag(
|
||||||
|
(state) => {
|
||||||
|
setWidth(-state.offset[0])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: () => [-width, 0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
pos="absolute"
|
||||||
|
right="0"
|
||||||
|
top={`0`}
|
||||||
|
h={`100%`}
|
||||||
|
w={`${width}px`}
|
||||||
|
bgColor={useColorModeValue('white', 'gray.900')}
|
||||||
|
borderLeftWidth={'1px'}
|
||||||
|
shadow="lg"
|
||||||
|
borderLeftRadius={'lg'}
|
||||||
|
onMouseOver={() => setIsResizeHandleVisible(true)}
|
||||||
|
onMouseLeave={() => setIsResizeHandleVisible(false)}
|
||||||
|
p="6"
|
||||||
|
zIndex={10}
|
||||||
|
>
|
||||||
|
<Fade in={isResizeHandleVisible}>
|
||||||
|
<ResizeHandle
|
||||||
|
{...useResizeHandleDrag()}
|
||||||
|
isDark={isDark}
|
||||||
|
pos="absolute"
|
||||||
|
left="-7.5px"
|
||||||
|
top={`calc(50% - ${headerHeight}px)`}
|
||||||
|
/>
|
||||||
|
</Fade>
|
||||||
|
|
||||||
|
<VStack w="full" spacing={4}>
|
||||||
|
<HStack justifyContent={'space-between'} w="full">
|
||||||
|
<HStack>
|
||||||
|
<RuntimeMenu
|
||||||
|
selectedRuntime={selectedRuntime}
|
||||||
|
onSelectRuntime={(runtime) => setSelectedRuntime(runtime)}
|
||||||
|
/>
|
||||||
|
{selectedRuntime.name === 'Web' ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleRestartClick}
|
||||||
|
isLoading={isSavingLoading}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<CloseButton onClick={handleCloseClick} />
|
||||||
|
</HStack>
|
||||||
|
<PreviewDrawerBody key={restartKey} runtime={selectedRuntime.name} />
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { runtimes } from '../data'
|
||||||
|
import { ApiPreviewInstructions } from './ApiPreviewInstructions'
|
||||||
|
import { WebPreview } from './WebPreview'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
runtime: (typeof runtimes)[number]['name']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviewDrawerBody = ({ runtime }: Props) => {
|
||||||
|
switch (runtime) {
|
||||||
|
case 'Web': {
|
||||||
|
return <WebPreview />
|
||||||
|
}
|
||||||
|
case 'API': {
|
||||||
|
return <ApiPreviewInstructions />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { FlexProps, Flex, useColorModeValue, Box } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
export const ResizeHandle = (props: { isDark: boolean } & FlexProps) => {
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
w="15px"
|
||||||
|
h="50px"
|
||||||
|
borderWidth={'1px'}
|
||||||
|
bgColor={useColorModeValue('white', 'gray.800')}
|
||||||
|
cursor={'col-resize'}
|
||||||
|
justifyContent={'center'}
|
||||||
|
align={'center'}
|
||||||
|
borderRadius={'sm'}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
w="2px"
|
||||||
|
bgColor={useColorModeValue('gray.300', 'gray.600')}
|
||||||
|
h="70%"
|
||||||
|
mr="0.5"
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
w="2px"
|
||||||
|
bgColor={useColorModeValue('gray.300', 'gray.600')}
|
||||||
|
h="70%"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
apps/builder/src/features/preview/components/RuntimeMenu.tsx
Normal file
54
apps/builder/src/features/preview/components/RuntimeMenu.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { ChevronDownIcon } from '@/components/icons'
|
||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
Button,
|
||||||
|
MenuList,
|
||||||
|
MenuItem,
|
||||||
|
Tag,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { runtimes } from '../data'
|
||||||
|
|
||||||
|
type Runtime = (typeof runtimes)[number]
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedRuntime: Runtime
|
||||||
|
onSelectRuntime: (runtime: Runtime) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RuntimeMenu = ({ selectedRuntime, onSelectRuntime }: Props) => (
|
||||||
|
<Menu>
|
||||||
|
<MenuButton
|
||||||
|
as={Button}
|
||||||
|
leftIcon={selectedRuntime.icon}
|
||||||
|
rightIcon={<ChevronDownIcon />}
|
||||||
|
>
|
||||||
|
<HStack justifyContent="space-between">
|
||||||
|
<Text>{selectedRuntime.name}</Text>
|
||||||
|
{'status' in selectedRuntime ? (
|
||||||
|
<Tag colorScheme="orange">{selectedRuntime.status}</Tag>
|
||||||
|
) : null}
|
||||||
|
</HStack>
|
||||||
|
</MenuButton>
|
||||||
|
<MenuList w="100px">
|
||||||
|
{runtimes
|
||||||
|
.filter((runtime) => runtime.name !== selectedRuntime.name)
|
||||||
|
.map((runtime) => (
|
||||||
|
<MenuItem
|
||||||
|
key={runtime.name}
|
||||||
|
icon={runtime.icon}
|
||||||
|
onClick={() => onSelectRuntime(runtime)}
|
||||||
|
>
|
||||||
|
<HStack justifyContent="space-between">
|
||||||
|
<Text>{runtime.name}</Text>
|
||||||
|
{'status' in runtime ? (
|
||||||
|
<Tag colorScheme="orange">{runtime.status}</Tag>
|
||||||
|
) : null}
|
||||||
|
</HStack>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
35
apps/builder/src/features/preview/components/WebPreview.tsx
Normal file
35
apps/builder/src/features/preview/components/WebPreview.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEditor } from '@/features/editor/providers/EditorProvider'
|
||||||
|
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||||
|
import { useGraph } from '@/features/graph'
|
||||||
|
import { useToast } from '@/hooks/useToast'
|
||||||
|
import { UseToastOptions } from '@chakra-ui/react'
|
||||||
|
import { Standard } from '@typebot.io/react'
|
||||||
|
import { ChatReply } from 'models'
|
||||||
|
|
||||||
|
export const WebPreview = () => {
|
||||||
|
const { typebot } = useTypebot()
|
||||||
|
const { startPreviewAtGroup } = useEditor()
|
||||||
|
const { setPreviewingBlock } = useGraph()
|
||||||
|
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
|
const handleNewLogs = (logs: ChatReply['logs']) => {
|
||||||
|
logs?.forEach((log) => showToast(log as UseToastOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!typebot) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Standard
|
||||||
|
key={`web-preview${startPreviewAtGroup ?? ''}`}
|
||||||
|
typebot={typebot}
|
||||||
|
startGroupId={startPreviewAtGroup}
|
||||||
|
onNewInputBlock={setPreviewingBlock}
|
||||||
|
onNewLogs={handleNewLogs}
|
||||||
|
style={{
|
||||||
|
borderWidth: '1px',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
apps/builder/src/features/preview/data.tsx
Normal file
9
apps/builder/src/features/preview/data.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { GlobeIcon, CodeIcon } from '@/components/icons'
|
||||||
|
|
||||||
|
export const runtimes = [
|
||||||
|
{
|
||||||
|
name: 'Web',
|
||||||
|
icon: <GlobeIcon />,
|
||||||
|
},
|
||||||
|
{ name: 'API', icon: <CodeIcon />, status: 'beta' },
|
||||||
|
] as const
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"label": "Chat (Experimental 🧪)"
|
"label": "Chat (Beta)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
setResultAsCompleted,
|
setResultAsCompleted,
|
||||||
startBotFlow,
|
startBotFlow,
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { omit } from 'utils'
|
import { env, omit } from 'utils'
|
||||||
|
|
||||||
export const sendMessageProcedure = publicProcedure
|
export const sendMessageProcedure = publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -41,61 +41,63 @@ export const sendMessageProcedure = publicProcedure
|
|||||||
})
|
})
|
||||||
.input(sendMessageInputSchema)
|
.input(sendMessageInputSchema)
|
||||||
.output(chatReplySchema)
|
.output(chatReplySchema)
|
||||||
.query(async ({ input: { sessionId, message, startParams } }) => {
|
.query(
|
||||||
const session = sessionId ? await getSession(sessionId) : null
|
async ({ input: { sessionId, message, startParams }, ctx: { user } }) => {
|
||||||
|
const session = sessionId ? await getSession(sessionId) : null
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const {
|
const {
|
||||||
sessionId,
|
sessionId,
|
||||||
typebot,
|
typebot,
|
||||||
messages,
|
messages,
|
||||||
input,
|
input,
|
||||||
resultId,
|
resultId,
|
||||||
dynamicTheme,
|
dynamicTheme,
|
||||||
logs,
|
logs,
|
||||||
clientSideActions,
|
clientSideActions,
|
||||||
} = await startSession(startParams)
|
} = await startSession(startParams, user?.id)
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
typebot: typebot
|
typebot: typebot
|
||||||
? {
|
? {
|
||||||
id: typebot.id,
|
id: typebot.id,
|
||||||
theme: typebot.theme,
|
theme: typebot.theme,
|
||||||
settings: typebot.settings,
|
settings: typebot.settings,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
messages,
|
messages,
|
||||||
input,
|
input,
|
||||||
resultId,
|
resultId,
|
||||||
dynamicTheme,
|
dynamicTheme,
|
||||||
logs,
|
logs,
|
||||||
clientSideActions,
|
clientSideActions,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const { messages, input, clientSideActions, newSessionState, logs } =
|
const { messages, input, clientSideActions, newSessionState, logs } =
|
||||||
await continueBotFlow(session.state)(message)
|
await continueBotFlow(session.state)(message)
|
||||||
|
|
||||||
await prisma.chatSession.updateMany({
|
await prisma.chatSession.updateMany({
|
||||||
where: { id: session.id },
|
where: { id: session.id },
|
||||||
data: {
|
data: {
|
||||||
state: newSessionState,
|
state: newSessionState,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!input && session.state.result?.hasStarted)
|
if (!input && session.state.result?.hasStarted)
|
||||||
await setResultAsCompleted(session.state.result.id)
|
await setResultAsCompleted(session.state.result.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
input,
|
input,
|
||||||
clientSideActions,
|
clientSideActions,
|
||||||
dynamicTheme: parseDynamicThemeReply(newSessionState),
|
dynamicTheme: parseDynamicThemeReply(newSessionState),
|
||||||
logs,
|
logs,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
const startSession = async (startParams?: StartParams) => {
|
const startSession = async (startParams?: StartParams, userId?: string) => {
|
||||||
if (!startParams?.typebot)
|
if (!startParams?.typebot)
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -105,7 +107,7 @@ const startSession = async (startParams?: StartParams) => {
|
|||||||
const isPreview =
|
const isPreview =
|
||||||
startParams?.isPreview || typeof startParams?.typebot !== 'string'
|
startParams?.isPreview || typeof startParams?.typebot !== 'string'
|
||||||
|
|
||||||
const typebot = await getTypebot(startParams)
|
const typebot = await getTypebot(startParams, userId)
|
||||||
|
|
||||||
const startVariables = startParams.prefilledVariables
|
const startVariables = startParams.prefilledVariables
|
||||||
? parsePrefilledVariables(typebot.variables, startParams.prefilledVariables)
|
? parsePrefilledVariables(typebot.variables, startParams.prefilledVariables)
|
||||||
@@ -198,14 +200,16 @@ const startSession = async (startParams?: StartParams) => {
|
|||||||
} satisfies ChatReply
|
} satisfies ChatReply
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypebot = async ({
|
const getTypebot = async (
|
||||||
typebot,
|
{ typebot, isPreview }: Pick<StartParams, 'typebot' | 'isPreview'>,
|
||||||
isPreview,
|
userId?: string
|
||||||
}: Pick<StartParams, 'typebot' | 'isPreview'>): Promise<StartTypebot> => {
|
): Promise<StartTypebot> => {
|
||||||
if (typeof typebot !== 'string') return typebot
|
if (typeof typebot !== 'string') return typebot
|
||||||
|
if (isPreview && !userId && env('E2E_TEST') !== 'true')
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
|
||||||
const typebotQuery = isPreview
|
const typebotQuery = isPreview
|
||||||
? await prisma.typebot.findUnique({
|
? await prisma.typebot.findFirst({
|
||||||
where: { id: typebot },
|
where: { id: typebot, workspace: { members: { some: { userId } } } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
groups: true,
|
groups: true,
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { captureException } from '@sentry/nextjs'
|
|||||||
import { createOpenApiNextHandler } from 'trpc-openapi'
|
import { createOpenApiNextHandler } from 'trpc-openapi'
|
||||||
import cors from 'nextjs-cors'
|
import cors from 'nextjs-cors'
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
import { createContext } from '@/utils/server/context'
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
await cors(req, res)
|
await cors(req, res)
|
||||||
|
|
||||||
return createOpenApiNextHandler({
|
return createOpenApiNextHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
|
createContext,
|
||||||
onError({ error }) {
|
onError({ error }) {
|
||||||
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
||||||
captureException(error)
|
captureException(error)
|
||||||
|
|||||||
35
apps/viewer/src/utils/server/context.ts
Normal file
35
apps/viewer/src/utils/server/context.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { inferAsyncReturnType } from '@trpc/server'
|
||||||
|
import * as trpcNext from '@trpc/server/adapters/next'
|
||||||
|
import { User } from 'db'
|
||||||
|
import { NextApiRequest } from 'next'
|
||||||
|
|
||||||
|
export async function createContext(opts: trpcNext.CreateNextContextOptions) {
|
||||||
|
const user = await getAuthenticatedUser(opts.req)
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAuthenticatedUser = async (
|
||||||
|
req: NextApiRequest
|
||||||
|
): Promise<User | undefined> => {
|
||||||
|
const bearerToken = extractBearerToken(req)
|
||||||
|
if (!bearerToken) return
|
||||||
|
return authenticateByToken(bearerToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticateByToken = async (
|
||||||
|
apiToken: string
|
||||||
|
): Promise<User | undefined> => {
|
||||||
|
if (typeof window !== 'undefined') return
|
||||||
|
return (await prisma.user.findFirst({
|
||||||
|
where: { apiTokens: { some: { token: apiToken } } },
|
||||||
|
})) as User
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractBearerToken = (req: NextApiRequest) =>
|
||||||
|
req.headers['authorization']?.slice(7)
|
||||||
|
|
||||||
|
export type Context = inferAsyncReturnType<typeof createContext>
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
import { initTRPC } from '@trpc/server'
|
import { initTRPC } from '@trpc/server'
|
||||||
import { OpenApiMeta } from 'trpc-openapi'
|
import { OpenApiMeta } from 'trpc-openapi'
|
||||||
import superjson from 'superjson'
|
import superjson from 'superjson'
|
||||||
|
import { Context } from './context'
|
||||||
|
|
||||||
const t = initTRPC.meta<OpenApiMeta>().create({
|
const t = initTRPC.context<Context>().meta<OpenApiMeta>().create({
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const injectUser = t.middleware(({ next, ctx }) => {
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
user: ctx.user,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
export const middleware = t.middleware
|
export const middleware = t.middleware
|
||||||
|
|
||||||
export const router = t.router
|
export const router = t.router
|
||||||
|
|
||||||
export const publicProcedure = t.procedure
|
export const publicProcedure = t.procedure.use(injectUser)
|
||||||
|
|||||||
Reference in New Issue
Block a user