2
0

♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@ -0,0 +1,87 @@
import { ResultHeaderCell, ResultWithAnswers } from 'models'
import { createContext, ReactNode, useContext, useMemo } from 'react'
import { KeyedMutator } from 'swr'
import { parseResultHeader } from 'utils'
import { useTypebot } from '../editor/providers/TypebotProvider'
import { useResultsQuery } from './hooks/useResultsQuery'
import { TableData } from './types'
import { convertResultsToTableData } from './utils'
const resultsContext = createContext<{
resultsList: { results: ResultWithAnswers[] }[] | undefined
flatResults: ResultWithAnswers[]
hasMore: boolean
resultHeader: ResultHeaderCell[]
totalResults: number
tableData: TableData[]
onDeleteResults: (totalResultsDeleted: number) => void
fetchMore: () => void
mutate: KeyedMutator<
{
results: ResultWithAnswers[]
}[]
>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export const ResultsProvider = ({
children,
workspaceId,
typebotId,
totalResults,
onDeleteResults,
}: {
children: ReactNode
workspaceId: string
typebotId: string
totalResults: number
onDeleteResults: (totalResultsDeleted: number) => void
}) => {
const { publishedTypebot, linkedTypebots } = useTypebot()
const { data, mutate, setSize, hasMore } = useResultsQuery({
workspaceId,
typebotId,
})
const fetchMore = () => setSize((state) => state + 1)
const resultHeader = useMemo(
() =>
publishedTypebot
? parseResultHeader(publishedTypebot, linkedTypebots)
: [],
[linkedTypebots, publishedTypebot]
)
const tableData = useMemo(
() =>
publishedTypebot
? convertResultsToTableData(
data?.flatMap((d) => d.results) ?? [],
resultHeader
)
: [],
[publishedTypebot, data, resultHeader]
)
return (
<resultsContext.Provider
value={{
resultsList: data,
flatResults: data?.flatMap((d) => d.results) ?? [],
hasMore: hasMore ?? true,
tableData,
resultHeader,
totalResults,
onDeleteResults,
fetchMore,
mutate,
}}
>
{children}
</resultsContext.Provider>
)
}
export const useResults = () => useContext(resultsContext)

View File

@ -0,0 +1,55 @@
import prisma from '@/lib/prisma'
import { canWriteTypebot } from '@/utils/api/dbRules'
import { deleteFiles } from '@/utils/api/storage'
import { User, Prisma } from 'db'
import { InputBlockType, Typebot } from 'models'
import { NextApiResponse } from 'next'
import { forbidden } from 'utils/api'
export const archiveResults =
(res: NextApiResponse) =>
async ({
typebotId,
user,
resultsFilter,
}: {
typebotId: string
user: User
resultsFilter?: Prisma.ResultWhereInput
}) => {
const typebot = await prisma.typebot.findFirst({
where: canWriteTypebot(typebotId, user),
select: { groups: true },
})
if (!typebot) return forbidden(res)
const fileUploadBlockIds = (typebot as Typebot).groups
.flatMap((g) => g.blocks)
.filter((b) => b.type === InputBlockType.FILE)
.map((b) => b.id)
if (fileUploadBlockIds.length > 0) {
const filesToDelete = await prisma.answer.findMany({
where: { result: resultsFilter, blockId: { in: fileUploadBlockIds } },
})
if (filesToDelete.length > 0)
await deleteFiles({
urls: filesToDelete.flatMap((a) => a.content.split(', ')),
})
}
await prisma.log.deleteMany({
where: {
result: resultsFilter,
},
})
await prisma.answer.deleteMany({
where: {
result: resultsFilter,
},
})
await prisma.result.updateMany({
where: resultsFilter,
data: {
isArchived: true,
variables: [],
},
})
}

View File

@ -0,0 +1 @@
export * from './archiveResults'

View File

@ -0,0 +1,102 @@
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 { isDefined } from 'utils'
import { useLogs } from '../hooks/useLogs'
type Props = {
typebotId: string
resultId: string | null
onClose: () => void
}
export const LogsModal = ({ typebotId, resultId, onClose }: Props) => {
const { isLoading, logs } = useLogs(typebotId, resultId ?? undefined)
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 />}
{!isLoading && (logs ?? []).length === 0 && (
<Text>No logs found.</Text>
)}
</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

@ -0,0 +1,55 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalCloseButton,
ModalBody,
Stack,
Heading,
Text,
HStack,
} from '@chakra-ui/react'
import { useResults } from '../ResultsProvider'
import React from 'react'
import { isDefined } from 'utils'
import { HeaderIcon } from '../utils'
type Props = {
resultIdx: number | null
onClose: () => void
}
export const ResultModal = ({ resultIdx, onClose }: Props) => {
const { tableData, resultHeader } = useResults()
const result = isDefined(resultIdx) ? tableData[resultIdx] : undefined
const getHeaderValue = (
val: string | { plainText: string; element?: JSX.Element | undefined }
) => (typeof val === 'string' ? val : val.element ?? val.plainText)
return (
<Modal isOpen={isDefined(result)} onClose={onClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody as={Stack} p="10" spacing="10">
{resultHeader.map((header) =>
result && result[header.label] ? (
<Stack key={header.id} spacing="4">
<HStack>
<HeaderIcon header={header} />
<Heading fontSize="md">{header.label}</Heading>
</HStack>
<Text whiteSpace="pre-wrap" textAlign="justify">
{getHeaderValue(result[header.label])}
</Text>
</Stack>
) : (
<></>
)
)}
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,175 @@
import { Seo } from '@/components/Seo'
import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
import { AnalyticsGraphContainer } from '@/features/analytics'
import { useUsage } from '@/features/billing'
import { useTypebot, TypebotHeader } from '@/features/editor'
import { useWorkspace } from '@/features/workspace'
import { useToast } from '@/hooks/useToast'
import { Flex, HStack, Button, Tag, Text } from '@chakra-ui/react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { getChatsLimit, getStorageLimit } from 'utils'
import { useStats } from '../hooks/useStats'
import { ResultsProvider } from '../ResultsProvider'
import { ResultsTableContainer } from './ResultsTableContainer'
const ALERT_CHATS_PERCENT_THRESHOLD = 80
const ALERT_STORAGE_PERCENT_THRESHOLD = 80
export const ResultsPage = () => {
const router = useRouter()
const { workspace } = useWorkspace()
const { typebot, publishedTypebot } = useTypebot()
const isAnalytics = useMemo(
() => router.pathname.endsWith('analytics'),
[router.pathname]
)
const { showToast } = useToast()
const { stats, mutate } = useStats({
typebotId: publishedTypebot?.typebotId,
onError: (err) => showToast({ title: err.name, description: err.message }),
})
const { data: usageData } = useUsage(workspace?.id)
const chatsLimitPercentage = useMemo(() => {
if (!usageData?.totalChatsUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalChatsUsed /
getChatsLimit({
additionalChatsIndex: workspace.additionalChatsIndex,
plan: workspace.plan,
customChatsLimit: workspace.customChatsLimit,
})) *
100
)
}, [
usageData?.totalChatsUsed,
workspace?.additionalChatsIndex,
workspace?.customChatsLimit,
workspace?.plan,
])
const storageLimitPercentage = useMemo(() => {
if (!usageData?.totalStorageUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalStorageUsed /
1024 /
1024 /
1024 /
getStorageLimit({
additionalStorageIndex: workspace.additionalStorageIndex,
plan: workspace.plan,
customStorageLimit: workspace.customStorageLimit,
})) *
100
)
}, [
usageData?.totalStorageUsed,
workspace?.additionalStorageIndex,
workspace?.customStorageLimit,
workspace?.plan,
])
const handleDeletedResults = (total: number) => {
if (!stats) return
mutate({
stats: { ...stats, totalStarts: stats.totalStarts - total },
})
}
return (
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo
title={router.pathname.endsWith('analytics') ? 'Analytics' : 'Results'}
/>
<TypebotHeader />
{chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanAlertInfo
status="warning"
contentLabel={
<>
Your workspace collected{' '}
<strong>{chatsLimitPercentage}%</strong> of your total chats
limit this month. Upgrade your plan to continue chatting with
your customers beyond this limit.
</>
}
buttonLabel="Upgrade"
/>
</Flex>
)}
{storageLimitPercentage > ALERT_STORAGE_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanAlertInfo
status="warning"
contentLabel={
<>
Your workspace collected{' '}
<strong>{storageLimitPercentage}%</strong> of your total storage
allowed. Upgrade your plan or delete some existing results to
continue collecting files from your user beyond this limit.
</>
}
buttonLabel="Upgrade"
/>
</Flex>
)}
<Flex h="full" w="full">
<Flex
pos="absolute"
zIndex={2}
bgColor="white"
w="full"
justifyContent="center"
h="60px"
display={['none', 'flex']}
>
<HStack maxW="1600px" w="full" px="4">
<Button
as={Link}
colorScheme={!isAnalytics ? 'blue' : 'gray'}
variant={!isAnalytics ? 'outline' : 'ghost'}
size="sm"
href={`/typebots/${typebot?.id}/results`}
>
<Text>Submissions</Text>
{(stats?.totalStarts ?? 0) > 0 && (
<Tag size="sm" colorScheme="blue" ml="1">
{stats?.totalStarts}
</Tag>
)}
</Button>
<Button
as={Link}
colorScheme={isAnalytics ? 'blue' : 'gray'}
variant={isAnalytics ? 'outline' : 'ghost'}
href={`/typebots/${typebot?.id}/results/analytics`}
size="sm"
>
Analytics
</Button>
</HStack>
</Flex>
<Flex pt={['10px', '60px']} w="full" justify="center">
{workspace &&
publishedTypebot &&
(isAnalytics ? (
<AnalyticsGraphContainer stats={stats} />
) : (
<ResultsProvider
workspaceId={workspace.id}
typebotId={publishedTypebot.typebotId}
totalResults={stats?.totalStarts ?? 0}
onDeleteResults={handleDeletedResults}
>
<ResultsTableContainer />
</ResultsProvider>
))}
</Flex>
</Flex>
</Flex>
)
}

