♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
87
apps/builder/src/features/results/ResultsProvider.tsx
Normal file
87
apps/builder/src/features/results/ResultsProvider.tsx
Normal 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)
|
55
apps/builder/src/features/results/api/archiveResults.ts
Normal file
55
apps/builder/src/features/results/api/archiveResults.ts
Normal 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: [],
|
||||
},
|
||||
})
|
||||
}
|
1
apps/builder/src/features/results/api/index.ts
Normal file
1
apps/builder/src/features/results/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './archiveResults'
|
102
apps/builder/src/features/results/components/LogsModal.tsx
Normal file
102
apps/builder/src/features/results/components/LogsModal.tsx
Normal 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>
|
||||
}
|
||||
}
|
55
apps/builder/src/features/results/components/ResultModal.tsx
Normal file
55
apps/builder/src/features/results/components/ResultModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
175
apps/builder/src/features/results/components/ResultsPage.tsx
Normal file
175
apps/builder/src/features/results/components/ResultsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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
|
||||
)
|
@ -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>
|
||||
}
|
||||
)
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ResultsTable as SubmissionsTable } from './ResultsTable'
|
@ -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>
|
||||
)
|
||||
}
|
19
apps/builder/src/features/results/hooks/useLogs.ts
Normal file
19
apps/builder/src/features/results/hooks/useLogs.ts
Normal 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,
|
||||
}
|
||||
}
|
64
apps/builder/src/features/results/hooks/useResultsQuery.ts
Normal file
64
apps/builder/src/features/results/hooks/useResultsQuery.ts
Normal 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}`
|
||||
}
|
22
apps/builder/src/features/results/hooks/useStats.ts
Normal file
22
apps/builder/src/features/results/hooks/useStats.ts
Normal 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,
|
||||
}
|
||||
}
|
1
apps/builder/src/features/results/index.ts
Normal file
1
apps/builder/src/features/results/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ResultsPage } from './components/ResultsPage'
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
@ -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
|
||||
}
|
174
apps/builder/src/features/results/results.spec.ts
Normal file
174
apps/builder/src/features/results/results.spec.ts
Normal 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)
|
10
apps/builder/src/features/results/types.ts
Normal file
10
apps/builder/src/features/results/types.ts
Normal 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>
|
112
apps/builder/src/features/results/utils.tsx
Normal file
112
apps/builder/src/features/results/utils.tsx
Normal 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>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user