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 (
-
-
- }>Columns
-
-
-
-
-
-
- Shown in table:
-
-
-
- {visibleHeaders.map((header) => (
-
- ))}
-
-
-
- {draggingColumnId ? : null}
-
-
-
-
- {hiddenHeaders.length > 0 && (
-
-
- Hidden in table:
-
- {hiddenHeaders.map((header) => (
-
-
-
- {header.label}
-
- }
- size="sm"
- aria-label={'Hide column'}
- onClick={onEyeClick(header.id)}
- />
-
- ))}
-
- )}
-
-
-
-
- )
-}
-
-const SortableColumns = ({
- header,
- onEyeClick,
-}: {
- header: ResultHeaderCell
- onEyeClick: (key: string) => () => void
-}) => {
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: header.id })
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- }
-
- return (
-
-
- }
- aria-label={'Drag'}
- variant="ghost"
- {...listeners}
- />
-
- {header.label}
-
- }
- size="sm"
- aria-label={'Hide column'}
- onClick={onEyeClick(header.id)}
- />
-
- )
-}
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.
+
+
+
+ }
+ size="sm"
+ isLoading={isExportLoading}
+ >
+ Export
+
+
+
+
+ )
+}
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,