View File

@ -0,0 +1,67 @@
import { chakra, Fade, Button } from '@chakra-ui/react'
import { Cell as CellProps, flexRender } from '@tanstack/react-table'
import { ExpandIcon } from '@/components/icons'
import { memo } from 'react'
import { TableData } from '../../types'
type Props = {
cell: CellProps<TableData, unknown>
size: number
isExpandButtonVisible: boolean
cellIndex: number
onExpandButtonClick: () => void
}
const Cell = ({
cell,
size,
isExpandButtonVisible,
cellIndex,
onExpandButtonClick,
}: Props) => {
return (
<chakra.td
key={cell.id}
px="4"
py="2"
border="1px"
borderColor="gray.200"
whiteSpace="nowrap"
wordBreak="normal"
overflow="hidden"
pos="relative"
style={{
minWidth: size,
maxWidth: size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
<chakra.span
pos="absolute"
top="0"
right={2}
h="full"
display="inline-flex"
alignItems="center"
>
<Fade unmountOnExit in={isExpandButtonVisible && cellIndex === 1}>
<Button
leftIcon={<ExpandIcon />}
shadow="lg"
size="xs"
onClick={onExpandButtonClick}
>
Open
</Button>
</Fade>
</chakra.span>
</chakra.td>
)
}
export default memo(
Cell,
(prev, next) =>
prev.size === next.size &&
prev.isExpandButtonVisible === next.isExpandButtonVisible
)

View File

@ -0,0 +1,218 @@
import {
Popover,
PopoverTrigger,
Button,
PopoverContent,
PopoverBody,
Stack,
IconButton,
Flex,
HStack,
Text,
Portal,
} from '@chakra-ui/react'
import { ToolIcon, EyeIcon, EyeOffIcon, GripIcon } from '@/components/icons'
import { ResultHeaderCell } from 'models'
import React, { forwardRef, useState } from 'react'
import { isNotDefined } from 'utils'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay,
} from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable'
import { HeaderIcon } from '../../utils'
type Props = {
resultHeader: ResultHeaderCell[]
columnVisibility: { [key: string]: boolean }
columnOrder: string[]
onColumnOrderChange: (columnOrder: string[]) => void
setColumnVisibility: (columnVisibility: { [key: string]: boolean }) => void
}
export const ColumnSettingsButton = ({
resultHeader,
columnVisibility,
setColumnVisibility,
columnOrder,
onColumnOrderChange,
}: Props) => {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null)
const onEyeClick = (id: string) => () => {
columnVisibility[id] === false
? setColumnVisibility({ ...columnVisibility, [id]: true })
: setColumnVisibility({ ...columnVisibility, [id]: false })
}
const visibleHeaders = resultHeader
.filter(
(header) =>
isNotDefined(columnVisibility[header.id]) || columnVisibility[header.id]
)
.sort((a, b) => columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id))
const hiddenHeaders = resultHeader.filter(
(header) => columnVisibility[header.id] === false
)
const handleDragStart = (event: DragStartEvent) => {
const { active } = event
setDraggingColumnId(active.id as string)
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (active.id !== over?.id) {
onColumnOrderChange
const oldIndex = columnOrder.indexOf(active.id as string)
const newIndex = columnOrder.indexOf(over?.id as string)
const newColumnOrder = arrayMove(columnOrder, oldIndex, newIndex)
onColumnOrderChange(newColumnOrder)
}
}
return (
<Popover isLazy placement="bottom-end">
<PopoverTrigger>
<Button leftIcon={<ToolIcon />}>Columns</Button>
</PopoverTrigger>
<PopoverContent w="400px">
<PopoverBody
as={Stack}
spacing="4"
p="4"
maxH="450px"
overflowY="scroll"
>
<Stack>
<Text fontWeight="semibold" fontSize="sm">
Shown in table:
</Text>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={columnOrder}
strategy={verticalListSortingStrategy}
>
{visibleHeaders.map((header) => (
<SortableColumns
key={header.id}
header={header}
onEyeClick={onEyeClick}
/>
))}
</SortableContext>
<Portal>
<DragOverlay dropAnimation={{ duration: 0 }}>
{draggingColumnId ? <SortableColumnOverlay /> : null}
</DragOverlay>
</Portal>
</DndContext>
</Stack>
{hiddenHeaders.length > 0 && (
<Stack>
<Text fontWeight="semibold" fontSize="sm">
Hidden in table:
</Text>
{hiddenHeaders.map((header) => (
<Flex key={header.id} justify="space-between">
<HStack>
<HeaderIcon header={header} />
<Text>{header.label}</Text>
</HStack>
<IconButton
icon={<EyeOffIcon />}
size="sm"
aria-label={'Hide column'}
onClick={onEyeClick(header.id)}
/>
</Flex>
))}
</Stack>
)}
</PopoverBody>
</PopoverContent>
</Popover>
)
}
const SortableColumns = ({
header,
onEyeClick,
}: {
header: ResultHeaderCell
onEyeClick: (key: string) => () => void
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: header.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<Flex
justify="space-between"
ref={setNodeRef}
style={style}
opacity={isDragging ? 0.5 : 1}
{...attributes}
>
<HStack>
<IconButton
size="sm"
cursor="grab"
icon={<GripIcon transform="rotate(90deg)" />}
aria-label={'Drag'}
variant="ghost"
{...listeners}
/>
<HeaderIcon header={header} />
<Text>{header.label}</Text>
</HStack>
<IconButton
icon={<EyeIcon />}
size="sm"
aria-label={'Hide column'}
onClick={onEyeClick(header.id)}
/>
</Flex>
)
}
const SortableColumnOverlay = forwardRef(
(_, ref: React.LegacyRef<HTMLDivElement>) => {
return <HStack ref={ref}></HStack>
}
)

