2
0

feat(results): Brand new Results table

- Resizable columns
- Hide / Show columns
- Reorganize columns
- Expand result
This commit is contained in:
Baptiste Arnaud
2022-07-01 17:08:35 +02:00
parent cf6e8a21be
commit d84f99074d
34 changed files with 1427 additions and 738 deletions

View File

@ -0,0 +1,218 @@
import {
Popover,
PopoverTrigger,
Button,
PopoverContent,
PopoverBody,
Stack,
IconButton,
Flex,
HStack,
Text,
Portal,
} from '@chakra-ui/react'
import { ToolIcon, EyeIcon, EyeOffIcon, GripIcon } from 'assets/icons'
import { ResultHeaderCell } from 'models'
import React, { forwardRef, useState } from 'react'
import { isNotDefined } from 'utils'
import { HeaderIcon } from './ResultsTable'
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'
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) {
onColumnOrderChange
const oldIndex = columnOrder.indexOf(active.id as string)
const newIndex = columnOrder.indexOf(over?.id as string)
const newColumnOrder = arrayMove(columnOrder, oldIndex, newIndex)
onColumnOrderChange(newColumnOrder)
}
}
return (
<Popover isLazy placement="bottom-end">
<PopoverTrigger>
<Button leftIcon={<ToolIcon />}>Columns</Button>
</PopoverTrigger>
<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 ? <SortableColumnOverlay /> : 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>
</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>
)
}
const SortableColumnOverlay = forwardRef(
(_, ref: React.LegacyRef<HTMLDivElement>) => {
return <HStack ref={ref}></HStack>
}
)

View File

@ -0,0 +1,60 @@
import { Box, BoxProps, chakra } from '@chakra-ui/react'
import { HeaderGroup } from '@tanstack/react-table'
import React from 'react'
type Props = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
headerGroup: HeaderGroup<any>
}
export const HeaderRow = ({ headerGroup }: Props) => {
return (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<chakra.th
key={header.id}
px="4"
py="2"
pos="relative"
border="1px"
borderColor="gray.200"
fontWeight="normal"
whiteSpace="nowrap"
wordBreak="normal"
colSpan={header.colSpan}
style={{
minWidth: header.getSize(),
maxWidth: header.getSize(),
}}
>
{header.isPlaceholder ? null : header.renderHeader()}
{header.column.getCanResize() && (
<ResizeHandle
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
/>
)}
</chakra.th>
)
})}
</tr>
)
}
const ResizeHandle = (props: BoxProps) => {
return (
<Box
pos="absolute"
right="-5px"
w="10px"
h="full"
top="0"
cursor="col-resize"
zIndex={2}
userSelect="none"
data-testid="resize-handle"
{...props}
/>
)
}

View File

@ -0,0 +1,41 @@
import { chakra, Checkbox, Flex, Skeleton } from '@chakra-ui/react'
import React from 'react'
type LoadingRowsProps = {
totalColumns: number
}
export const LoadingRows = ({ totalColumns }: LoadingRowsProps) => {
return (
<>
{Array.from(Array(3)).map((_, idx) => (
<tr key={idx}>
<chakra.td
px="2"
py="2"
border="1px"
borderColor="gray.200"
width="40px"
>
<Flex ml="1">
<Checkbox isDisabled />
</Flex>
</chakra.td>
{Array.from(Array(totalColumns)).map((_, idx) => {
return (
<chakra.td
key={idx}
px="4"
py="2"
border="1px"
borderColor="gray.200"
>
<Skeleton height="5px" w="full" />
</chakra.td>
)
})}
</tr>
))}
</>
)
}

View File

