✨ (preview) Add preview runtime dropdown
User can select between Web and API previews Closes #247
This commit is contained in:
@ -90,8 +90,16 @@ test('Variable buttons should work', async ({ page }) => {
|
||||
await page.click('[data-testid="block1-icon"]')
|
||||
await page.click('text=Multiple choice?')
|
||||
await page.click('text="Restart"')
|
||||
await page.getByTestId('button').first().click()
|
||||
await page.getByTestId('button').nth(1).click()
|
||||
await page
|
||||
.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 expect(
|
||||
page.locator('text="Variable item, Variable item"')
|
||||
|
@ -47,7 +47,7 @@ test.describe('Builder', () => {
|
||||
`${process.env.NEXTAUTH_URL}/api/mock/webhook`
|
||||
)
|
||||
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=Query params')
|
||||
|
@ -15,7 +15,7 @@ import { useTypebot } from '../providers/TypebotProvider'
|
||||
import { BlocksSideBar } from './BlocksSideBar'
|
||||
import { BoardMenuButton } from './BoardMenuButton'
|
||||
import { GettingStartedModal } from './GettingStartedModal'
|
||||
import { PreviewDrawer } from './PreviewDrawer'
|
||||
import { PreviewDrawer } from '@/features/preview/components/PreviewDrawer'
|
||||
import { TypebotHeader } from './TypebotHeader'
|
||||
|
||||
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
|
Reference in New Issue
Block a user