View File

@ -0,0 +1,62 @@
import { Box, BoxProps, chakra } from '@chakra-ui/react'
import { flexRender, HeaderGroup } from '@tanstack/react-table'
import React from 'react'
import { TableData } from '../../types'
type Props = {
headerGroup: HeaderGroup<TableData>
}
export const HeaderRow = ({ headerGroup }: Props) => {
return (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<chakra.th
key={header.id}
px="4"
py="2"
pos="relative"
border="1px"
borderColor="gray.200"
fontWeight="normal"
whiteSpace="nowrap"
wordBreak="normal"
colSpan={header.colSpan}
style={{
minWidth: header.getSize(),
maxWidth: header.getSize(),
}}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanResize() && (
<ResizeHandle
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
/>
)}
</chakra.th>
)
})}
</tr>
)
}
const ResizeHandle = (props: BoxProps) => {
return (
<Box
pos="absolute"
right="-5px"
w="10px"
h="full"
top="0"
cursor="col-resize"
zIndex={2}
userSelect="none"
data-testid="resize-handle"
{...props}
/>
)
}

View File

@ -0,0 +1,41 @@
import { chakra, 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((_, idx) => (
<tr key={idx}>
<chakra.td
px="2"
py="2"
border="1px"
borderColor="gray.200"
width="40px"
>
<Flex ml="1">
<Checkbox isDisabled />
</Flex>
</chakra.td>
{Array.from(Array(totalColumns)).map((_, idx) => {
return (
<chakra.td
key={idx}
px="4"
py="2"
border="1px"
borderColor="gray.200"
>
<Skeleton height="5px" w="full" />
</chakra.td>
)
})}
</tr>
))}
</>
)
}