@ -0,0 +1,175 @@
import {
HStack,
Button,
Fade,
Tag,
Text,
useDisclosure,
StackProps,
} from '@chakra-ui/react'
import { DownloadIcon, TrashIcon } from 'assets/icons'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { useToast } from 'components/shared/hooks/useToast'
import { useResults } from 'contexts/ResultsProvider'
import { useTypebot } from 'contexts/TypebotContext'
import { unparse } from 'papaparse'
import React, { useState } from 'react'
import {
convertResultsToTableData,
getAllResults,
deleteResults as deleteFetchResults,
} from 'services/typebots'
type ResultsActionButtonsProps = {
selectedResultsId: string[]
onClearSelection: () => void
}
export const ResultsActionButtons = ({
selectedResultsId,
onClearSelection,
...props
}: ResultsActionButtonsProps & StackProps) => {
const { typebot } = useTypebot()
const { showToast } = useToast()
const {
resultsList: data,
flatResults: results,
resultHeader,
mutate,
totalResults,
totalHiddenResults,
tableData,
onDeleteResults,
} = useResults()
const { isOpen, onOpen, onClose } = useDisclosure()
const [isDeleteLoading, setIsDeleteLoading] = useState(false)
const [isExportLoading, setIsExportLoading] = useState(false)
const workspaceId = typebot?.workspaceId
const typebotId = typebot?.id
const getAllTableData = async () => {
if (!workspaceId || !typebotId) return []
const results = await getAllResults(workspaceId, typebotId)
return convertResultsToTableData(results, resultHeader)
}
const totalSelected =
selectedResultsId.length > 0 && selectedResultsId.length === results?.length
? totalResults - (totalHiddenResults ?? 0)
: selectedResultsId.length
const deleteResults = async () => {
if (!workspaceId || !typebotId) return
setIsDeleteLoading(true)
const { error } = await deleteFetchResults(
workspaceId,
typebotId,
totalSelected === totalResults ? [] : selectedResultsId
)
if (error) showToast({ description: error.message, title: error.name })
else {
mutate(
totalSelected === totalResults
? []
: data?.map((d) => ({
results: d.results.filter(
(r) => !selectedResultsId.includes(r.id)
),
}))
)
}
onDeleteResults(selectedResultsId.length)
onClearSelection()
setIsDeleteLoading(false)
}
const exportResultsToCSV = async () => {
setIsExportLoading(true)
const isSelectAll =
totalSelected === 0 ||
totalSelected === totalResults - (totalHiddenResults ?? 0)
const dataToUnparse = isSelectAll
? await getAllTableData()
: tableData.filter((data) => selectedResultsId.includes(data.id))
const csvData = new Blob(
[
unparse({
data: dataToUnparse.map<{ [key: string]: string }>((data) => {
const newObject: { [key: string]: string } = {}
Object.keys(data).forEach((key) => {
newObject[key] = (data[key] as { plainText: string })
.plainText as string
})
return newObject
}),
fields: resultHeader.map((h) => h.label),
}),
],
{
type: 'text/csv;charset=utf-8;',
}
)
const fileName =
`typebot-export_${new Date().toLocaleDateString().replaceAll('/', '-')}` +
(isSelectAll ? `_all` : ``)
const tempLink = document.createElement('a')
tempLink.href = window.URL.createObjectURL(csvData)
tempLink.setAttribute('download', `${fileName}.csv`)
tempLink.click()
setIsExportLoading(false)
}
return (
<HStack {...props}>
<HStack
as={Button}
onClick={exportResultsToCSV}
isLoading={isExportLoading}
>
<DownloadIcon />
<Text>Export {totalSelected > 0 ? '' : 'all'}</Text>
{totalSelected && (
<Tag variant="solid" size="sm">
{totalSelected}
</Tag>
)}
</HStack>
<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>
</HStack>
)
}

View File

