diff --git a/apps/builder/assets/icons.tsx b/apps/builder/assets/icons.tsx
index 38e0343ff2..0e3b674e64 100644
--- a/apps/builder/assets/icons.tsx
+++ b/apps/builder/assets/icons.tsx
@@ -474,3 +474,10 @@ export const BuoyIcon = (props: IconProps) => (
)
+
+export const EyeOffIcon = (props: IconProps) => (
+
+
+
+
+)
diff --git a/apps/builder/layouts/results/AnalyticsContent.tsx b/apps/builder/components/analytics/AnalyticsContent.tsx
similarity index 100%
rename from apps/builder/layouts/results/AnalyticsContent.tsx
rename to apps/builder/components/analytics/AnalyticsContent.tsx
diff --git a/apps/builder/layouts/results/LogsModal.tsx b/apps/builder/components/results/LogsModal.tsx
similarity index 95%
rename from apps/builder/layouts/results/LogsModal.tsx
rename to apps/builder/components/results/LogsModal.tsx
index 0b40f38120..5421ab2383 100644
--- a/apps/builder/layouts/results/LogsModal.tsx
+++ b/apps/builder/components/results/LogsModal.tsx
@@ -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 (
diff --git a/apps/builder/components/results/ResultModal.tsx b/apps/builder/components/results/ResultModal.tsx
new file mode 100644
index 0000000000..7043d2af13
--- /dev/null
+++ b/apps/builder/components/results/ResultModal.tsx
@@ -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 (
+
+
+
+
+
+ {resultHeader.map((header) =>
+ result && result[header.label] ? (
+
+
+
+ {header.label}
+
+
+ {getHeaderValue(result[header.label])}
+
+
+ ) : (
+ <>>
+ )
+ )}
+
+
+
+ )
+}
diff --git a/apps/builder/components/results/ResultsActionButtons.tsx b/apps/builder/components/results/ResultsActionButtons.tsx
deleted file mode 100644
index 9704036e9c..0000000000
--- a/apps/builder/components/results/ResultsActionButtons.tsx
+++ /dev/null
@@ -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
- onExportClick: () => void
-}
-
-export const ResultsActionButtons = ({
- totalSelected,
- isDeleteLoading,
- isExportLoading,
- onDeleteClick,
- onExportClick,
-}: ResultsActionButtonsProps) => {
- const { isOpen, onOpen, onClose } = useDisclosure()
- return (
-
- 0} unmountOnExit>
-
-
- Export
-
-
- {totalSelected}
-
-
-
-
- 0} unmountOnExit>
-
-
- Delete
- {totalSelected > 0 && (
-
- {totalSelected}
-
- )}
-
-
- You are about to delete{' '}
-
- {totalSelected} submission
- {totalSelected > 1 ? 's' : ''}
-
- . Are you sure you wish to continue?
-
- }
- confirmButtonLabel={'Delete'}
- />
-
-
- )
-}
diff --git a/apps/builder/components/results/ResultsContent.tsx b/apps/builder/components/results/ResultsContent.tsx
new file mode 100644
index 0000000000..44fda05103
--- /dev/null
+++ b/apps/builder/components/results/ResultsContent.tsx
@@ -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(
+ 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 (
+
+ {totalHiddenResults && (
+
+ )}
+ {publishedTypebot && (
+
+ )}
+
+
+ {typebot && (
+
+ )}
+
+ )
+}
diff --git a/apps/builder/components/results/ResultsTable/ColumnsSettingsButton.tsx b/apps/builder/components/results/ResultsTable/ColumnsSettingsButton.tsx
new file mode 100644
index 0000000000..ef43fc97ab
--- /dev/null
+++ b/apps/builder/components/results/ResultsTable/ColumnsSettingsButton.tsx
@@ -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(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 (
+
+
+ }>Columns
+
+
+
+
+
+ Shown in table:
+
+
+
+ {visibleHeaders.map((header) => (
+
+ ))}
+
+
+
+ {draggingColumnId ? : null}
+
+
+
+
+ {hiddenHeaders.length > 0 && (
+
+
+ Hidden in table:
+
+ {hiddenHeaders.map((header) => (
+
+
+
+ {header.label}
+
+ }
+ size="sm"
+ aria-label={'Hide column'}
+ onClick={onEyeClick(header.id)}
+ />
+
+ ))}
+
+ )}
+
+
+
+ )
+}
+
+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 (
+
+
+ }
+ aria-label={'Drag'}
+ variant="ghost"
+ {...listeners}
+ />
+
+ {header.label}
+
+ }
+ size="sm"
+ aria-label={'Hide column'}
+ onClick={onEyeClick(header.id)}
+ />
+
+ )
+}
+
+const SortableColumnOverlay = forwardRef(
+ (_, ref: React.LegacyRef) => {
+ return
+ }
+)
diff --git a/apps/builder/components/results/ResultsTable/HeaderRow.tsx b/apps/builder/components/results/ResultsTable/HeaderRow.tsx
new file mode 100644
index 0000000000..fb914cdb46
--- /dev/null
+++ b/apps/builder/components/results/ResultsTable/HeaderRow.tsx
@@ -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
+}
+
+export const HeaderRow = ({ headerGroup }: Props) => {
+ return (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder ? null : header.renderHeader()}
+ {header.column.getCanResize() && (
+
+ )}
+
+ )
+ })}
+
+ )
+}
+
+const ResizeHandle = (props: BoxProps) => {
+ return (
+
+ )
+}
diff --git a/apps/builder/components/results/SubmissionsTable/LoadingRows.tsx b/apps/builder/components/results/ResultsTable/LoadingRows.tsx
similarity index 81%
rename from apps/builder/components/results/SubmissionsTable/LoadingRows.tsx
rename to apps/builder/components/results/ResultsTable/LoadingRows.tsx
index f5c74fd462..7880f923eb 100644
--- a/apps/builder/components/results/SubmissionsTable/LoadingRows.tsx
+++ b/apps/builder/components/results/ResultsTable/LoadingRows.tsx
@@ -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) => (
-
+
- {Array.from(Array(totalColumns)).map((cell, idx) => {
+ {Array.from(Array(totalColumns)).map((_, idx) => {
return (
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 (
+
+
+
+ Export {totalSelected > 0 ? '' : 'all'}
+
+ {totalSelected && (
+
+ {totalSelected}
+
+ )}
+
+
+ 0} unmountOnExit>
+
+
+ Delete
+ {totalSelected > 0 && (
+
+ {totalSelected}
+
+ )}
+
+
+ You are about to delete{' '}
+
+ {totalSelected} submission
+ {totalSelected > 1 ? 's' : ''}
+
+ . Are you sure you wish to continue?
+
+ }
+ confirmButtonLabel={'Delete'}
+ />
+
+
+ )
+}
diff --git a/apps/builder/components/results/ResultsTable/ResultsTable.tsx b/apps/builder/components/results/ResultsTable/ResultsTable.tsx
new file mode 100644
index 0000000000..38094ff838
--- /dev/null
+++ b/apps/builder/components/results/ResultsTable/ResultsTable.tsx
@@ -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()
+
+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>({})
+ const [columnsVisibility, setColumnsVisibility] = useState<
+ Record
+ >(preferences?.columnsVisibility || {})
+ const [columnsWidth, setColumnsWidth] = useState>(
+ preferences?.columnsWidth || {}
+ )
+ const [debouncedColumnsWidth] = useDebounce(columnsWidth, 500)
+ const [columnsOrder, setColumnsOrder] = useState([
+ '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(null)
+ const tableWrapper = useRef(null)
+
+ const columns = React.useMemo(
+ () => [
+ table.createDisplayColumn({
+ id: 'select',
+ enableResizing: false,
+ maxSize: 40,
+ header: ({ instance }) => (
+
+ ),
+ cell: ({ row }) => (
+
+
+
+ ),
+ }),
+ ...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: () => (
+
+
+ {header.label}
+
+ ),
+ })
+ ),
+ table.createDisplayColumn({
+ id: 'logs',
+ enableResizing: false,
+ maxSize: 110,
+ header: () => (
+
+
+ Logs
+
+ ),
+ cell: ({ row }) => (
+
+ ),
+ }),
+ ],
+ // 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 (
+
+
+ setRowSelection({})}
+ mr="2"
+ />
+
+
+
+
+
+ {instance.getHeaderGroups().map((headerGroup) => (
+
+ ))}
+
+
+
+ {instance.getRowModel().rows.map((row, rowIndex) => (
+
+ ))}
+ {hasMore === true && (
+
+ )}
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+ )
+ }
+)
+
+export const HeaderIcon = ({ header }: { header: ResultHeaderCell }) =>
+ header.blockType ? (
+
+ ) : header.variableId ? (
+
+ ) : (
+
+ )
diff --git a/apps/builder/components/results/ResultsTable/Row.tsx b/apps/builder/components/results/ResultsTable/Row.tsx
new file mode 100644
index 0000000000..5061e5f50d
--- /dev/null
+++ b/apps/builder/components/results/ResultsTable/Row.tsx
@@ -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
+ isSelected: boolean
+ bottomElement?: React.MutableRefObject
+ onExpandButtonClick: () => void
+}
+
+const Row = ({ row, bottomElement, onExpandButtonClick }: Props) => {
+ const [isExpandButtonVisible, setIsExpandButtonVisible] = useState(false)
+
+ const showExpandButton = () => setIsExpandButtonVisible(true)
+ const hideExpandButton = () => setIsExpandButtonVisible(false)
+ return (
+ {
+ if (bottomElement && bottomElement.current?.dataset.rowid !== row.id)
+ bottomElement.current = ref
+ }}
+ onMouseEnter={showExpandButton}
+ onClick={showExpandButton}
+ onMouseLeave={hideExpandButton}
+ >
+ {row.getVisibleCells().map((cell, cellIndex) => (
+
+ {cell.renderCell()}
+
+
+ }
+ shadow="lg"
+ size="xs"
+ onClick={onExpandButtonClick}
+ >
+ Open
+
+
+
+
+ ))}
+
+ )
+}
+
+export default memo(
+ Row,
+ (prev, next) =>
+ prev.row.id === next.row.id && prev.isSelected === next.isSelected
+)
diff --git a/apps/builder/components/results/ResultsTable/index.tsx b/apps/builder/components/results/ResultsTable/index.tsx
new file mode 100644
index 0000000000..256ba146bf
--- /dev/null
+++ b/apps/builder/components/results/ResultsTable/index.tsx
@@ -0,0 +1 @@
+export { ResultsTable as SubmissionsTable } from './ResultsTable'
diff --git a/apps/builder/components/results/SubmissionsTable/SubmissionsTable.tsx b/apps/builder/components/results/SubmissionsTable/SubmissionsTable.tsx
deleted file mode 100644
index 8ba97201b5..0000000000
--- a/apps/builder/components/results/SubmissionsTable/SubmissionsTable.tsx
+++ /dev/null
@@ -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(null)
- const tableWrapper = useRef(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 (
-
-
-
- {headerGroups.map((headerGroup: any) => {
- return (
-
- {headerGroup.headers.map((column: any) => {
- return (
-
- {column.render('Header')}
-
- )
- })}
-
-
-
- Logs
-
-
-
- )
- })}
-
-
-
- {rows.map((row: any, idx: number) => {
- prepareRow(row)
- return (
- {
- if (idx === data.length - 10) bottomElement.current = ref
- }}
- >
- {row.cells.map((cell: any) => {
- return (
- 100 ? 'normal' : 'nowrap'
- }
- {...cell.getCellProps()}
- >
- {isNotDefined(cell.value)
- ? cell.render('Cell')
- : cell.value.element ?? cell.value.plainText}
-
- )
- })}
-
-
-
-
- )
- })}
- {hasMore === true && (
-
- )}
-
-
-
- )
-}
-
-const checkboxColumnHook = (hooks: Hooks) => {
- hooks.visibleColumns.push((columns) => [
- {
- id: 'selection',
- Header: ({ getToggleAllRowsSelectedProps }: any) => (
-
- ),
- Cell: ({ row }: any) => (
-
- ),
- },
- ...columns,
- ])
-}
-
-const IndeterminateCheckbox = React.forwardRef(
- ({ indeterminate, checked, ...rest }: any, ref) => {
- const defaultRef = React.useRef()
- const resolvedRef: any = ref || defaultRef
-
- return (
-
-
-
- )
- }
-)
diff --git a/apps/builder/components/results/SubmissionsTable/index.tsx b/apps/builder/components/results/SubmissionsTable/index.tsx
deleted file mode 100644
index 9dd1f677db..0000000000
--- a/apps/builder/components/results/SubmissionsTable/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { SubmissionsTable } from './SubmissionsTable'
diff --git a/apps/builder/contexts/ResultsProvider.tsx b/apps/builder/contexts/ResultsProvider.tsx
new file mode 100644
index 0000000000..878e743910
--- /dev/null
+++ b/apps/builder/contexts/ResultsProvider.tsx
@@ -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 (
+ d.results) ?? [],
+ hasMore: hasMore ?? true,
+ tableData,
+ resultHeader,
+ totalResults,
+ totalHiddenResults,
+ onDeleteResults,
+ fetchMore,
+ mutate,
+ }}
+ >
+ {children}
+
+ )
+}
+
+export const useResults = () => useContext(resultsContext)
diff --git a/apps/builder/contexts/TypebotContext/TypebotContext.tsx b/apps/builder/contexts/TypebotContext/TypebotContext.tsx
index 3ca9c5568b..677764f5c0 100644
--- a/apps/builder/contexts/TypebotContext/TypebotContext.tsx
+++ b/apps/builder/contexts/TypebotContext/TypebotContext.tsx
@@ -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 = (
diff --git a/apps/builder/layouts/results/ResultsContent.tsx b/apps/builder/layouts/results/ResultsContent.tsx
deleted file mode 100644
index 15ef7518df..0000000000
--- a/apps/builder/layouts/results/ResultsContent.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
- {workspace &&
- publishedTypebot &&
- (isAnalytics ? (
-
- ) : (
-
- ))}
-
-
- )
-}
diff --git a/apps/builder/layouts/results/SubmissionContent.tsx b/apps/builder/layouts/results/SubmissionContent.tsx
deleted file mode 100644
index f8755a8655..0000000000
--- a/apps/builder/layouts/results/SubmissionContent.tsx
+++ /dev/null
@@ -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([])
- const [isDeleteLoading, setIsDeleteLoading] = useState(false)
- const [isExportLoading, setIsExportLoading] = useState(false)
- const [inspectingLogsResultId, setInspectingLogsResultId] = useState()
-
- 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 (
-
- {totalHiddenResults && (
-
- )}
- {publishedTypebot && (
-
- )}
-
-
-
-
-
-
- )
-}
diff --git a/apps/builder/package.json b/apps/builder/package.json
index 76c30af963..0b0e04edef 100644
--- a/apps/builder/package.json
+++ b/apps/builder/package.json
@@ -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",
diff --git a/apps/builder/pages/api/typebots/[typebotId].ts b/apps/builder/pages/api/typebots/[typebotId].ts
index 95244320d3..945fb2381e 100644
--- a/apps/builder/pages/api/typebots/[typebotId].ts
+++ b/apps/builder/pages/api/typebots/[typebotId].ts
@@ -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 })
diff --git a/apps/builder/pages/typebots/[typebotId]/results.tsx b/apps/builder/pages/typebots/[typebotId]/results.tsx
index 438be5af7e..4ed996fe29 100644
--- a/apps/builder/pages/typebots/[typebotId]/results.tsx
+++ b/apps/builder/pages/typebots/[typebotId]/results.tsx
@@ -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'
-
-const ResultsPage = () => (
-
-
-
-
-
-)
+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 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 (
+
+
+
+
+
+
+
+
+
+
+
+ {workspace &&
+ publishedTypebot &&
+ (isAnalytics ? (
+
+ ) : (
+
+
+
+ ))}
+
+
+
+ )
+}
export default ResultsPage
diff --git a/apps/builder/pages/typebots/[typebotId]/results/analytics.tsx b/apps/builder/pages/typebots/[typebotId]/results/analytics.tsx
index 9c7db48c92..e330789597 100644
--- a/apps/builder/pages/typebots/[typebotId]/results/analytics.tsx
+++ b/apps/builder/pages/typebots/[typebotId]/results/analytics.tsx
@@ -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 = () => (
-
-
-
-
-
-)
+const AnalyticsPage = ResultsPage
export default AnalyticsPage
diff --git a/apps/builder/playwright/tests/integrations/sendEmail.spec.ts b/apps/builder/playwright/tests/integrations/sendEmail.spec.ts
index 49156cf7fc..45f8b01c80 100644
--- a/apps/builder/playwright/tests/integrations/sendEmail.spec.ts
+++ b/apps/builder/playwright/tests/integrations/sendEmail.spec.ts
@@ -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()
})
})
diff --git a/apps/builder/playwright/tests/results.spec.ts b/apps/builder/playwright/tests/results.spec.ts
index 11b2bbfe42..f52fce98f0 100644
--- a/apps/builder/playwright/tests/results.spec.ts
+++ b/apps/builder/playwright/tests/results.spec.ts
@@ -16,123 +16,174 @@ 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)
- })
+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 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=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=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()
- })
+ 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('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)
- 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 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[]) => {
expect(data).toHaveLength(3)
expect((data[1] as unknown[])[1]).toBe('content199')
diff --git a/apps/builder/playwright/tests/settings.spec.ts b/apps/builder/playwright/tests/settings.spec.ts
index d75d984564..d371c01899 100644
--- a/apps/builder/playwright/tests/settings.spec.ts
+++ b/apps/builder/playwright/tests/settings.spec.ts
@@ -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', '')
diff --git a/apps/builder/services/typebots/results.tsx b/apps/builder/services/typebots/results.tsx
index b7cfdc48f9..4262823481 100644
--- a/apps/builder/services/typebots/results.tsx
+++ b/apps/builder/services/typebots/results.tsx
@@ -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),
},
diff --git a/apps/viewer/playwright/tests/fileUpload.spec.ts b/apps/viewer/playwright/tests/fileUpload.spec.ts
index 67ebd0f1d7..ab3e8b79b7 100644
--- a/apps/viewer/playwright/tests/fileUpload.spec.ts
+++ b/apps/viewer/playwright/tests/fileUpload.spec.ts
@@ -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()
diff --git a/apps/viewer/playwright/tests/hugeBlock.spec.ts b/apps/viewer/playwright/tests/hugeBlock.spec.ts
index 3fc36af941..f1a74967c1 100644
--- a/apps/viewer/playwright/tests/hugeBlock.spec.ts
+++ b/apps/viewer/playwright/tests/hugeBlock.spec.ts
@@ -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()
})
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 07a0555e8c..9c6c95cada 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -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])
diff --git a/packages/models/src/result.ts b/packages/models/src/result.ts
index d30b3f80e7..c07aed4334 100644
--- a/packages/models/src/result.ts
+++ b/packages/models/src/result.ts
@@ -15,6 +15,7 @@ export type ResultValues = Pick<
>
export type ResultHeaderCell = {
+ id: string
label: string
blockId?: string
blockType?: InputBlockType
diff --git a/packages/models/src/typebot/typebot.ts b/packages/models/src/typebot/typebot.ts
index 34a4cad552..8e508bac02 100644
--- a/packages/models/src/typebot/typebot.ts
+++ b/packages/models/src/typebot/typebot.ts
@@ -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
@@ -55,3 +62,6 @@ export type Target = z.infer
export type Source = z.infer
export type Edge = z.infer
export type Group = z.infer
+export type ResultsTablePreferences = z.infer<
+ typeof resultsTablePreferencesSchema
+>
diff --git a/packages/utils/src/results.ts b/packages/utils/src/results.ts
index 32e875c372..14ef995d7f 100644
--- a/packages/utils/src/results.ts
+++ b/packages/utils/src/results.ts
@@ -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,
},
diff --git a/yarn.lock b/yarn.lock
index 2ce17faa31..feac8adcef 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"