View File

@ -0,0 +1,192 @@
import {
HStack,
Button,
Fade,
Tag,
Text,
useDisclosure,
StackProps,
} from '@chakra-ui/react'
import { DownloadIcon, TrashIcon } from '@/components/icons'
import { ConfirmModal } from '@/components/ConfirmModal'
import { useTypebot } from '@/features/editor'
import { unparse } from 'papaparse'
import React, { useState } from 'react'
import { useToast } from '@/hooks/useToast'
import { getAllResultsQuery } from '../../queries/getAllResultsQuery'
import { convertResultsToTableData } from '../../utils'
import { deleteResultsQuery } from '../../queries/deleteResultsQuery'
import { useResults } from '../../ResultsProvider'
type ResultsActionButtonsProps = {
selectedResultsId: string[]
onClearSelection: () => void
}
export const ResultsActionButtons = ({
selectedResultsId,
onClearSelection,
...props
}: ResultsActionButtonsProps & StackProps) => {
const { typebot } = useTypebot()
const { showToast } = useToast()
const {
resultsList: data,
flatResults: results,
resultHeader,
mutate,
totalResults,
tableData,
onDeleteResults,
} = useResults()
const { isOpen, onOpen, onClose } = useDisclosure()
const [isDeleteLoading, setIsDeleteLoading] = useState(false)
const [isExportLoading, setIsExportLoading] = useState(false)
const workspaceId = typebot?.workspaceId
const typebotId = typebot?.id
const getAllTableData = async () => {
if (!workspaceId || !typebotId) return []
const results = await getAllResultsQuery(workspaceId, typebotId)
return convertResultsToTableData(results, resultHeader)
}
const totalSelected =
selectedResultsId.length > 0 && selectedResultsId.length === results?.length
? totalResults
: selectedResultsId.length
const deleteResults = async () => {
if (!workspaceId || !typebotId) return
setIsDeleteLoading(true)
const { error } = await deleteResultsQuery(
workspaceId,
typebotId,
totalSelected === totalResults ? [] : selectedResultsId
)
if (error) showToast({ description: error.message, title: error.name })
else {
mutate(
totalSelected === totalResults
? []
: data?.map((d) => ({
results: d.results.filter(
(r) => !selectedResultsId.includes(r.id)
),
}))
)
}
onDeleteResults(selectedResultsId.length)
onClearSelection()
setIsDeleteLoading(false)
}
const exportResultsToCSV = async () => {
setIsExportLoading(true)
const isSelectAll = totalSelected === 0 || totalSelected === totalResults
const dataToUnparse = isSelectAll
? await getAllTableData()
: tableData.filter((data) =>
selectedResultsId.includes(data.id.plainText)
)
const fields = typebot?.resultsTablePreferences?.columnsOrder
? typebot.resultsTablePreferences.columnsOrder.reduce<string[]>(
(currentHeaderLabels, columnId) => {
if (
typebot.resultsTablePreferences?.columnsVisibility[columnId] ===
false
)
return currentHeaderLabels
const columnLabel = resultHeader.find(
(headerCell) => headerCell.id === columnId
)?.label
if (!columnLabel) return currentHeaderLabels
return [...currentHeaderLabels, columnLabel]
},
[]
)
: resultHeader.map((headerCell) => headerCell.label)
const data = dataToUnparse.map<{ [key: string]: string }>((data) => {
const newObject: { [key: string]: string } = {}
fields?.forEach((field) => {
newObject[field] = data[field]?.plainText
})
return newObject
})
const csvData = new Blob(
[
unparse({
data,
fields,
}),
],
{
type: 'text/csv;charset=utf-8;',
}
)
const fileName =
`typebot-export_${new Date().toLocaleDateString().replaceAll('/', '-')}` +
(isSelectAll ? `_all` : ``)
const tempLink = document.createElement('a')
tempLink.href = window.URL.createObjectURL(csvData)
tempLink.setAttribute('download', `${fileName}.csv`)
tempLink.click()
setIsExportLoading(false)
}
return (
<HStack {...props}>
<HStack
as={Button}
onClick={exportResultsToCSV}
isLoading={isExportLoading}
>
<DownloadIcon />
<Text>Export {totalSelected > 0 ? '' : 'all'}</Text>
{totalSelected && (
<Tag variant="solid" size="sm">
{totalSelected}
</Tag>
)}
</HStack>
<Fade in={totalSelected > 0} unmountOnExit>
<HStack
as={Button}
colorScheme="red"
onClick={onOpen}
isLoading={isDeleteLoading}
>
<TrashIcon />
<Text>Delete</Text>
{totalSelected > 0 && (
<Tag colorScheme="red" variant="subtle" size="sm">
{totalSelected}
</Tag>
)}
</HStack>
<ConfirmModal
isOpen={isOpen}
onConfirm={deleteResults}
onClose={onClose}
message={
<Text>
You are about to delete{' '}
<strong>
{totalSelected} submission
{totalSelected > 1 ? 's' : ''}
</strong>
. Are you sure you wish to continue?
</Text>
}
confirmButtonLabel={'Delete'}
/>
</Fade>
</HStack>
)
}

View File