@ -0,0 +1,277 @@
import {
Box,
Button,
chakra,
Checkbox,
Flex,
HStack,
Stack,
Text,
} from '@chakra-ui/react'
import { AlignLeftTextIcon, CalendarIcon, CodeIcon } from 'assets/icons'
import { ResultHeaderCell, ResultsTablePreferences } from 'models'
import React, { useEffect, useRef, useState } from 'react'
import { LoadingRows } from './LoadingRows'
import {
createTable,
useTableInstance,
getCoreRowModel,
ColumnOrderState,
} from '@tanstack/react-table'
import { BlockIcon } from 'components/editor/BlocksSideBar/BlockIcon'
import { ColumnSettingsButton } from './ColumnsSettingsButton'
import { useTypebot } from 'contexts/TypebotContext'
import { useDebounce } from 'use-debounce'
import { ResultsActionButtons } from './ResultsActionButtons'
import Row from './Row'
import { HeaderRow } from './HeaderRow'
type RowType = {
id: string
[key: string]:
| {
plainText: string
element?: JSX.Element | undefined
}
| string
}
const table = createTable().setRowType<RowType>()
type ResultsTableProps = {
resultHeader: ResultHeaderCell[]
data: RowType[]
hasMore?: boolean
preferences?: ResultsTablePreferences
onScrollToBottom: () => void
onLogOpenIndex: (index: number) => () => void
onResultExpandIndex: (index: number) => () => void
}
export const ResultsTable = ({
resultHeader,
data,
hasMore,
preferences,
onScrollToBottom,
onLogOpenIndex,
onResultExpandIndex,
}: ResultsTableProps) => {
const { updateTypebot } = useTypebot()
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
const [columnsVisibility, setColumnsVisibility] = useState<
Record<string, boolean>
>(preferences?.columnsVisibility || {})
const [columnsWidth, setColumnsWidth] = useState<Record<string, number>>(
preferences?.columnsWidth || {}
)
const [debouncedColumnsWidth] = useDebounce(columnsWidth, 500)
const [columnsOrder, setColumnsOrder] = useState<ColumnOrderState>([
'select',
...(preferences?.columnsOrder
? resultHeader
.map((h) => h.id)
.sort(
(a, b) =>
preferences?.columnsOrder.indexOf(a) -
preferences?.columnsOrder.indexOf(b)
)
: resultHeader.map((h) => h.id)),
'logs',
])
useEffect(() => {
updateTypebot({
resultsTablePreferences: {
columnsVisibility,
columnsOrder,
columnsWidth: debouncedColumnsWidth,
},
})
}, [columnsOrder, columnsVisibility, debouncedColumnsWidth, updateTypebot])
const bottomElement = useRef<HTMLDivElement | null>(null)
const tableWrapper = useRef<HTMLDivElement | null>(null)
const columns = React.useMemo(
() => [
table.createDisplayColumn({
id: 'select',
enableResizing: false,
maxSize: 40,
header: ({ instance }) => (
<IndeterminateCheckbox
{...{
checked: instance.getIsAllRowsSelected(),
indeterminate: instance.getIsSomeRowsSelected(),
onChange: instance.getToggleAllRowsSelectedHandler(),
}}
/>
),
cell: ({ row }) => (
<div className="px-1">
<IndeterminateCheckbox
{...{
checked: row.getIsSelected(),
indeterminate: row.getIsSomeSelected(),
onChange: row.getToggleSelectedHandler(),
}}
/>
</div>
),
}),
...resultHeader.map((header) =>
table.createDataColumn(header.label, {
id: header.id,
size: header.isLong ? 400 : 200,
cell: (info) => {
const value = info.getValue()
if (!value) return
if (typeof value === 'string') return ''
return value.element || value.plainText || ''
},
header: () => (
<HStack overflow="hidden" data-testid={`${header.label} header`}>
<HeaderIcon header={header} />
<Text>{header.label}</Text>
</HStack>
),
})
),
table.createDisplayColumn({
id: 'logs',
enableResizing: false,
maxSize: 110,
header: () => (
<HStack>
<AlignLeftTextIcon />
<Text>Logs</Text>
</HStack>
),
cell: ({ row }) => (
<Button size="sm" onClick={onLogOpenIndex(row.index)}>
See logs
</Button>
),
}),
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[resultHeader]
)
const instance = useTableInstance(table, {
data,
columns,
state: {
rowSelection,
columnVisibility: columnsVisibility,
columnOrder: columnsOrder,
columnSizing: columnsWidth,
},
getRowId: (row) => row.id,
columnResizeMode: 'onChange',
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: setColumnsVisibility,
onColumnSizingChange: setColumnsWidth,
onColumnOrderChange: setColumnsOrder,
getCoreRowModel: getCoreRowModel(),
})
useEffect(() => {
if (!bottomElement.current) return
const options: IntersectionObserverInit = {
root: tableWrapper.current,
threshold: 0,
}
const observer = new IntersectionObserver(handleObserver, options)
if (bottomElement.current) observer.observe(bottomElement.current)
return () => {
observer.disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bottomElement.current])
const handleObserver = (entities: IntersectionObserverEntry[]) => {
const target = entities[0]
if (target.isIntersecting) onScrollToBottom()
}
return (
<Stack
maxW="1600px"
px="4"
overflow="scroll"
spacing={6}
ref={tableWrapper}
>
<Flex w="full" justifyContent="flex-end">
<ResultsActionButtons
selectedResultsId={Object.keys(rowSelection)}
onClearSelection={() => setRowSelection({})}
mr="2"
/>
<ColumnSettingsButton
resultHeader={resultHeader}
columnVisibility={columnsVisibility}
setColumnVisibility={setColumnsVisibility}
columnOrder={columnsOrder}
onColumnOrderChange={instance.setColumnOrder}
/>
</Flex>
<Box className="table-wrapper" overflow="scroll" rounded="md">
<chakra.table rounded="md">
<thead>
{instance.getHeaderGroups().map((headerGroup) => (
<HeaderRow key={headerGroup.id} headerGroup={headerGroup} />
))}
</thead>
<tbody>
{instance.getRowModel().rows.map((row, rowIndex) => (
<Row
row={row}
key={row.id}
bottomElement={
rowIndex === data.length - 10 ? bottomElement : undefined
}
isSelected={row.getIsSelected()}
onExpandButtonClick={onResultExpandIndex(rowIndex)}
/>
))}
{hasMore === true && (
<LoadingRows totalColumns={columns.length - 1} />
)}
</tbody>
</chakra.table>
</Box>
</Stack>
)
}
const IndeterminateCheckbox = React.forwardRef(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ indeterminate, checked, ...rest }: any, ref) => {
const defaultRef = React.useRef()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedRef: any = ref || defaultRef
return (
<Flex justify="center" data-testid="checkbox">
<Checkbox
ref={resolvedRef}
{...rest}
isIndeterminate={indeterminate}
isChecked={checked}
/>
</Flex>
)
}
)
export const HeaderIcon = ({ header }: { header: ResultHeaderCell }) =>
header.blockType ? (
<BlockIcon type={header.blockType} />
) : header.variableId ? (
<CodeIcon />
) : (
<CalendarIcon />
)

