🚸 (results) Improve results action buttons
Now have an export all modal with settings and a better column order form
This commit is contained in:
@ -31,6 +31,12 @@ export const ChevronLeftIcon = (props: IconProps) => (
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ChevronRightIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const PlusIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
@ -57,6 +63,14 @@ export const MoreVerticalIcon = (props: IconProps) => (
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const MoreHorizontalIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="19" cy="12" r="1"></circle>
|
||||
<circle cx="5" cy="12" r="1"></circle>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const GlobeIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
@ -505,3 +519,14 @@ export const CloudOffIcon = (props: IconProps) => (
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ListIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<line x1="8" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="8" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="8" y1="18" x2="21" y2="18"></line>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
@ -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 = () => {
|
||||
<Stack spacing={6}>
|
||||
<Heading size="md">Appearance</Heading>
|
||||
<AppearanceRadioGroup
|
||||
defaultValue={user?.preferredAppAppearance ?? 'system'}
|
||||
defaultValue={
|
||||
user?.preferredAppAppearance
|
||||
? user.preferredAppAppearance
|
||||
: colorMode
|
||||
}
|
||||
onChange={changeAppearance}
|
||||
/>
|
||||
</Stack>
|
||||
|
@ -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<string | null>(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 (
|
||||
<Stack>
|
||||
<Stack>
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
Shown in table:
|
||||
</Text>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnOrder}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{sortedHeader.map((header) => (
|
||||
<SortableColumns
|
||||
key={header.id}
|
||||
header={header}
|
||||
onEyeClick={onEyeClick}
|
||||
hiddenHeaders={hiddenHeaders}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<Portal>
|
||||
<DragOverlay dropAnimation={{ duration: 0 }}>
|
||||
{draggingColumnId ? <Flex /> : null}
|
||||
</DragOverlay>
|
||||
</Portal>
|
||||
</DndContext>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
opacity={isDragging || isHidden ? 0.5 : 1}
|
||||
{...attributes}
|
||||
>
|
||||
<HStack>
|
||||
<IconButton
|
||||
size="sm"
|
||||
cursor="grab"
|
||||
icon={<GripIcon transform="rotate(90deg)" />}
|
||||
aria-label={'Drag'}
|
||||
variant="ghost"
|
||||
{...listeners}
|
||||
/>
|
||||
<HeaderIcon header={header} />
|
||||
<Text noOfLines={1}>{header.label}</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={isHidden ? <EyeOffIcon /> : <EyeIcon />}
|
||||
size="sm"
|
||||
aria-label={'Hide column'}
|
||||
onClick={onEyeClick(header.id)}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
@ -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<string | null>(null)
|
||||
|
||||
const onEyeClick = (id: string) => () => {
|
||||
columnVisibility[id] === false
|
||||
? setColumnVisibility({ ...columnVisibility, [id]: true })
|
||||
: setColumnVisibility({ ...columnVisibility, [id]: false })
|
||||
}
|
||||
const visibleHeaders = resultHeader
|
||||
.filter(
|
||||
(header) =>
|
||||
isNotDefined(columnVisibility[header.id]) || columnVisibility[header.id]
|
||||
)
|
||||
.sort((a, b) => columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id))
|
||||
const hiddenHeaders = resultHeader.filter(
|
||||
(header) => columnVisibility[header.id] === false
|
||||
)
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event
|
||||
setDraggingColumnId(active.id as string)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
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 (
|
||||
<Popover isLazy placement="bottom-end">
|
||||
<PopoverTrigger>
|
||||
<Button leftIcon={<ToolIcon />}>Columns</Button>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent w="400px">
|
||||
<PopoverBody
|
||||
as={Stack}
|
||||
spacing="4"
|
||||
p="4"
|
||||
maxH="450px"
|
||||
overflowY="scroll"
|
||||
>
|
||||
<Stack>
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
Shown in table:
|
||||
</Text>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnOrder}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{visibleHeaders.map((header) => (
|
||||
<SortableColumns
|
||||
key={header.id}
|
||||
header={header}
|
||||
onEyeClick={onEyeClick}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<Portal>
|
||||
<DragOverlay dropAnimation={{ duration: 0 }}>
|
||||
{draggingColumnId ? <Flex /> : null}
|
||||
</DragOverlay>
|
||||
</Portal>
|
||||
</DndContext>
|
||||
</Stack>
|
||||
{hiddenHeaders.length > 0 && (
|
||||
<Stack>
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
Hidden in table:
|
||||
</Text>
|
||||
{hiddenHeaders.map((header) => (
|
||||
<Flex key={header.id} justify="space-between">
|
||||
<HStack>
|
||||
<HeaderIcon header={header} />
|
||||
<Text>{header.label}</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<EyeOffIcon />}
|
||||
size="sm"
|
||||
aria-label={'Hide column'}
|
||||
onClick={onEyeClick(header.id)}
|
||||
/>
|
||||
</Flex>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const SortableColumns = ({
|
||||
header,
|
||||
onEyeClick,
|
||||
}: {
|
||||
header: ResultHeaderCell
|
||||
onEyeClick: (key: string) => () => void
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: header.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
opacity={isDragging ? 0.5 : 1}
|
||||
{...attributes}
|
||||
>
|
||||
<HStack>
|
||||
<IconButton
|
||||
size="sm"
|
||||
cursor="grab"
|
||||
icon={<GripIcon transform="rotate(90deg)" />}
|
||||
aria-label={'Drag'}
|
||||
variant="ghost"
|
||||
{...listeners}
|
||||
/>
|
||||
<HeaderIcon header={header} />
|
||||
<Text>{header.label}</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
size="sm"
|
||||
aria-label={'Hide column'}
|
||||
onClick={onEyeClick(header.id)}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
@ -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<string[]>(
|
||||
(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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="md">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader />
|
||||
<ModalBody as={Stack} spacing="4">
|
||||
<SwitchWithLabel
|
||||
label="Include deleted blocks"
|
||||
moreInfoContent="Blocks from previous bot version that have been deleted"
|
||||
initialValue={false}
|
||||
onCheckChange={setAreDeletedBlocksIncluded}
|
||||
/>
|
||||
<AlertInfo>The export may take up to 1 minute.</AlertInfo>
|
||||
</ModalBody>
|
||||
<ModalFooter as={HStack}>
|
||||
<Button onClick={onClose} variant="ghost" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={exportAllResultsToCSV}
|
||||
leftIcon={<DownloadIcon />}
|
||||
size="sm"
|
||||
isLoading={isExportLoading}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
@ -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<HTMLDivElement | null>(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 (
|
||||
<Stack maxW="1600px" px="4" overflowY="hidden" spacing={6}>
|
||||
<Flex w="full" justifyContent="flex-end">
|
||||
<ResultsActionButtons
|
||||
<HStack w="full" justifyContent="flex-end">
|
||||
<SelectionToolbar
|
||||
selectedResultsId={Object.keys(rowSelection)}
|
||||
onClearSelection={() => setRowSelection({})}
|
||||
mr="2"
|
||||
/>
|
||||
<ColumnSettingsButton
|
||||
<TableSettingsButton
|
||||
resultHeader={resultHeader}
|
||||
columnVisibility={columnsVisibility}
|
||||
setColumnVisibility={changeColumnVisibility}
|
||||
columnOrder={columnsOrder}
|
||||
onColumnOrderChange={changeColumnOrder}
|
||||
/>
|
||||
</Flex>
|
||||
</HStack>
|
||||
<Box
|
||||
ref={tableWrapper}
|
||||
overflow="scroll"
|
||||
@ -265,8 +266,16 @@ export const ResultsTable = ({
|
||||
)
|
||||
}
|
||||
|
||||
const parseDefaultColumnOrder = (resultHeader: ResultHeaderCell[]) => [
|
||||
'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']
|
||||
|
@ -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<string[]>(
|
||||
@ -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 (
|
||||
<HStack {...props}>
|
||||
<HStack
|
||||
as={Button}
|
||||
<HStack rounded="md" spacing={0}>
|
||||
<Button
|
||||
color={selectLabelColor}
|
||||
borderRightWidth="1px"
|
||||
borderRightRadius="none"
|
||||
onClick={onClearSelection}
|
||||
size="sm"
|
||||
>
|
||||
{totalSelected} selected
|
||||
</Button>
|
||||
<IconButton
|
||||
borderRightWidth="1px"
|
||||
borderRightRadius="none"
|
||||
borderLeftRadius="none"
|
||||
aria-label="Export"
|
||||
icon={<DownloadIcon />}
|
||||
onClick={exportResultsToCSV}
|
||||
isLoading={isExportLoading}
|
||||
>
|
||||
<DownloadIcon />
|
||||
<Text>Export {totalSelected > 0 ? '' : 'all'}</Text>
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{totalSelected && (
|
||||
<Tag variant="solid" size="sm">
|
||||
{totalSelected}
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
<IconButton
|
||||
aria-label="Delete"
|
||||
borderLeftRadius="none"
|
||||
icon={<TrashIcon />}
|
||||
onClick={onOpen}
|
||||
isLoading={isDeleteLoading}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Fade in={totalSelected > 0} unmountOnExit>
|
||||
<HStack
|
||||
as={Button}
|
||||
colorScheme="red"
|
||||
onClick={onOpen}
|
||||
isLoading={isDeleteLoading}
|
||||
>
|
||||
<TrashIcon />
|
||||
<Text>Delete</Text>
|
||||
{totalSelected > 0 && (
|
||||
<Tag colorScheme="red" variant="subtle" size="sm">
|
||||
{totalSelected}
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
<ConfirmModal
|
||||
isOpen={isOpen}
|
||||
onConfirm={deleteResults}
|
||||
onClose={onClose}
|
||||
message={
|
||||
<Text>
|
||||
You are about to delete{' '}
|
||||
<strong>
|
||||
{totalSelected} submission
|
||||
{totalSelected > 1 ? 's' : ''}
|
||||
</strong>
|
||||
. Are you sure you wish to continue?
|
||||
</Text>
|
||||
}
|
||||
confirmButtonLabel={'Delete'}
|
||||
/>
|
||||
</Fade>
|
||||
<ConfirmModal
|
||||
isOpen={isOpen}
|
||||
onConfirm={deleteResults}
|
||||
onClose={onClose}
|
||||
message={
|
||||
<Text>
|
||||
You are about to delete{' '}
|
||||
<strong>
|
||||
{totalSelected} submission
|
||||
{totalSelected > 1 ? 's' : ''}
|
||||
</strong>
|
||||
. Are you sure you wish to continue?
|
||||
</Text>
|
||||
}
|
||||
confirmButtonLabel={'Delete'}
|
||||
/>
|
||||
</HStack>
|
||||
)
|
||||
}
|
@ -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 (
|
||||
<>
|
||||
<Popover isLazy placement="bottom-end">
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label="Open table settings"
|
||||
icon={<MoreHorizontalIcon />}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent w="300px">
|
||||
<TableSettingsMenu {...props} onExportAllClick={onOpen} />
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
<ExportAllResultsModal onClose={onClose} isOpen={isOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TableSettingsMenu = ({
|
||||
resultHeader,
|
||||
columnVisibility,
|
||||
setColumnVisibility,
|
||||
columnOrder,
|
||||
onColumnOrderChange,
|
||||
onExportAllClick,
|
||||
}: Props & { onExportAllClick: () => void }) => {
|
||||
const [selectedMenu, setSelectedMenu] = useState<
|
||||
'export' | 'columnSettings' | null
|
||||
>(null)
|
||||
|
||||
switch (selectedMenu) {
|
||||
case 'columnSettings':
|
||||
return (
|
||||
<PopoverBody
|
||||
as={Stack}
|
||||
spacing="4"
|
||||
p="4"
|
||||
maxH="450px"
|
||||
overflowY="scroll"
|
||||
>
|
||||
<ColumnSettings
|
||||
resultHeader={resultHeader}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
/>
|
||||
</PopoverBody>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<PopoverBody as={Stack} p="0" spacing="0">
|
||||
<Button
|
||||
onClick={() => setSelectedMenu('columnSettings')}
|
||||
variant="ghost"
|
||||
borderBottomRadius={0}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<HStack>
|
||||
<ListIcon />
|
||||
<Text>Column settings</Text>
|
||||
</HStack>
|
||||
|
||||
<ChevronRightIcon color="gray.400" />
|
||||
</Button>
|
||||
noOfLines={1}
|
||||
<Button
|
||||
onClick={onExportAllClick}
|
||||
variant="ghost"
|
||||
borderTopRadius={0}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<HStack>
|
||||
<DownloadIcon />
|
||||
<Text>Export all</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
</PopoverBody>
|
||||
)
|
||||
}
|
||||
}
|
@ -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) =>
|
||||
|
@ -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<Typebot, 'workspaceId'> & {
|
||||
@ -22,5 +21,5 @@ export const isReadTypebotForbidden = async (
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
return isNotDefined(memberInWorkspace)
|
||||
return memberInWorkspace === null
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -99,9 +99,10 @@ const processAndSaveAnswer =
|
||||
block: InputBlock
|
||||
) =>
|
||||
async (reply: string): Promise<Variable[]> => {
|
||||
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<ChatReply, 'messages' | 'input'> => {
|
||||
|
@ -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<Typebot, 'groups' | 'variables'>,
|
||||
linkedTypebots: Pick<Typebot, 'groups' | 'variables'>[] | undefined
|
||||
linkedTypebots: Pick<Typebot, 'groups' | 'variables'>[] | 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<Typebot, 'groups' | 'variables'>,
|
||||
|
Reference in New Issue
Block a user