@ -0,0 +1,259 @@
import {
Box,
Button,
chakra,
Checkbox,
Flex,
HStack,
Stack,
Text,
} from '@chakra-ui/react'
import { AlignLeftTextIcon } from '@/components/icons'
import { ResultHeaderCell, ResultsTablePreferences } from 'models'
import React, { useEffect, useRef, useState } from 'react'
import { LoadingRows } from './LoadingRows'
import {
useReactTable,
getCoreRowModel,
ColumnOrderState,
ColumnDef,
} from '@tanstack/react-table'
import { ColumnSettingsButton } from './ColumnsSettingsButton'
import { useTypebot } from '@/features/editor'
import { useDebounce } from 'use-debounce'
import { ResultsActionButtons } from './ResultsActionButtons'
import { Row } from './Row'
import { HeaderRow } from './HeaderRow'
import { CellValueType, TableData } from '../../types'
import { HeaderIcon } from '../../utils'
type ResultsTableProps = {
resultHeader: ResultHeaderCell[]
data: TableData[]
hasMore?: boolean
preferences?: ResultsTablePreferences
onScrollToBottom: () => void
onLogOpenIndex: (index: number) => () => void
onResultExpandIndex: (index: number) => () => void
}
export const ResultsTable = ({
resultHeader,
data,
hasMore,
preferences,
onScrollToBottom,
onLogOpenIndex,
onResultExpandIndex,
}: ResultsTableProps) => {
const { updateTypebot } = useTypebot()
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
const [columnsVisibility, setColumnsVisibility] = useState<
Record<string, boolean>
>(preferences?.columnsVisibility || {})
const [columnsWidth, setColumnsWidth] = useState<Record<string, number>>(
preferences?.columnsWidth || {}
)
const [debouncedColumnsWidth] = useDebounce(columnsWidth, 500)
const [columnsOrder, setColumnsOrder] = useState<ColumnOrderState>([
'select',
...(preferences?.columnsOrder
? resultHeader
.map((h) => h.id)
.sort(
(a, b) =>
preferences?.columnsOrder.indexOf(a) -
preferences?.columnsOrder.indexOf(b)
)
: resultHeader.map((h) => h.id)),
'logs',
])
useEffect(() => {
updateTypebot({
resultsTablePreferences: {
columnsVisibility,
columnsOrder,
columnsWidth: debouncedColumnsWidth,
},
})
}, [columnsOrder, columnsVisibility, debouncedColumnsWidth, updateTypebot])
const bottomElement = useRef<HTMLDivElement | null>(null)
const tableWrapper = useRef<HTMLDivElement | null>(null)
const columns = React.useMemo<ColumnDef<TableData>[]>(
() => [
{
id: 'select',
enableResizing: false,
maxSize: 40,
header: ({ table }) => (
<IndeterminateCheckbox
{...{
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onChange: table.getToggleAllRowsSelectedHandler(),
}}
/>
),
cell: ({ row }) => (
<div className="px-1">
<IndeterminateCheckbox
{...{
checked: row.getIsSelected(),
indeterminate: row.getIsSomeSelected(),
onChange: row.getToggleSelectedHandler(),
}}
/>
</div>
),
},
...resultHeader.map<ColumnDef<TableData>>((header) => ({
id: header.id,
accessorKey: header.label,
size: 200,
header: () => (
<HStack overflow="hidden" data-testid={`${header.label} header`}>
<HeaderIcon header={header} />
<Text>{header.label}</Text>
</HStack>
),
cell: (info) => {
try {
const value = info?.getValue() as CellValueType | undefined
if (!value) return
return value.element || value.plainText || ''
} catch (err) {
console.error(err)
return
}
},
})),
{
id: 'logs',
enableResizing: false,
maxSize: 110,
header: () => (
<HStack>
<AlignLeftTextIcon />
<Text>Logs</Text>
</HStack>
),
cell: ({ row }) => (
<Button size="sm" onClick={onLogOpenIndex(row.index)}>
See logs
</Button>
),
},
],
[onLogOpenIndex, resultHeader]
)
const instance = useReactTable({
data,
columns,
state: {
rowSelection,
columnVisibility: columnsVisibility,
columnOrder: columnsOrder,
columnSizing: columnsWidth,
},
getRowId: (row) => row.id.plainText,
columnResizeMode: 'onChange',
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: setColumnsVisibility,
onColumnSizingChange: setColumnsWidth,
onColumnOrderChange: setColumnsOrder,
getCoreRowModel: getCoreRowModel(),
})
useEffect(() => {
if (!bottomElement.current) return
const options: IntersectionObserverInit = {
root: tableWrapper.current,
threshold: 0,
}
const observer = new IntersectionObserver(handleObserver, options)
if (bottomElement.current) observer.observe(bottomElement.current)
return () => {
observer.disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bottomElement.current])
const handleObserver = (entities: IntersectionObserverEntry[]) => {
const target = entities[0]
if (target.isIntersecting) onScrollToBottom()
}
return (
<Stack
maxW="1600px"
px="4"
overflow="scroll"
spacing={6}
ref={tableWrapper}
>
<Flex w="full" justifyContent="flex-end">
<ResultsActionButtons
selectedResultsId={Object.keys(rowSelection)}
onClearSelection={() => setRowSelection({})}
mr="2"
/>
<ColumnSettingsButton
resultHeader={resultHeader}
columnVisibility={columnsVisibility}
setColumnVisibility={setColumnsVisibility}
columnOrder={columnsOrder}
onColumnOrderChange={instance.setColumnOrder}
/>
</Flex>
<Box className="table-wrapper" overflow="scroll" rounded="md">
<chakra.table rounded="md">
<thead>
{instance.getHeaderGroups().map((headerGroup) => (
<HeaderRow key={headerGroup.id} headerGroup={headerGroup} />
))}
</thead>
<tbody>
{instance.getRowModel().rows.map((row, rowIndex) => (
<Row
row={row}
key={row.id}
bottomElement={
rowIndex === data.length - 10 ? bottomElement : undefined
}
isSelected={row.getIsSelected()}
onExpandButtonClick={onResultExpandIndex(rowIndex)}
/>
))}
{hasMore === true && (
<LoadingRows totalColumns={columns.length - 1} />
)}
</tbody>
</chakra.table>
</Box>
</Stack>
)
}
const IndeterminateCheckbox = React.forwardRef(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ indeterminate, checked, ...rest }: any, ref) => {
const defaultRef = React.useRef()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedRef: any = ref || defaultRef
return (
<Flex justify="center" data-testid="checkbox" {...rest}>
<Checkbox
ref={resolvedRef}
isIndeterminate={indeterminate}
isChecked={checked}
/>
</Flex>
)
}
)

