2
0

(preview) Add preview runtime dropdown

User can select between Web and API previews

Closes #247
This commit is contained in:
Baptiste Arnaud
2023-02-22 11:40:04 +01:00
parent a265143dc0
commit 3967e5f1d0
17 changed files with 464 additions and 206 deletions

View File

@ -37,6 +37,12 @@ export const ChevronRightIcon = (props: IconProps) => (
</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) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="12" y1="5" x2="12" y2="19"></line>

View File

@ -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"')

View File

@ -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')

View File

@ -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 = () => {

View File

@ -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>
)
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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 />
}
}
}

View File

@ -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>
)
}

View 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>
)

View 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',
}}
/>
)
}

View 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

View File

@ -1,3 +1,3 @@
{
"label": "Chat (Experimental 🧪)"
"label": "Chat (Beta)"
}

View File

@ -27,7 +27,7 @@ import {
setResultAsCompleted,
startBotFlow,
} from '../utils'
import { omit } from 'utils'
import { env, omit } from 'utils'
export const sendMessageProcedure = publicProcedure
.meta({
@ -41,61 +41,63 @@ export const sendMessageProcedure = publicProcedure
})
.input(sendMessageInputSchema)
.output(chatReplySchema)
.query(async ({ input: { sessionId, message, startParams } }) => {
const session = sessionId ? await getSession(sessionId) : null
.query(
async ({ input: { sessionId, message, startParams }, ctx: { user } }) => {
const session = sessionId ? await getSession(sessionId) : null
if (!session) {
const {
sessionId,
typebot,
messages,
input,
resultId,
dynamicTheme,
logs,
clientSideActions,
} = await startSession(startParams)
return {
sessionId,
typebot: typebot
? {
id: typebot.id,
theme: typebot.theme,
settings: typebot.settings,
}
: undefined,
messages,
input,
resultId,
dynamicTheme,
logs,
clientSideActions,
}
} else {
const { messages, input, clientSideActions, newSessionState, logs } =
await continueBotFlow(session.state)(message)
if (!session) {
const {
sessionId,
typebot,
messages,
input,
resultId,
dynamicTheme,
logs,
clientSideActions,
} = await startSession(startParams, user?.id)
return {
sessionId,
typebot: typebot
? {
id: typebot.id,
theme: typebot.theme,
settings: typebot.settings,
}
: undefined,
messages,
input,
resultId,
dynamicTheme,
logs,
clientSideActions,
}
} else {
const { messages, input, clientSideActions, newSessionState, logs } =
await continueBotFlow(session.state)(message)
await prisma.chatSession.updateMany({
where: { id: session.id },
data: {
state: newSessionState,
},
})
await prisma.chatSession.updateMany({
where: { id: session.id },
data: {
state: newSessionState,
},
})
if (!input && session.state.result?.hasStarted)
await setResultAsCompleted(session.state.result.id)
if (!input && session.state.result?.hasStarted)
await setResultAsCompleted(session.state.result.id)
return {
messages,
input,
clientSideActions,
dynamicTheme: parseDynamicThemeReply(newSessionState),
logs,
return {
messages,
input,
clientSideActions,
dynamicTheme: parseDynamicThemeReply(newSessionState),
logs,
}
}
}
})
)
const startSession = async (startParams?: StartParams) => {
const startSession = async (startParams?: StartParams, userId?: string) => {
if (!startParams?.typebot)
throw new TRPCError({
code: 'BAD_REQUEST',
@ -105,7 +107,7 @@ const startSession = async (startParams?: StartParams) => {
const isPreview =
startParams?.isPreview || typeof startParams?.typebot !== 'string'
const typebot = await getTypebot(startParams)
const typebot = await getTypebot(startParams, userId)
const startVariables = startParams.prefilledVariables
? parsePrefilledVariables(typebot.variables, startParams.prefilledVariables)
@ -198,14 +200,16 @@ const startSession = async (startParams?: StartParams) => {
} satisfies ChatReply
}
const getTypebot = async ({
typebot,
isPreview,
}: Pick<StartParams, 'typebot' | 'isPreview'>): Promise<StartTypebot> => {
const getTypebot = async (
{ typebot, isPreview }: Pick<StartParams, 'typebot' | 'isPreview'>,
userId?: string
): Promise<StartTypebot> => {
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
? await prisma.typebot.findUnique({
where: { id: typebot },
? await prisma.typebot.findFirst({
where: { id: typebot, workspace: { members: { some: { userId } } } },
select: {
id: true,
groups: true,

View File

@ -3,12 +3,14 @@ import { captureException } from '@sentry/nextjs'
import { createOpenApiNextHandler } from 'trpc-openapi'
import cors from 'nextjs-cors'
import { NextApiRequest, NextApiResponse } from 'next'
import { createContext } from '@/utils/server/context'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
return createOpenApiNextHandler({
router: appRouter,
createContext,
onError({ error }) {
if (error.code === 'INTERNAL_SERVER_ERROR') {
captureException(error)

View 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>

View File

@ -1,13 +1,22 @@
import { initTRPC } from '@trpc/server'
import { OpenApiMeta } from 'trpc-openapi'
import superjson from 'superjson'
import { Context } from './context'
const t = initTRPC.meta<OpenApiMeta>().create({
const t = initTRPC.context<Context>().meta<OpenApiMeta>().create({
transformer: superjson,
})
const injectUser = t.middleware(({ next, ctx }) => {
return next({
ctx: {
user: ctx.user,
},
})
})
export const middleware = t.middleware
export const router = t.router
export const publicProcedure = t.procedure
export const publicProcedure = t.procedure.use(injectUser)