2
0

🚸 (results) Improve results action buttons

Now have an export all modal with settings and a better column order form
This commit is contained in:
Baptiste Arnaud
2023-02-14 14:33:26 +01:00
parent 1a3596b15c
commit 08e33fbe70
16 changed files with 645 additions and 373 deletions

View File

@ -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>
)

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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']

View File

@ -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>
)
}

View File

@ -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>
)
}
}

View File

@ -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) =>

View File

@ -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
}

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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'> => {

View File

@ -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'>,