🚸 (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>
|
</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) => (
|
export const PlusIcon = (props: IconProps) => (
|
||||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
@@ -57,6 +63,14 @@ export const MoreVerticalIcon = (props: IconProps) => (
|
|||||||
</Icon>
|
</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) => (
|
export const GlobeIcon = (props: IconProps) => (
|
||||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
<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>
|
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||||
</Icon>
|
</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'
|
import { AppearanceRadioGroup } from './AppearanceRadioGroup'
|
||||||
|
|
||||||
export const UserPreferencesForm = () => {
|
export const UserPreferencesForm = () => {
|
||||||
const { setColorMode } = useColorMode()
|
const { colorMode, setColorMode } = useColorMode()
|
||||||
const { user, updateUser } = useUser()
|
const { user, updateUser } = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,7 +35,11 @@ export const UserPreferencesForm = () => {
|
|||||||
<Stack spacing={6}>
|
<Stack spacing={6}>
|
||||||
<Heading size="md">Appearance</Heading>
|
<Heading size="md">Appearance</Heading>
|
||||||
<AppearanceRadioGroup
|
<AppearanceRadioGroup
|
||||||
defaultValue={user?.preferredAppAppearance ?? 'system'}
|
defaultValue={
|
||||||
|
user?.preferredAppAppearance
|
||||||
|
? user.preferredAppAppearance
|
||||||
|
: colorMode
|
||||||
|
}
|
||||||
onChange={changeAppearance}
|
onChange={changeAppearance}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</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,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
chakra,
|
chakra,
|
||||||
Flex,
|
|
||||||
HStack,
|
HStack,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -18,9 +17,9 @@ import {
|
|||||||
ColumnDef,
|
ColumnDef,
|
||||||
Updater,
|
Updater,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
import { ColumnSettingsButton } from './ColumnsSettingsButton'
|
import { TableSettingsButton } from './TableSettingsButton'
|
||||||
import { useTypebot } from '@/features/editor'
|
import { useTypebot } from '@/features/editor'
|
||||||
import { ResultsActionButtons } from './ResultsActionButtons'
|
import { SelectionToolbar } from './SelectionToolbar'
|
||||||
import { Row } from './Row'
|
import { Row } from './Row'
|
||||||
import { HeaderRow } from './HeaderRow'
|
import { HeaderRow } from './HeaderRow'
|
||||||
import { CellValueType, TableData } from '../../types'
|
import { CellValueType, TableData } from '../../types'
|
||||||
@@ -55,10 +54,13 @@ export const ResultsTable = ({
|
|||||||
const tableWrapper = useRef<HTMLDivElement | null>(null)
|
const tableWrapper = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
columnsOrder = parseDefaultColumnOrder(resultHeader),
|
columnsOrder,
|
||||||
columnsVisibility = {},
|
columnsVisibility = {},
|
||||||
columnsWidth = {},
|
columnsWidth = {},
|
||||||
} = preferences ?? {}
|
} = {
|
||||||
|
...preferences,
|
||||||
|
columnsOrder: parseColumnOrder(preferences?.columnsOrder, resultHeader),
|
||||||
|
}
|
||||||
|
|
||||||
const changeColumnOrder = (newColumnOrder: string[]) => {
|
const changeColumnOrder = (newColumnOrder: string[]) => {
|
||||||
if (typeof newColumnOrder === 'function') return
|
if (typeof newColumnOrder === 'function') return
|
||||||
@@ -198,20 +200,19 @@ export const ResultsTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack maxW="1600px" px="4" overflowY="hidden" spacing={6}>
|
<Stack maxW="1600px" px="4" overflowY="hidden" spacing={6}>
|
||||||
<Flex w="full" justifyContent="flex-end">
|
<HStack w="full" justifyContent="flex-end">
|
||||||
<ResultsActionButtons
|
<SelectionToolbar
|
||||||
selectedResultsId={Object.keys(rowSelection)}
|
selectedResultsId={Object.keys(rowSelection)}
|
||||||
onClearSelection={() => setRowSelection({})}
|
onClearSelection={() => setRowSelection({})}
|
||||||
mr="2"
|
|
||||||
/>
|
/>
|
||||||
<ColumnSettingsButton
|
<TableSettingsButton
|
||||||
resultHeader={resultHeader}
|
resultHeader={resultHeader}
|
||||||
columnVisibility={columnsVisibility}
|
columnVisibility={columnsVisibility}
|
||||||
setColumnVisibility={changeColumnVisibility}
|
setColumnVisibility={changeColumnVisibility}
|
||||||
columnOrder={columnsOrder}
|
columnOrder={columnsOrder}
|
||||||
onColumnOrderChange={changeColumnOrder}
|
onColumnOrderChange={changeColumnOrder}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</HStack>
|
||||||
<Box
|
<Box
|
||||||
ref={tableWrapper}
|
ref={tableWrapper}
|
||||||
overflow="scroll"
|
overflow="scroll"
|
||||||
@@ -265,8 +266,16 @@ export const ResultsTable = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseDefaultColumnOrder = (resultHeader: ResultHeaderCell[]) => [
|
const parseColumnOrder = (
|
||||||
'select',
|
existingOrder: string[] | undefined,
|
||||||
...resultHeader.map((h) => h.id),
|
resultHeader: ResultHeaderCell[]
|
||||||
'logs',
|
) =>
|
||||||
]
|
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 {
|
import {
|
||||||
HStack,
|
HStack,
|
||||||
Button,
|
Button,
|
||||||
Fade,
|
|
||||||
Tag,
|
|
||||||
Text,
|
Text,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
StackProps,
|
IconButton,
|
||||||
|
useColorModeValue,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { DownloadIcon, TrashIcon } from '@/components/icons'
|
import { DownloadIcon, TrashIcon } from '@/components/icons'
|
||||||
import { ConfirmModal } from '@/components/ConfirmModal'
|
import { ConfirmModal } from '@/components/ConfirmModal'
|
||||||
@@ -13,21 +12,20 @@ import { useTypebot } from '@/features/editor'
|
|||||||
import { unparse } from 'papaparse'
|
import { unparse } from 'papaparse'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useToast } from '@/hooks/useToast'
|
import { useToast } from '@/hooks/useToast'
|
||||||
import { convertResultsToTableData, parseAccessor } from '../../utils'
|
import { parseAccessor } from '../../utils'
|
||||||
import { useResults } from '../../ResultsProvider'
|
import { useResults } from '../../ResultsProvider'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { trpc } from '@/lib/trpc'
|
||||||
import { TRPCError } from '@trpc/server'
|
|
||||||
|
|
||||||
type ResultsActionButtonsProps = {
|
type Props = {
|
||||||
selectedResultsId: string[]
|
selectedResultsId: string[]
|
||||||
onClearSelection: () => void
|
onClearSelection: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResultsActionButtons = ({
|
export const SelectionToolbar = ({
|
||||||
selectedResultsId,
|
selectedResultsId,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
...props
|
}: Props) => {
|
||||||
}: ResultsActionButtonsProps & StackProps) => {
|
const selectLabelColor = useColorModeValue('blue.500', 'blue.200')
|
||||||
const { typebot } = useTypebot()
|
const { typebot } = useTypebot()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const {
|
const {
|
||||||
@@ -59,28 +57,6 @@ export const ResultsActionButtons = ({
|
|||||||
const workspaceId = typebot?.workspaceId
|
const workspaceId = typebot?.workspaceId
|
||||||
const typebotId = typebot?.id
|
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 =
|
const totalSelected =
|
||||||
selectedResultsId.length > 0 && selectedResultsId.length === results?.length
|
selectedResultsId.length > 0 && selectedResultsId.length === results?.length
|
||||||
? totalResults
|
? totalResults
|
||||||
@@ -90,22 +66,16 @@ export const ResultsActionButtons = ({
|
|||||||
if (!workspaceId || !typebotId) return
|
if (!workspaceId || !typebotId) return
|
||||||
deleteResultsMutation.mutate({
|
deleteResultsMutation.mutate({
|
||||||
typebotId,
|
typebotId,
|
||||||
resultIds:
|
resultIds: selectedResultsId.join(','),
|
||||||
totalSelected === totalResults
|
|
||||||
? undefined
|
|
||||||
: selectedResultsId.join(','),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportResultsToCSV = async () => {
|
const exportResultsToCSV = async () => {
|
||||||
setIsExportLoading(true)
|
setIsExportLoading(true)
|
||||||
const isSelectAll = totalSelected === 0 || totalSelected === totalResults
|
|
||||||
|
|
||||||
const dataToUnparse = isSelectAll
|
const dataToUnparse = tableData.filter((data) =>
|
||||||
? await getAllTableData()
|
selectedResultsId.includes(data.id.plainText)
|
||||||
: tableData.filter((data) =>
|
)
|
||||||
selectedResultsId.includes(data.id.plainText)
|
|
||||||
)
|
|
||||||
|
|
||||||
const fields = typebot?.resultsTablePreferences?.columnsOrder
|
const fields = typebot?.resultsTablePreferences?.columnsOrder
|
||||||
? typebot.resultsTablePreferences.columnsOrder.reduce<string[]>(
|
? typebot.resultsTablePreferences.columnsOrder.reduce<string[]>(
|
||||||
@@ -144,64 +114,65 @@ export const ResultsActionButtons = ({
|
|||||||
type: 'text/csv;charset=utf-8;',
|
type: 'text/csv;charset=utf-8;',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const fileName =
|
const fileName = `typebot-export_${new Date()
|
||||||
`typebot-export_${new Date().toLocaleDateString().replaceAll('/', '-')}` +
|
.toLocaleDateString()
|
||||||
(isSelectAll ? `_all` : ``)
|
.replaceAll('/', '-')}`
|
||||||
const tempLink = document.createElement('a')
|
const tempLink = document.createElement('a')
|
||||||
tempLink.href = window.URL.createObjectURL(csvData)
|
tempLink.href = window.URL.createObjectURL(csvData)
|
||||||
tempLink.setAttribute('download', `${fileName}.csv`)
|
tempLink.setAttribute('download', `${fileName}.csv`)
|
||||||
tempLink.click()
|
tempLink.click()
|
||||||
setIsExportLoading(false)
|
setIsExportLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (totalSelected === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack {...props}>
|
<HStack rounded="md" spacing={0}>
|
||||||
<HStack
|
<Button
|
||||||
as={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}
|
onClick={exportResultsToCSV}
|
||||||
isLoading={isExportLoading}
|
isLoading={isExportLoading}
|
||||||
>
|
size="sm"
|
||||||
<DownloadIcon />
|
/>
|
||||||
<Text>Export {totalSelected > 0 ? '' : 'all'}</Text>
|
|
||||||
|
|
||||||
{totalSelected && (
|
<IconButton
|
||||||
<Tag variant="solid" size="sm">
|
aria-label="Delete"
|
||||||
{totalSelected}
|
borderLeftRadius="none"
|
||||||
</Tag>
|
icon={<TrashIcon />}
|
||||||
)}
|
onClick={onOpen}
|
||||||
</HStack>
|
isLoading={isDeleteLoading}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
<Fade in={totalSelected > 0} unmountOnExit>
|
<ConfirmModal
|
||||||
<HStack
|
isOpen={isOpen}
|
||||||
as={Button}
|
onConfirm={deleteResults}
|
||||||
colorScheme="red"
|
onClose={onClose}
|
||||||
onClick={onOpen}
|
message={
|
||||||
isLoading={isDeleteLoading}
|
<Text>
|
||||||
>
|
You are about to delete{' '}
|
||||||
<TrashIcon />
|
<strong>
|
||||||
<Text>Delete</Text>
|
{totalSelected} submission
|
||||||
{totalSelected > 0 && (
|
{totalSelected > 1 ? 's' : ''}
|
||||||
<Tag colorScheme="red" variant="subtle" size="sm">
|
</strong>
|
||||||
{totalSelected}
|
. Are you sure you wish to continue?
|
||||||
</Tag>
|
</Text>
|
||||||
)}
|
}
|
||||||
</HStack>
|
confirmButtonLabel={'Delete'}
|
||||||
<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>
|
|
||||||
</HStack>
|
</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"]')
|
page.locator('[data-testid="Submitted at header"]')
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
await expect(page.locator('[data-testid="Email 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=0')
|
||||||
await page.click('[aria-label="Hide column"] >> nth=1')
|
await page.click('[aria-label="Hide column"] >> nth=2')
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('[data-testid="Submitted at header"]')
|
page.locator('[data-testid="Submitted at header"]')
|
||||||
).toBeHidden()
|
).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=1')).toHaveText('Welcome')
|
||||||
await expect(page.locator('th >> nth=2')).toHaveText('Name')
|
await expect(page.locator('th >> nth=2')).toHaveText('Name')
|
||||||
await page.dragAndDrop(
|
await page.dragAndDrop(
|
||||||
'[aria-label="Drag"] >> nth=0',
|
'[aria-label="Drag"] >> nth=3',
|
||||||
'[aria-label="Drag"] >> nth=0',
|
'[aria-label="Drag"] >> nth=3',
|
||||||
{ targetPosition: { x: 0, y: 80 }, force: true }
|
{ targetPosition: { x: 0, y: 80 }, force: true }
|
||||||
)
|
)
|
||||||
await expect(page.locator('th >> nth=1')).toHaveText('Name')
|
await expect(page.locator('th >> nth=3')).toHaveText('Name')
|
||||||
await expect(page.locator('th >> nth=2')).toHaveText('Welcome')
|
await expect(page.locator('th >> nth=1')).toHaveText('Welcome')
|
||||||
})
|
})
|
||||||
|
|
||||||
await test.step('Preferences should be persisted', async () => {
|
await test.step('Preferences should be persisted', async () => {
|
||||||
await saveAndReload(page)
|
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(
|
await expect(
|
||||||
page.locator('[data-testid="Submitted at header"]')
|
page.locator('[data-testid="Submitted at header"]')
|
||||||
).toBeHidden()
|
).toBeHidden()
|
||||||
await expect(page.locator('[data-testid="Email 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=1')).toHaveText('Welcome')
|
||||||
await expect(page.locator('th >> nth=2')).toHaveText('Welcome')
|
await expect(page.locator('th >> nth=3')).toHaveText('Name')
|
||||||
})
|
})
|
||||||
|
|
||||||
await test.step('Infinite scroll', async () => {
|
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
|
// For some reason, we need to double click on checkboxes to check them
|
||||||
await getNthCheckbox(page, 1).dblclick()
|
await getNthCheckbox(page, 1).dblclick()
|
||||||
await getNthCheckbox(page, 2).dblclick()
|
await getNthCheckbox(page, 2).dblclick()
|
||||||
|
await expect(page.getByRole('button', { name: '2 selected' })).toBeVisible()
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
page.getByRole('button', { name: 'Export 2' }).click(),
|
page.getByRole('button', { name: 'Export' }).click(),
|
||||||
])
|
])
|
||||||
const path = await download.path()
|
const path = await download.path()
|
||||||
expect(path).toBeDefined()
|
expect(path).toBeDefined()
|
||||||
@@ -116,9 +118,12 @@ test('table features should work', async ({ page }) => {
|
|||||||
validateExportSelection(data)
|
validateExportSelection(data)
|
||||||
|
|
||||||
await getNthCheckbox(page, 0).click()
|
await getNthCheckbox(page, 0).click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: '200 selected' })
|
||||||
|
).toBeVisible()
|
||||||
const [downloadAll] = await Promise.all([
|
const [downloadAll] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
page.getByRole('button', { name: 'Export 200' }).click(),
|
page.getByRole('button', { name: 'Export' }).click(),
|
||||||
])
|
])
|
||||||
const pathAll = await downloadAll.path()
|
const pathAll = await downloadAll.path()
|
||||||
expect(pathAll).toBeDefined()
|
expect(pathAll).toBeDefined()
|
||||||
@@ -126,18 +131,30 @@ test('table features should work', async ({ page }) => {
|
|||||||
const { data: dataAll } = parse(fileAll)
|
const { data: dataAll } = parse(fileAll)
|
||||||
validateExportAll(dataAll)
|
validateExportAll(dataAll)
|
||||||
await getNthCheckbox(page, 0).click()
|
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 test.step('Delete', async () => {
|
||||||
await getNthCheckbox(page, 1).click()
|
await getNthCheckbox(page, 1).click()
|
||||||
await getNthCheckbox(page, 2).click()
|
await getNthCheckbox(page, 2).click()
|
||||||
await page.click('text="Delete"')
|
await page.getByRole('button', { name: 'Delete' }).click()
|
||||||
await deleteButtonInConfirmDialog(page).click()
|
await deleteButtonInConfirmDialog(page).click()
|
||||||
await expect(page.locator('text=content199')).toBeHidden()
|
await expect(page.locator('text=content199')).toBeHidden()
|
||||||
await expect(page.locator('text=content198')).toBeHidden()
|
await expect(page.locator('text=content198')).toBeHidden()
|
||||||
await page.waitForTimeout(1000)
|
await page.waitForTimeout(1000)
|
||||||
await page.click('[data-testid="checkbox"] >> nth=0')
|
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 deleteButtonInConfirmDialog(page).click()
|
||||||
await page.waitForTimeout(1000)
|
await page.waitForTimeout(1000)
|
||||||
expect(await page.locator('tr').count()).toBe(1)
|
expect(await page.locator('tr').count()).toBe(1)
|
||||||
@@ -147,14 +164,14 @@ test('table features should work', async ({ page }) => {
|
|||||||
|
|
||||||
const validateExportSelection = (data: unknown[]) => {
|
const validateExportSelection = (data: unknown[]) => {
|
||||||
expect(data).toHaveLength(3)
|
expect(data).toHaveLength(3)
|
||||||
expect((data[1] as unknown[])[0]).toBe('content199')
|
expect((data[1] as unknown[])[2]).toBe('content199')
|
||||||
expect((data[2] as unknown[])[0]).toBe('content198')
|
expect((data[2] as unknown[])[2]).toBe('content198')
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateExportAll = (data: unknown[]) => {
|
const validateExportAll = (data: unknown[]) => {
|
||||||
expect(data).toHaveLength(201)
|
expect(data).toHaveLength(201)
|
||||||
expect((data[1] as unknown[])[0]).toBe('content199')
|
expect((data[1] as unknown[])[2]).toBe('content199')
|
||||||
expect((data[200] as unknown[])[0]).toBe('content0')
|
expect((data[200] as unknown[])[2]).toBe('content0')
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToBottom = (page: Page) =>
|
const scrollToBottom = (page: Page) =>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { CollaboratorsOnTypebots, User } from 'db'
|
import { CollaboratorsOnTypebots, User } from 'db'
|
||||||
import { Typebot } from 'models'
|
import { Typebot } from 'models'
|
||||||
import { isNotDefined } from 'utils'
|
|
||||||
|
|
||||||
export const isReadTypebotForbidden = async (
|
export const isReadTypebotForbidden = async (
|
||||||
typebot: Pick<Typebot, 'workspaceId'> & {
|
typebot: Pick<Typebot, 'workspaceId'> & {
|
||||||
@@ -22,5 +21,5 @@ export const isReadTypebotForbidden = async (
|
|||||||
userId: user.id,
|
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')
|
await page.click('[data-testid="checkbox"] >> nth=0')
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
page.locator('text="Export"').click(),
|
page.getByRole('button', { name: 'Export' }).click(),
|
||||||
])
|
])
|
||||||
const downloadPath = await download.path()
|
const downloadPath = await download.path()
|
||||||
expect(downloadPath).toBeDefined()
|
expect(downloadPath).toBeDefined()
|
||||||
@@ -71,8 +71,8 @@ test('should work as expected', async ({ page, browser }) => {
|
|||||||
await page2.goto(urls[0])
|
await page2.goto(urls[0])
|
||||||
await expect(page2.locator('pre')).toBeVisible()
|
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"').click()
|
||||||
await page.locator('button >> text="Delete" >> nth=1').click()
|
|
||||||
await expect(page.locator('text="api.json"')).toBeHidden()
|
await expect(page.locator('text="api.json"')).toBeHidden()
|
||||||
await page2.goto(urls[0])
|
await page2.goto(urls[0])
|
||||||
await expect(page2.locator('pre')).toBeHidden()
|
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')
|
await page.click('[data-testid="checkbox"] >> nth=0')
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
page.locator('text="Export"').click(),
|
page.getByRole('button', { name: 'Export' }).click(),
|
||||||
])
|
])
|
||||||
const downloadPath = await download.path()
|
const downloadPath = await download.path()
|
||||||
expect(downloadPath).toBeDefined()
|
expect(downloadPath).toBeDefined()
|
||||||
@@ -68,8 +68,8 @@ test('should work as expected', async ({ page, browser }) => {
|
|||||||
await page2.goto(urls[0])
|
await page2.goto(urls[0])
|
||||||
await expect(page2.locator('pre')).toBeVisible()
|
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"').click()
|
||||||
await page.locator('button >> text="Delete" >> nth=1').click()
|
|
||||||
await expect(page.locator('text="api.json"')).toBeHidden()
|
await expect(page.locator('text="api.json"')).toBeHidden()
|
||||||
await page2.goto(urls[0])
|
await page2.goto(urls[0])
|
||||||
await expect(page2.locator('pre')).toBeHidden()
|
await expect(page2.locator('pre')).toBeHidden()
|
||||||
|
|||||||
@@ -3,17 +3,15 @@ import { createId } from '@paralleldrive/cuid2'
|
|||||||
import { HttpMethod, Typebot } from 'models'
|
import { HttpMethod, Typebot } from 'models'
|
||||||
import {
|
import {
|
||||||
createWebhook,
|
createWebhook,
|
||||||
deleteTypebots,
|
|
||||||
deleteWebhooks,
|
|
||||||
importTypebotInDatabase,
|
importTypebotInDatabase,
|
||||||
} from 'utils/playwright/databaseActions'
|
} from 'utils/playwright/databaseActions'
|
||||||
import { typebotViewer } from 'utils/playwright/testHelpers'
|
import { typebotViewer } from 'utils/playwright/testHelpers'
|
||||||
import { apiToken } from 'utils/playwright/databaseSetup'
|
import { apiToken } from 'utils/playwright/databaseSetup'
|
||||||
import { getTestAsset } from '@/test/utils/playwright'
|
import { getTestAsset } from '@/test/utils/playwright'
|
||||||
|
|
||||||
test.describe('Bot', () => {
|
const typebotId = createId()
|
||||||
const typebotId = createId()
|
|
||||||
|
|
||||||
|
test.describe('Bot', () => {
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
await importTypebotInDatabase(getTestAsset('typebots/webhook.json'), {
|
await importTypebotInDatabase(getTestAsset('typebots/webhook.json'), {
|
||||||
id: typebotId,
|
id: typebotId,
|
||||||
@@ -45,19 +43,10 @@ test.describe('Bot', () => {
|
|||||||
body: `{{Full body}}`,
|
body: `{{Full body}}`,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} 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 }) => {
|
test('should execute webhooks properly', async ({ page }) => {
|
||||||
await page.goto(`/${typebotId}-public`)
|
await page.goto(`/${typebotId}-public`)
|
||||||
await typebotViewer(page).locator('text=Send failing webhook').click()
|
await typebotViewer(page).locator('text=Send failing webhook').click()
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { createId } from '@paralleldrive/cuid2'
|
|||||||
import { HttpMethod } from 'models'
|
import { HttpMethod } from 'models'
|
||||||
import {
|
import {
|
||||||
createWebhook,
|
createWebhook,
|
||||||
deleteTypebots,
|
|
||||||
deleteWebhooks,
|
|
||||||
importTypebotInDatabase,
|
importTypebotInDatabase,
|
||||||
} from 'utils/playwright/databaseActions'
|
} from 'utils/playwright/databaseActions'
|
||||||
import { getTestAsset } from '@/test/utils/playwright'
|
import { getTestAsset } from '@/test/utils/playwright'
|
||||||
@@ -42,19 +40,10 @@ test.beforeEach(async () => {
|
|||||||
body: `{{Full body}}`,
|
body: `{{Full body}}`,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} 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 }) => {
|
test('should execute webhooks properly', async ({ page }) => {
|
||||||
await page.goto(`/next/${typebotId}-public`)
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
await page.locator('text=Send failing webhook').click()
|
await page.locator('text=Send failing webhook').click()
|
||||||
|
|||||||
@@ -99,9 +99,10 @@ const processAndSaveAnswer =
|
|||||||
block: InputBlock
|
block: InputBlock
|
||||||
) =>
|
) =>
|
||||||
async (reply: string): Promise<Variable[]> => {
|
async (reply: string): Promise<Variable[]> => {
|
||||||
state.result &&
|
if (!state.isPreview && state.result) {
|
||||||
!state.isPreview &&
|
await saveAnswer(state.result.id, block)(reply)
|
||||||
(await saveAnswer(state.result.id, block)(reply))
|
if (!state.result.hasStarted) await setResultAsStarted(state.result.id)
|
||||||
|
}
|
||||||
const newVariables = saveVariableValueIfAny(state, block)(reply)
|
const newVariables = saveVariableValueIfAny(state, block)(reply)
|
||||||
return newVariables
|
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 = (
|
const parseRetryMessage = (
|
||||||
block: InputBlock
|
block: InputBlock
|
||||||
): Pick<ChatReply, 'messages' | 'input'> => {
|
): Pick<ChatReply, 'messages' | 'input'> => {
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import {
|
|||||||
VariableWithValue,
|
VariableWithValue,
|
||||||
Typebot,
|
Typebot,
|
||||||
ResultWithAnswersInput,
|
ResultWithAnswersInput,
|
||||||
|
ResultWithAnswers,
|
||||||
|
InputBlockType,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import { isInputBlock, isDefined, byId } from './utils'
|
import { isInputBlock, isDefined, byId, isNotEmpty } from './utils'
|
||||||
|
|
||||||
export const parseResultHeader = (
|
export const parseResultHeader = (
|
||||||
typebot: Pick<Typebot, 'groups' | 'variables'>,
|
typebot: Pick<Typebot, 'groups' | 'variables'>,
|
||||||
linkedTypebots: Pick<Typebot, 'groups' | 'variables'>[] | undefined
|
linkedTypebots: Pick<Typebot, 'groups' | 'variables'>[] | undefined,
|
||||||
|
results?: ResultWithAnswers[]
|
||||||
): ResultHeaderCell[] => {
|
): ResultHeaderCell[] => {
|
||||||
const parsedGroups = [
|
const parsedGroups = [
|
||||||
...typebot.groups,
|
...typebot.groups,
|
||||||
@@ -32,6 +35,7 @@ export const parseResultHeader = (
|
|||||||
{ label: 'Submitted at', id: 'date' },
|
{ label: 'Submitted at', id: 'date' },
|
||||||
...inputsResultHeader,
|
...inputsResultHeader,
|
||||||
...parseVariablesHeaders(parsedVariables, inputsResultHeader),
|
...parseVariablesHeaders(parsedVariables, inputsResultHeader),
|
||||||
|
...parseResultsFromPreviousBotVersions(results ?? [], inputsResultHeader),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +172,32 @@ const parseVariablesHeaders = (
|
|||||||
return [...existingHeaders, newHeaderCell]
|
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 =
|
export const parseAnswers =
|
||||||
(
|
(
|
||||||
typebot: Pick<Typebot, 'groups' | 'variables'>,
|
typebot: Pick<Typebot, 'groups' | 'variables'>,
|
||||||
|
|||||||
Reference in New Issue
Block a user