feat(results): ✨ Brand new Results table
- Resizable columns - Hide / Show columns - Reorganize columns - Expand result
This commit is contained in:
@@ -474,3 +474,10 @@ export const BuoyIcon = (props: IconProps) => (
|
|||||||
<line x1="4.93" y1="19.07" x2="9.17" y2="14.83"></line>
|
<line x1="4.93" y1="19.07" x2="9.17" y2="14.83"></line>
|
||||||
</Icon>
|
</Icon>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const EyeOffIcon = (props: IconProps) => (
|
||||||
|
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||||
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import { isDefined } from 'utils'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
typebotId: string
|
typebotId: string
|
||||||
resultId?: string
|
resultId: string | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
export const LogsModal = ({ typebotId, resultId, onClose }: Props) => {
|
export const LogsModal = ({ typebotId, resultId, onClose }: Props) => {
|
||||||
const { isLoading, logs } = useLogs(typebotId, resultId)
|
const { isLoading, logs } = useLogs(typebotId, resultId ?? undefined)
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isDefined(resultId)} onClose={onClose} size="xl">
|
<Modal isOpen={isDefined(resultId)} onClose={onClose} size="xl">
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
55
apps/builder/components/results/ResultModal.tsx
Normal file
55
apps/builder/components/results/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 'contexts/ResultsProvider'
|
||||||
|
import React from 'react'
|
||||||
|
import { isDefined } from 'utils'
|
||||||
|
import { HeaderIcon } from './ResultsTable/ResultsTable'
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import {
|
|
||||||
HStack,
|
|
||||||
Button,
|
|
||||||
Fade,
|
|
||||||
Tag,
|
|
||||||
Text,
|
|
||||||
useDisclosure,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { DownloadIcon, TrashIcon } from 'assets/icons'
|
|
||||||
import { ConfirmModal } from 'components/modals/ConfirmModal'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
type ResultsActionButtonsProps = {
|
|
||||||
totalSelected: number
|
|
||||||
isDeleteLoading: boolean
|
|
||||||
isExportLoading: boolean
|
|
||||||
onDeleteClick: () => Promise<void>
|
|
||||||
onExportClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResultsActionButtons = ({
|
|
||||||
totalSelected,
|
|
||||||
isDeleteLoading,
|
|
||||||
isExportLoading,
|
|
||||||
onDeleteClick,
|
|
||||||
onExportClick,
|
|
||||||
}: ResultsActionButtonsProps) => {
|
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
|
||||||
return (
|
|
||||||
<HStack>
|
|
||||||
<Fade in={totalSelected > 0} unmountOnExit>
|
|
||||||
<HStack
|
|
||||||
as={Button}
|
|
||||||
colorScheme="blue"
|
|
||||||
onClick={onExportClick}
|
|
||||||
isLoading={isExportLoading}
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
<Text>Export</Text>
|
|
||||||
|
|
||||||
<Tag colorScheme="blue" variant="subtle" size="sm">
|
|
||||||
{totalSelected}
|
|
||||||
</Tag>
|
|
||||||
</HStack>
|
|
||||||
</Fade>
|
|
||||||
|
|
||||||
<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={onDeleteClick}
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
81
apps/builder/components/results/ResultsContent.tsx
Normal file
81
apps/builder/components/results/ResultsContent.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Stack } from '@chakra-ui/react'
|
||||||
|
import { SubmissionsTable } from 'components/results/ResultsTable'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { UnlockPlanInfo } from 'components/shared/Info'
|
||||||
|
import { LogsModal } from './LogsModal'
|
||||||
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
|
import { Plan } from 'db'
|
||||||
|
import { useResults } from 'contexts/ResultsProvider'
|
||||||
|
import { ResultModal } from './ResultModal'
|
||||||
|
|
||||||
|
export const ResultsContent = () => {
|
||||||
|
const {
|
||||||
|
flatResults: results,
|
||||||
|
fetchMore,
|
||||||
|
hasMore,
|
||||||
|
resultHeader,
|
||||||
|
totalHiddenResults,
|
||||||
|
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) 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"
|
||||||
|
>
|
||||||
|
{totalHiddenResults && (
|
||||||
|
<UnlockPlanInfo
|
||||||
|
buttonLabel={`Unlock ${totalHiddenResults} results`}
|
||||||
|
contentLabel="You are seeing complete submissions only."
|
||||||
|
plan={Plan.PRO}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 'assets/icons'
|
||||||
|
import { ResultHeaderCell } from 'models'
|
||||||
|
import React, { forwardRef, useState } from 'react'
|
||||||
|
import { isNotDefined } from 'utils'
|
||||||
|
import { HeaderIcon } from './ResultsTable'
|
||||||
|
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'
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
)
|
||||||
60
apps/builder/components/results/ResultsTable/HeaderRow.tsx
Normal file
60
apps/builder/components/results/ResultsTable/HeaderRow.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Box, BoxProps, chakra } from '@chakra-ui/react'
|
||||||
|
import { HeaderGroup } from '@tanstack/react-table'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
headerGroup: HeaderGroup<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : header.renderHeader()}
|
||||||
|
{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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,20 +8,20 @@ type LoadingRowsProps = {
|
|||||||
export const LoadingRows = ({ totalColumns }: LoadingRowsProps) => {
|
export const LoadingRows = ({ totalColumns }: LoadingRowsProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{Array.from(Array(3)).map((row, idx) => (
|
{Array.from(Array(3)).map((_, idx) => (
|
||||||
<tr key={idx}>
|
<tr key={idx}>
|
||||||
<chakra.td
|
<chakra.td
|
||||||
px="4"
|
px="2"
|
||||||
py="2"
|
py="2"
|
||||||
border="1px"
|
border="1px"
|
||||||
borderColor="gray.200"
|
borderColor="gray.200"
|
||||||
width="50px"
|
width="40px"
|
||||||
>
|
>
|
||||||
<Flex>
|
<Flex ml="1">
|
||||||
<Checkbox isDisabled />
|
<Checkbox isDisabled />
|
||||||
</Flex>
|
</Flex>
|
||||||
</chakra.td>
|
</chakra.td>
|
||||||
{Array.from(Array(totalColumns)).map((cell, idx) => {
|
{Array.from(Array(totalColumns)).map((_, idx) => {
|
||||||
return (
|
return (
|
||||||
<chakra.td
|
<chakra.td
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
HStack,
|
||||||
|
Button,
|
||||||
|
Fade,
|
||||||
|
Tag,
|
||||||
|
Text,
|
||||||
|
useDisclosure,
|
||||||
|
StackProps,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { DownloadIcon, TrashIcon } from 'assets/icons'
|
||||||
|
import { ConfirmModal } from 'components/modals/ConfirmModal'
|
||||||
|
import { useToast } from 'components/shared/hooks/useToast'
|
||||||
|
import { useResults } from 'contexts/ResultsProvider'
|
||||||
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
|
import { unparse } from 'papaparse'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
convertResultsToTableData,
|
||||||
|
getAllResults,
|
||||||
|
deleteResults as deleteFetchResults,
|
||||||
|
} from 'services/typebots'
|
||||||
|
|
||||||
|
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,
|
||||||
|
totalHiddenResults,
|
||||||
|
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 getAllResults(workspaceId, typebotId)
|
||||||
|
return convertResultsToTableData(results, resultHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSelected =
|
||||||
|
selectedResultsId.length > 0 && selectedResultsId.length === results?.length
|
||||||
|
? totalResults - (totalHiddenResults ?? 0)
|
||||||
|
: selectedResultsId.length
|
||||||
|
|
||||||
|
const deleteResults = async () => {
|
||||||
|
if (!workspaceId || !typebotId) return
|
||||||
|
setIsDeleteLoading(true)
|
||||||
|
const { error } = await deleteFetchResults(
|
||||||
|
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 - (totalHiddenResults ?? 0)
|
||||||
|
|
||||||
|
const dataToUnparse = isSelectAll
|
||||||
|
? await getAllTableData()
|
||||||
|
: tableData.filter((data) => selectedResultsId.includes(data.id))
|
||||||
|
const csvData = new Blob(
|
||||||
|
[
|
||||||
|
unparse({
|
||||||
|
data: dataToUnparse.map<{ [key: string]: string }>((data) => {
|
||||||
|
const newObject: { [key: string]: string } = {}
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
newObject[key] = (data[key] as { plainText: string })
|
||||||
|
.plainText as string
|
||||||
|
})
|
||||||
|
return newObject
|
||||||
|
}),
|
||||||
|
fields: resultHeader.map((h) => h.label),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
277
apps/builder/components/results/ResultsTable/ResultsTable.tsx
Normal file
277
apps/builder/components/results/ResultsTable/ResultsTable.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
chakra,
|
||||||
|
Checkbox,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { AlignLeftTextIcon, CalendarIcon, CodeIcon } from 'assets/icons'
|
||||||
|
import { ResultHeaderCell, ResultsTablePreferences } from 'models'
|
||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { LoadingRows } from './LoadingRows'
|
||||||
|
import {
|
||||||
|
createTable,
|
||||||
|
useTableInstance,
|
||||||
|
getCoreRowModel,
|
||||||
|
ColumnOrderState,
|
||||||
|
} from '@tanstack/react-table'
|
||||||
|
import { BlockIcon } from 'components/editor/BlocksSideBar/BlockIcon'
|
||||||
|
import { ColumnSettingsButton } from './ColumnsSettingsButton'
|
||||||
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
|
import { useDebounce } from 'use-debounce'
|
||||||
|
import { ResultsActionButtons } from './ResultsActionButtons'
|
||||||
|
import Row from './Row'
|
||||||
|
import { HeaderRow } from './HeaderRow'
|
||||||
|
|
||||||
|
type RowType = {
|
||||||
|
id: string
|
||||||
|
[key: string]:
|
||||||
|
| {
|
||||||
|
plainText: string
|
||||||
|
element?: JSX.Element | undefined
|
||||||
|
}
|
||||||
|
| string
|
||||||
|
}
|
||||||
|
const table = createTable().setRowType<RowType>()
|
||||||
|
|
||||||
|
type ResultsTableProps = {
|
||||||
|
resultHeader: ResultHeaderCell[]
|
||||||
|
data: RowType[]
|
||||||
|
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(
|
||||||
|
() => [
|
||||||
|
table.createDisplayColumn({
|
||||||
|
id: 'select',
|
||||||
|
enableResizing: false,
|
||||||
|
maxSize: 40,
|
||||||
|
header: ({ instance }) => (
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
{...{
|
||||||
|
checked: instance.getIsAllRowsSelected(),
|
||||||
|
indeterminate: instance.getIsSomeRowsSelected(),
|
||||||
|
onChange: instance.getToggleAllRowsSelectedHandler(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="px-1">
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
{...{
|
||||||
|
checked: row.getIsSelected(),
|
||||||
|
indeterminate: row.getIsSomeSelected(),
|
||||||
|
onChange: row.getToggleSelectedHandler(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
...resultHeader.map((header) =>
|
||||||
|
table.createDataColumn(header.label, {
|
||||||
|
id: header.id,
|
||||||
|
size: header.isLong ? 400 : 200,
|
||||||
|
cell: (info) => {
|
||||||
|
const value = info.getValue()
|
||||||
|
if (!value) return
|
||||||
|
if (typeof value === 'string') return ''
|
||||||
|
return value.element || value.plainText || ''
|
||||||
|
},
|
||||||
|
header: () => (
|
||||||
|
<HStack overflow="hidden" data-testid={`${header.label} header`}>
|
||||||
|
<HeaderIcon header={header} />
|
||||||
|
<Text>{header.label}</Text>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
table.createDisplayColumn({
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[resultHeader]
|
||||||
|
)
|
||||||
|
|
||||||
|
const instance = useTableInstance(table, {
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
rowSelection,
|
||||||
|
columnVisibility: columnsVisibility,
|
||||||
|
columnOrder: columnsOrder,
|
||||||
|
columnSizing: columnsWidth,
|
||||||
|
},
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
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">
|
||||||
|
<Checkbox
|
||||||
|
ref={resolvedRef}
|
||||||
|
{...rest}
|
||||||
|
isIndeterminate={indeterminate}
|
||||||
|
isChecked={checked}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const HeaderIcon = ({ header }: { header: ResultHeaderCell }) =>
|
||||||
|
header.blockType ? (
|
||||||
|
<BlockIcon type={header.blockType} />
|
||||||
|
) : header.variableId ? (
|
||||||
|
<CodeIcon />
|
||||||
|
) : (
|
||||||
|
<CalendarIcon />
|
||||||
|
)
|
||||||
73
apps/builder/components/results/ResultsTable/Row.tsx
Normal file
73
apps/builder/components/results/ResultsTable/Row.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { memo, useState } from 'react'
|
||||||
|
import { Row as RowProps } from '@tanstack/react-table'
|
||||||
|
import { Button, chakra, Fade } from '@chakra-ui/react'
|
||||||
|
import { ExpandIcon } from 'assets/icons'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
row: RowProps<any>
|
||||||
|
isSelected: boolean
|
||||||
|
bottomElement?: React.MutableRefObject<HTMLDivElement | null>
|
||||||
|
onExpandButtonClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<chakra.td
|
||||||
|
key={cell.id}
|
||||||
|
px="4"
|
||||||
|
py="2"
|
||||||
|
border="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
wordBreak="normal"
|
||||||
|
overflow="hidden"
|
||||||
|
pos="relative"
|
||||||
|
>
|
||||||
|
{cell.renderCell()}
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(
|
||||||
|
Row,
|
||||||
|
(prev, next) =>
|
||||||
|
prev.row.id === next.row.id && prev.isSelected === next.isSelected
|
||||||
|
)
|
||||||
1
apps/builder/components/results/ResultsTable/index.tsx
Normal file
1
apps/builder/components/results/ResultsTable/index.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ResultsTable as SubmissionsTable } from './ResultsTable'
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
/* eslint-disable react/jsx-key */
|
|
||||||
import { Button, chakra, Checkbox, Flex, HStack, Text } from '@chakra-ui/react'
|
|
||||||
import { AlignLeftTextIcon } from 'assets/icons'
|
|
||||||
import { ResultHeaderCell } from 'models'
|
|
||||||
import React, { useEffect, useMemo, useRef } from 'react'
|
|
||||||
import { Hooks, useRowSelect, useTable } from 'react-table'
|
|
||||||
import { parseSubmissionsColumns } from 'services/typebots'
|
|
||||||
import { isNotDefined } from 'utils'
|
|
||||||
import { LoadingRows } from './LoadingRows'
|
|
||||||
|
|
||||||
type SubmissionsTableProps = {
|
|
||||||
resultHeader: ResultHeaderCell[]
|
|
||||||
data?: any
|
|
||||||
hasMore?: boolean
|
|
||||||
onNewSelection: (indices: number[]) => void
|
|
||||||
onScrollToBottom: () => void
|
|
||||||
onLogOpenIndex: (index: number) => () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SubmissionsTable = ({
|
|
||||||
resultHeader,
|
|
||||||
data,
|
|
||||||
hasMore,
|
|
||||||
onNewSelection,
|
|
||||||
onScrollToBottom,
|
|
||||||
onLogOpenIndex,
|
|
||||||
}: SubmissionsTableProps) => {
|
|
||||||
const columns: any = useMemo(
|
|
||||||
() => parseSubmissionsColumns(resultHeader),
|
|
||||||
[resultHeader]
|
|
||||||
)
|
|
||||||
const bottomElement = useRef<HTMLDivElement | null>(null)
|
|
||||||
const tableWrapper = useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
const {
|
|
||||||
getTableProps,
|
|
||||||
headerGroups,
|
|
||||||
rows,
|
|
||||||
prepareRow,
|
|
||||||
getTableBodyProps,
|
|
||||||
selectedFlatRows,
|
|
||||||
} = useTable({ columns, data }, useRowSelect, checkboxColumnHook) as any
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onNewSelection(selectedFlatRows.map((row: any) => row.index))
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedFlatRows])
|
|
||||||
|
|
||||||
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)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [bottomElement.current])
|
|
||||||
|
|
||||||
const handleObserver = (entities: any[]) => {
|
|
||||||
const target = entities[0]
|
|
||||||
if (target.isIntersecting) onScrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
maxW="full"
|
|
||||||
overflow="scroll"
|
|
||||||
ref={tableWrapper}
|
|
||||||
className="table-wrapper"
|
|
||||||
rounded="md"
|
|
||||||
>
|
|
||||||
<chakra.table rounded="md" {...getTableProps()}>
|
|
||||||
<thead>
|
|
||||||
{headerGroups.map((headerGroup: any) => {
|
|
||||||
return (
|
|
||||||
<tr {...headerGroup.getHeaderGroupProps()}>
|
|
||||||
{headerGroup.headers.map((column: any) => {
|
|
||||||
return (
|
|
||||||
<chakra.th
|
|
||||||
px="4"
|
|
||||||
py="2"
|
|
||||||
border="1px"
|
|
||||||
borderColor="gray.200"
|
|
||||||
fontWeight="normal"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
{...column.getHeaderProps()}
|
|
||||||
>
|
|
||||||
{column.render('Header')}
|
|
||||||
</chakra.th>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<chakra.th
|
|
||||||
px="4"
|
|
||||||
py="2"
|
|
||||||
border="1px"
|
|
||||||
borderColor="gray.200"
|
|
||||||
fontWeight="normal"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
>
|
|
||||||
<HStack>
|
|
||||||
<AlignLeftTextIcon />
|
|
||||||
<Text>Logs</Text>
|
|
||||||
</HStack>
|
|
||||||
</chakra.th>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody {...getTableBodyProps()}>
|
|
||||||
{rows.map((row: any, idx: number) => {
|
|
||||||
prepareRow(row)
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
{...row.getRowProps()}
|
|
||||||
ref={(ref) => {
|
|
||||||
if (idx === data.length - 10) bottomElement.current = ref
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.cells.map((cell: any) => {
|
|
||||||
return (
|
|
||||||
<chakra.td
|
|
||||||
px="4"
|
|
||||||
py="2"
|
|
||||||
border="1px"
|
|
||||||
borderColor="gray.200"
|
|
||||||
whiteSpace={
|
|
||||||
cell?.value?.length > 100 ? 'normal' : 'nowrap'
|
|
||||||
}
|
|
||||||
{...cell.getCellProps()}
|
|
||||||
>
|
|
||||||
{isNotDefined(cell.value)
|
|
||||||
? cell.render('Cell')
|
|
||||||
: cell.value.element ?? cell.value.plainText}
|
|
||||||
</chakra.td>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<chakra.td px="4" py="2" border="1px" borderColor="gray.200">
|
|
||||||
<Button size="sm" onClick={onLogOpenIndex(idx)}>
|
|
||||||
See logs
|
|
||||||
</Button>
|
|
||||||
</chakra.td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{hasMore === true && (
|
|
||||||
<LoadingRows totalColumns={columns.length + 1} />
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</chakra.table>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkboxColumnHook = (hooks: Hooks<any>) => {
|
|
||||||
hooks.visibleColumns.push((columns) => [
|
|
||||||
{
|
|
||||||
id: 'selection',
|
|
||||||
Header: ({ getToggleAllRowsSelectedProps }: any) => (
|
|
||||||
<IndeterminateCheckbox {...getToggleAllRowsSelectedProps()} />
|
|
||||||
),
|
|
||||||
Cell: ({ row }: any) => (
|
|
||||||
<IndeterminateCheckbox {...row.getToggleRowSelectedProps()} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...columns,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const IndeterminateCheckbox = React.forwardRef(
|
|
||||||
({ indeterminate, checked, ...rest }: any, ref) => {
|
|
||||||
const defaultRef = React.useRef()
|
|
||||||
const resolvedRef: any = ref || defaultRef
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex justify="center" data-testid="checkbox">
|
|
||||||
<Checkbox
|
|
||||||
ref={resolvedRef}
|
|
||||||
{...rest}
|
|
||||||
isIndeterminate={indeterminate}
|
|
||||||
isChecked={checked}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { SubmissionsTable } from './SubmissionsTable'
|
|
||||||
100
apps/builder/contexts/ResultsProvider.tsx
Normal file
100
apps/builder/contexts/ResultsProvider.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { ResultHeaderCell, ResultWithAnswers } from 'models'
|
||||||
|
import { createContext, ReactNode, useContext, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
convertResultsToTableData,
|
||||||
|
useResults as useFetchResults,
|
||||||
|
} from 'services/typebots'
|
||||||
|
import { KeyedMutator } from 'swr'
|
||||||
|
import { isDefined, parseResultHeader } from 'utils'
|
||||||
|
import { useTypebot } from './TypebotContext'
|
||||||
|
|
||||||
|
const resultsContext = createContext<{
|
||||||
|
resultsList: { results: ResultWithAnswers[] }[] | undefined
|
||||||
|
flatResults: ResultWithAnswers[]
|
||||||
|
hasMore: boolean
|
||||||
|
resultHeader: ResultHeaderCell[]
|
||||||
|
totalResults: number
|
||||||
|
totalHiddenResults?: number
|
||||||
|
tableData: {
|
||||||
|
id: string
|
||||||
|
[key: string]: { plainText: string; element?: JSX.Element } | string
|
||||||
|
}[]
|
||||||
|
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,
|
||||||
|
totalHiddenResults,
|
||||||
|
onDeleteResults,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
workspaceId: string
|
||||||
|
typebotId: string
|
||||||
|
totalResults: number
|
||||||
|
totalHiddenResults?: number
|
||||||
|
onDeleteResults: (totalResultsDeleted: number) => void
|
||||||
|
}) => {
|
||||||
|
const { publishedTypebot, linkedTypebots } = useTypebot()
|
||||||
|
const { data, mutate, setSize, hasMore } = useFetchResults({
|
||||||
|
workspaceId,
|
||||||
|
typebotId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchMore = () => setSize((state) => state + 1)
|
||||||
|
|
||||||
|
const groupsAndVariables = {
|
||||||
|
groups: [
|
||||||
|
...(publishedTypebot?.groups ?? []),
|
||||||
|
...(linkedTypebots?.flatMap((t) => t.groups) ?? []),
|
||||||
|
].filter(isDefined),
|
||||||
|
variables: [
|
||||||
|
...(publishedTypebot?.variables ?? []),
|
||||||
|
...(linkedTypebots?.flatMap((t) => t.variables) ?? []),
|
||||||
|
].filter(isDefined),
|
||||||
|
}
|
||||||
|
const resultHeader = parseResultHeader(groupsAndVariables)
|
||||||
|
|
||||||
|
const tableData = useMemo(
|
||||||
|
() =>
|
||||||
|
publishedTypebot
|
||||||
|
? convertResultsToTableData(
|
||||||
|
data?.flatMap((d) => d.results) ?? [],
|
||||||
|
resultHeader
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[publishedTypebot?.id, resultHeader.length, data]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<resultsContext.Provider
|
||||||
|
value={{
|
||||||
|
resultsList: data,
|
||||||
|
flatResults: data?.flatMap((d) => d.results) ?? [],
|
||||||
|
hasMore: hasMore ?? true,
|
||||||
|
tableData,
|
||||||
|
resultHeader,
|
||||||
|
totalResults,
|
||||||
|
totalHiddenResults,
|
||||||
|
onDeleteResults,
|
||||||
|
fetchMore,
|
||||||
|
mutate,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</resultsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useResults = () => useContext(resultsContext)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
LogicBlockType,
|
LogicBlockType,
|
||||||
PublicTypebot,
|
PublicTypebot,
|
||||||
|
ResultsTablePreferences,
|
||||||
Settings,
|
Settings,
|
||||||
Theme,
|
Theme,
|
||||||
Typebot,
|
Typebot,
|
||||||
@@ -53,6 +54,7 @@ type UpdateTypebotPayload = Partial<{
|
|||||||
publishedTypebotId: string
|
publishedTypebotId: string
|
||||||
icon: string
|
icon: string
|
||||||
customDomain: string
|
customDomain: string
|
||||||
|
resultsTablePreferences: ResultsTablePreferences
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export type SetTypebot = (
|
export type SetTypebot = (
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import { Button, Flex, HStack, Tag, Text } from '@chakra-ui/react'
|
|
||||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
|
||||||
import { useToast } from 'components/shared/hooks/useToast'
|
|
||||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
|
||||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
import { useStats } from 'services/analytics'
|
|
||||||
import { isFreePlan } from 'services/workspace'
|
|
||||||
import { AnalyticsContent } from './AnalyticsContent'
|
|
||||||
import { SubmissionsContent } from './SubmissionContent'
|
|
||||||
|
|
||||||
export const ResultsContent = () => {
|
|
||||||
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 handleDeletedResults = (total: number) => {
|
|
||||||
if (!stats) return
|
|
||||||
mutate({
|
|
||||||
stats: { ...stats, totalStarts: stats.totalStarts - total },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex h="full" w="full">
|
|
||||||
<Flex
|
|
||||||
pos="absolute"
|
|
||||||
zIndex={2}
|
|
||||||
bgColor="white"
|
|
||||||
w="full"
|
|
||||||
justifyContent="center"
|
|
||||||
h="60px"
|
|
||||||
display={['none', 'flex']}
|
|
||||||
>
|
|
||||||
<HStack maxW="1200px" w="full">
|
|
||||||
<Button
|
|
||||||
as={NextChakraLink}
|
|
||||||
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={NextChakraLink}
|
|
||||||
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 ? (
|
|
||||||
<AnalyticsContent stats={stats} />
|
|
||||||
) : (
|
|
||||||
<SubmissionsContent
|
|
||||||
workspaceId={workspace.id}
|
|
||||||
typebotId={publishedTypebot.typebotId}
|
|
||||||
onDeleteResults={handleDeletedResults}
|
|
||||||
totalResults={stats?.totalStarts ?? 0}
|
|
||||||
totalHiddenResults={
|
|
||||||
isFreePlan(workspace)
|
|
||||||
? (stats?.totalStarts ?? 0) - (stats?.totalCompleted ?? 0)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { Stack, Flex } from '@chakra-ui/react'
|
|
||||||
import { ResultsActionButtons } from 'components/results/ResultsActionButtons'
|
|
||||||
import { SubmissionsTable } from 'components/results/SubmissionsTable'
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react'
|
|
||||||
import {
|
|
||||||
convertResultsToTableData,
|
|
||||||
deleteResults,
|
|
||||||
getAllResults,
|
|
||||||
useResults,
|
|
||||||
} from 'services/typebots'
|
|
||||||
import { unparse } from 'papaparse'
|
|
||||||
import { UnlockPlanInfo } from 'components/shared/Info'
|
|
||||||
import { LogsModal } from './LogsModal'
|
|
||||||
import { useTypebot } from 'contexts/TypebotContext'
|
|
||||||
import { isDefined, parseResultHeader } from 'utils'
|
|
||||||
import { Plan } from 'db'
|
|
||||||
import { useToast } from 'components/shared/hooks/useToast'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
workspaceId: string
|
|
||||||
typebotId: string
|
|
||||||
totalResults: number
|
|
||||||
totalHiddenResults?: number
|
|
||||||
onDeleteResults: (total: number) => void
|
|
||||||
}
|
|
||||||
export const SubmissionsContent = ({
|
|
||||||
workspaceId,
|
|
||||||
typebotId,
|
|
||||||
totalResults,
|
|
||||||
totalHiddenResults,
|
|
||||||
onDeleteResults,
|
|
||||||
}: Props) => {
|
|
||||||
const { publishedTypebot, linkedTypebots } = useTypebot()
|
|
||||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
|
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false)
|
|
||||||
const [isExportLoading, setIsExportLoading] = useState(false)
|
|
||||||
const [inspectingLogsResultId, setInspectingLogsResultId] = useState<string>()
|
|
||||||
|
|
||||||
const { showToast } = useToast()
|
|
||||||
|
|
||||||
const groupsAndVariables = {
|
|
||||||
groups: [
|
|
||||||
...(publishedTypebot?.groups ?? []),
|
|
||||||
...(linkedTypebots?.flatMap((t) => t.groups) ?? []),
|
|
||||||
].filter(isDefined),
|
|
||||||
variables: [
|
|
||||||
...(publishedTypebot?.variables ?? []),
|
|
||||||
...(linkedTypebots?.flatMap((t) => t.variables) ?? []),
|
|
||||||
].filter(isDefined),
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultHeader = parseResultHeader(groupsAndVariables)
|
|
||||||
|
|
||||||
const { data, mutate, setSize, hasMore } = useResults({
|
|
||||||
workspaceId,
|
|
||||||
typebotId,
|
|
||||||
onError: (err) => showToast({ title: err.name, description: err.message }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const results = useMemo(() => data?.flatMap((d) => d.results), [data])
|
|
||||||
|
|
||||||
const handleNewSelection = (newSelectionIndices: number[]) => {
|
|
||||||
if (newSelectionIndices.length === selectedIndices.length) return
|
|
||||||
setSelectedIndices(newSelectionIndices)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteSelection = async () => {
|
|
||||||
setIsDeleteLoading(true)
|
|
||||||
const selectedIds = (results ?? [])
|
|
||||||
.filter((_, idx) => selectedIndices.includes(idx))
|
|
||||||
.map((result) => result.id)
|
|
||||||
const { error } = await deleteResults(
|
|
||||||
workspaceId,
|
|
||||||
typebotId,
|
|
||||||
totalSelected === totalResults ? [] : selectedIds
|
|
||||||
)
|
|
||||||
if (error) showToast({ description: error.message, title: error.name })
|
|
||||||
else {
|
|
||||||
mutate(
|
|
||||||
totalSelected === totalResults
|
|
||||||
? []
|
|
||||||
: data?.map((d) => ({
|
|
||||||
results: d.results.filter((r) => !selectedIds.includes(r.id)),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
onDeleteResults(totalSelected)
|
|
||||||
}
|
|
||||||
setIsDeleteLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalSelected =
|
|
||||||
selectedIndices.length > 0 && selectedIndices.length === results?.length
|
|
||||||
? totalResults - (totalHiddenResults ?? 0)
|
|
||||||
: selectedIndices.length
|
|
||||||
|
|
||||||
const handleScrolledToBottom = useCallback(
|
|
||||||
() => setSize((state) => state + 1),
|
|
||||||
[setSize]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleExportSelection = async () => {
|
|
||||||
setIsExportLoading(true)
|
|
||||||
const isSelectAll =
|
|
||||||
totalSelected === totalResults - (totalHiddenResults ?? 0)
|
|
||||||
const dataToUnparse = isSelectAll
|
|
||||||
? await getAllTableData()
|
|
||||||
: tableData.filter((_, idx) => selectedIndices.includes(idx))
|
|
||||||
const csvData = new Blob(
|
|
||||||
[
|
|
||||||
unparse({
|
|
||||||
data: dataToUnparse.map<{ [key: string]: string }>((data) => {
|
|
||||||
const newObject: { [key: string]: string } = {}
|
|
||||||
Object.keys(data).forEach((key) => {
|
|
||||||
newObject[key] = data[key].plainText
|
|
||||||
})
|
|
||||||
return newObject
|
|
||||||
}),
|
|
||||||
fields: resultHeader.map((h) => h.label),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAllTableData = async () => {
|
|
||||||
if (!publishedTypebot) return []
|
|
||||||
const results = await getAllResults(workspaceId, typebotId)
|
|
||||||
return convertResultsToTableData(results, resultHeader)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableData: {
|
|
||||||
[key: string]: { plainText: string; element?: JSX.Element }
|
|
||||||
}[] = useMemo(
|
|
||||||
() =>
|
|
||||||
publishedTypebot ? convertResultsToTableData(results, resultHeader) : [],
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[publishedTypebot?.id, resultHeader.length, results]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleLogsModalClose = () => setInspectingLogsResultId(undefined)
|
|
||||||
|
|
||||||
const handleLogOpenIndex = (index: number) => () => {
|
|
||||||
if (!results) return
|
|
||||||
setInspectingLogsResultId(results[index].id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack maxW="1200px" w="full" pb="28" px={['4', '0']} spacing="4">
|
|
||||||
{totalHiddenResults && (
|
|
||||||
<UnlockPlanInfo
|
|
||||||
buttonLabel={`Unlock ${totalHiddenResults} results`}
|
|
||||||
contentLabel="You are seeing complete submissions only."
|
|
||||||
plan={Plan.PRO}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{publishedTypebot && (
|
|
||||||
<LogsModal
|
|
||||||
typebotId={publishedTypebot?.typebotId}
|
|
||||||
resultId={inspectingLogsResultId}
|
|
||||||
onClose={handleLogsModalClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Flex w="full" justifyContent="flex-end">
|
|
||||||
<ResultsActionButtons
|
|
||||||
isDeleteLoading={isDeleteLoading}
|
|
||||||
isExportLoading={isExportLoading}
|
|
||||||
totalSelected={totalSelected}
|
|
||||||
onDeleteClick={handleDeleteSelection}
|
|
||||||
onExportClick={handleExportSelection}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<SubmissionsTable
|
|
||||||
resultHeader={resultHeader}
|
|
||||||
data={tableData}
|
|
||||||
onNewSelection={handleNewSelection}
|
|
||||||
onScrollToBottom={handleScrolledToBottom}
|
|
||||||
hasMore={hasMore}
|
|
||||||
onLogOpenIndex={handleLogOpenIndex}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
"@codemirror/lang-javascript": "^0.20.0",
|
"@codemirror/lang-javascript": "^0.20.0",
|
||||||
"@codemirror/lang-json": "^0.20.0",
|
"@codemirror/lang-json": "^0.20.0",
|
||||||
"@codemirror/text": "^0.19.6",
|
"@codemirror/text": "^0.19.6",
|
||||||
|
"@dnd-kit/core": "^6.0.5",
|
||||||
|
"@dnd-kit/sortable": "^7.0.1",
|
||||||
"@emotion/react": "^11.9.0",
|
"@emotion/react": "^11.9.0",
|
||||||
"@emotion/styled": "^11.8.1",
|
"@emotion/styled": "^11.8.1",
|
||||||
"@giphy/js-fetch-api": "^4.1.2",
|
"@giphy/js-fetch-api": "^4.1.2",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"@googleapis/drive": "^2.3.0",
|
"@googleapis/drive": "^2.3.0",
|
||||||
"@sentry/nextjs": "^6.19.7",
|
"@sentry/nextjs": "^6.19.7",
|
||||||
"@stripe/stripe-js": "^1.29.0",
|
"@stripe/stripe-js": "^1.29.0",
|
||||||
|
"@tanstack/react-table": "^8.0.13",
|
||||||
"@udecode/plate-basic-marks": "^13.1.0",
|
"@udecode/plate-basic-marks": "^13.1.0",
|
||||||
"@udecode/plate-common": "^7.0.2",
|
"@udecode/plate-common": "^7.0.2",
|
||||||
"@udecode/plate-core": "^13.1.0",
|
"@udecode/plate-core": "^13.1.0",
|
||||||
@@ -70,7 +73,6 @@
|
|||||||
"react": "^18.1.0",
|
"react": "^18.1.0",
|
||||||
"react-dom": "^18.1.0",
|
"react-dom": "^18.1.0",
|
||||||
"react-draggable": "^4.4.5",
|
"react-draggable": "^4.4.5",
|
||||||
"react-table": "^7.7.0",
|
|
||||||
"slate": "^0.81.1",
|
"slate": "^0.81.1",
|
||||||
"slate-history": "^0.66.0",
|
"slate-history": "^0.66.0",
|
||||||
"slate-hyperscript": "^0.77.0",
|
"slate-hyperscript": "^0.77.0",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
...data,
|
...data,
|
||||||
theme: data.theme ?? undefined,
|
theme: data.theme ?? undefined,
|
||||||
settings: data.settings ?? undefined,
|
settings: data.settings ?? undefined,
|
||||||
|
resultsTablePreferences: data.resultsTablePreferences ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return res.send({ typebots })
|
return res.send({ typebots })
|
||||||
|
|||||||
@@ -1,15 +1,106 @@
|
|||||||
import { Flex } from '@chakra-ui/layout'
|
import { Flex, Text } from '@chakra-ui/layout'
|
||||||
import { ResultsContent } from 'layouts/results/ResultsContent'
|
|
||||||
import { Seo } from 'components/Seo'
|
import { Seo } from 'components/Seo'
|
||||||
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
|
import { HStack, Button, Tag } from '@chakra-ui/react'
|
||||||
|
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||||
|
import { ResultsContent } from 'components/results/ResultsContent'
|
||||||
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
|
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||||
|
import { AnalyticsContent } from 'components/analytics/AnalyticsContent'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useStats } from 'services/analytics'
|
||||||
|
import { isFreePlan } from 'services/workspace'
|
||||||
|
import { useToast } from 'components/shared/hooks/useToast'
|
||||||
|
import { ResultsProvider } from 'contexts/ResultsProvider'
|
||||||
|
|
||||||
const ResultsPage = () => (
|
const ResultsPage = () => {
|
||||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
const router = useRouter()
|
||||||
<Seo title="Share" />
|
const { workspace } = useWorkspace()
|
||||||
<TypebotHeader />
|
const { typebot, publishedTypebot } = useTypebot()
|
||||||
<ResultsContent />
|
const isAnalytics = useMemo(
|
||||||
</Flex>
|
() => 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 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 />
|
||||||
|
<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={NextChakraLink}
|
||||||
|
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={NextChakraLink}
|
||||||
|
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 ? (
|
||||||
|
<AnalyticsContent stats={stats} />
|
||||||
|
) : (
|
||||||
|
<ResultsProvider
|
||||||
|
workspaceId={workspace.id}
|
||||||
|
typebotId={publishedTypebot.typebotId}
|
||||||
|
totalResults={stats?.totalStarts ?? 0}
|
||||||
|
totalHiddenResults={
|
||||||
|
isFreePlan(workspace)
|
||||||
|
? (stats?.totalStarts ?? 0) - (stats?.totalCompleted ?? 0)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDeleteResults={handleDeletedResults}
|
||||||
|
>
|
||||||
|
<ResultsContent />
|
||||||
|
</ResultsProvider>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default ResultsPage
|
export default ResultsPage
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { Flex } from '@chakra-ui/layout'
|
import ResultsPage from '../results'
|
||||||
import { ResultsContent } from 'layouts/results/ResultsContent'
|
|
||||||
import { Seo } from 'components/Seo'
|
|
||||||
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
const AnalyticsPage = () => (
|
const AnalyticsPage = ResultsPage
|
||||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
|
||||||
<Seo title="Analytics" />
|
|
||||||
<TypebotHeader />
|
|
||||||
<ResultsContent />
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default AnalyticsPage
|
export default AnalyticsPage
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ test.describe('Send email block', () => {
|
|||||||
await page.click('text=Preview')
|
await page.click('text=Preview')
|
||||||
await typebotViewer(page).locator('text=Go').click()
|
await typebotViewer(page).locator('text=Go').click()
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('text=Emails are not sent in preview mode')
|
page.locator('text=Emails are not sent in preview mode >> nth=0')
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,121 +16,172 @@ import { deleteButtonInConfirmDialog } from '../services/selectorUtils'
|
|||||||
|
|
||||||
const typebotId = cuid()
|
const typebotId = cuid()
|
||||||
|
|
||||||
test.describe('Results page', () => {
|
test('Submission table header should be parsed correctly', async ({ page }) => {
|
||||||
test('Submission table header should be parsed correctly', async ({
|
const typebotId = cuid()
|
||||||
page,
|
await importTypebotInDatabase(
|
||||||
}) => {
|
path.join(__dirname, '../fixtures/typebots/results/submissionHeader.json'),
|
||||||
const typebotId = cuid()
|
{
|
||||||
await importTypebotInDatabase(
|
id: typebotId,
|
||||||
path.join(
|
}
|
||||||
__dirname,
|
)
|
||||||
'../fixtures/typebots/results/submissionHeader.json'
|
await page.goto(`/typebots/${typebotId}/results`)
|
||||||
),
|
await expect(page.locator('text=Submitted at')).toBeVisible()
|
||||||
{
|
await expect(page.locator('text=Welcome')).toBeVisible()
|
||||||
id: typebotId,
|
await expect(page.locator('text=Email')).toBeVisible()
|
||||||
}
|
await expect(page.locator('text=Name')).toBeVisible()
|
||||||
)
|
await expect(page.locator('text=Services')).toBeVisible()
|
||||||
await page.goto(`/typebots/${typebotId}/results`)
|
await expect(page.locator('text=Additional information')).toBeVisible()
|
||||||
await expect(page.locator('text=Submitted at')).toBeVisible()
|
await expect(page.locator('text=utm_source')).toBeVisible()
|
||||||
await expect(page.locator('text=Welcome')).toBeVisible()
|
await expect(page.locator('text=utm_userid')).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()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('results should be deletable', async ({ page }) => {
|
test('results should be deletable', async ({ page }) => {
|
||||||
await createTypebots([
|
await createTypebots([
|
||||||
{
|
{
|
||||||
id: typebotId,
|
id: typebotId,
|
||||||
...parseDefaultGroupWithBlock({
|
...parseDefaultGroupWithBlock({
|
||||||
type: InputBlockType.TEXT,
|
type: InputBlockType.TEXT,
|
||||||
options: defaultTextInputOptions,
|
options: defaultTextInputOptions,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
await createResults({ typebotId })
|
await createResults({ typebotId })
|
||||||
await page.goto(`/typebots/${typebotId}/results`)
|
await page.goto(`/typebots/${typebotId}/results`)
|
||||||
await selectFirstResults(page)
|
await selectFirstResults(page)
|
||||||
await page.click('button:has-text("Delete2")')
|
await page.click('text="Delete"')
|
||||||
await deleteButtonInConfirmDialog(page).click()
|
await deleteButtonInConfirmDialog(page).click()
|
||||||
await expect(page.locator('text=content199')).toBeHidden()
|
await expect(page.locator('text=content199')).toBeHidden()
|
||||||
await expect(page.locator('text=content198')).toBeHidden()
|
await expect(page.locator('text=content198')).toBeHidden()
|
||||||
await page.waitForTimeout(1000)
|
await page.waitForTimeout(1000)
|
||||||
await page.click('[data-testid="checkbox"] >> nth=0')
|
await page.click('[data-testid="checkbox"] >> nth=0')
|
||||||
await page.click('button:has-text("Delete198")')
|
await page.click('text="Delete"')
|
||||||
await deleteButtonInConfirmDialog(page).click()
|
await deleteButtonInConfirmDialog(page).click()
|
||||||
await page.waitForTimeout(1000)
|
await page.waitForTimeout(1000)
|
||||||
expect(await page.locator('tr').count()).toBe(1)
|
expect(await page.locator('tr').count()).toBe(1)
|
||||||
})
|
await expect(page.locator('text="Delete"')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
test('submissions table should have infinite scroll', async ({ page }) => {
|
test('submissions table should have infinite scroll', async ({ page }) => {
|
||||||
const scrollToBottom = () =>
|
const scrollToBottom = () =>
|
||||||
page.evaluate(() => {
|
page.evaluate(() => {
|
||||||
const tableWrapper = document.querySelector('.table-wrapper')
|
const tableWrapper = document.querySelector('.table-wrapper')
|
||||||
if (!tableWrapper) return
|
if (!tableWrapper) return
|
||||||
tableWrapper.scrollTo(0, tableWrapper.scrollHeight)
|
tableWrapper.scrollTo(0, tableWrapper.scrollHeight)
|
||||||
})
|
|
||||||
|
|
||||||
await createResults({ typebotId })
|
|
||||||
await page.goto(`/typebots/${typebotId}/results`)
|
|
||||||
await expect(page.locator('text=content199')).toBeVisible()
|
|
||||||
|
|
||||||
await expect(page.locator('text=content149')).toBeHidden()
|
|
||||||
await scrollToBottom()
|
|
||||||
await expect(page.locator('text=content149')).toBeVisible()
|
|
||||||
|
|
||||||
await expect(page.locator('text=content99')).toBeHidden()
|
|
||||||
await scrollToBottom()
|
|
||||||
await expect(page.locator('text=content99')).toBeVisible()
|
|
||||||
|
|
||||||
await expect(page.locator('text=content49')).toBeHidden()
|
|
||||||
await scrollToBottom()
|
|
||||||
await expect(page.locator('text=content49')).toBeVisible()
|
|
||||||
await expect(page.locator('text=content0')).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should correctly export selection in CSV', async ({ page }) => {
|
|
||||||
await page.goto(`/typebots/${typebotId}/results`)
|
|
||||||
await selectFirstResults(page)
|
|
||||||
const [download] = await Promise.all([
|
|
||||||
page.waitForEvent('download'),
|
|
||||||
page.locator('button:has-text("Export2")').click(),
|
|
||||||
])
|
|
||||||
const path = await download.path()
|
|
||||||
expect(path).toBeDefined()
|
|
||||||
const file = readFileSync(path as string).toString()
|
|
||||||
const { data } = parse(file)
|
|
||||||
validateExportSelection(data)
|
|
||||||
|
|
||||||
await page.click('[data-testid="checkbox"] >> nth=0')
|
|
||||||
const [downloadAll] = await Promise.all([
|
|
||||||
page.waitForEvent('download'),
|
|
||||||
page.locator('button:has-text("Export200")').click(),
|
|
||||||
])
|
|
||||||
const pathAll = await downloadAll.path()
|
|
||||||
expect(pathAll).toBeDefined()
|
|
||||||
const fileAll = readFileSync(pathAll as string).toString()
|
|
||||||
const { data: dataAll } = parse(fileAll)
|
|
||||||
validateExportAll(dataAll)
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe('Free user', async () => {
|
|
||||||
test.use({
|
|
||||||
storageState: path.join(__dirname, '../freeUser.json'),
|
|
||||||
})
|
|
||||||
test("Incomplete results shouldn't be displayed", async ({ page }) => {
|
|
||||||
await prisma.typebot.update({
|
|
||||||
where: { id: typebotId },
|
|
||||||
data: { workspaceId: freeWorkspaceId },
|
|
||||||
})
|
|
||||||
await page.goto(`/typebots/${typebotId}/results`)
|
|
||||||
await page.click('text=Unlock')
|
|
||||||
await expect(page.locator('text=For solo creator')).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await createResults({ typebotId })
|
||||||
|
await page.goto(`/typebots/${typebotId}/results`)
|
||||||
|
await expect(page.locator('text=content199')).toBeVisible()
|
||||||
|
|
||||||
|
await expect(page.locator('text=content149')).toBeHidden()
|
||||||
|
await scrollToBottom()
|
||||||
|
await expect(page.locator('text=content149')).toBeVisible()
|
||||||
|
|
||||||
|
await expect(page.locator('text=content99')).toBeHidden()
|
||||||
|
await scrollToBottom()
|
||||||
|
await expect(page.locator('text=content99')).toBeVisible()
|
||||||
|
|
||||||
|
await expect(page.locator('text=content49')).toBeHidden()
|
||||||
|
await scrollToBottom()
|
||||||
|
await expect(page.locator('text=content49')).toBeVisible()
|
||||||
|
await expect(page.locator('text=content0')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should correctly export selection in CSV', async ({ page }) => {
|
||||||
|
await page.goto(`/typebots/${typebotId}/results`)
|
||||||
|
await selectFirstResults(page)
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.locator('text="Export"').click(),
|
||||||
|
])
|
||||||
|
const path = await download.path()
|
||||||
|
expect(path).toBeDefined()
|
||||||
|
const file = readFileSync(path as string).toString()
|
||||||
|
const { data } = parse(file)
|
||||||
|
validateExportSelection(data)
|
||||||
|
|
||||||
|
await page.click('[data-testid="checkbox"] >> nth=0')
|
||||||
|
const [downloadAll] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.locator('text="Export"').click(),
|
||||||
|
])
|
||||||
|
const pathAll = await downloadAll.path()
|
||||||
|
expect(pathAll).toBeDefined()
|
||||||
|
const fileAll = readFileSync(pathAll as string).toString()
|
||||||
|
const { data: dataAll } = parse(fileAll)
|
||||||
|
validateExportAll(dataAll)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Free user', async () => {
|
||||||
|
test.use({
|
||||||
|
storageState: path.join(__dirname, '../freeUser.json'),
|
||||||
})
|
})
|
||||||
|
test("Incomplete results shouldn't be displayed", async ({ page }) => {
|
||||||
|
await prisma.typebot.update({
|
||||||
|
where: { id: typebotId },
|
||||||
|
data: { workspaceId: freeWorkspaceId },
|
||||||
|
})
|
||||||
|
await page.goto(`/typebots/${typebotId}/results`)
|
||||||
|
await page.click('text=Unlock')
|
||||||
|
await expect(page.locator('text=For solo creator')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can resize, hide and reorder columns', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await importTypebotInDatabase(
|
||||||
|
path.join(__dirname, '../fixtures/typebots/results/submissionHeader.json'),
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await page.goto(`/typebots/${typebotId}/results`)
|
||||||
|
|
||||||
|
// Resize
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Hide
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Reorder
|
||||||
|
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')
|
||||||
|
|
||||||
|
// Preferences should be persisted
|
||||||
|
const saveAndReload = async (page: Page) => {
|
||||||
|
await page.click('text="Theme"')
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
await page.goto(`/typebots/${typebotId}/results`)
|
||||||
|
}
|
||||||
|
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')
|
||||||
})
|
})
|
||||||
|
|
||||||
const validateExportSelection = (data: unknown[]) => {
|
const validateExportSelection = (data: unknown[]) => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ test.describe.parallel('Settings page', () => {
|
|||||||
typebotViewer(page).locator('a:has-text("Made with Typebot")')
|
typebotViewer(page).locator('a:has-text("Made with Typebot")')
|
||||||
).toBeHidden()
|
).toBeHidden()
|
||||||
|
|
||||||
await page.click('text=Create new session on page refresh')
|
await page.click('text="Remember session"')
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('input[type="checkbox"] >> nth=-1')
|
page.locator('input[type="checkbox"] >> nth=-1')
|
||||||
).toHaveAttribute('checked', '')
|
).toHaveAttribute('checked', '')
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const useResults = ({
|
|||||||
}: {
|
}: {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
typebotId: string
|
typebotId: string
|
||||||
onError: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { data, error, mutate, setSize, size, isValidating } = useSWRInfinite<
|
const { data, error, mutate, setSize, size, isValidating } = useSWRInfinite<
|
||||||
{ results: ResultWithAnswers[] },
|
{ results: ResultWithAnswers[] },
|
||||||
@@ -58,7 +58,7 @@ export const useResults = ({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (error) onError(error)
|
if (error && onError) onError(error)
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
isLoading: !error && !data,
|
isLoading: !error && !data,
|
||||||
@@ -150,8 +150,12 @@ const HeaderIcon = ({ header }: { header: ResultHeaderCell }) =>
|
|||||||
export const convertResultsToTableData = (
|
export const convertResultsToTableData = (
|
||||||
results: ResultWithAnswers[] | undefined,
|
results: ResultWithAnswers[] | undefined,
|
||||||
headerCells: ResultHeaderCell[]
|
headerCells: ResultHeaderCell[]
|
||||||
): { [key: string]: { element?: JSX.Element; plainText: string } }[] =>
|
): {
|
||||||
|
id: string
|
||||||
|
[key: string]: { element?: JSX.Element; plainText: string } | string
|
||||||
|
}[] =>
|
||||||
(results ?? []).map((result) => ({
|
(results ?? []).map((result) => ({
|
||||||
|
id: result.id,
|
||||||
'Submitted at': {
|
'Submitted at': {
|
||||||
plainText: parseDateToReadable(result.createdAt),
|
plainText: parseDateToReadable(result.createdAt),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ test('should work as expected', async ({ page, browser }) => {
|
|||||||
await page.click('[data-testid="checkbox"] >> nth=0')
|
await page.click('[data-testid="checkbox"] >> nth=0')
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
page.locator('button:has-text("Export1")').click(),
|
page.locator('text="Export"').click(),
|
||||||
])
|
])
|
||||||
const downloadPath = await download.path()
|
const downloadPath = await download.path()
|
||||||
expect(path).toBeDefined()
|
expect(path).toBeDefined()
|
||||||
|
|||||||
@@ -20,4 +20,9 @@ test('should work as expected', async ({ page }) => {
|
|||||||
await expect(page.locator('text="Baptiste"')).toBeVisible()
|
await expect(page.locator('text="Baptiste"')).toBeVisible()
|
||||||
await expect(page.locator('text="26"')).toBeVisible()
|
await expect(page.locator('text="26"')).toBeVisible()
|
||||||
await expect(page.locator('text="Yes"')).toBeVisible()
|
await expect(page.locator('text="Yes"')).toBeVisible()
|
||||||
|
await page.hover('tbody > tr')
|
||||||
|
await page.click('button >> text="Open"')
|
||||||
|
await expect(page.locator('text="Baptiste" >> nth=1')).toBeVisible()
|
||||||
|
await expect(page.locator('text="26" >> nth=1')).toBeVisible()
|
||||||
|
await expect(page.locator('text="Yes" >> nth=1')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -159,28 +159,29 @@ model DashboardFolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Typebot {
|
model Typebot {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
icon String?
|
icon String?
|
||||||
name String
|
name String
|
||||||
publishedTypebotId String?
|
publishedTypebotId String?
|
||||||
publishedTypebot PublicTypebot?
|
publishedTypebot PublicTypebot?
|
||||||
results Result[]
|
results Result[]
|
||||||
folderId String?
|
folderId String?
|
||||||
folder DashboardFolder? @relation(fields: [folderId], references: [id])
|
folder DashboardFolder? @relation(fields: [folderId], references: [id])
|
||||||
groups Json
|
groups Json
|
||||||
variables Json[]
|
variables Json[]
|
||||||
edges Json
|
edges Json
|
||||||
theme Json
|
theme Json
|
||||||
settings Json
|
settings Json
|
||||||
publicId String? @unique
|
publicId String? @unique
|
||||||
customDomain String? @unique
|
customDomain String? @unique
|
||||||
collaborators CollaboratorsOnTypebots[]
|
collaborators CollaboratorsOnTypebots[]
|
||||||
invitations Invitation[]
|
invitations Invitation[]
|
||||||
webhooks Webhook[]
|
webhooks Webhook[]
|
||||||
workspaceId String
|
workspaceId String
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
resultsTablePreferences Json?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Invitation {
|
model Invitation {
|
||||||
@@ -249,13 +250,13 @@ model Log {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Answer {
|
model Answer {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
resultId String
|
resultId String
|
||||||
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
||||||
blockId String
|
blockId String
|
||||||
groupId String
|
groupId String
|
||||||
variableId String?
|
variableId String?
|
||||||
content String
|
content String
|
||||||
storageUsed Int?
|
storageUsed Int?
|
||||||
|
|
||||||
@@unique([resultId, blockId, groupId])
|
@@unique([resultId, blockId, groupId])
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type ResultValues = Pick<
|
|||||||
>
|
>
|
||||||
|
|
||||||
export type ResultHeaderCell = {
|
export type ResultHeaderCell = {
|
||||||
|
id: string
|
||||||
label: string
|
label: string
|
||||||
blockId?: string
|
blockId?: string
|
||||||
blockType?: InputBlockType
|
blockType?: InputBlockType
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ const edgeSchema = z.object({
|
|||||||
to: targetSchema,
|
to: targetSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const resultsTablePreferencesSchema = z.object({
|
||||||
|
columnsOrder: z.array(z.string()),
|
||||||
|
columnsVisibility: z.record(z.string(), z.boolean()),
|
||||||
|
columnsWidth: z.record(z.string(), z.number()),
|
||||||
|
})
|
||||||
|
|
||||||
const typebotSchema = z.object({
|
const typebotSchema = z.object({
|
||||||
version: z.enum(['2']).optional(),
|
version: z.enum(['2']).optional(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -48,6 +54,7 @@ const typebotSchema = z.object({
|
|||||||
publicId: z.string().nullable(),
|
publicId: z.string().nullable(),
|
||||||
customDomain: z.string().nullable(),
|
customDomain: z.string().nullable(),
|
||||||
workspaceId: z.string(),
|
workspaceId: z.string(),
|
||||||
|
resultsTablePreferences: resultsTablePreferencesSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Typebot = z.infer<typeof typebotSchema>
|
export type Typebot = z.infer<typeof typebotSchema>
|
||||||
@@ -55,3 +62,6 @@ export type Target = z.infer<typeof targetSchema>
|
|||||||
export type Source = z.infer<typeof sourceSchema>
|
export type Source = z.infer<typeof sourceSchema>
|
||||||
export type Edge = z.infer<typeof edgeSchema>
|
export type Edge = z.infer<typeof edgeSchema>
|
||||||
export type Group = z.infer<typeof groupSchema>
|
export type Group = z.infer<typeof groupSchema>
|
||||||
|
export type ResultsTablePreferences = z.infer<
|
||||||
|
typeof resultsTablePreferencesSchema
|
||||||
|
>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const parseResultHeader = ({
|
|||||||
}): ResultHeaderCell[] => {
|
}): ResultHeaderCell[] => {
|
||||||
const parsedGroups = parseInputsResultHeader({ groups, variables })
|
const parsedGroups = parseInputsResultHeader({ groups, variables })
|
||||||
return [
|
return [
|
||||||
{ label: 'Submitted at' },
|
{ label: 'Submitted at', id: 'date' },
|
||||||
...parsedGroups,
|
...parsedGroups,
|
||||||
...parseVariablesHeaders(variables, parsedGroups),
|
...parseVariablesHeaders(variables, parsedGroups),
|
||||||
]
|
]
|
||||||
@@ -62,6 +62,7 @@ const parseInputsResultHeader = ({
|
|||||||
return [
|
return [
|
||||||
...headers,
|
...headers,
|
||||||
{
|
{
|
||||||
|
id: inputBlock.id,
|
||||||
blockType: inputBlock.type,
|
blockType: inputBlock.type,
|
||||||
blockId: inputBlock.id,
|
blockId: inputBlock.id,
|
||||||
variableId: inputBlock.options.variableId,
|
variableId: inputBlock.options.variableId,
|
||||||
@@ -80,6 +81,7 @@ const parseVariablesHeaders = (
|
|||||||
return [
|
return [
|
||||||
...headers,
|
...headers,
|
||||||
{
|
{
|
||||||
|
id: v.id,
|
||||||
label: v.name,
|
label: v.name,
|
||||||
variableId: v.id,
|
variableId: v.id,
|
||||||
},
|
},
|
||||||
|
|||||||
48
yarn.lock
48
yarn.lock
@@ -1885,6 +1885,37 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.4.1.tgz#75b4c27948c81e88ccd3a8902047bcd797f38d32"
|
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.4.1.tgz#75b4c27948c81e88ccd3a8902047bcd797f38d32"
|
||||||
integrity sha512-ej5oVy6lykXsvieQtqZxCOaLT+xD4+QNarq78cIYISHmZXshCvROLudpQN3lfL8G0NL7plMSSK+zlyvCaIJ4Iw==
|
integrity sha512-ej5oVy6lykXsvieQtqZxCOaLT+xD4+QNarq78cIYISHmZXshCvROLudpQN3lfL8G0NL7plMSSK+zlyvCaIJ4Iw==
|
||||||
|
|
||||||
|
"@dnd-kit/accessibility@^3.0.0":
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c"
|
||||||
|
integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.0.0"
|
||||||
|
|
||||||
|
"@dnd-kit/core@^6.0.5":
|
||||||
|
version "6.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.5.tgz#5670ad0dcc83cd51dbf2fa8c6a5c8af4ac0c1989"
|
||||||
|
integrity sha512-3nL+Zy5cT+1XwsWdlXIvGIFvbuocMyB4NBxTN74DeBaBqeWdH9JsnKwQv7buZQgAHmAH+eIENfS1ginkvW6bCw==
|
||||||
|
dependencies:
|
||||||
|
"@dnd-kit/accessibility" "^3.0.0"
|
||||||
|
"@dnd-kit/utilities" "^3.2.0"
|
||||||
|
tslib "^2.0.0"
|
||||||
|
|
||||||
|
"@dnd-kit/sortable@^7.0.1":
|
||||||
|
version "7.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb"
|
||||||
|
integrity sha512-n77qAzJQtMMywu25sJzhz3gsHnDOUlEjTtnRl8A87rWIhnu32zuP+7zmFjwGgvqfXmRufqiHOSlH7JPC/tnJ8Q==
|
||||||
|
dependencies:
|
||||||
|
"@dnd-kit/utilities" "^3.2.0"
|
||||||
|
tslib "^2.0.0"
|
||||||
|
|
||||||
|
"@dnd-kit/utilities@^3.2.0":
|
||||||
|
version "3.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.0.tgz#b3e956ea63a1347c9d0e1316b037ddcc6140acda"
|
||||||
|
integrity sha512-h65/pn2IPCCIWwdlR2BMLqRkDxpTEONA+HQW3n765HBijLYGyrnTCLa2YQt8VVjjSQD6EfFlTE6aS2Q/b6nb2g==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.0.0"
|
||||||
|
|
||||||
"@docsearch/css@3.1.0":
|
"@docsearch/css@3.1.0":
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.1.0.tgz#6781cad43fc2e034d012ee44beddf8f93ba21f19"
|
resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.1.0.tgz#6781cad43fc2e034d012ee44beddf8f93ba21f19"
|
||||||
@@ -3620,6 +3651,18 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
defer-to-connect "^2.0.1"
|
defer-to-connect "^2.0.1"
|
||||||
|
|
||||||
|
"@tanstack/react-table@^8.0.13":
|
||||||
|
version "8.0.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.0.13.tgz#47020beeaddac0c64d215a3463fc536a9589410b"
|
||||||
|
integrity sha512-4arYr+cGvpLiGSeAeLSTSlzuIB884pCFGzKI/MAX8/aFz7QZvkdu9AyWt9owCs/CYuPjd9iQoSwjfe3VK4yNPg==
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/table-core" "8.0.13"
|
||||||
|
|
||||||
|
"@tanstack/table-core@8.0.13":
|
||||||
|
version "8.0.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.0.13.tgz#e5dc7ab9a5ca8224e128251b55749a13b81bd421"
|
||||||
|
integrity sha512-2G9DVpeIarsCkWNFe4ZPDDQQHK6XEqGr8C+G8eoSiPvM077LRg+oK150get3kRjbNUaHUT6RUEjc0lXNC6V83w==
|
||||||
|
|
||||||
"@tippyjs/react@^4.2.6":
|
"@tippyjs/react@^4.2.6":
|
||||||
version "4.2.6"
|
version "4.2.6"
|
||||||
resolved "https://registry.yarnpkg.com/@tippyjs/react/-/react-4.2.6.tgz#971677a599bf663f20bb1c60a62b9555b749cc71"
|
resolved "https://registry.yarnpkg.com/@tippyjs/react/-/react-4.2.6.tgz#971677a599bf663f20bb1c60a62b9555b749cc71"
|
||||||
@@ -12353,11 +12396,6 @@ react-style-singleton@^2.2.1:
|
|||||||
invariant "^2.2.4"
|
invariant "^2.2.4"
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
react-table@^7.7.0:
|
|
||||||
version "7.8.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.8.0.tgz#07858c01c1718c09f7f1aed7034fcfd7bda907d2"
|
|
||||||
integrity sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA==
|
|
||||||
|
|
||||||
react-textarea-autosize@^8.3.2:
|
react-textarea-autosize@^8.3.2:
|
||||||
version "8.3.4"
|
version "8.3.4"
|
||||||
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524"
|
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524"
|
||||||
|
|||||||
Reference in New Issue
Block a user