diff --git a/apps/builder/src/components/icons.tsx b/apps/builder/src/components/icons.tsx index 8a97a534a..9aabe54ce 100644 --- a/apps/builder/src/components/icons.tsx +++ b/apps/builder/src/components/icons.tsx @@ -31,6 +31,12 @@ export const ChevronLeftIcon = (props: IconProps) => ( ) +export const ChevronRightIcon = (props: IconProps) => ( + + + +) + export const PlusIcon = (props: IconProps) => ( @@ -57,6 +63,14 @@ export const MoreVerticalIcon = (props: IconProps) => ( ) +export const MoreHorizontalIcon = (props: IconProps) => ( + + + + + +) + export const GlobeIcon = (props: IconProps) => ( @@ -505,3 +519,14 @@ export const CloudOffIcon = (props: IconProps) => ( ) + +export const ListIcon = (props: IconProps) => ( + + + + + + + + +) diff --git a/apps/builder/src/features/account/components/UserPreferenceForm/UserPreferencesForm.tsx b/apps/builder/src/features/account/components/UserPreferenceForm/UserPreferencesForm.tsx index de353d3c3..0e2fa6dc7 100644 --- a/apps/builder/src/features/account/components/UserPreferenceForm/UserPreferencesForm.tsx +++ b/apps/builder/src/features/account/components/UserPreferenceForm/UserPreferencesForm.tsx @@ -6,7 +6,7 @@ import { GraphNavigationRadioGroup } from './GraphNavigationRadioGroup' import { AppearanceRadioGroup } from './AppearanceRadioGroup' export const UserPreferencesForm = () => { - const { setColorMode } = useColorMode() + const { colorMode, setColorMode } = useColorMode() const { user, updateUser } = useUser() useEffect(() => { @@ -35,7 +35,11 @@ export const UserPreferencesForm = () => { Appearance diff --git a/apps/builder/src/features/results/components/ResultsTable/ColumnSettings.tsx b/apps/builder/src/features/results/components/ResultsTable/ColumnSettings.tsx new file mode 100644 index 000000000..dfdbe90cd --- /dev/null +++ b/apps/builder/src/features/results/components/ResultsTable/ColumnSettings.tsx @@ -0,0 +1,170 @@ +import { EyeOffIcon, GripIcon, EyeIcon } from '@/components/icons' +import { Stack, Portal, Flex, HStack, IconButton } from '@chakra-ui/react' +import { + DndContext, + closestCenter, + DragOverlay, + useSensors, + PointerSensor, + KeyboardSensor, + useSensor, + DragEndEvent, + DragStartEvent, +} from '@dnd-kit/core' +import { + SortableContext, + verticalListSortingStrategy, + useSortable, + sortableKeyboardCoordinates, + arrayMove, +} from '@dnd-kit/sortable' +import { Text } from '@chakra-ui/react' +import { ResultHeaderCell } from 'models' +import { HeaderIcon } from '../../utils' +import { useState } from 'react' +import { CSS } from '@dnd-kit/utilities' + +type Props = { + resultHeader: ResultHeaderCell[] + columnVisibility: { [key: string]: boolean } + columnOrder: string[] + onColumnOrderChange: (columnOrder: string[]) => void + setColumnVisibility: (columnVisibility: { [key: string]: boolean }) => void +} + +export const ColumnSettings = ({ + 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 sortedHeader = resultHeader.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) { + const oldIndex = columnOrder.indexOf(active.id as string) + const newIndex = columnOrder.indexOf(over?.id as string) + if (newIndex === -1 || oldIndex === -1) return + const newColumnOrder = arrayMove(columnOrder, oldIndex, newIndex) + onColumnOrderChange(newColumnOrder) + } + } + + return ( + + + + Shown in table: + + + + {sortedHeader.map((header) => ( + + ))} + + + + {draggingColumnId ? : null} + + + + + + ) +} + +const SortableColumns = ({ + header, + hiddenHeaders, + onEyeClick, +}: { + header: ResultHeaderCell + hiddenHeaders: ResultHeaderCell[] + onEyeClick: (key: string) => () => void +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: header.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + const isHidden = hiddenHeaders.some( + (hiddenHeader) => hiddenHeader.id === header.id + ) + + return ( + + + } + aria-label={'Drag'} + variant="ghost" + {...listeners} + /> + + {header.label} + + : } + size="sm" + aria-label={'Hide column'} + onClick={onEyeClick(header.id)} + /> + + ) +} diff --git a/apps/builder/src/features/results/components/ResultsTable/ColumnsSettingsButton.tsx b/apps/builder/src/features/results/components/ResultsTable/ColumnsSettingsButton.tsx deleted file mode 100644 index 25dae657d..000000000 --- a/apps/builder/src/features/results/components/ResultsTable/ColumnsSettingsButton.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { - Popover, - PopoverTrigger, - Button, - PopoverContent, - PopoverBody, - Stack, - IconButton, - Flex, - HStack, - Text, - Portal, -} from '@chakra-ui/react' -import { ToolIcon, EyeIcon, EyeOffIcon, GripIcon } from '@/components/icons' -import { ResultHeaderCell } from 'models' -import React, { useState } from 'react' -import { isNotDefined } from 'utils' -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragEndEvent, - DragStartEvent, - DragOverlay, -} from '@dnd-kit/core' -import { CSS } from '@dnd-kit/utilities' -import { - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, - useSortable, - arrayMove, -} from '@dnd-kit/sortable' -import { HeaderIcon } from '../../utils' - -type Props = { - resultHeader: ResultHeaderCell[] - columnVisibility: { [key: string]: boolean } - columnOrder: string[] - onColumnOrderChange: (columnOrder: string[]) => void - setColumnVisibility: (columnVisibility: { [key: string]: boolean }) => void -} - -export const ColumnSettingsButton = ({ - resultHeader, - columnVisibility, - setColumnVisibility, - columnOrder, - onColumnOrderChange, -}: Props) => { - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ) - const [draggingColumnId, setDraggingColumnId] = useState(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) { - const oldIndex = columnOrder.indexOf(active.id as string) - const newIndex = columnOrder.indexOf(over?.id as string) - if (newIndex === -1 || oldIndex === -1) return - 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)} - /> - - ) -} diff --git a/apps/builder/src/features/results/components/ResultsTable/ExportAllResultsModal.tsx b/apps/builder/src/features/results/components/ResultsTable/ExportAllResultsModal.tsx new file mode 100644 index 000000000..8f2810a00 --- /dev/null +++ b/apps/builder/src/features/results/components/ResultsTable/ExportAllResultsModal.tsx @@ -0,0 +1,157 @@ +import { AlertInfo } from '@/components/AlertInfo' +import { DownloadIcon } from '@/components/icons' +import { SwitchWithLabel } from '@/components/SwitchWithLabel' +import { useTypebot } from '@/features/editor' +import { useToast } from '@/hooks/useToast' +import { trpc } from '@/lib/trpc' +import { + Button, + HStack, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Stack, +} from '@chakra-ui/react' +import { TRPCError } from '@trpc/server' +import { unparse } from 'papaparse' +import { useState } from 'react' +import { parseResultHeader } from 'utils/results' +import { useResults } from '../../ResultsProvider' +import { convertResultsToTableData, parseAccessor } from '../../utils' + +type Props = { + isOpen: boolean + onClose: () => void +} + +export const ExportAllResultsModal = ({ isOpen, onClose }: Props) => { + const { typebot, publishedTypebot, linkedTypebots } = useTypebot() + const workspaceId = typebot?.workspaceId + const typebotId = typebot?.id + const { showToast } = useToast() + const { resultHeader: existingResultHeader } = useResults() + const trpcContext = trpc.useContext() + const [isExportLoading, setIsExportLoading] = useState(false) + + const [areDeletedBlocksIncluded, setAreDeletedBlocksIncluded] = + useState(false) + + const getAllResults = async () => { + if (!workspaceId || !typebotId) return [] + const allResults = [] + let cursor: string | undefined + do { + try { + const { results, nextCursor } = + await trpcContext.results.getResults.fetch({ + typebotId, + limit: '200', + cursor, + }) + allResults.push(...results) + cursor = nextCursor ?? undefined + } catch (error) { + showToast({ description: (error as TRPCError).message }) + } + } while (cursor) + + return allResults + } + + const exportAllResultsToCSV = async () => { + if (!publishedTypebot) return + + setIsExportLoading(true) + + const results = await getAllResults() + + const resultHeader = areDeletedBlocksIncluded + ? parseResultHeader(publishedTypebot, linkedTypebots, results) + : existingResultHeader + + const dataToUnparse = convertResultsToTableData(results, resultHeader) + + const fields = + typebot?.resultsTablePreferences?.columnsOrder && + !areDeletedBlocksIncluded + ? typebot.resultsTablePreferences.columnsOrder.reduce( + (currentHeaderLabels, columnId) => { + if ( + typebot.resultsTablePreferences?.columnsVisibility[columnId] === + false + ) + return currentHeaderLabels + const columnLabel = resultHeader.find( + (headerCell) => headerCell.id === columnId + )?.label + if (!columnLabel) return currentHeaderLabels + return [...currentHeaderLabels, columnLabel] + }, + [] + ) + : resultHeader.map((headerCell) => headerCell.label) + + const data = dataToUnparse.map<{ [key: string]: string }>((data) => { + const newObject: { [key: string]: string } = {} + fields?.forEach((field) => { + newObject[field] = data[parseAccessor(field)]?.plainText + }) + return newObject + }) + + const csvData = new Blob( + [ + unparse({ + data, + fields, + }), + ], + { + type: 'text/csv;charset=utf-8;', + } + ) + const fileName = `typebot-export_${new Date() + .toLocaleDateString() + .replaceAll('/', '-')}` + const tempLink = document.createElement('a') + tempLink.href = window.URL.createObjectURL(csvData) + tempLink.setAttribute('download', `${fileName}.csv`) + tempLink.click() + setIsExportLoading(false) + } + + return ( + + + + + + + The export may take up to 1 minute. + + + + + + + + ) +} diff --git a/apps/builder/src/features/results/components/ResultsTable/ResultsTable.tsx b/apps/builder/src/features/results/components/ResultsTable/ResultsTable.tsx index bad7d873c..f1f108269 100644 --- a/apps/builder/src/features/results/components/ResultsTable/ResultsTable.tsx +++ b/apps/builder/src/features/results/components/ResultsTable/ResultsTable.tsx @@ -2,7 +2,6 @@ import { Box, Button, chakra, - Flex, HStack, Stack, Text, @@ -18,9 +17,9 @@ import { ColumnDef, Updater, } from '@tanstack/react-table' -import { ColumnSettingsButton } from './ColumnsSettingsButton' +import { TableSettingsButton } from './TableSettingsButton' import { useTypebot } from '@/features/editor' -import { ResultsActionButtons } from './ResultsActionButtons' +import { SelectionToolbar } from './SelectionToolbar' import { Row } from './Row' import { HeaderRow } from './HeaderRow' import { CellValueType, TableData } from '../../types' @@ -55,10 +54,13 @@ export const ResultsTable = ({ const tableWrapper = useRef(null) const { - columnsOrder = parseDefaultColumnOrder(resultHeader), + columnsOrder, columnsVisibility = {}, columnsWidth = {}, - } = preferences ?? {} + } = { + ...preferences, + columnsOrder: parseColumnOrder(preferences?.columnsOrder, resultHeader), + } const changeColumnOrder = (newColumnOrder: string[]) => { if (typeof newColumnOrder === 'function') return @@ -198,20 +200,19 @@ export const ResultsTable = ({ return ( - - + setRowSelection({})} - mr="2" /> - - + [ - 'select', - ...resultHeader.map((h) => h.id), - 'logs', -] +const parseColumnOrder = ( + existingOrder: string[] | undefined, + resultHeader: ResultHeaderCell[] +) => + existingOrder + ? [ + ...existingOrder.slice(0, -1), + ...resultHeader + .filter((header) => !existingOrder.includes(header.id)) + .map((h) => h.id), + 'logs', + ] + : ['select', ...resultHeader.map((h) => h.id), 'logs'] diff --git a/apps/builder/src/features/results/components/ResultsTable/ResultsActionButtons.tsx b/apps/builder/src/features/results/components/ResultsTable/SelectionToolbar.tsx similarity index 56% rename from apps/builder/src/features/results/components/ResultsTable/ResultsActionButtons.tsx rename to apps/builder/src/features/results/components/ResultsTable/SelectionToolbar.tsx index 423504816..2ee205a97 100644 --- a/apps/builder/src/features/results/components/ResultsTable/ResultsActionButtons.tsx +++ b/apps/builder/src/features/results/components/ResultsTable/SelectionToolbar.tsx @@ -1,11 +1,10 @@ import { HStack, Button, - Fade, - Tag, Text, useDisclosure, - StackProps, + IconButton, + useColorModeValue, } from '@chakra-ui/react' import { DownloadIcon, TrashIcon } from '@/components/icons' import { ConfirmModal } from '@/components/ConfirmModal' @@ -13,21 +12,20 @@ import { useTypebot } from '@/features/editor' import { unparse } from 'papaparse' import React, { useState } from 'react' import { useToast } from '@/hooks/useToast' -import { convertResultsToTableData, parseAccessor } from '../../utils' +import { parseAccessor } from '../../utils' import { useResults } from '../../ResultsProvider' import { trpc } from '@/lib/trpc' -import { TRPCError } from '@trpc/server' -type ResultsActionButtonsProps = { +type Props = { selectedResultsId: string[] onClearSelection: () => void } -export const ResultsActionButtons = ({ +export const SelectionToolbar = ({ selectedResultsId, onClearSelection, - ...props -}: ResultsActionButtonsProps & StackProps) => { +}: Props) => { + const selectLabelColor = useColorModeValue('blue.500', 'blue.200') const { typebot } = useTypebot() const { showToast } = useToast() const { @@ -59,28 +57,6 @@ export const ResultsActionButtons = ({ const workspaceId = typebot?.workspaceId const typebotId = typebot?.id - const getAllTableData = async () => { - if (!workspaceId || !typebotId) return [] - const allResults = [] - let cursor: string | undefined - do { - try { - const { results, nextCursor } = - await trpcContext.results.getResults.fetch({ - typebotId, - limit: '200', - cursor, - }) - allResults.push(...results) - cursor = nextCursor ?? undefined - } catch (error) { - showToast({ description: (error as TRPCError).message }) - } - } while (cursor) - - return convertResultsToTableData(allResults, resultHeader) - } - const totalSelected = selectedResultsId.length > 0 && selectedResultsId.length === results?.length ? totalResults @@ -90,22 +66,16 @@ export const ResultsActionButtons = ({ if (!workspaceId || !typebotId) return deleteResultsMutation.mutate({ typebotId, - resultIds: - totalSelected === totalResults - ? undefined - : selectedResultsId.join(','), + resultIds: selectedResultsId.join(','), }) } const exportResultsToCSV = async () => { setIsExportLoading(true) - const isSelectAll = totalSelected === 0 || totalSelected === totalResults - const dataToUnparse = isSelectAll - ? await getAllTableData() - : tableData.filter((data) => - selectedResultsId.includes(data.id.plainText) - ) + const dataToUnparse = tableData.filter((data) => + selectedResultsId.includes(data.id.plainText) + ) const fields = typebot?.resultsTablePreferences?.columnsOrder ? typebot.resultsTablePreferences.columnsOrder.reduce( @@ -144,64 +114,65 @@ export const ResultsActionButtons = ({ type: 'text/csv;charset=utf-8;', } ) - const fileName = - `typebot-export_${new Date().toLocaleDateString().replaceAll('/', '-')}` + - (isSelectAll ? `_all` : ``) + const fileName = `typebot-export_${new Date() + .toLocaleDateString() + .replaceAll('/', '-')}` const tempLink = document.createElement('a') tempLink.href = window.URL.createObjectURL(csvData) tempLink.setAttribute('download', `${fileName}.csv`) tempLink.click() setIsExportLoading(false) } + + if (totalSelected === 0) return null + return ( - - + + } onClick={exportResultsToCSV} isLoading={isExportLoading} - > - - Export {totalSelected > 0 ? '' : 'all'} + size="sm" + /> - {totalSelected && ( - - {totalSelected} - - )} - + } + onClick={onOpen} + isLoading={isDeleteLoading} + size="sm" + /> - 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'} - /> - + + You are about to delete{' '} + + {totalSelected} submission + {totalSelected > 1 ? 's' : ''} + + . Are you sure you wish to continue? + + } + confirmButtonLabel={'Delete'} + /> ) } diff --git a/apps/builder/src/features/results/components/ResultsTable/TableSettingsButton.tsx b/apps/builder/src/features/results/components/ResultsTable/TableSettingsButton.tsx new file mode 100644 index 000000000..bcccaf64d --- /dev/null +++ b/apps/builder/src/features/results/components/ResultsTable/TableSettingsButton.tsx @@ -0,0 +1,118 @@ +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverBody, + Stack, + IconButton, + Portal, + Button, + Text, + HStack, + useDisclosure, +} from '@chakra-ui/react' +import { + ChevronRightIcon, + DownloadIcon, + ListIcon, + MoreHorizontalIcon, +} from '@/components/icons' +import { ResultHeaderCell } from 'models' +import React, { useState } from 'react' +import { ColumnSettings } from './ColumnSettings' +import { ExportAllResultsModal } from './ExportAllResultsModal' + +type Props = { + resultHeader: ResultHeaderCell[] + columnVisibility: { [key: string]: boolean } + columnOrder: string[] + onColumnOrderChange: (columnOrder: string[]) => void + setColumnVisibility: (columnVisibility: { [key: string]: boolean }) => void +} + +export const TableSettingsButton = (props: Props) => { + const { isOpen, onOpen, onClose } = useDisclosure() + return ( + <> + + + } + /> + + + + + + + + + + ) +} + +const TableSettingsMenu = ({ + resultHeader, + columnVisibility, + setColumnVisibility, + columnOrder, + onColumnOrderChange, + onExportAllClick, +}: Props & { onExportAllClick: () => void }) => { + const [selectedMenu, setSelectedMenu] = useState< + 'export' | 'columnSettings' | null + >(null) + + switch (selectedMenu) { + case 'columnSettings': + return ( + + + + ) + default: + return ( + + + noOfLines={1} + + + ) + } +} diff --git a/apps/builder/src/features/results/results.spec.ts b/apps/builder/src/features/results/results.spec.ts index 9d8281b89..85257d6f8 100644 --- a/apps/builder/src/features/results/results.spec.ts +++ b/apps/builder/src/features/results/results.spec.ts @@ -52,9 +52,10 @@ test('table features should work', async ({ page }) => { 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.getByRole('button', { name: 'Open table settings' }).click() + await page.getByRole('button', { name: 'Column settings' }).click() await page.click('[aria-label="Hide column"] >> nth=0') - await page.click('[aria-label="Hide column"] >> nth=1') + await page.click('[aria-label="Hide column"] >> nth=2') await expect( page.locator('[data-testid="Submitted at header"]') ).toBeHidden() @@ -65,23 +66,23 @@ test('table features should work', async ({ page }) => { 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', + '[aria-label="Drag"] >> nth=3', + '[aria-label="Drag"] >> nth=3', { targetPosition: { x: 0, y: 80 }, force: true } ) - await expect(page.locator('th >> nth=1')).toHaveText('Name') - await expect(page.locator('th >> nth=2')).toHaveText('Welcome') + await expect(page.locator('th >> nth=3')).toHaveText('Name') + await expect(page.locator('th >> nth=1')).toHaveText('Welcome') }) await test.step('Preferences should be persisted', async () => { await saveAndReload(page) - expect((await page.locator('th >> nth=1').boundingBox())?.width).toBe(345) + expect((await page.locator('th >> nth=3').boundingBox())?.width).toBe(345) await expect( page.locator('[data-testid="Submitted at header"]') ).toBeHidden() await expect(page.locator('[data-testid="Email header"]')).toBeHidden() - await expect(page.locator('th >> nth=1')).toHaveText('Name') - await expect(page.locator('th >> nth=2')).toHaveText('Welcome') + await expect(page.locator('th >> nth=1')).toHaveText('Welcome') + await expect(page.locator('th >> nth=3')).toHaveText('Name') }) await test.step('Infinite scroll', async () => { @@ -105,9 +106,10 @@ test('table features should work', async ({ page }) => { // For some reason, we need to double click on checkboxes to check them await getNthCheckbox(page, 1).dblclick() await getNthCheckbox(page, 2).dblclick() + await expect(page.getByRole('button', { name: '2 selected' })).toBeVisible() const [download] = await Promise.all([ page.waitForEvent('download'), - page.getByRole('button', { name: 'Export 2' }).click(), + page.getByRole('button', { name: 'Export' }).click(), ]) const path = await download.path() expect(path).toBeDefined() @@ -116,9 +118,12 @@ test('table features should work', async ({ page }) => { validateExportSelection(data) await getNthCheckbox(page, 0).click() + await expect( + page.getByRole('button', { name: '200 selected' }) + ).toBeVisible() const [downloadAll] = await Promise.all([ page.waitForEvent('download'), - page.getByRole('button', { name: 'Export 200' }).click(), + page.getByRole('button', { name: 'Export' }).click(), ]) const pathAll = await downloadAll.path() expect(pathAll).toBeDefined() @@ -126,18 +131,30 @@ test('table features should work', async ({ page }) => { const { data: dataAll } = parse(fileAll) validateExportAll(dataAll) await getNthCheckbox(page, 0).click() + await page.getByRole('button', { name: 'Open table settings' }).click() + await page.getByRole('button', { name: 'Export all' }).click() + const [downloadAllFromMenu] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Export' }).click(), + ]) + const pathAllFromMenu = await downloadAllFromMenu.path() + expect(pathAllFromMenu).toBeDefined() + const fileAllFromMenu = readFileSync(pathAllFromMenu as string).toString() + const { data: dataAllFromMenu } = parse(fileAllFromMenu) + validateExportAll(dataAllFromMenu) + await page.getByRole('button', { name: 'Cancel' }).click() }) await test.step('Delete', async () => { await getNthCheckbox(page, 1).click() await getNthCheckbox(page, 2).click() - await page.click('text="Delete"') + await page.getByRole('button', { name: 'Delete' }).click() 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 page.getByRole('button', { name: 'Delete' }).click() await deleteButtonInConfirmDialog(page).click() await page.waitForTimeout(1000) expect(await page.locator('tr').count()).toBe(1) @@ -147,14 +164,14 @@ test('table features should work', async ({ page }) => { const validateExportSelection = (data: unknown[]) => { expect(data).toHaveLength(3) - expect((data[1] as unknown[])[0]).toBe('content199') - expect((data[2] as unknown[])[0]).toBe('content198') + expect((data[1] as unknown[])[2]).toBe('content199') + expect((data[2] as unknown[])[2]).toBe('content198') } const validateExportAll = (data: unknown[]) => { expect(data).toHaveLength(201) - expect((data[1] as unknown[])[0]).toBe('content199') - expect((data[200] as unknown[])[0]).toBe('content0') + expect((data[1] as unknown[])[2]).toBe('content199') + expect((data[200] as unknown[])[2]).toBe('content0') } const scrollToBottom = (page: Page) => diff --git a/apps/builder/src/features/typebot/api/utils/isReadTypebotForbidden.ts b/apps/builder/src/features/typebot/api/utils/isReadTypebotForbidden.ts index 63350595e..16a689f9e 100644 --- a/apps/builder/src/features/typebot/api/utils/isReadTypebotForbidden.ts +++ b/apps/builder/src/features/typebot/api/utils/isReadTypebotForbidden.ts @@ -1,7 +1,6 @@ import prisma from '@/lib/prisma' import { CollaboratorsOnTypebots, User } from 'db' import { Typebot } from 'models' -import { isNotDefined } from 'utils' export const isReadTypebotForbidden = async ( typebot: Pick & { @@ -22,5 +21,5 @@ export const isReadTypebotForbidden = async ( userId: user.id, }, }) - return isNotDefined(memberInWorkspace) + return memberInWorkspace === null } diff --git a/apps/viewer/src/features/blocks/inputs/fileUpload/fileUpload.spec.ts b/apps/viewer/src/features/blocks/inputs/fileUpload/fileUpload.spec.ts index 297f0e095..db8cdd1a1 100644 --- a/apps/viewer/src/features/blocks/inputs/fileUpload/fileUpload.spec.ts +++ b/apps/viewer/src/features/blocks/inputs/fileUpload/fileUpload.spec.ts @@ -48,7 +48,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('text="Export"').click(), + page.getByRole('button', { name: 'Export' }).click(), ]) const downloadPath = await download.path() expect(downloadPath).toBeDefined() @@ -71,8 +71,8 @@ test('should work as expected', async ({ page, browser }) => { await page2.goto(urls[0]) await expect(page2.locator('pre')).toBeVisible() + page.getByRole('button', { name: 'Delete' }).click() await page.locator('button >> text="Delete"').click() - await page.locator('button >> text="Delete" >> nth=1').click() await expect(page.locator('text="api.json"')).toBeHidden() await page2.goto(urls[0]) await expect(page2.locator('pre')).toBeHidden() diff --git a/apps/viewer/src/features/blocks/inputs/fileUpload/fileUploadV2.spec.ts b/apps/viewer/src/features/blocks/inputs/fileUpload/fileUploadV2.spec.ts index 1526d095e..914c28297 100644 --- a/apps/viewer/src/features/blocks/inputs/fileUpload/fileUploadV2.spec.ts +++ b/apps/viewer/src/features/blocks/inputs/fileUpload/fileUploadV2.spec.ts @@ -45,7 +45,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('text="Export"').click(), + page.getByRole('button', { name: 'Export' }).click(), ]) const downloadPath = await download.path() expect(downloadPath).toBeDefined() @@ -68,8 +68,8 @@ test('should work as expected', async ({ page, browser }) => { await page2.goto(urls[0]) await expect(page2.locator('pre')).toBeVisible() + page.getByRole('button', { name: 'Delete' }).click() await page.locator('button >> text="Delete"').click() - await page.locator('button >> text="Delete" >> nth=1').click() await expect(page.locator('text="api.json"')).toBeHidden() await page2.goto(urls[0]) await expect(page2.locator('pre')).toBeHidden() diff --git a/apps/viewer/src/features/blocks/integrations/webhook/webhook.spec.ts b/apps/viewer/src/features/blocks/integrations/webhook/webhook.spec.ts index f7f089ff3..b19d6f825 100644 --- a/apps/viewer/src/features/blocks/integrations/webhook/webhook.spec.ts +++ b/apps/viewer/src/features/blocks/integrations/webhook/webhook.spec.ts @@ -3,17 +3,15 @@ import { createId } from '@paralleldrive/cuid2' import { HttpMethod, Typebot } from 'models' import { createWebhook, - deleteTypebots, - deleteWebhooks, importTypebotInDatabase, } from 'utils/playwright/databaseActions' import { typebotViewer } from 'utils/playwright/testHelpers' import { apiToken } from 'utils/playwright/databaseSetup' import { getTestAsset } from '@/test/utils/playwright' -test.describe('Bot', () => { - const typebotId = createId() +const typebotId = createId() +test.describe('Bot', () => { test.beforeEach(async () => { await importTypebotInDatabase(getTestAsset('typebots/webhook.json'), { id: typebotId, @@ -45,19 +43,10 @@ test.describe('Bot', () => { body: `{{Full body}}`, }) } catch (err) { - console.log(err) + // Webhooks already created } }) - test.afterEach(async () => { - await deleteTypebots([typebotId]) - await deleteWebhooks([ - 'failing-webhook', - 'partial-body-webhook', - 'full-body-webhook', - ]) - }) - test('should execute webhooks properly', async ({ page }) => { await page.goto(`/${typebotId}-public`) await typebotViewer(page).locator('text=Send failing webhook').click() diff --git a/apps/viewer/src/features/blocks/integrations/webhook/webhookV2.spec.ts b/apps/viewer/src/features/blocks/integrations/webhook/webhookV2.spec.ts index 3bb02f29e..bb78bb73e 100644 --- a/apps/viewer/src/features/blocks/integrations/webhook/webhookV2.spec.ts +++ b/apps/viewer/src/features/blocks/integrations/webhook/webhookV2.spec.ts @@ -3,8 +3,6 @@ import { createId } from '@paralleldrive/cuid2' import { HttpMethod } from 'models' import { createWebhook, - deleteTypebots, - deleteWebhooks, importTypebotInDatabase, } from 'utils/playwright/databaseActions' import { getTestAsset } from '@/test/utils/playwright' @@ -42,19 +40,10 @@ test.beforeEach(async () => { body: `{{Full body}}`, }) } catch (err) { - console.log(err) + // Webhooks already created } }) -test.afterEach(async () => { - await deleteTypebots([typebotId]) - await deleteWebhooks([ - 'failing-webhook', - 'partial-body-webhook', - 'full-body-webhook', - ]) -}) - test('should execute webhooks properly', async ({ page }) => { await page.goto(`/next/${typebotId}-public`) await page.locator('text=Send failing webhook').click() diff --git a/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts b/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts index 6a6b3968e..56cb4bd26 100644 --- a/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts +++ b/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts @@ -99,9 +99,10 @@ const processAndSaveAnswer = block: InputBlock ) => async (reply: string): Promise => { - state.result && - !state.isPreview && - (await saveAnswer(state.result.id, block)(reply)) + if (!state.isPreview && state.result) { + await saveAnswer(state.result.id, block)(reply) + if (!state.result.hasStarted) await setResultAsStarted(state.result.id) + } const newVariables = saveVariableValueIfAny(state, block)(reply) return newVariables } @@ -126,6 +127,13 @@ const saveVariableValueIfAny = ] } +const setResultAsStarted = async (resultId: string) => { + await prisma.result.update({ + where: { id: resultId }, + data: { hasStarted: true }, + }) +} + const parseRetryMessage = ( block: InputBlock ): Pick => { diff --git a/packages/utils/results.ts b/packages/utils/results.ts index f9b4ad613..087b7c485 100644 --- a/packages/utils/results.ts +++ b/packages/utils/results.ts @@ -7,12 +7,15 @@ import { VariableWithValue, Typebot, ResultWithAnswersInput, + ResultWithAnswers, + InputBlockType, } from 'models' -import { isInputBlock, isDefined, byId } from './utils' +import { isInputBlock, isDefined, byId, isNotEmpty } from './utils' export const parseResultHeader = ( typebot: Pick, - linkedTypebots: Pick[] | undefined + linkedTypebots: Pick[] | undefined, + results?: ResultWithAnswers[] ): ResultHeaderCell[] => { const parsedGroups = [ ...typebot.groups, @@ -32,6 +35,7 @@ export const parseResultHeader = ( { label: 'Submitted at', id: 'date' }, ...inputsResultHeader, ...parseVariablesHeaders(parsedVariables, inputsResultHeader), + ...parseResultsFromPreviousBotVersions(results ?? [], inputsResultHeader), ] } @@ -168,6 +172,32 @@ const parseVariablesHeaders = ( return [...existingHeaders, newHeaderCell] }, []) +const parseResultsFromPreviousBotVersions = ( + results: ResultWithAnswers[], + existingInputResultHeaders: ResultHeaderCell[] +): ResultHeaderCell[] => + results + .flatMap((result) => result.answers) + .filter( + (answer) => + !answer.variableId && + existingInputResultHeaders.every( + (header) => header.id !== answer.blockId + ) && + isNotEmpty(answer.content) + ) + .map((answer) => ({ + id: answer.blockId, + label: `Deleted block`, + blocks: [ + { + id: answer.blockId, + groupId: answer.groupId, + }, + ], + blockType: InputBlockType.TEXT, + })) + export const parseAnswers = ( typebot: Pick,