View File

@ -0,0 +1,42 @@
import React, { useState } from 'react'
import { Row as RowProps } from '@tanstack/react-table'
import Cell from './Cell'
import { TableData } from '../../types'
type Props = {
row: RowProps<TableData>
isSelected: boolean
bottomElement?: React.MutableRefObject<HTMLDivElement | null>
onExpandButtonClick: () => void
}
export const Row = ({ row, bottomElement, onExpandButtonClick }: Props) => {
const [isExpandButtonVisible, setIsExpandButtonVisible] = useState(false)
const showExpandButton = () => setIsExpandButtonVisible(true)
const hideExpandButton = () => setIsExpandButtonVisible(false)
return (
<tr
key={row.id}
data-rowid={row.id}
ref={(ref) => {
if (bottomElement && bottomElement.current?.dataset.rowid !== row.id)
bottomElement.current = ref
}}
onMouseEnter={showExpandButton}
onClick={showExpandButton}
onMouseLeave={hideExpandButton}
>
{row.getVisibleCells().map((cell, cellIndex) => (
<Cell
key={cell.id}
cell={cell}
size={cell.column.getSize()}
isExpandButtonVisible={isExpandButtonVisible}
cellIndex={cellIndex}
onExpandButtonClick={onExpandButtonClick}
/>
))}
</tr>
)
}

View File

@ -0,0 +1 @@
export { ResultsTable as SubmissionsTable } from './ResultsTable'

View File

@ -0,0 +1,71 @@
import { Stack } from '@chakra-ui/react'
import React, { useState } from 'react'
import { LogsModal } from './LogsModal'
import { useTypebot } from '@/features/editor'
import { useResults } from '../ResultsProvider'
import { ResultModal } from './ResultModal'
import { SubmissionsTable } from './ResultsTable'
export const ResultsTableContainer = () => {
const {
flatResults: results,
fetchMore,
hasMore,
resultHeader,
tableData,
} = useResults()
const { typebot, publishedTypebot } = useTypebot()
const [inspectingLogsResultId, setInspectingLogsResultId] = useState<
string | null
>(null)
const [expandedResultIndex, setExpandedResultIndex] = useState<number | null>(
null
)
const handleLogsModalClose = () => setInspectingLogsResultId(null)
const handleResultModalClose = () => setExpandedResultIndex(null)
const handleLogOpenIndex = (index: number) => () => {
if (!results[index]) return
setInspectingLogsResultId(results[index].id)
}
const handleResultExpandIndex = (index: number) => () =>
setExpandedResultIndex(index)
return (
<Stack
pb="28"
px={['4', '0']}
spacing="4"
maxW="1600px"
overflow="scroll"
w="full"
>
{publishedTypebot && (
<LogsModal
typebotId={publishedTypebot?.typebotId}
resultId={inspectingLogsResultId}
onClose={handleLogsModalClose}
/>
)}
<ResultModal
resultIdx={expandedResultIndex}
onClose={handleResultModalClose}
/>
{typebot && (
<SubmissionsTable
preferences={typebot.resultsTablePreferences}
resultHeader={resultHeader}
data={tableData}
onScrollToBottom={fetchMore}
hasMore={hasMore}
onLogOpenIndex={handleLogOpenIndex}
onResultExpandIndex={handleResultExpandIndex}
/>
)}
</Stack>
)
}

View File

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

View File

@ -0,0 +1,64 @@
import { fetcher } from '@/utils/helpers'
import { ResultWithAnswers } from 'models'
import { env } from 'utils'
import useSWRInfinite from 'swr/infinite'
const paginationLimit = 50
export const useResultsQuery = ({
workspaceId,
typebotId,
onError,
}: {
workspaceId: string
typebotId: string
onError?: (error: Error) => void
}) => {
const { data, error, mutate, setSize, size, isValidating } = useSWRInfinite<
{ results: ResultWithAnswers[] },
Error
>(
(
pageIndex: number,
previousPageData: {
results: ResultWithAnswers[]
}
) => getKey(workspaceId, typebotId, pageIndex, previousPageData),
fetcher,
{
revalidateAll: true,
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
if (error && onError) onError(error)
return {
data,
isLoading: !error && !data,
mutate,
setSize,
size,
hasMore:
isValidating ||
(data &&
data.length > 0 &&
data[data.length - 1].results.length > 0 &&
data.length === paginationLimit),
}
}
const getKey = (
workspaceId: string,
typebotId: string,
pageIndex: number,
previousPageData: {
results: ResultWithAnswers[]
}
) => {
if (previousPageData && previousPageData.results.length === 0) return null
if (pageIndex === 0)
return `/api/typebots/${typebotId}/results?limit=50&workspaceId=${workspaceId}`
return `/api/typebots/${typebotId}/results?lastResultId=${
previousPageData.results[previousPageData.results.length - 1].id
}&limit=${paginationLimit}&workspaceId=${workspaceId}`
}

View File

@ -0,0 +1,22 @@
import { Stats } from 'models'
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
export const useStats = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ stats: Stats }, Error>(
typebotId ? `/api/typebots/${typebotId}/results/stats` : null,
fetcher
)
if (error) onError(error)
return {
stats: data?.stats,
isLoading: !error && !data,
mutate,
}
}

