2
0

🪥 Consult submissions

This commit is contained in:
Baptiste Arnaud
2021-12-30 10:24:16 +01:00
parent f088f694b9
commit 1093453c07
25 changed files with 575 additions and 138 deletions

View File

@ -200,3 +200,11 @@ export const UploadIcon = (props: IconProps) => (
<polyline points="16 16 12 12 8 16"></polyline>
</Icon>
)
export const DownloadIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</Icon>
)

View File

@ -0,0 +1,44 @@
import { Checkbox, Flex, Skeleton } from '@chakra-ui/react'
import React from 'react'
type LoadingRowsProps = {
totalColumns: number
}
export const LoadingRows = ({ totalColumns }: LoadingRowsProps) => {
return (
<>
{Array.from(Array(3)).map((row, idx) => (
<Flex as="tr" key={idx}>
<Flex
key={idx}
py={2}
px={4}
border="1px"
as="td"
borderColor="gray.200"
flex="0"
>
<Checkbox isDisabled />
</Flex>
{Array.from(Array(totalColumns)).map((cell, idx) => {
return (
<Flex
key={idx}
py={2}
px={4}
border="1px"
as="td"
borderColor="gray.200"
flex="1"
align="center"
>
<Skeleton height="5px" w="full" />
</Flex>
)
})}
</Flex>
))}
</>
)
}

View File

