feat(results): ✨ Add logs in results
This commit is contained in:
@@ -362,3 +362,12 @@ export const UsersIcon = (props: IconProps) => (
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FlexProps,
|
||||
useEventListener,
|
||||
useToast,
|
||||
UseToastOptions,
|
||||
VStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { TypebotViewer } from 'bot-engine'
|
||||
@@ -14,7 +15,8 @@ import { headerHeight } from 'components/shared/TypebotHeader'
|
||||
import { useEditor } from 'contexts/EditorContext'
|
||||
import { useGraph } from 'contexts/GraphContext'
|
||||
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'
|
||||
|
||||
export const PreviewDrawer = () => {
|
||||
@@ -57,20 +59,10 @@ export const PreviewDrawer = () => {
|
||||
setRightPanel(undefined)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onMessageFromBot = (event: MessageEvent) => {
|
||||
if (event.data.typebotInfo) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
const handleNewLog = (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => {
|
||||
toast(log as UseToastOptions)
|
||||
console.log(log.details)
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -113,6 +105,7 @@ export const PreviewDrawer = () => {
|
||||
<TypebotViewer
|
||||
typebot={publicTypebot}
|
||||
onNewBlockVisible={setPreviewingEdge}
|
||||
onNewLog={handleNewLog}
|
||||
isPreview
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* 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 React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { Hooks, useRowSelect, useTable } from 'react-table'
|
||||
@@ -12,6 +13,7 @@ type SubmissionsTableProps = {
|
||||
hasMore?: boolean
|
||||
onNewSelection: (indices: number[]) => void
|
||||
onScrollToBottom: () => void
|
||||
onLogOpenIndex: (index: number) => () => void
|
||||
}
|
||||
|
||||
export const SubmissionsTable = ({
|
||||
@@ -19,13 +21,13 @@ export const SubmissionsTable = ({
|
||||
hasMore,
|
||||
onNewSelection,
|
||||
onScrollToBottom,
|
||||
onLogOpenIndex,
|
||||
}: SubmissionsTableProps) => {
|
||||
const { publishedTypebot } = useTypebot()
|
||||
const columns: any = useMemo(
|
||||
() => (publishedTypebot ? parseSubmissionsColumns(publishedTypebot) : []),
|
||||
[publishedTypebot]
|
||||
)
|
||||
|
||||
const bottomElement = useRef<HTMLDivElement | null>(null)
|
||||
const tableWrapper = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -87,6 +89,19 @@ export const SubmissionsTable = ({
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
@@ -118,10 +133,17 @@ export const SubmissionsTable = ({
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
{hasMore === true && <LoadingRows totalColumns={columns.length} />}
|
||||
{hasMore === true && (
|
||||
<LoadingRows totalColumns={columns.length + 1} />
|
||||
)}
|
||||
</tbody>
|
||||
</chakra.table>
|
||||
</Flex>
|
||||
|
||||
99
apps/builder/layouts/results/LogsModal.tsx
Normal file
99
apps/builder/layouts/results/LogsModal.tsx
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'services/typebots'
|
||||
import { unparse } from 'papaparse'
|
||||
import { UnlockProPlanInfo } from 'components/shared/Info'
|
||||
import { LogsModal } from './LogsModal'
|
||||
|
||||
type Props = {
|
||||
typebotId: string
|
||||
@@ -27,6 +28,7 @@ export const SubmissionsContent = ({
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false)
|
||||
const [isExportLoading, setIsExportLoading] = useState(false)
|
||||
const [inspectingLogsResultId, setInspectingLogsResultId] = useState<string>()
|
||||
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
@@ -109,6 +111,13 @@ export const SubmissionsContent = ({
|
||||
[results]
|
||||
)
|
||||
|
||||
const handleLogsModalClose = () => setInspectingLogsResultId(undefined)
|
||||
|
||||
const handleLogOpenIndex = (index: number) => () => {
|
||||
if (!results) return
|
||||
setInspectingLogsResultId(results[index].id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack maxW="1200px" w="full" pb="28">
|
||||
{totalHiddenResults && (
|
||||
@@ -117,6 +126,10 @@ export const SubmissionsContent = ({
|
||||
contentLabel="You are seeing complete submissions only."
|
||||
/>
|
||||
)}
|
||||
<LogsModal
|
||||
resultId={inspectingLogsResultId}
|
||||
onClose={handleLogsModalClose}
|
||||
/>
|
||||
<Flex w="full" justifyContent="flex-end">
|
||||
<ResultsActionButtons
|
||||
isDeleteLoading={isDeleteLoading}
|
||||
@@ -132,6 +145,7 @@ export const SubmissionsContent = ({
|
||||
onNewSelection={handleNewSelection}
|
||||
onScrollToBottom={handleScrolledToBottom}
|
||||
hasMore={hasMore}
|
||||
onLogOpenIndex={handleLogOpenIndex}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
typebotId,
|
||||
typebot: { ownerId: user.email === adminEmail ? undefined : user.id },
|
||||
answers: { some: {} },
|
||||
isCompleted: isFreePlan(user) ? false : undefined,
|
||||
isCompleted: isFreePlan(user) ? true : undefined,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
||||
@@ -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)
|
||||
15
apps/builder/services/typebots/logs.ts
Normal file
15
apps/builder/services/typebots/logs.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { TypebotViewer } from 'bot-engine'
|
||||
import { Log } from 'db'
|
||||
import { Answer, PublicTypebot, VariableWithValue } from 'models'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { upsertAnswer } from 'services/answer'
|
||||
import { SEO } from '../components/Seo'
|
||||
import { createResult, updateResult } from '../services/result'
|
||||
import { createLog, createResult, updateResult } from '../services/result'
|
||||
import { ErrorPage } from './ErrorPage'
|
||||
|
||||
export type TypebotPageProps = {
|
||||
@@ -58,6 +59,14 @@ export const TypebotPage = ({
|
||||
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) {
|
||||
return <ErrorPage error={error} />
|
||||
}
|
||||
@@ -74,6 +83,7 @@ export const TypebotPage = ({
|
||||
onNewAnswer={handleNewAnswer}
|
||||
onCompleted={handleCompleted}
|
||||
onVariablesPrefilled={initializeResult}
|
||||
onNewLog={handleNewLog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
@@ -1,5 +1,5 @@
|
||||
import prisma from 'libs/prisma'
|
||||
import { ResultWithAnswers, Typebot } from 'models'
|
||||
import { ResultWithAnswers, Typebot, VariableWithValue } from 'models'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { authenticateUser } from 'services/api/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' })
|
||||
const limit = Number(req.query.limit)
|
||||
console.log(limit, typebot.id)
|
||||
const results = (await prisma.result.findMany({
|
||||
where: { typebotId: typebot.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
include: { answers: true },
|
||||
})) as unknown as ResultWithAnswers[]
|
||||
res.send({
|
||||
console.log(results)
|
||||
return res.send({
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const data = (
|
||||
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
) as { isCompleted: true }
|
||||
const id = req.query.id.toString()
|
||||
const resultId = req.query.resultId as string
|
||||
const result = await prisma.result.update({
|
||||
where: { id },
|
||||
where: { id: resultId },
|
||||
data,
|
||||
})
|
||||
return res.send(result)
|
||||
@@ -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)
|
||||
@@ -15,7 +15,9 @@ test.beforeAll(async () => {
|
||||
)
|
||||
await createWebhook(typebotId)
|
||||
await createResults({ typebotId })
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
})
|
||||
|
||||
test('can list typebots', async ({ request }) => {
|
||||
@@ -112,10 +114,10 @@ test('can get a sample result', async ({ request }) => {
|
||||
const data = await response.json()
|
||||
expect(data).toMatchObject({
|
||||
message: 'This is a sample result, it has been generated ⬇️',
|
||||
Welcome: 'Item 1, Item 2, Item3',
|
||||
Welcome: 'Hi!',
|
||||
Email: 'test@email.com',
|
||||
Name: 'answer value',
|
||||
Services: 'Item 1, Item 2, Item3',
|
||||
Services: 'Website dev, Content Marketing, Social Media, UI / UX Design',
|
||||
'Additional information': 'answer value',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
InputStepType,
|
||||
Metadata,
|
||||
} from 'models'
|
||||
import { typebotViewer } from '../services/selectorUtils'
|
||||
|
||||
test('Should correctly parse metadata', async ({ page }) => {
|
||||
const typebotId = generate()
|
||||
@@ -48,4 +49,9 @@ test('Should correctly parse metadata', async ({ page }) => {
|
||||
(document.querySelector('link[rel="icon"]') as any).getAttribute('href')
|
||||
)
|
||||
).toBe(customMetadata.favIconUrl)
|
||||
await expect(
|
||||
typebotViewer(page).locator(
|
||||
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Answer } from 'models'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const upsertAnswer = async (answer: Answer & { resultId: string }) => {
|
||||
return sendRequest<Answer>({
|
||||
url: `/api/answers`,
|
||||
export const upsertAnswer = async (answer: Answer & { resultId: string }) =>
|
||||
sendRequest<Answer>({
|
||||
url: `/api/typebots/t/results/r/answers`,
|
||||
method: 'PUT',
|
||||
body: answer,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Result } from 'db'
|
||||
import { Log, Result } from 'db'
|
||||
import { VariableWithValue } from 'models'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
@@ -7,19 +7,25 @@ export const createResult = async (
|
||||
prefilledVariables: VariableWithValue[]
|
||||
) => {
|
||||
return sendRequest<Result>({
|
||||
url: `/api/results`,
|
||||
url: `/api/typebots/${typebotId}/results`,
|
||||
method: 'POST',
|
||||
body: { typebotId, prefilledVariables },
|
||||
body: { prefilledVariables },
|
||||
})
|
||||
}
|
||||
|
||||
export const updateResult = async (
|
||||
resultId: string,
|
||||
result: Partial<Result>
|
||||
) => {
|
||||
return sendRequest<Result>({
|
||||
url: `/api/results/${resultId}`,
|
||||
export const updateResult = async (resultId: string, result: Partial<Result>) =>
|
||||
sendRequest<Result>({
|
||||
url: `/api/typebots/t/results/${resultId}`,
|
||||
method: 'PATCH',
|
||||
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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user