View File

@ -0,0 +1 @@
export { ResultsPage } from './components/ResultsPage'

View File

@ -0,0 +1,19 @@
import { stringify } from 'qs'
import { sendRequest } from 'utils'
export const deleteResultsQuery = async (
workspaceId: string,
typebotId: string,
ids: string[]
) => {
const params = stringify({
workspaceId,
})
return sendRequest({
url: `/api/typebots/${typebotId}/results?${params}`,
method: 'DELETE',
body: {
ids,
},
})
}

View File

@ -0,0 +1,29 @@
import { ResultWithAnswers } from 'models'
import { stringify } from 'qs'
import { sendRequest } from 'utils'
export const getAllResultsQuery = async (
workspaceId: string,
typebotId: string
) => {
const results = []
let hasMore = true
let lastResultId: string | undefined = undefined
do {
const query = stringify({ limit: 200, lastResultId, workspaceId })
const { data, error } = await sendRequest<{ results: ResultWithAnswers[] }>(
{
url: `/api/typebots/${typebotId}/results?${query}`,
method: 'GET',
}
)
if (error) {
console.error(error)
break
}
results.push(...(data?.results ?? []))
lastResultId = results[results.length - 1]?.id as string | undefined
if (data?.results.length === 0) hasMore = false
} while (hasMore)
return results
}

View File

@ -0,0 +1,174 @@
import { getTestAsset } from '@/test/utils/playwright'
import { deleteButtonInConfirmDialog } from '@/test/utils/selectorUtils'
import test, { expect, Page } from '@playwright/test'
import cuid from 'cuid'
import { readFileSync } from 'fs'
import { parse } from 'papaparse'
import {
importTypebotInDatabase,
injectFakeResults,
} from 'utils/playwright/databaseActions'
const typebotId = cuid()
test.beforeEach(async () => {
await importTypebotInDatabase(
getTestAsset('typebots/results/submissionHeader.json'),
{
id: typebotId,
}
)
await injectFakeResults({ typebotId, count: 200, isChronological: true })
})
test('table features should work', async ({ page }) => {
await page.goto(`/typebots/${typebotId}/results`)
await test.step('Check header format', async () => {
await expect(page.locator('text=Submitted at')).toBeVisible()
await expect(page.locator('text=Welcome')).toBeVisible()
await expect(page.locator('text=Email')).toBeVisible()
await expect(page.locator('text=Name')).toBeVisible()
await expect(page.locator('text=Services')).toBeVisible()
await expect(page.locator('text=Additional information')).toBeVisible()
await expect(page.locator('text=utm_source')).toBeVisible()
await expect(page.locator('text=utm_userid')).toBeVisible()
})
await test.step('Resize columns', async () => {
expect((await page.locator('th >> nth=4').boundingBox())?.width).toBe(200)
await page.waitForTimeout(500)
await page.dragAndDrop(
'[data-testid="resize-handle"] >> nth=3',
'[data-testid="resize-handle"] >> nth=3',
{ targetPosition: { x: 150, y: 0 }, force: true }
)
await page.waitForTimeout(500)
expect((await page.locator('th >> nth=4').boundingBox())?.width).toBe(345)
})
await test.step('Hide columns', async () => {
await expect(
page.locator('[data-testid="Submitted at header"]')
).toBeVisible()
await expect(page.locator('[data-testid="Email header"]')).toBeVisible()
await page.click('button >> text="Columns"')
await page.click('[aria-label="Hide column"] >> nth=0')
await page.click('[aria-label="Hide column"] >> nth=1')
await expect(
page.locator('[data-testid="Submitted at header"]')
).toBeHidden()
await expect(page.locator('[data-testid="Email header"]')).toBeHidden()
})
await test.step('Reorder columns', async () => {
await expect(page.locator('th >> nth=1')).toHaveText('Welcome')
await expect(page.locator('th >> nth=2')).toHaveText('Name')
await page.dragAndDrop(
'[aria-label="Drag"] >> nth=0',
'[aria-label="Drag"] >> nth=0',
{ targetPosition: { x: 0, y: 80 }, force: true }
)
await expect(page.locator('th >> nth=1')).toHaveText('Name')
await expect(page.locator('th >> nth=2')).toHaveText('Welcome')
})
await test.step('Preferences should be persisted', async () => {
await saveAndReload(page)
expect((await page.locator('th >> nth=1').boundingBox())?.width).toBe(345)
await expect(
page.locator('[data-testid="Submitted at header"]')
).toBeHidden()
await expect(page.locator('[data-testid="Email header"]')).toBeHidden()
await expect(page.locator('th >> nth=1')).toHaveText('Name')
await expect(page.locator('th >> nth=2')).toHaveText('Welcome')
})
await test.step('Infinite scroll', async () => {
await expect(page.locator('text=content199')).toBeVisible()
await expect(page.locator('text=content149')).toBeHidden()
await scrollToBottom(page)
await expect(page.locator('text=content149')).toBeVisible()
await expect(page.locator('text=content99')).toBeHidden()
await scrollToBottom(page)
await expect(page.locator('text=content99')).toBeVisible()
await expect(page.locator('text=content49')).toBeHidden()
await scrollToBottom(page)
await expect(page.locator('text=content49')).toBeVisible()
await expect(page.locator('text=content0')).toBeVisible()
})
await test.step('Export', async () => {
// For some reason, we need to double click on checkboxes to check them
await getNthCheckbox(page, 1).dblclick()
await getNthCheckbox(page, 2).dblclick()
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Export 2' }).click(),
])
const path = await download.path()
expect(path).toBeDefined()
const file = readFileSync(path as string).toString()
const { data } = parse(file)
validateExportSelection(data)
await getNthCheckbox(page, 0).click()
const [downloadAll] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Export 200' }).click(),
])
const pathAll = await downloadAll.path()
expect(pathAll).toBeDefined()
const fileAll = readFileSync(pathAll as string).toString()
const { data: dataAll } = parse(fileAll)
validateExportAll(dataAll)
await getNthCheckbox(page, 0).click()
})
await test.step('Delete', async () => {
await getNthCheckbox(page, 1).click()
await getNthCheckbox(page, 2).click()
await page.click('text="Delete"')
await deleteButtonInConfirmDialog(page).click()
await expect(page.locator('text=content199')).toBeHidden()
await expect(page.locator('text=content198')).toBeHidden()
await page.waitForTimeout(1000)
await page.click('[data-testid="checkbox"] >> nth=0')
await page.click('text="Delete"')
await deleteButtonInConfirmDialog(page).click()
await page.waitForTimeout(1000)
expect(await page.locator('tr').count()).toBe(1)
await expect(page.locator('text="Delete"')).toBeHidden()
})
})
const validateExportSelection = (data: unknown[]) => {
expect(data).toHaveLength(3)
expect((data[1] as unknown[])[0]).toBe('content199')
expect((data[2] as unknown[])[0]).toBe('content198')
}
const validateExportAll = (data: unknown[]) => {
expect(data).toHaveLength(201)
expect((data[1] as unknown[])[0]).toBe('content199')
expect((data[200] as unknown[])[0]).toBe('content0')
}
const scrollToBottom = (page: Page) =>
page.evaluate(() => {
const tableWrapper = document.querySelector('.table-wrapper')
if (!tableWrapper) return
tableWrapper.scrollTo(0, tableWrapper.scrollHeight)
})
const saveAndReload = async (page: Page) => {
await page.click('text="Theme"')
await page.waitForTimeout(2000)
await page.goto(`/typebots/${typebotId}/results`)
}
const getNthCheckbox = (page: Page, n: number) =>
page.getByTestId('checkbox').nth(n)