@ -1,73 +1,110 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-key */
import { Box, Flex } from '@chakra-ui/react'
import { Box, Checkbox, Flex } from '@chakra-ui/react'
import { Answer, Result } from 'bot-engine'
import { useTypebot } from 'contexts/TypebotContext'
import React from 'react'
import { useTable } from 'react-table'
import React, { useEffect } from 'react'
import { Hooks, useRowSelect, useTable } from 'react-table'
import { parseSubmissionsColumns } from 'services/publicTypebot'
import { parseDateToReadable } from 'services/results'
import { LoadingRows } from './LoadingRows'
// eslint-disable-next-line @typescript-eslint/ban-types
type SubmissionsTableProps = {}
type SubmissionsTableProps = {
results?: (Result & { answers: Answer[] })[]
onNewSelection: (selection: string[]) => void
}
export const SubmissionsTable = ({}: SubmissionsTableProps) => {
export const SubmissionsTable = ({
results,
onNewSelection,
}: SubmissionsTableProps) => {
const { publishedTypebot } = useTypebot()
const columns: any = React.useMemo(
() => parseSubmissionsColumns(publishedTypebot),
[publishedTypebot]
)
const data = React.useMemo(() => [], [])
const { getTableProps, headerGroups, rows, prepareRow, getTableBodyProps } =
useTable({ columns, data })
const data = React.useMemo(
() =>
(results ?? []).map((result) => ({
createdAt: parseDateToReadable(result.createdAt),
...result.answers.reduce(
(o, answer) => ({ ...o, [answer.blockId]: answer.content }),
{}
),
})),
[results]
)
const {
getTableProps,
headerGroups,
rows,
prepareRow,
getTableBodyProps,
selectedFlatRows,
} = useTable({ columns, data }, useRowSelect, checkboxColumnHook) as any
useEffect(() => {
onNewSelection(selectedFlatRows.map((row: any) => row.id))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFlatRows])
return (
<Flex overflowX="scroll" maxW="full" className="table-wrapper" rounded="md">
<Box as="table" rounded="md" {...getTableProps()}>
<Box as="table" rounded="md" {...getTableProps()} w="full">
<Box as="thead">
{headerGroups.map((headerGroup) => {
{headerGroups.map((headerGroup: any) => {
return (
<Box as="tr" {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column) => {
<Flex as="tr" {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column: any, idx: number) => {
return (
<Box
<Flex
py={2}
px={4}
border="1px"
borderColor="gray.200"
as="th"
minW="200px"
color="gray.500"
fontWeight="normal"
textAlign="left"
minW={idx > 0 ? '200px' : 'unset'}
flex={idx > 0 ? '1' : '0'}
{...column.getHeaderProps()}
>
{column.render('Header')}
</Box>
</Flex>
)
})}
</Box>
</Flex>
)
})}
</Box>
<Box as="tbody" {...getTableBodyProps()}>
{rows.map((row) => {
{results === undefined && (
<LoadingRows totalColumns={columns.length} />
)}
{rows.map((row: any) => {
prepareRow(row)
return (
<Box as="tr" {...row.getRowProps()}>
{row.cells.map((cell) => {
<Flex as="tr" {...row.getRowProps()}>
{row.cells.map((cell: any, idx: number) => {
return (
<Box
<Flex
py={2}
px={4}
border="1px"
as="td"
minW="200px"
borderColor="gray.200"
minW={idx > 0 ? '200px' : 'unset'}
{...cell.getCellProps()}
flex={idx > 0 ? '1' : '0'}
>
{cell.render('Cell')}
</Box>
</Flex>
)
})}
</Box>
</Flex>
)
})}
</Box>
@ -75,3 +112,34 @@ export const SubmissionsTable = ({}: SubmissionsTableProps) => {
</Flex>
)
}
const checkboxColumnHook = (hooks: Hooks<any>) => {
hooks.visibleColumns.push((columns) => [
{
id: 'selection',
Header: ({ getToggleAllRowsSelectedProps }: any) => (
<IndeterminateCheckbox {...getToggleAllRowsSelectedProps()} />
),
Cell: ({ row }: any) => (
<IndeterminateCheckbox {...row.getToggleRowSelectedProps()} />
),
},
...columns,
])
}
const IndeterminateCheckbox = React.forwardRef(
({ indeterminate, checked, ...rest }: any, ref) => {
const defaultRef = React.useRef()
const resolvedRef: any = ref || defaultRef
return (
<Checkbox
ref={resolvedRef}
{...rest}
isIndeterminate={indeterminate}
isChecked={checked}
/>
)
}
)

View File

@ -1,8 +1,17 @@
import { Button, Flex, HStack, Stack } from '@chakra-ui/react'
import {
Button,
Flex,
HStack,
Stack,
Tag,
useToast,
Text,
} from '@chakra-ui/react'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { useTypebot } from 'contexts/TypebotContext'
import { useRouter } from 'next/router'
import React, { useMemo } from 'react'
import { useResultsCount } from 'services/results'
import { AnalyticsContent } from './AnalyticsContent'
import { SubmissionsContent } from './SubmissionContent'
@ -13,6 +22,15 @@ export const ResultsContent = () => {
() => router.pathname.endsWith('analytics'),
[router.pathname]
)
const toast = useToast({
position: 'top-right',
status: 'error',
})
const { totalResults } = useResultsCount({
typebotId: typebot?.id,
onError: (err) => toast({ title: err.name, description: err.message }),
})
return (
<Flex h="full" w="full" justifyContent="center" align="flex-start">
<Stack maxW="1200px" w="full" pt="4" spacing={6}>
@ -24,7 +42,12 @@ export const ResultsContent = () => {
size="sm"
href={`/typebots/${typebot?.id}/results`}
>
Submissions
<Text>Submissions</Text>
{totalResults && (
<Tag size="sm" colorScheme="blue" ml="1">
{totalResults}
</Tag>
)}
</Button>
<Button
as={NextChakraLink}
@ -36,7 +59,15 @@ export const ResultsContent = () => {
Analytics
</Button>
</HStack>
{isAnalytics ? <AnalyticsContent /> : <SubmissionsContent />}
{typebot &&
(isAnalytics ? (
<AnalyticsContent />
) : (
<SubmissionsContent
typebotId={typebot.id}
totalResults={totalResults ?? 0}
/>
))}
</Stack>
</Flex>
)

View File

@ -1,10 +1,75 @@
import {
Button,
HStack,
Stack,
Tag,
useToast,
Text,
Fade,
Flex,
} from '@chakra-ui/react'
import { DownloadIcon, TrashIcon } from 'assets/icons'
import { SubmissionsTable } from 'components/results/SubmissionsTable'
import React from 'react'
import React, { useMemo, useState } from 'react'
import { useResults } from 'services/results'
type Props = { typebotId: string; totalResults: number }
export const SubmissionsContent = ({ typebotId, totalResults }: Props) => {
const [lastResultId, setLastResultId] = useState<string>()
const [selectedIds, setSelectedIds] = useState<string[]>([])
const toast = useToast({
position: 'top-right',
status: 'error',
})
const { results } = useResults({
lastResultId,
typebotId,
onError: (err) => toast({ title: err.name, description: err.message }),
})
const handleNewSelection = (newSelection: string[]) => {
if (newSelection.length === selectedIds.length) return
setSelectedIds(newSelection)
}
const totalSelected = useMemo(
() =>
selectedIds.length === results?.length
? totalResults
: selectedIds.length,
[results?.length, selectedIds.length, totalResults]
)
export const SubmissionsContent = () => {
return (
<>
<SubmissionsTable />
</>
<Stack>
<Flex w="full" justifyContent="flex-end">
<HStack>
<HStack as={Button} colorScheme="blue">
<DownloadIcon />
<Text>Export</Text>
<Fade in={totalSelected > 0} unmountOnExit>
<Tag colorScheme="blue" variant="subtle" size="sm">
{totalSelected}
</Tag>
</Fade>
</HStack>
<Fade in={totalSelected > 0} unmountOnExit>
<HStack as={Button} colorScheme="red">
<TrashIcon />
<Text>Delete</Text>
{totalSelected > 0 && (
<Tag colorScheme="red" variant="subtle" size="sm">
{totalSelected}
</Tag>
)}
</HStack>
</Fade>
</HStack>
</Flex>
<SubmissionsTable results={results} onNewSelection={handleNewSelection} />
</Stack>
)
}

View File

@ -38,6 +38,7 @@
"next-auth": "beta",
"nodemailer": "^6.7.2",
"nprogress": "^0.2.0",
"qs": "^6.10.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-frame-component": "^5.2.1",
@ -59,6 +60,7 @@
"@types/micro-cors": "^0.1.2",
"@types/node": "^16.11.9",
"@types/nprogress": "^0.2.0",
"@types/qs": "^6.9.7",
"@types/react": "^17.0.37",
"@types/react-table": "^7.7.9",
"@types/testing-library__cypress": "^5.0.9",

View File

@ -9,10 +9,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!session?.user)
return res.status(401).json({ message: 'Not authenticated' })
const id = req.query.id.toString()
const typebotId = req.query.typebotId.toString()
if (req.method === 'GET') {
const typebot = await prisma.typebot.findUnique({
where: { id },
where: { id: typebotId },
include: {
publishedTypebot: true,
},
@ -23,14 +23,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
if (req.method === 'DELETE') {
const typebots = await prisma.typebot.delete({
where: { id },
where: { id: typebotId },
})
return res.send({ typebots })
}
if (req.method === 'PUT') {
const data = JSON.parse(req.body)
const typebots = await prisma.typebot.update({
where: { id },
where: { id: typebotId },
data,
})
return res.send({ typebots })
@ -38,7 +38,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'PATCH') {
const data = JSON.parse(req.body)
const typebots = await prisma.typebot.update({
where: { id },
where: { id: typebotId },
data,
})
return res.send({ typebots })

View File

@ -0,0 +1,39 @@
import { User } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
import { methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req })
if (!session?.user)
return res.status(401).send({ message: 'Not authenticated' })
const user = session.user as User
if (req.method === 'GET') {
const typebotId = req.query.typebotId.toString()
const lastResultId = req.query.lastResultId?.toString()
const results = await prisma.result.findMany({
take: 50,
skip: lastResultId ? 1 : 0,
cursor: lastResultId
? {
id: lastResultId,
}
: undefined,
where: {
typebotId,
typebot: { ownerId: user.id },
},
orderBy: {
createdAt: 'desc',
},
include: { answers: true },
})
return res.status(200).send({ results })
}
return methodNotAllowed(res)
}
export default handler

View File

@ -0,0 +1,27 @@
import { User } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
import { methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req })
if (!session?.user)
return res.status(401).send({ message: 'Not authenticated' })
const user = session.user as User
if (req.method === 'GET') {
const typebotId = req.query.typebotId.toString()
const totalResults = await prisma.result.count({
where: {
typebotId,
typebot: { ownerId: user.id },
},
})
return res.status(200).send({ totalResults })
}
return methodNotAllowed(res)
}
export default handler

View File

@ -1,6 +1,7 @@
import { DashboardFolder } from '.prisma/client'
import useSWR from 'swr'
import { fetcher, sendRequest } from './utils'
import { stringify } from 'qs'
export const useFolders = ({
parentId,
@ -9,9 +10,7 @@ export const useFolders = ({
parentId?: string
onError: (error: Error) => void
}) => {
const params = new URLSearchParams(
parentId ? { parentId: parentId.toString() } : undefined
)
const params = stringify({ parentId })
const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>(
`/api/folders?${params}`,
fetcher

View File

@ -8,6 +8,9 @@ import {
} from 'bot-engine'
import { sendRequest } from './utils'
import shortId from 'short-uuid'
import { HStack, Text } from '@chakra-ui/react'
import { CalendarIcon } from 'assets/icons'
import { StepIcon } from 'components/board/StepTypesList/StepIcon'
export const parseTypebotToPublicTypebot = (
typebot: Typebot
@ -44,12 +47,32 @@ export const updatePublishedTypebot = async (
export const parseSubmissionsColumns = (
typebot?: PublicTypebot
): {
Header: string
Header: JSX.Element
accessor: string
}[] =>
(typebot?.blocks ?? [])
.filter(blockContainsInput)
.map((block) => ({ Header: block.title, accessor: block.id }))
}[] => [
{
Header: (
<HStack>
<CalendarIcon />
<Text>Submitted at</Text>
</HStack>
),
accessor: 'createdAt',
},
...(typebot?.blocks ?? []).filter(blockContainsInput).map((block) => ({
Header: (
<HStack>
<StepIcon
type={
block.steps.find((step) => step.target)?.type ?? StepType.TEXT_INPUT
}
/>
<Text>{block.title}</Text>
</HStack>
),
accessor: block.id,
})),
]
const blockContainsInput = (block: Block) => block.steps.some(stepIsInput)

View File

@ -0,0 +1,60 @@
import { Result } from 'bot-engine'
import useSWR from 'swr'
import { fetcher } from './utils'
import { stringify } from 'qs'
import { Answer } from 'db'
export const useResults = ({
lastResultId,
typebotId,
onError,
}: {
lastResultId?: string
typebotId: string
onError: (error: Error) => void
}) => {
const params = stringify({
lastResultId,
})
const { data, error, mutate } = useSWR<
{ results: (Result & { answers: Answer[] })[] },
Error
>(`/api/typebots/${typebotId}/results?${params}`, fetcher)
if (error) onError(error)
return {
results: data?.results,
isLoading: !error && !data,
mutate,
}
}
export const useResultsCount = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ totalResults: number }, Error>(
typebotId ? `/api/typebots/${typebotId}/results/count` : null,
fetcher
)
if (error) onError(error)
return {
totalResults: data?.totalResults,
isLoading: !error && !data,
mutate,
}
}
export const parseDateToReadable = (dateStr: string): string => {
const date = new Date(dateStr)
return (
date.toDateString().split(' ').slice(1, 3).join(' ') +
', ' +
date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
)
}

View File

@ -11,6 +11,7 @@ import { Typebot } from 'bot-engine'
import useSWR from 'swr'
import { fetcher, sendRequest, toKebabCase } from './utils'
import { deepEqual } from 'fast-equals'
import { stringify } from 'qs'
export const useTypebots = ({
folderId,
@ -19,9 +20,7 @@ export const useTypebots = ({
folderId?: string
onError: (error: Error) => void
}) => {
const params = new URLSearchParams(
folderId ? { folderId: folderId.toString() } : undefined
)
const params = stringify({ folderId })
const { data, error, mutate } = useSWR<{ typebots: Typebot[] }, Error>(
`/api/typebots?${params}`,
fetcher

View File

@ -1,5 +1,6 @@
import { Answer, PublicTypebot, TypebotViewer } from 'bot-engine'
import React, { useEffect, useState } from 'react'
import { upsertAnswer } from 'services/answer'
import { SEO } from '../components/Seo'
import { createResult, updateResult } from '../services/result'
import { ErrorPage } from './ErrorPage'
@ -38,9 +39,9 @@ export const TypebotPage = ({ typebot, isIE, url }: TypebotPageProps) => {
}
}
const handleAnswersUpdate = async (answers: Answer[]) => {
const handleNewAnswer = async (answer: Answer) => {
if (!resultId) return setError(new Error('Result was not created'))
const { error } = await updateResult(resultId, { answers })
const { error } = await upsertAnswer({ ...answer, resultId })
if (error) setError(error)
}
@ -62,7 +63,7 @@ export const TypebotPage = ({ typebot, isIE, url }: TypebotPageProps) => {
{resultId && (
<TypebotViewer
typebot={typebot}
onAnswersUpdate={handleAnswersUpdate}
onNewAnswer={handleNewAnswer}
onCompleted={handleCompleted}
/>
)}

View File

@ -0,0 +1,25 @@
import { Answer } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'PUT') {
const answer = JSON.parse(req.body) as Answer
const result = await prisma.answer.upsert({
where: {
resultId_blockId_stepId: {
resultId: answer.resultId,
blockId: answer.blockId,
stepId: answer.stepId,
},
},
create: answer,
update: answer,
})
return res.send(result)
}
return methodNotAllowed(res)
}
export default handler

View File

@ -0,0 +1,10 @@
import { Answer } from 'db'
import { sendRequest } from 'utils'
export const upsertAnswer = async (answer: Answer) => {
return sendRequest<Answer>({
url: `/api/answers`,
method: 'PUT',
body: answer,
})
}