2
0

feat(results): Add logs in results

This commit is contained in:
Baptiste Arnaud
2022-03-01 11:40:22 +01:00
parent 4630512b8b
commit ebf92b5536
27 changed files with 408 additions and 120 deletions

View File

@@ -362,3 +362,12 @@ export const UsersIcon = (props: IconProps) => (
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path> <path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</Icon> </Icon>
) )
export const AlignLeftTextIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="17" y1="10" x2="3" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="17" y1="18" x2="3" y2="18"></line>
</Icon>
)

View File

@@ -7,6 +7,7 @@ import {
FlexProps, FlexProps,
useEventListener, useEventListener,
useToast, useToast,
UseToastOptions,
VStack, VStack,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { TypebotViewer } from 'bot-engine' import { TypebotViewer } from 'bot-engine'
@@ -14,7 +15,8 @@ import { headerHeight } from 'components/shared/TypebotHeader'
import { useEditor } from 'contexts/EditorContext' import { useEditor } from 'contexts/EditorContext'
import { useGraph } from 'contexts/GraphContext' import { useGraph } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import React, { useEffect, useMemo, useState } from 'react' import { Log } from 'db'
import React, { useMemo, useState } from 'react'
import { parseTypebotToPublicTypebot } from 'services/publicTypebot' import { parseTypebotToPublicTypebot } from 'services/publicTypebot'
export const PreviewDrawer = () => { export const PreviewDrawer = () => {
@@ -57,20 +59,10 @@ export const PreviewDrawer = () => {
setRightPanel(undefined) setRightPanel(undefined)
} }
useEffect(() => { const handleNewLog = (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => {
const onMessageFromBot = (event: MessageEvent) => { toast(log as UseToastOptions)
if (event.data.typebotInfo) { console.log(log.details)
toast({ description: event.data.typebotInfo }) }
}
if (event.data.typebotError) {
toast({ description: event.data.typebotError, status: 'error' })
}
}
window.addEventListener('message', onMessageFromBot)
return () => {
window.removeEventListener('message', onMessageFromBot)
}
})
return ( return (
<Flex <Flex
@@ -113,6 +105,7 @@ export const PreviewDrawer = () => {
<TypebotViewer <TypebotViewer
typebot={publicTypebot} typebot={publicTypebot}
onNewBlockVisible={setPreviewingEdge} onNewBlockVisible={setPreviewingEdge}
onNewLog={handleNewLog}
isPreview isPreview
/> />
</Flex> </Flex>

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-key */ /* eslint-disable react/jsx-key */
import { chakra, Checkbox, Flex } from '@chakra-ui/react' import { Button, chakra, Checkbox, Flex, HStack, Text } from '@chakra-ui/react'
import { AlignLeftTextIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import React, { useEffect, useMemo, useRef } from 'react' import React, { useEffect, useMemo, useRef } from 'react'
import { Hooks, useRowSelect, useTable } from 'react-table' import { Hooks, useRowSelect, useTable } from 'react-table'
@@ -12,6 +13,7 @@ type SubmissionsTableProps = {
hasMore?: boolean hasMore?: boolean
onNewSelection: (indices: number[]) => void onNewSelection: (indices: number[]) => void
onScrollToBottom: () => void onScrollToBottom: () => void
onLogOpenIndex: (index: number) => () => void
} }
export const SubmissionsTable = ({ export const SubmissionsTable = ({
@@ -19,13 +21,13 @@ export const SubmissionsTable = ({
hasMore, hasMore,
onNewSelection, onNewSelection,
onScrollToBottom, onScrollToBottom,
onLogOpenIndex,
}: SubmissionsTableProps) => { }: SubmissionsTableProps) => {
const { publishedTypebot } = useTypebot() const { publishedTypebot } = useTypebot()
const columns: any = useMemo( const columns: any = useMemo(
() => (publishedTypebot ? parseSubmissionsColumns(publishedTypebot) : []), () => (publishedTypebot ? parseSubmissionsColumns(publishedTypebot) : []),
[publishedTypebot] [publishedTypebot]
) )
const bottomElement = useRef<HTMLDivElement | null>(null) const bottomElement = useRef<HTMLDivElement | null>(null)
const tableWrapper = useRef<HTMLDivElement | null>(null) const tableWrapper = useRef<HTMLDivElement | null>(null)
@@ -87,6 +89,19 @@ export const SubmissionsTable = ({
</chakra.th> </chakra.th>
) )
})} })}
<chakra.th
px="4"
py="2"
border="1px"
borderColor="gray.200"
fontWeight="normal"
whiteSpace="nowrap"
>
<HStack>
<AlignLeftTextIcon />
<Text>Logs</Text>
</HStack>
</chakra.th>
</tr> </tr>
) )
})} })}
@@ -118,10 +133,17 @@ export const SubmissionsTable = ({
</chakra.td> </chakra.td>
) )
})} })}
<chakra.td px="4" py="2" border="1px" borderColor="gray.200">
<Button size="sm" onClick={onLogOpenIndex(idx)}>
See logs
</Button>
</chakra.td>
</tr> </tr>
) )
})} })}
{hasMore === true && <LoadingRows totalColumns={columns.length} />} {hasMore === true && (
<LoadingRows totalColumns={columns.length + 1} />
)}
</tbody> </tbody>
</chakra.table> </chakra.table>
</Flex> </Flex>