View File

@ -0,0 +1,10 @@
export type HeaderCell = {
Header: JSX.Element
accessor: string
}
export type CellValueType = { element?: JSX.Element; plainText: string }
export type TableData = {
id: Pick<CellValueType, 'plainText'>
} & Record<string, CellValueType>

View File

@ -0,0 +1,112 @@
import {
ResultWithAnswers,
VariableWithValue,
ResultHeaderCell,
InputBlockType,
} from 'models'
import { Answer } from 'db'
import { isDefined } from 'utils'
import { HStack, Wrap, WrapItem, Text } from '@chakra-ui/react'
import { BlockIcon } from '@/features/editor'
import { HeaderCell, TableData } from './types'
import { CodeIcon, CalendarIcon, FileIcon } from '@/components/icons'
import { TextLink } from '@/components/TextLink'
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',
})
)
}
export const parseSubmissionsColumns = (
resultHeader: ResultHeaderCell[]
): HeaderCell[] =>
resultHeader.map((header) => ({
Header: (
<HStack minW="150px" maxW="500px">
<HeaderIcon header={header} />
<Text>{header.label}</Text>
</HStack>
),
accessor: header.label,
}))
export const HeaderIcon = ({ header }: { header: ResultHeaderCell }) =>
header.blockType ? (
<BlockIcon type={header.blockType} />
) : header.variableIds ? (
<CodeIcon />
) : (
<CalendarIcon />
)
export const convertResultsToTableData = (
results: ResultWithAnswers[] | undefined,
headerCells: ResultHeaderCell[]
): TableData[] =>
(results ?? []).map((result) => ({
id: { plainText: result.id },
'Submitted at': {
plainText: parseDateToReadable(result.createdAt),
},
...[...result.answers, ...result.variables].reduce<{
[key: string]: { element?: JSX.Element; plainText: string }
}>((o, answerOrVariable) => {
if ('groupId' in answerOrVariable) {
const answer = answerOrVariable as Answer
const header = answer.variableId
? headerCells.find((headerCell) =>
headerCell.variableIds?.includes(answer.variableId as string)
)
: headerCells.find((headerCell) =>
headerCell.blocks?.some((block) => block.id === answer.blockId)
)
if (!header || !header.blocks || !header.blockType) return o
return {
...o,
[header.label]: {
element: parseContent(answer.content, header.blockType),
plainText: answer.content,
},
}
}
const variable = answerOrVariable as VariableWithValue
const key = headerCells.find((headerCell) =>
headerCell.variableIds?.includes(variable.id)
)?.label
if (!key) return o
if (isDefined(o[key])) return o
return {
...o,
[key]: { plainText: variable.value?.toString() },
}
}, {}),
}))
const parseContent = (
str: string,
blockType: InputBlockType
): JSX.Element | undefined =>
blockType === InputBlockType.FILE ? parseFileContent(str) : undefined
const parseFileContent = (str: string) => {
const fileNames = str.split(', ')
return (
<Wrap maxW="300px">
{fileNames.map((name) => (
<HStack as={WrapItem} key={name}>
<FileIcon />
<TextLink href={name} isExternal>
{name.split('/').pop()}
</TextLink>
</HStack>
))}
</Wrap>
)
}