2
0

feat(results): Brand new Results table

- Resizable columns
- Hide / Show columns
- Reorganize columns
- Expand result
This commit is contained in:
Baptiste Arnaud
2022-07-01 17:08:35 +02:00
parent cf6e8a21be
commit d84f99074d
34 changed files with 1427 additions and 738 deletions

View File

@ -474,3 +474,10 @@ export const BuoyIcon = (props: IconProps) => (
<line x1="4.93" y1="19.07" x2="9.17" y2="14.83"></line>
</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>
)

View File

@ -23,11 +23,11 @@ import { isDefined } from 'utils'
type Props = {
typebotId: string
resultId?: string
resultId: string | null
onClose: () => void
}
export const LogsModal = ({ typebotId, resultId, onClose }: Props) => {
const { isLoading, logs } = useLogs(typebotId, resultId)
const { isLoading, logs } = useLogs(typebotId, resultId ?? undefined)
return (
<Modal isOpen={isDefined(resultId)} onClose={onClose} size="xl">
<ModalOverlay />

View 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>
)
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -0,0 +1,218 @@
import {
Popover,
PopoverTrigger,
Button,
PopoverContent,
PopoverBody,
Stack,
IconButton,
Flex,
HStack,
Text,
Portal,
} from '@chakra-ui/react'
import { ToolIcon, EyeIcon, EyeOffIcon, GripIcon } from '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>
}
)

View 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}
/>
)
}

View File

