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 ( + + + + + + + + + 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()} + + + + + + + ))} + + ) +} + +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"