View File

@ -0,0 +1,73 @@
import React, { memo, useState } from 'react'
import { Row as RowProps } from '@tanstack/react-table'
import { Button, chakra, Fade } from '@chakra-ui/react'
import { ExpandIcon } from 'assets/icons'
type Props = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
row: RowProps<any>
isSelected: boolean
bottomElement?: React.MutableRefObject<HTMLDivElement | null>
onExpandButtonClick: () => void
}
const Row = ({ row, bottomElement, onExpandButtonClick }: Props) => {
const [isExpandButtonVisible, setIsExpandButtonVisible] = useState(false)
const showExpandButton = () => setIsExpandButtonVisible(true)
const hideExpandButton = () => setIsExpandButtonVisible(false)
return (
<tr
key={row.id}
data-rowid={row.id}
ref={(ref) => {
if (bottomElement && bottomElement.current?.dataset.rowid !== row.id)
bottomElement.current = ref
}}
onMouseEnter={showExpandButton}
onClick={showExpandButton}
onMouseLeave={hideExpandButton}
>
{row.getVisibleCells().map((cell, cellIndex) => (
<chakra.td
key={cell.id}
px="4"
py="2"
border="1px"
borderColor="gray.200"
whiteSpace="nowrap"
wordBreak="normal"
overflow="hidden"
pos="relative"
>
{cell.renderCell()}
<chakra.span
pos="absolute"
top="0"
right={2}
h="full"
display="inline-flex"
alignItems="center"
>
<Fade unmountOnExit in={isExpandButtonVisible && cellIndex === 1}>
<Button
leftIcon={<ExpandIcon />}
shadow="lg"
size="xs"
onClick={onExpandButtonClick}
>
Open
</Button>
</Fade>
</chakra.span>
</chakra.td>
))}
</tr>
)
}
export default memo(
Row,
(prev, next) =>
prev.row.id === next.row.id && prev.isSelected === next.isSelected
)

View File

@ -0,0 +1 @@
export { ResultsTable as SubmissionsTable } from './ResultsTable'