View File

@@ -0,0 +1,99 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
Spinner,
ModalFooter,
Accordion,
AccordionItem,
AccordionButton,
HStack,
AccordionIcon,
AccordionPanel,
Text,
Tag,
} from '@chakra-ui/react'
import { Log } from 'db'
import { useLogs } from 'services/typebots/logs'
import { isDefined } from 'utils'
export const LogsModal = ({
resultId,
onClose,
}: {
resultId?: string
onClose: () => void
}) => {
const { isLoading, logs } = useLogs(resultId)
return (
<Modal isOpen={isDefined(resultId)} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Logs</ModalHeader>
<ModalCloseButton />
<ModalBody as={Stack}>
{logs?.map((log, idx) => (
<LogCard key={idx} log={log} />
))}
{isLoading && <Spinner />}
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}
const LogCard = ({ log }: { log: Log }) => {
if (log.details)
return (
<Accordion allowToggle>
<AccordionItem style={{ borderBottomWidth: 0, borderWidth: 0 }}>
<AccordionButton
as={HStack}
p="4"
cursor="pointer"
justifyContent="space-between"
borderRadius="md"
>
<HStack>
<StatusTag status={log.status} />
<Text>{log.description}</Text>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel
as="pre"
overflow="scroll"
borderWidth="1px"
borderRadius="md"
>
{log.details}
</AccordionPanel>
</AccordionItem>
</Accordion>
)
return (
<HStack p="4">
<StatusTag status={log.status} />
<Text>{log.description}</Text>
</HStack>
)
}
const StatusTag = ({ status }: { status: string }) => {
switch (status) {
case 'error':
return <Tag colorScheme={'red'}>Fail</Tag>
case 'warning':
return <Tag colorScheme={'orange'}>Warn</Tag>
case 'info':
return <Tag colorScheme={'blue'}>Info</Tag>
default:
return <Tag colorScheme={'green'}>Ok</Tag>
}
}

View File

@@ -11,6 +11,7 @@ import {
} from 'services/typebots' } from 'services/typebots'
import { unparse } from 'papaparse' import { unparse } from 'papaparse'
import { UnlockProPlanInfo } from 'components/shared/Info' import { UnlockProPlanInfo } from 'components/shared/Info'
import { LogsModal } from './LogsModal'
type Props = { type Props = {
typebotId: string typebotId: string
@@ -27,6 +28,7 @@ export const SubmissionsContent = ({
const [selectedIndices, setSelectedIndices] = useState<number[]>([]) const [selectedIndices, setSelectedIndices] = useState<number[]>([])
const [isDeleteLoading, setIsDeleteLoading] = useState(false) const [isDeleteLoading, setIsDeleteLoading] = useState(false)
const [isExportLoading, setIsExportLoading] = useState(false) const [isExportLoading, setIsExportLoading] = useState(false)
const [inspectingLogsResultId, setInspectingLogsResultId] = useState<string>()
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
@@ -109,6 +111,13 @@ export const SubmissionsContent = ({
[results] [results]
) )
const handleLogsModalClose = () => setInspectingLogsResultId(undefined)
const handleLogOpenIndex = (index: number) => () => {
if (!results) return
setInspectingLogsResultId(results[index].id)
}
return ( return (
<Stack maxW="1200px" w="full" pb="28"> <Stack maxW="1200px" w="full" pb="28">
{totalHiddenResults && ( {totalHiddenResults && (
@@ -117,6 +126,10 @@ export const SubmissionsContent = ({
contentLabel="You are seeing complete submissions only." contentLabel="You are seeing complete submissions only."
/> />
)} )}
<LogsModal
resultId={inspectingLogsResultId}
onClose={handleLogsModalClose}
/>
<Flex w="full" justifyContent="flex-end"> <Flex w="full" justifyContent="flex-end">
<ResultsActionButtons <ResultsActionButtons
isDeleteLoading={isDeleteLoading} isDeleteLoading={isDeleteLoading}
@@ -132,6 +145,7 @@ export const SubmissionsContent = ({
onNewSelection={handleNewSelection} onNewSelection={handleNewSelection}
onScrollToBottom={handleScrolledToBottom} onScrollToBottom={handleScrolledToBottom}
hasMore={hasMore} hasMore={hasMore}
onLogOpenIndex={handleLogOpenIndex}
/> />
</Stack> </Stack>
) )

View File

@@ -31,7 +31,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
typebotId, typebotId,
typebot: { ownerId: user.email === adminEmail ? undefined : user.id }, typebot: { ownerId: user.email === adminEmail ? undefined : user.id },
answers: { some: {} }, answers: { some: {} },
isCompleted: isFreePlan(user) ? false : undefined, isCompleted: isFreePlan(user) ? true : undefined,
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',

View File

@@ -0,0 +1,18 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
import { methodNotAllowed, notAuthenticated } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const resultId = req.query.resultId as string
const logs = await prisma.log.findMany({ where: { resultId } })
return res.send({ logs })
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -0,0 +1,15 @@
import { Log } from 'db'
import { fetcher } from 'services/utils'
import useSWR from 'swr'
export const useLogs = (resultId?: string, onError?: (e: Error) => void) => {
const { data, error } = useSWR<{ logs: Log[] }>(
resultId ? `/api/typebots/t/results/${resultId}/logs` : null,
fetcher
)
if (error && onError) onError(error)
return {
logs: data?.logs,
isLoading: !error && !data,
}
}

View File

@@ -1,9 +1,10 @@
import { TypebotViewer } from 'bot-engine' import { TypebotViewer } from 'bot-engine'
import { Log } from 'db'
import { Answer, PublicTypebot, VariableWithValue } from 'models' import { Answer, PublicTypebot, VariableWithValue } from 'models'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { upsertAnswer } from 'services/answer' import { upsertAnswer } from 'services/answer'
import { SEO } from '../components/Seo' import { SEO } from '../components/Seo'
import { createResult, updateResult } from '../services/result' import { createLog, createResult, updateResult } from '../services/result'
import { ErrorPage } from './ErrorPage' import { ErrorPage } from './ErrorPage'
export type TypebotPageProps = { export type TypebotPageProps = {
@@ -58,6 +59,14 @@ export const TypebotPage = ({
if (error) setError(error) if (error) setError(error)
} }
const handleNewLog = async (
log: Omit<Log, 'id' | 'createdAt' | 'resultId'>
) => {
if (!resultId) return setError(new Error('Result was not created'))
const { error } = await createLog(resultId, log)
if (error) setError(error)
}
if (error) { if (error) {
return <ErrorPage error={error} /> return <ErrorPage error={error} />
} }
@@ -74,6 +83,7 @@ export const TypebotPage = ({
onNewAnswer={handleNewAnswer} onNewAnswer={handleNewAnswer}
onCompleted={handleCompleted} onCompleted={handleCompleted}
onVariablesPrefilled={initializeResult} onVariablesPrefilled={initializeResult}
onNewLog={handleNewLog}
/> />
)} )}
</div> </div>

View File

@@ -1,23 +0,0 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { VariableWithValue } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const resultData = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as {
typebotId: string
prefilledVariables: VariableWithValue[]
}
const result = await prisma.result.create({
data: { ...resultData, isCompleted: false },
})
return res.send(result)
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -1,5 +1,5 @@
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { ResultWithAnswers, Typebot } from 'models' import { ResultWithAnswers, Typebot, VariableWithValue } from 'models'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils' import { authenticateUser } from 'services/api/utils'
import { methodNotAllowed, parseAnswers } from 'utils' import { methodNotAllowed, parseAnswers } from 'utils'
@@ -14,16 +14,30 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}) })
if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
const limit = Number(req.query.limit) const limit = Number(req.query.limit)
console.log(limit, typebot.id)
const results = (await prisma.result.findMany({ const results = (await prisma.result.findMany({
where: { typebotId: typebot.id }, where: { typebotId: typebot.id },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit, take: limit,
include: { answers: true }, include: { answers: true },
})) as unknown as ResultWithAnswers[] })) as unknown as ResultWithAnswers[]
res.send({ console.log(results)
return res.send({
results: results.map(parseAnswers(typebot as unknown as Typebot)), results: results.map(parseAnswers(typebot as unknown as Typebot)),
}) })
} }
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const resultData = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as {
prefilledVariables: VariableWithValue[]
}
const result = await prisma.result.create({
data: { ...resultData, typebotId, isCompleted: false },
})
return res.send(result)
}
methodNotAllowed(res) methodNotAllowed(res)
} }