@ -8,20 +8,20 @@ type LoadingRowsProps = {
export const LoadingRows = ({ totalColumns }: LoadingRowsProps) => {
return (
<>
{Array.from(Array(3)).map((row, idx) => (
{Array.from(Array(3)).map((_, idx) => (
<tr key={idx}>
<chakra.td
px="4"
px="2"
py="2"
border="1px"
borderColor="gray.200"
width="50px"
width="40px"
>
<Flex>
<Flex ml="1">
<Checkbox isDisabled />
</Flex>
</chakra.td>
{Array.from(Array(totalColumns)).map((cell, idx) => {
{Array.from(Array(totalColumns)).map((_, idx) => {
return (
<chakra.td
key={idx}

View File

@ -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>
)
}

View 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 />
)

View 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
)

View File

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

View File

@ -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>
)
}
)

View File

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

View 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)

View File

@ -1,6 +1,7 @@
import {
LogicBlockType,
PublicTypebot,
ResultsTablePreferences,
Settings,
Theme,
Typebot,
@ -53,6 +54,7 @@ type UpdateTypebotPayload = Partial<{
publishedTypebotId: string
icon: string
customDomain: string
resultsTablePreferences: ResultsTablePreferences
}>
export type SetTypebot = (

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -23,6 +23,8 @@
"@codemirror/lang-javascript": "^0.20.0",
"@codemirror/lang-json": "^0.20.0",
"@codemirror/text": "^0.19.6",
"@dnd-kit/core": "^6.0.5",
"@dnd-kit/sortable": "^7.0.1",
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@giphy/js-fetch-api": "^4.1.2",
@ -31,6 +33,7 @@
"@googleapis/drive": "^2.3.0",
"@sentry/nextjs": "^6.19.7",
"@stripe/stripe-js": "^1.29.0",
"@tanstack/react-table": "^8.0.13",
"@udecode/plate-basic-marks": "^13.1.0",
"@udecode/plate-common": "^7.0.2",
"@udecode/plate-core": "^13.1.0",
@ -70,7 +73,6 @@
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-draggable": "^4.4.5",
"react-table": "^7.7.0",
"slate": "^0.81.1",
"slate-history": "^0.66.0",
"slate-hyperscript": "^0.77.0",

View File

@ -48,6 +48,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
...data,
theme: data.theme ?? undefined,
settings: data.settings ?? undefined,
resultsTablePreferences: data.resultsTablePreferences ?? undefined,
},
})
return res.send({ typebots })

View File

@ -1,15 +1,106 @@
import { Flex } from '@chakra-ui/layout'
import { ResultsContent } from 'layouts/results/ResultsContent'
import { Flex, Text } from '@chakra-ui/layout'
import { Seo } from 'components/Seo'
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 = () => (
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title="Share" />
<TypebotHeader />
<ResultsContent />
</Flex>
)
const ResultsPage = () => {
const router = useRouter()
const { workspace } = useWorkspace()
const { typebot, publishedTypebot } = useTypebot()
const isAnalytics = useMemo(
() => router.pathname.endsWith('analytics'),
[router.pathname]
)
const { showToast } = useToast()
const { stats, mutate } = useStats({
typebotId: publishedTypebot?.typebotId,
onError: (err) => showToast({ title: err.name, description: err.message }),
})
const 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

View File

@ -1,15 +1,5 @@
import { Flex } from '@chakra-ui/layout'
import { ResultsContent } from 'layouts/results/ResultsContent'
import { Seo } from 'components/Seo'
import { TypebotHeader } from 'components/shared/TypebotHeader'
import React from 'react'
import ResultsPage from '../results'
const AnalyticsPage = () => (
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title="Analytics" />
<TypebotHeader />
<ResultsContent />
</Flex>
)
const AnalyticsPage = ResultsPage
export default AnalyticsPage

View File

@ -72,7 +72,7 @@ test.describe('Send email block', () => {
await page.click('text=Preview')
await typebotViewer(page).locator('text=Go').click()
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()
})
})

View File

@ -16,121 +16,172 @@ import { deleteButtonInConfirmDialog } from '../services/selectorUtils'
const typebotId = cuid()
test.describe('Results page', () => {
test('Submission table header should be parsed correctly', async ({
page,
}) => {
const typebotId = cuid()
await importTypebotInDatabase(
path.join(
__dirname,
'../fixtures/typebots/results/submissionHeader.json'
),
{
id: typebotId,
}
)
await page.goto(`/typebots/${typebotId}/results`)
await expect(page.locator('text=Submitted at')).toBeVisible()
await expect(page.locator('text=Welcome')).toBeVisible()
await expect(page.locator('text=Email')).toBeVisible()
await expect(page.locator('text=Name')).toBeVisible()
await expect(page.locator('text=Services')).toBeVisible()
await expect(page.locator('text=Additional information')).toBeVisible()
await expect(page.locator('text=utm_source')).toBeVisible()
await expect(page.locator('text=utm_userid')).toBeVisible()
})
test('Submission table header should be parsed correctly', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/results/submissionHeader.json'),
{
id: typebotId,
}
)
await page.goto(`/typebots/${typebotId}/results`)
await expect(page.locator('text=Submitted at')).toBeVisible()
await expect(page.locator('text=Welcome')).toBeVisible()
await expect(page.locator('text=Email')).toBeVisible()
await expect(page.locator('text=Name')).toBeVisible()
await expect(page.locator('text=Services')).toBeVisible()
await expect(page.locator('text=Additional information')).toBeVisible()
await expect(page.locator('text=utm_source')).toBeVisible()
await expect(page.locator('text=utm_userid')).toBeVisible()
})
test('results should be deletable', async ({ page }) => {
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await createResults({ typebotId })
await page.goto(`/typebots/${typebotId}/results`)
await selectFirstResults(page)
await page.click('button:has-text("Delete2")')
await deleteButtonInConfirmDialog(page).click()
await expect(page.locator('text=content199')).toBeHidden()
await expect(page.locator('text=content198')).toBeHidden()
await page.waitForTimeout(1000)
await page.click('[data-testid="checkbox"] >> nth=0')
await page.click('button:has-text("Delete198")')
await deleteButtonInConfirmDialog(page).click()
await page.waitForTimeout(1000)
expect(await page.locator('tr').count()).toBe(1)
})
test('results should be deletable', async ({ page }) => {
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await createResults({ typebotId })
await page.goto(`/typebots/${typebotId}/results`)
await selectFirstResults(page)
await page.click('text="Delete"')
await deleteButtonInConfirmDialog(page).click()
await expect(page.locator('text=content199')).toBeHidden()
await expect(page.locator('text=content198')).toBeHidden()
await page.waitForTimeout(1000)
await page.click('[data-testid="checkbox"] >> nth=0')
await page.click('text="Delete"')
await deleteButtonInConfirmDialog(page).click()
await page.waitForTimeout(1000)
expect(await page.locator('tr').count()).toBe(1)
await expect(page.locator('text="Delete"')).toBeHidden()
})
test('submissions table should have infinite scroll', async ({ page }) => {
const scrollToBottom = () =>
page.evaluate(() => {
const tableWrapper = document.querySelector('.table-wrapper')
if (!tableWrapper) return
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()
test('submissions table should have infinite scroll', async ({ page }) => {
const scrollToBottom = () =>
page.evaluate(() => {
const tableWrapper = document.querySelector('.table-wrapper')
if (!tableWrapper) return
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('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[]) => {

View File

@ -26,7 +26,7 @@ test.describe.parallel('Settings page', () => {
typebotViewer(page).locator('a:has-text("Made with Typebot")')
).toBeHidden()
await page.click('text=Create new session on page refresh')
await page.click('text="Remember session"')
await expect(
page.locator('input[type="checkbox"] >> nth=-1')
).toHaveAttribute('checked', '')

View File

@ -39,7 +39,7 @@ export const useResults = ({
}: {
workspaceId: string
typebotId: string
onError: (error: Error) => void
onError?: (error: Error) => void
}) => {
const { data, error, mutate, setSize, size, isValidating } = useSWRInfinite<
{ results: ResultWithAnswers[] },
@ -58,7 +58,7 @@ export const useResults = ({
}
)
if (error) onError(error)
if (error && onError) onError(error)
return {
data,
isLoading: !error && !data,
@ -150,8 +150,12 @@ const HeaderIcon = ({ header }: { header: ResultHeaderCell }) =>
export const convertResultsToTableData = (
results: ResultWithAnswers[] | undefined,
headerCells: ResultHeaderCell[]
): { [key: string]: { element?: JSX.Element; plainText: string } }[] =>
): {
id: string
[key: string]: { element?: JSX.Element; plainText: string } | string
}[] =>
(results ?? []).map((result) => ({
id: result.id,
'Submitted at': {
plainText: parseDateToReadable(result.createdAt),
},

View File

@ -43,7 +43,7 @@ test('should work as expected', async ({ page, browser }) => {
await page.click('[data-testid="checkbox"] >> nth=0')
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('button:has-text("Export1")').click(),
page.locator('text="Export"').click(),
])
const downloadPath = await download.path()
expect(path).toBeDefined()

View File

@ -20,4 +20,9 @@ test('should work as expected', async ({ page }) => {
await expect(page.locator('text="Baptiste"')).toBeVisible()
await expect(page.locator('text="26"')).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()
})

View File

@ -159,28 +159,29 @@ model DashboardFolder {
}
model Typebot {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
icon String?
name String
publishedTypebotId String?
publishedTypebot PublicTypebot?
results Result[]
folderId String?
folder DashboardFolder? @relation(fields: [folderId], references: [id])
groups Json
variables Json[]
edges Json
theme Json
settings Json
publicId String? @unique
customDomain String? @unique
collaborators CollaboratorsOnTypebots[]
invitations Invitation[]
webhooks Webhook[]
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
icon String?
name String
publishedTypebotId String?
publishedTypebot PublicTypebot?
results Result[]
folderId String?
folder DashboardFolder? @relation(fields: [folderId], references: [id])
groups Json
variables Json[]
edges Json
theme Json
settings Json
publicId String? @unique
customDomain String? @unique
collaborators CollaboratorsOnTypebots[]
invitations Invitation[]
webhooks Webhook[]
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
resultsTablePreferences Json?
}
model Invitation {
@ -249,13 +250,13 @@ model Log {
}
model Answer {
createdAt DateTime @default(now())
resultId String
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
blockId String
groupId String
variableId String?
content String
createdAt DateTime @default(now())
resultId String
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
blockId String
groupId String
variableId String?
content String
storageUsed Int?
@@unique([resultId, blockId, groupId])

View File

@ -15,6 +15,7 @@ export type ResultValues = Pick<
>
export type ResultHeaderCell = {
id: string
label: string
blockId?: string
blockType?: InputBlockType

View File

@ -31,6 +31,12 @@ const edgeSchema = z.object({
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({
version: z.enum(['2']).optional(),
id: z.string(),
@ -48,6 +54,7 @@ const typebotSchema = z.object({
publicId: z.string().nullable(),
customDomain: z.string().nullable(),
workspaceId: z.string(),
resultsTablePreferences: resultsTablePreferencesSchema.optional(),
})
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 Edge = z.infer<typeof edgeSchema>
export type Group = z.infer<typeof groupSchema>
export type ResultsTablePreferences = z.infer<
typeof resultsTablePreferencesSchema
>

View File

@ -18,7 +18,7 @@ export const parseResultHeader = ({
}): ResultHeaderCell[] => {
const parsedGroups = parseInputsResultHeader({ groups, variables })
return [
{ label: 'Submitted at' },
{ label: 'Submitted at', id: 'date' },
...parsedGroups,
...parseVariablesHeaders(variables, parsedGroups),
]
@ -62,6 +62,7 @@ const parseInputsResultHeader = ({
return [
...headers,
{
id: inputBlock.id,
blockType: inputBlock.type,
blockId: inputBlock.id,
variableId: inputBlock.options.variableId,
@ -80,6 +81,7 @@ const parseVariablesHeaders = (
return [
...headers,
{
id: v.id,
label: v.name,
variableId: v.id,
},

View File

@ -1885,6 +1885,37 @@
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.4.1.tgz#75b4c27948c81e88ccd3a8902047bcd797f38d32"
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":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.1.0.tgz#6781cad43fc2e034d012ee44beddf8f93ba21f19"
@ -3620,6 +3651,18 @@
dependencies:
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":
version "4.2.6"
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"
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:
version "8.3.4"
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524"