View File

@@ -8,9 +8,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const data = ( const data = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as { isCompleted: true } ) as { isCompleted: true }
const id = req.query.id.toString() const resultId = req.query.resultId as string
const result = await prisma.result.update({ const result = await prisma.result.update({
where: { id }, where: { id: resultId },
data, data,
}) })
return res.send(result) return res.send(result)

View File

@@ -0,0 +1,20 @@
import { withSentry } from '@sentry/nextjs'
import { Log } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { badRequest, methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const resultId = req.query.resultId as string
const log = req.body as
| Omit<Log, 'id' | 'createdAt' | 'resultId'>
| undefined
if (!log) return badRequest(res)
const createdLog = await prisma.log.create({ data: { ...log, resultId } })
return res.send(createdLog)
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -15,7 +15,9 @@ test.beforeAll(async () => {
) )
await createWebhook(typebotId) await createWebhook(typebotId)
await createResults({ typebotId }) await createResults({ typebotId })
} catch (err) {} } catch (err) {
console.log(err)
}
}) })
test('can list typebots', async ({ request }) => { test('can list typebots', async ({ request }) => {
@@ -112,10 +114,10 @@ test('can get a sample result', async ({ request }) => {
const data = await response.json() const data = await response.json()
expect(data).toMatchObject({ expect(data).toMatchObject({
message: 'This is a sample result, it has been generated ⬇️', message: 'This is a sample result, it has been generated ⬇️',
Welcome: 'Item 1, Item 2, Item3', Welcome: 'Hi!',
Email: 'test@email.com', Email: 'test@email.com',
Name: 'answer value', Name: 'answer value',
Services: 'Item 1, Item 2, Item3', Services: 'Website dev, Content Marketing, Social Media, UI / UX Design',
'Additional information': 'answer value', 'Additional information': 'answer value',
}) })
}) })

View File

@@ -7,6 +7,7 @@ import {
InputStepType, InputStepType,
Metadata, Metadata,
} from 'models' } from 'models'
import { typebotViewer } from '../services/selectorUtils'
test('Should correctly parse metadata', async ({ page }) => { test('Should correctly parse metadata', async ({ page }) => {
const typebotId = generate() const typebotId = generate()
@@ -48,4 +49,9 @@ test('Should correctly parse metadata', async ({ page }) => {
(document.querySelector('link[rel="icon"]') as any).getAttribute('href') (document.querySelector('link[rel="icon"]') as any).getAttribute('href')
) )
).toBe(customMetadata.favIconUrl) ).toBe(customMetadata.favIconUrl)
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`
)
).toBeVisible()
}) })

View File

@@ -1,10 +1,9 @@
import { Answer } from 'models' import { Answer } from 'models'
import { sendRequest } from 'utils' import { sendRequest } from 'utils'
export const upsertAnswer = async (answer: Answer & { resultId: string }) => { export const upsertAnswer = async (answer: Answer & { resultId: string }) =>
return sendRequest<Answer>({ sendRequest<Answer>({
url: `/api/answers`, url: `/api/typebots/t/results/r/answers`,
method: 'PUT', method: 'PUT',
body: answer, body: answer,
}) })
}

View File

@@ -1,4 +1,4 @@
import { Result } from 'db' import { Log, Result } from 'db'
import { VariableWithValue } from 'models' import { VariableWithValue } from 'models'
import { sendRequest } from 'utils' import { sendRequest } from 'utils'
@@ -7,19 +7,25 @@ export const createResult = async (
prefilledVariables: VariableWithValue[] prefilledVariables: VariableWithValue[]
) => { ) => {
return sendRequest<Result>({ return sendRequest<Result>({
url: `/api/results`, url: `/api/typebots/${typebotId}/results`,
method: 'POST', method: 'POST',
body: { typebotId, prefilledVariables }, body: { prefilledVariables },
}) })
} }
export const updateResult = async ( export const updateResult = async (resultId: string, result: Partial<Result>) =>
resultId: string, sendRequest<Result>({
result: Partial<Result> url: `/api/typebots/t/results/${resultId}`,
) => {
return sendRequest<Result>({
url: `/api/results/${resultId}`,
method: 'PATCH', method: 'PATCH',
body: result, body: result,
}) })
}
export const createLog = (
resultId: string,
log: Omit<Log, 'id' | 'createdAt' | 'resultId'>
) =>
sendRequest<Result>({
url: `/api/typebots/t/results/${resultId}/logs`,
method: 'POST',
body: log,
})

View File

@@ -31,8 +31,14 @@ export const ChatBlock = ({
onScroll, onScroll,
onBlockEnd, onBlockEnd,
}: ChatBlockProps) => { }: ChatBlockProps) => {
const { typebot, updateVariableValue, createEdge, apiHost, isPreview } = const {
useTypebot() typebot,
updateVariableValue,
createEdge,
apiHost,
isPreview,
onNewLog,
} = useTypebot()
const { resultValues } = useAnswers() const { resultValues } = useAnswers()
const [displayedSteps, setDisplayedSteps] = useState<Step[]>([]) const [displayedSteps, setDisplayedSteps] = useState<Step[]>([])
@@ -72,6 +78,7 @@ export const ChatBlock = ({
updateVariableValue, updateVariableValue,
resultValues, resultValues,
blocks: typebot.blocks, blocks: typebot.blocks,
onNewLog,
}, },
}) })
nextEdgeId ? onBlockEnd(nextEdgeId) : displayNextStep() nextEdgeId ? onBlockEnd(nextEdgeId) : displayNextStep()

View File

@@ -4,8 +4,7 @@ import { ChatBlock } from './ChatBlock/ChatBlock'
import { useFrame } from 'react-frame-component' import { useFrame } from 'react-frame-component'
import { setCssVariablesValue } from '../services/theme' import { setCssVariablesValue } from '../services/theme'
import { useAnswers } from '../contexts/AnswersContext' import { useAnswers } from '../contexts/AnswersContext'
import { deepEqual } from 'fast-equals' import { Block, Edge, Theme, VariableWithValue } from 'models'
import { Answer, Block, Edge, Theme, VariableWithValue } from 'models'
import { byId, isNotDefined } from 'utils' import { byId, isNotDefined } from 'utils'
import { animateScroll as scroll } from 'react-scroll' import { animateScroll as scroll } from 'react-scroll'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
@@ -13,14 +12,12 @@ import { useTypebot } from 'contexts/TypebotContext'
type Props = { type Props = {
theme: Theme theme: Theme
onNewBlockVisible: (edge: Edge) => void onNewBlockVisible: (edge: Edge) => void
onNewAnswer: (answer: Answer) => void
onCompleted: () => void onCompleted: () => void
onVariablesPrefilled?: (prefilledVariables: VariableWithValue[]) => void onVariablesPrefilled?: (prefilledVariables: VariableWithValue[]) => void
} }
export const ConversationContainer = ({ export const ConversationContainer = ({
theme, theme,
onNewBlockVisible, onNewBlockVisible,
onNewAnswer,
onCompleted, onCompleted,
onVariablesPrefilled, onVariablesPrefilled,
}: Props) => { }: Props) => {
@@ -29,11 +26,7 @@ export const ConversationContainer = ({
const [displayedBlocks, setDisplayedBlocks] = useState< const [displayedBlocks, setDisplayedBlocks] = useState<
{ block: Block; startStepIndex: number }[] { block: Block; startStepIndex: number }[]
>([]) >([])
const [localAnswer, setLocalAnswer] = useState<Answer | undefined>() const { setPrefilledVariables } = useAnswers()
const {
resultValues: { answers },
setPrefilledVariables,
} = useAnswers()
const bottomAnchor = useRef<HTMLDivElement | null>(null) const bottomAnchor = useRef<HTMLDivElement | null>(null)
const scrollableContainer = useRef<HTMLDivElement | null>(null) const scrollableContainer = useRef<HTMLDivElement | null>(null)
@@ -80,14 +73,6 @@ export const ConversationContainer = ({
setCssVariablesValue(theme, frameDocument.body.style) setCssVariablesValue(theme, frameDocument.body.style)
}, [theme, frameDocument]) }, [theme, frameDocument])
useEffect(() => {
const answer = [...answers].pop()
if (!answer || deepEqual(localAnswer, answer)) return
setLocalAnswer(answer)
onNewAnswer(answer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers])
const autoScrollToBottom = () => { const autoScrollToBottom = () => {
if (!scrollableContainer.current) return if (!scrollableContainer.current) return
scroll.scrollToBottom({ scroll.scrollToBottom({

View File

@@ -17,6 +17,7 @@ import {
PublicTypebot, PublicTypebot,
VariableWithValue, VariableWithValue,
} from 'models' } from 'models'
import { Log } from 'db'
export type TypebotViewerProps = { export type TypebotViewerProps = {
typebot: PublicTypebot typebot: PublicTypebot
@@ -24,6 +25,7 @@ export type TypebotViewerProps = {
apiHost?: string apiHost?: string
onNewBlockVisible?: (edge: Edge) => void onNewBlockVisible?: (edge: Edge) => void
onNewAnswer?: (answer: Answer) => void onNewAnswer?: (answer: Answer) => void
onNewLog?: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
onCompleted?: () => void onCompleted?: () => void
onVariablesPrefilled?: (prefilledVariables: VariableWithValue[]) => void onVariablesPrefilled?: (prefilledVariables: VariableWithValue[]) => void
} }
@@ -31,6 +33,7 @@ export const TypebotViewer = ({
typebot, typebot,
apiHost = process.env.NEXT_PUBLIC_VIEWER_HOST, apiHost = process.env.NEXT_PUBLIC_VIEWER_HOST,
isPreview = false, isPreview = false,
onNewLog,
onNewBlockVisible, onNewBlockVisible,
onNewAnswer, onNewAnswer,
onCompleted, onCompleted,
@@ -43,15 +46,15 @@ export const TypebotViewer = ({
: 'transparent', : 'transparent',
[typebot?.theme?.general?.background] [typebot?.theme?.general?.background]
) )
const handleNewBlockVisible = (edge: Edge) => { const handleNewBlockVisible = (edge: Edge) =>
if (onNewBlockVisible) onNewBlockVisible(edge) onNewBlockVisible && onNewBlockVisible(edge)
}
const handleNewAnswer = (answer: Answer) => { const handleNewAnswer = (answer: Answer) => onNewAnswer && onNewAnswer(answer)
if (onNewAnswer) onNewAnswer(answer)
} const handleNewLog = (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) =>
const handleCompleted = () => { onNewLog && onNewLog(log)
if (onCompleted) onCompleted()
} const handleCompleted = () => onCompleted && onCompleted()
if (!apiHost) if (!apiHost)
return <p>process.env.NEXT_PUBLIC_VIEWER_HOST is missing in env</p> return <p>process.env.NEXT_PUBLIC_VIEWER_HOST is missing in env</p>
@@ -76,8 +79,13 @@ export const TypebotViewer = ({
}:wght@300;400;600&display=swap');`, }:wght@300;400;600&display=swap');`,
}} }}
/> />
<TypebotContext typebot={typebot} apiHost={apiHost} isPreview={isPreview}> <TypebotContext
<AnswersContext> typebot={typebot}
apiHost={apiHost}
isPreview={isPreview}
onNewLog={handleNewLog}
>
<AnswersContext onNewAnswer={handleNewAnswer}>
<div <div
className="flex text-base overflow-hidden bg-cover h-screen w-screen flex-col items-center typebot-container" className="flex text-base overflow-hidden bg-cover h-screen w-screen flex-col items-center typebot-container"
style={{ style={{
@@ -90,7 +98,6 @@ export const TypebotViewer = ({
<ConversationContainer <ConversationContainer
theme={typebot.theme} theme={typebot.theme}
onNewBlockVisible={handleNewBlockVisible} onNewBlockVisible={handleNewBlockVisible}
onNewAnswer={handleNewAnswer}
onCompleted={handleCompleted} onCompleted={handleCompleted}
onVariablesPrefilled={onVariablesPrefilled} onVariablesPrefilled={onVariablesPrefilled}
/> />

View File

@@ -9,18 +9,26 @@ const answersContext = createContext<{
//@ts-ignore //@ts-ignore
}>({}) }>({})
export const AnswersContext = ({ children }: { children: ReactNode }) => { export const AnswersContext = ({
children,
onNewAnswer,
}: {
onNewAnswer: (answer: Answer) => void
children: ReactNode
}) => {
const [resultValues, setResultValues] = useState<ResultValues>({ const [resultValues, setResultValues] = useState<ResultValues>({
answers: [], answers: [],
prefilledVariables: [], prefilledVariables: [],
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}) })
const addAnswer = (answer: Answer) => const addAnswer = (answer: Answer) => {
setResultValues((resultValues) => ({ setResultValues((resultValues) => ({
...resultValues, ...resultValues,
answers: [...resultValues.answers, answer], answers: [...resultValues.answers, answer],
})) }))
onNewAnswer(answer)
}
const setPrefilledVariables = (variables: VariableWithValue[]) => const setPrefilledVariables = (variables: VariableWithValue[]) =>
setResultValues((resultValues) => ({ setResultValues((resultValues) => ({

View File

@@ -1,3 +1,4 @@
import { Log } from 'db'
import { Edge, PublicTypebot } from 'models' import { Edge, PublicTypebot } from 'models'
import React, { import React, {
createContext, createContext,
@@ -13,6 +14,7 @@ const typebotContext = createContext<{
isPreview: boolean isPreview: boolean
updateVariableValue: (variableId: string, value: string) => void updateVariableValue: (variableId: string, value: string) => void
createEdge: (edge: Edge) => void createEdge: (edge: Edge) => void
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
}>({}) }>({})
@@ -22,11 +24,13 @@ export const TypebotContext = ({
typebot, typebot,
apiHost, apiHost,
isPreview, isPreview,
onNewLog,
}: { }: {
children: ReactNode children: ReactNode
typebot: PublicTypebot typebot: PublicTypebot
apiHost: string apiHost: string
isPreview: boolean isPreview: boolean
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
}) => { }) => {
const [localTypebot, setLocalTypebot] = useState<PublicTypebot>(typebot) const [localTypebot, setLocalTypebot] = useState<PublicTypebot>(typebot)
@@ -63,6 +67,7 @@ export const TypebotContext = ({
isPreview, isPreview,
updateVariableValue, updateVariableValue,
createEdge, createEdge,
onNewLog,
}} }}
> >
{children} {children}

View File

@@ -1,3 +1,4 @@
import { Log } from 'db'
import { import {
IntegrationStep, IntegrationStep,
IntegrationStepType, IntegrationStepType,
@@ -18,7 +19,6 @@ import {
import { stringify } from 'qs' import { stringify } from 'qs'
import { sendRequest } from 'utils' import { sendRequest } from 'utils'
import { sendGaEvent } from '../../lib/gtag' import { sendGaEvent } from '../../lib/gtag'
import { sendErrorMessage, sendInfoMessage } from './postMessage'
import { parseVariables, parseVariablesInObject } from './variable' import { parseVariables, parseVariablesInObject } from './variable'
const safeEval = eval const safeEval = eval
@@ -33,6 +33,7 @@ type IntegrationContext = {
resultValues: ResultValues resultValues: ResultValues
blocks: Block[] blocks: Block[]
updateVariableValue: (variableId: string, value: string) => void updateVariableValue: (variableId: string, value: string) => void
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
} }
export const executeIntegration = ({ export const executeIntegration = ({
@@ -87,10 +88,17 @@ const executeGoogleSheetIntegration = async (
const insertRowInGoogleSheets = async ( const insertRowInGoogleSheets = async (
options: GoogleSheetsInsertRowOptions, options: GoogleSheetsInsertRowOptions,
{ variables, apiHost }: IntegrationContext { variables, apiHost, onNewLog }: IntegrationContext
) => { ) => {
if (!options.cellsToInsert) return if (!options.cellsToInsert) {
return sendRequest({ onNewLog({
status: 'warning',
description: 'Cells to insert are undefined',
details: null,
})
return
}
const { error } = await sendRequest({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}`, url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}`,
method: 'POST', method: 'POST',
body: { body: {
@@ -98,14 +106,21 @@ const insertRowInGoogleSheets = async (
values: parseCellValues(options.cellsToInsert, variables), values: parseCellValues(options.cellsToInsert, variables),
}, },
}) })
onNewLog(
parseLog(
error,
'Succesfully inserted a row in the sheet',
'Failed to insert a row in the sheet'
)
)
} }
const updateRowInGoogleSheets = async ( const updateRowInGoogleSheets = async (
options: GoogleSheetsUpdateRowOptions, options: GoogleSheetsUpdateRowOptions,
{ variables, apiHost }: IntegrationContext { variables, apiHost, onNewLog }: IntegrationContext
) => { ) => {
if (!options.cellsToUpsert || !options.referenceCell) return if (!options.cellsToUpsert || !options.referenceCell) return
return sendRequest({ const { error } = await sendRequest({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}`, url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}`,
method: 'PATCH', method: 'PATCH',
body: { body: {
@@ -117,11 +132,18 @@ const updateRowInGoogleSheets = async (
}, },
}, },
}) })
onNewLog(
parseLog(
error,
'Succesfully updated a row in the sheet',
'Failed to update a row in the sheet'
)
)
} }
const getRowFromGoogleSheets = async ( const getRowFromGoogleSheets = async (
options: GoogleSheetsGetOptions, options: GoogleSheetsGetOptions,
{ variables, updateVariableValue, apiHost }: IntegrationContext { variables, updateVariableValue, apiHost, onNewLog }: IntegrationContext
) => { ) => {
if (!options.referenceCell || !options.cellsToExtract) return if (!options.referenceCell || !options.cellsToExtract) return
const queryParams = stringify( const queryParams = stringify(
@@ -135,10 +157,17 @@ const getRowFromGoogleSheets = async (
}, },
{ indices: false } { indices: false }
) )
const { data } = await sendRequest<{ [key: string]: string }>({ const { data, error } = await sendRequest<{ [key: string]: string }>({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}?${queryParams}`, url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}?${queryParams}`,
method: 'GET', method: 'GET',
}) })
onNewLog(
parseLog(
error,
'Succesfully fetched data from sheet',
'Failed to fetch data from sheet'
)
)
if (!data) return if (!data) return
options.cellsToExtract.forEach((cell) => options.cellsToExtract.forEach((cell) =>
updateVariableValue(cell.variableId ?? '', data[cell.column ?? '']) updateVariableValue(cell.variableId ?? '', data[cell.column ?? ''])
@@ -167,7 +196,7 @@ const executeWebhook = async (
typebotId, typebotId,
apiHost, apiHost,
resultValues, resultValues,
isPreview, onNewLog,
}: IntegrationContext }: IntegrationContext
) => { ) => {
const { data, error } = await sendRequest({ const { data, error } = await sendRequest({
@@ -178,8 +207,15 @@ const executeWebhook = async (
resultValues, resultValues,
}, },
}) })
console.error(error) const statusCode = (data as Record<string, string>).statusCode.toString()
if (isPreview && error) sendErrorMessage(`Webhook failed: ${error.message}`) const isError = statusCode.startsWith('4') || statusCode.startsWith('5')
onNewLog({
status: error ? 'error' : isError ? 'warning' : 'success',
description: isError
? 'Webhook returned an error'
: 'Webhook successfuly executed',
details: JSON.stringify(error ?? data, null, 2).substring(0, 1000),
})
step.options.responseVariableMapping.forEach((varMapping) => { step.options.responseVariableMapping.forEach((varMapping) => {
if (!varMapping?.bodyPath || !varMapping.variableId) return if (!varMapping?.bodyPath || !varMapping.variableId) return
const value = safeEval(`(${JSON.stringify(data)}).${varMapping?.bodyPath}`) const value = safeEval(`(${JSON.stringify(data)}).${varMapping?.bodyPath}`)
@@ -190,10 +226,16 @@ const executeWebhook = async (
const sendEmail = async ( const sendEmail = async (
step: SendEmailStep, step: SendEmailStep,
{ variables, apiHost, isPreview }: IntegrationContext { variables, apiHost, isPreview, onNewLog }: IntegrationContext
) => { ) => {
if (isPreview) sendInfoMessage('Emails are not sent in preview mode') if (isPreview) {
if (isPreview) return step.outgoingEdgeId onNewLog({
status: 'info',
description: 'Emails are not sent in preview mode',
details: null,
})
return step.outgoingEdgeId
}
const { options } = step const { options } = step
const { error } = await sendRequest({ const { error } = await sendRequest({
url: `${apiHost}/api/integrations/email`, url: `${apiHost}/api/integrations/email`,
@@ -207,6 +249,18 @@ const sendEmail = async (
bcc: (options.bcc ?? []).map(parseVariables(variables)), bcc: (options.bcc ?? []).map(parseVariables(variables)),
}, },
}) })
console.error(error) onNewLog(
parseLog(error, 'Succesfully sent an email', 'Failed to send an email')
)
return step.outgoingEdgeId return step.outgoingEdgeId
} }
const parseLog = (
error: Error | undefined,
successMessage: string,
errorMessage: string
): Omit<Log, 'id' | 'createdAt' | 'resultId'> => ({
status: error ? 'error' : 'success',
description: error ? errorMessage : successMessage,
details: (error && JSON.stringify(error, null, 2).substring(0, 1000)) ?? null,
})

View File

@@ -1,7 +0,0 @@
export const sendInfoMessage = (typebotInfo: string) => {
parent.postMessage({ typebotInfo })
}
export const sendErrorMessage = (typebotError: string) => {
parent.postMessage({ typebotError })
}

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "Log" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resultId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"description" TEXT NOT NULL,
"details" TEXT,
CONSTRAINT "Log_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Log" ADD CONSTRAINT "Log_resultId_fkey" FOREIGN KEY ("resultId") REFERENCES "Result"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -129,7 +129,7 @@ model Typebot {
customDomain String? @unique customDomain String? @unique
collaborators CollaboratorsOnTypebots[] collaborators CollaboratorsOnTypebots[]
invitations Invitation[] invitations Invitation[]
webhooks Webhook[] webhooks Webhook[]
@@unique([id, ownerId]) @@unique([id, ownerId])
} }
@@ -184,6 +184,17 @@ model Result {
answers Answer[] answers Answer[]
prefilledVariables Json[] prefilledVariables Json[]
isCompleted Boolean isCompleted Boolean
logs Log[]
}
model Log {
id String @id @default(cuid())
createdAt DateTime @default(now())
resultId String
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
status String
description String
details String?
} }
model Answer { model Answer {