feat(results): ✨ Brand new Results table
- Resizable columns - Hide / Show columns - Reorganize columns - Expand result
This commit is contained in:
@ -474,3 +474,10 @@ export const BuoyIcon = (props: IconProps) => (
|
||||
<line x1="4.93" y1="19.07" x2="9.17" y2="14.83"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const EyeOffIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
@ -23,11 +23,11 @@ import { isDefined } from 'utils'
|
||||
|
||||
type Props = {
|
||||
typebotId: string
|
||||
resultId?: string
|
||||
resultId: string | null
|
||||
onClose: () => void
|
||||
}
|
||||
export const LogsModal = ({ typebotId, resultId, onClose }: Props) => {
|
||||
const { isLoading, logs } = useLogs(typebotId, resultId)
|
||||
const { isLoading, logs } = useLogs(typebotId, resultId ?? undefined)
|
||||
return (
|
||||
<Modal isOpen={isDefined(resultId)} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
55
apps/builder/components/results/ResultModal.tsx
Normal file
55
apps/builder/components/results/ResultModal.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
Stack,
|
||||
Heading,
|
||||
Text,
|
||||
HStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { useResults } from 'contexts/ResultsProvider'
|
||||
import React from 'react'
|
||||
import { isDefined } from 'utils'
|
||||
import { HeaderIcon } from './ResultsTable/ResultsTable'
|
||||
|
||||
type Props = {
|
||||
resultIdx: number | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const ResultModal = ({ resultIdx, onClose }: Props) => {
|
||||
const { tableData, resultHeader } = useResults()
|
||||
const result = isDefined(resultIdx) ? tableData[resultIdx] : undefined
|
||||
|
||||
const getHeaderValue = (
|
||||
val: string | { plainText: string; element?: JSX.Element | undefined }
|
||||
) => (typeof val === 'string' ? val : val.element ?? val.plainText)
|
||||
|
||||
return (
|
||||
<Modal isOpen={isDefined(result)} onClose={onClose} size="2xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalCloseButton />
|
||||
<ModalBody as={Stack} p="10" spacing="10">
|
||||
{resultHeader.map((header) =>
|
||||
result && result[header.label] ? (
|
||||
<Stack key={header.id} spacing="4">
|
||||
<HStack>
|
||||
<HeaderIcon header={header} />
|
||||
<Heading fontSize="md">{header.label}</Heading>
|
||||
</HStack>
|
||||
<Text whiteSpace="pre-wrap" textAlign="justify">
|
||||
{getHeaderValue(result[header.label])}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
@ -1,81 +0,0 @@
|
||||
import {
|
||||
HStack,
|
||||
Button,
|
||||
Fade,
|
||||
Tag,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { DownloadIcon, TrashIcon } from 'assets/icons'
|
||||
import { ConfirmModal } from 'components/modals/ConfirmModal'
|
||||
import React from 'react'
|
||||
|
||||
type ResultsActionButtonsProps = {
|
||||
totalSelected: number
|
||||
isDeleteLoading: boolean
|
||||
isExportLoading: boolean
|
||||
onDeleteClick: () => Promise<void>
|
||||
onExportClick: () => void
|
||||
}
|
||||
|
||||
export const ResultsActionButtons = ({
|
||||
totalSelected,
|
||||
isDeleteLoading,
|
||||
isExportLoading,
|
||||
onDeleteClick,
|
||||
onExportClick,
|
||||
}: ResultsActionButtonsProps) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
return (
|
||||
<HStack>
|
||||
<Fade in={totalSelected > 0} unmountOnExit>
|
||||
<HStack
|
||||
as={Button}
|
||||
colorScheme="blue"
|
||||
onClick={onExportClick}
|
||||
isLoading={isExportLoading}
|
||||
>
|
||||
<DownloadIcon />
|
||||
<Text>Export</Text>
|
||||
|
||||
<Tag colorScheme="blue" variant="subtle" size="sm">
|
||||
{totalSelected}
|
||||
</Tag>
|
||||
</HStack>
|
||||
</Fade>
|
||||
|
||||
<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={onDeleteClick}
|
||||
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>
|
||||
)
|
||||
}
|
81
apps/builder/components/results/ResultsContent.tsx
Normal file
81
apps/builder/components/results/ResultsContent.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { Stack } from '@chakra-ui/react'
|
||||
import { SubmissionsTable } from 'components/results/ResultsTable'
|
||||
import React, { useState } from 'react'
|
||||
import { UnlockPlanInfo } from 'components/shared/Info'
|
||||
import { LogsModal } from './LogsModal'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { Plan } from 'db'
|
||||
import { useResults } from 'contexts/ResultsProvider'
|
||||
import { ResultModal } from './ResultModal'
|
||||
|
||||
export const ResultsContent = () => {
|
||||
const {
|
||||
flatResults: results,
|
||||
fetchMore,
|
||||
hasMore,
|
||||
resultHeader,
|
||||
totalHiddenResults,
|
||||
tableData,
|
||||
} = useResults()
|
||||
const { typebot, publishedTypebot } = useTypebot()
|
||||
const [inspectingLogsResultId, setInspectingLogsResultId] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const [expandedResultIndex, setExpandedResultIndex] = useState<number | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const handleLogsModalClose = () => setInspectingLogsResultId(null)
|
||||
|
||||
const handleResultModalClose = () => setExpandedResultIndex(null)
|
||||
|
||||
const handleLogOpenIndex = (index: number) => () => {
|
||||
if (!results) return
|
||||
setInspectingLogsResultId(results[index].id)
|
||||
}
|
||||
|
||||
const handleResultExpandIndex = (index: number) => () =>
|
||||
setExpandedResultIndex(index)
|
||||
|
||||
return (
|
||||
<Stack
|
||||
pb="28"
|
||||
px={['4', '0']}
|
||||
spacing="4"
|
||||
maxW="1600px"
|
||||
overflow="scroll"
|
||||
w="full"
|
||||
>
|
||||
{totalHiddenResults && (
|
||||
<UnlockPlanInfo
|
||||
buttonLabel={`Unlock ${totalHiddenResults} results`}
|
||||
contentLabel="You are seeing complete submissions only."
|
||||
plan={Plan.PRO}
|
||||
/>
|
||||
)}
|
||||
{publishedTypebot && (
|
||||
<LogsModal
|
||||
typebotId={publishedTypebot?.typebotId}
|
||||
resultId={inspectingLogsResultId}
|
||||
onClose={handleLogsModalClose}
|
||||
/>
|
||||
)}
|
||||
<ResultModal
|
||||
resultIdx={expandedResultIndex}
|
||||
onClose={handleResultModalClose}
|
||||
/>
|
||||
|
||||
{typebot && (
|
||||
<SubmissionsTable
|
||||
preferences={typebot.resultsTablePreferences}
|
||||
resultHeader={resultHeader}
|
||||
data={tableData}
|
||||
onScrollToBottom={fetchMore}
|
||||
hasMore={hasMore}
|
||||
onLogOpenIndex={handleLogOpenIndex}
|
||||
onResultExpandIndex={handleResultExpandIndex}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -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>
|
||||
}
|
||||
)
|
60
apps/builder/components/results/ResultsTable/HeaderRow.tsx
Normal file
60
apps/builder/components/results/ResultsTable/HeaderRow.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -8,20 +8,20 @@ type LoadingRowsProps = {
|
||||
export const LoadingRows = ({ totalColumns }: LoadingRowsProps) => {
|
||||
return (
|
||||
<>
|
||||
{Array.from(Array(3)).map((row, idx) => (
|
||||
{Array.from(Array(3)).map((_, idx) => (
|
||||
<tr key={idx}>
|
||||
<chakra.td
|
||||
px="4"
|
||||
px="2"
|
||||
py="2"
|
||||
border="1px"
|
||||
borderColor="gray.200"
|
||||
width="50px"
|
||||
width="40px"
|
||||
>
|
||||
<Flex>
|
||||
<Flex ml="1">
|
||||
<Checkbox isDisabled />
|
||||
</Flex>
|
||||
</chakra.td>
|
||||
{Array.from(Array(totalColumns)).map((cell, idx) => {
|
||||
{Array.from(Array(totalColumns)).map((_, idx) => {
|
||||
return (
|
||||
<chakra.td
|
||||
key={idx}
|
@ -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>
|
||||
)
|
||||
}
|
277
apps/builder/components/results/ResultsTable/ResultsTable.tsx
Normal file
277
apps/builder/components/results/ResultsTable/ResultsTable.tsx
Normal 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 />
|
||||
)
|
73
apps/builder/components/results/ResultsTable/Row.tsx
Normal file
73
apps/builder/components/results/ResultsTable/Row.tsx
Normal 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
|
||||
)
|
1
apps/builder/components/results/ResultsTable/index.tsx
Normal file
1
apps/builder/components/results/ResultsTable/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { ResultsTable as SubmissionsTable } from './ResultsTable'
|
@ -1,188 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react/jsx-key */
|
||||
import { Button, chakra, Checkbox, Flex, HStack, Text } from '@chakra-ui/react'
|
||||
import { AlignLeftTextIcon } from 'assets/icons'
|
||||
import { ResultHeaderCell } from 'models'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { Hooks, useRowSelect, useTable } from 'react-table'
|
||||
import { parseSubmissionsColumns } from 'services/typebots'
|
||||
import { isNotDefined } from 'utils'
|
||||
import { LoadingRows } from './LoadingRows'
|
||||
|
||||
type SubmissionsTableProps = {
|
||||
resultHeader: ResultHeaderCell[]
|
||||
data?: any
|
||||
hasMore?: boolean
|
||||
onNewSelection: (indices: number[]) => void
|
||||
onScrollToBottom: () => void
|
||||
onLogOpenIndex: (index: number) => () => void
|
||||
}
|
||||
|
||||
export const SubmissionsTable = ({
|
||||
resultHeader,
|
||||
data,
|
||||
hasMore,
|
||||
onNewSelection,
|
||||
onScrollToBottom,
|
||||
onLogOpenIndex,
|
||||
}: SubmissionsTableProps) => {
|
||||
const columns: any = useMemo(
|
||||
() => parseSubmissionsColumns(resultHeader),
|
||||
[resultHeader]
|
||||
)
|
||||
const bottomElement = useRef<HTMLDivElement | null>(null)
|
||||
const tableWrapper = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
getTableBodyProps,
|
||||
selectedFlatRows,
|
||||
} = useTable({ columns, data }, useRowSelect, checkboxColumnHook) as any
|
||||
|
||||
useEffect(() => {
|
||||
onNewSelection(selectedFlatRows.map((row: any) => row.index))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedFlatRows])
|
||||
|
||||
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)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bottomElement.current])
|
||||
|
||||
const handleObserver = (entities: any[]) => {
|
||||
const target = entities[0]
|
||||
if (target.isIntersecting) onScrollToBottom()
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
maxW="full"
|
||||
overflow="scroll"
|
||||
ref={tableWrapper}
|
||||
className="table-wrapper"
|
||||
rounded="md"
|
||||
>
|
||||
<chakra.table rounded="md" {...getTableProps()}>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup: any) => {
|
||||
return (
|
||||
<tr {...headerGroup.getHeaderGroupProps()}>
|
||||
{headerGroup.headers.map((column: any) => {
|
||||
return (
|
||||
<chakra.th
|
||||
px="4"
|
||||
py="2"
|
||||
border="1px"
|
||||
borderColor="gray.200"
|
||||
fontWeight="normal"
|
||||
whiteSpace="nowrap"
|
||||
{...column.getHeaderProps()}
|
||||
>
|
||||
{column.render('Header')}
|
||||
</chakra.th>
|
||||
)
|
||||
})}
|
||||
<chakra.th
|
||||
px="4"
|
||||
py="2"
|
||||
border="1px"
|
||||
borderColor="gray.200"
|
||||
fontWeight="normal"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
<HStack>
|
||||
<AlignLeftTextIcon />
|
||||
<Text>Logs</Text>
|
||||
</HStack>
|
||||
</chakra.th>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</thead>
|
||||
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{rows.map((row: any, idx: number) => {
|
||||
prepareRow(row)
|
||||
return (
|
||||
<tr
|
||||
{...row.getRowProps()}
|
||||
ref={(ref) => {
|
||||
if (idx === data.length - 10) bottomElement.current = ref
|
||||
}}
|
||||
>
|
||||
{row.cells.map((cell: any) => {
|
||||
return (
|
||||
<chakra.td
|
||||
px="4"
|
||||
py="2"
|
||||
border="1px"
|
||||
borderColor="gray.200"
|
||||
whiteSpace={
|
||||
cell?.value?.length > 100 ? 'normal' : 'nowrap'
|
||||
}
|
||||
{...cell.getCellProps()}
|
||||
>
|
||||
{isNotDefined(cell.value)
|
||||
? cell.render('Cell')
|
||||
: cell.value.element ?? cell.value.plainText}
|
||||
</chakra.td>
|
||||
)
|
||||
})}
|
||||
<chakra.td px="4" py="2" border="1px" borderColor="gray.200">
|
||||
<Button size="sm" onClick={onLogOpenIndex(idx)}>
|
||||
See logs
|
||||
</Button>
|
||||
</chakra.td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{hasMore === true && (
|
||||
<LoadingRows totalColumns={columns.length + 1} />
|
||||
)}
|
||||
</tbody>
|
||||
</chakra.table>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const checkboxColumnHook = (hooks: Hooks<any>) => {
|
||||
hooks.visibleColumns.push((columns) => [
|
||||
{
|
||||
id: 'selection',
|
||||
Header: ({ getToggleAllRowsSelectedProps }: any) => (
|
||||
<IndeterminateCheckbox {...getToggleAllRowsSelectedProps()} />
|
||||
),
|
||||
Cell: ({ row }: any) => (
|
||||
<IndeterminateCheckbox {...row.getToggleRowSelectedProps()} />
|
||||
),
|
||||
},
|
||||
...columns,
|
||||
])
|
||||
}
|
||||
|
||||
const IndeterminateCheckbox = React.forwardRef(
|
||||
({ indeterminate, checked, ...rest }: any, ref) => {
|
||||
const defaultRef = React.useRef()
|
||||
const resolvedRef: any = ref || defaultRef
|
||||
|
||||
return (
|
||||
<Flex justify="center" data-testid="checkbox">
|
||||
<Checkbox
|
||||
ref={resolvedRef}
|
||||
{...rest}
|
||||
isIndeterminate={indeterminate}
|
||||
isChecked={checked}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
)
|
@ -1 +0,0 @@
|
||||
export { SubmissionsTable } from './SubmissionsTable'
|
100
apps/builder/contexts/ResultsProvider.tsx
Normal file
100
apps/builder/contexts/ResultsProvider.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { ResultHeaderCell, ResultWithAnswers } from 'models'
|
||||
import { createContext, ReactNode, useContext, useMemo } from 'react'
|
||||
import {
|
||||
convertResultsToTableData,
|
||||
useResults as useFetchResults,
|
||||
} from 'services/typebots'
|
||||
import { KeyedMutator } from 'swr'
|
||||
import { isDefined, parseResultHeader } from 'utils'
|
||||
import { useTypebot } from './TypebotContext'
|
||||
|
||||
const resultsContext = createContext<{
|
||||
resultsList: { results: ResultWithAnswers[] }[] | undefined
|
||||
flatResults: ResultWithAnswers[]
|
||||
hasMore: boolean
|
||||
resultHeader: ResultHeaderCell[]
|
||||
totalResults: number
|
||||
totalHiddenResults?: number
|
||||
tableData: {
|
||||
id: string
|
||||
[key: string]: { plainText: string; element?: JSX.Element } | string
|
||||
}[]
|
||||
onDeleteResults: (totalResultsDeleted: number) => void
|
||||
fetchMore: () => void
|
||||
mutate: KeyedMutator<
|
||||
{
|
||||
results: ResultWithAnswers[]
|
||||
}[]
|
||||
>
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const ResultsProvider = ({
|
||||
children,
|
||||
workspaceId,
|
||||
typebotId,
|
||||
totalResults,
|
||||
totalHiddenResults,
|
||||
onDeleteResults,
|
||||
}: {
|
||||
children: ReactNode
|
||||
workspaceId: string
|
||||
typebotId: string
|
||||
totalResults: number
|
||||
totalHiddenResults?: number
|
||||
onDeleteResults: (totalResultsDeleted: number) => void
|
||||
}) => {
|
||||
const { publishedTypebot, linkedTypebots } = useTypebot()
|
||||
const { data, mutate, setSize, hasMore } = useFetchResults({
|
||||
workspaceId,
|
||||
typebotId,
|
||||
})
|
||||
|
||||
const fetchMore = () => setSize((state) => state + 1)
|
||||
|
||||
const groupsAndVariables = {
|
||||
groups: [
|
||||
...(publishedTypebot?.groups ?? []),
|
||||
...(linkedTypebots?.flatMap((t) => t.groups) ?? []),
|
||||
].filter(isDefined),
|
||||
variables: [
|
||||
...(publishedTypebot?.variables ?? []),
|
||||
...(linkedTypebots?.flatMap((t) => t.variables) ?? []),
|
||||
].filter(isDefined),
|
||||
}
|
||||
const resultHeader = parseResultHeader(groupsAndVariables)
|
||||
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
publishedTypebot
|
||||
? convertResultsToTableData(
|
||||
data?.flatMap((d) => d.results) ?? [],
|
||||
resultHeader
|
||||
)
|
||||
: [],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[publishedTypebot?.id, resultHeader.length, data]
|
||||
)
|
||||
|
||||
return (
|
||||
<resultsContext.Provider
|
||||
value={{
|
||||
resultsList: data,
|
||||
flatResults: data?.flatMap((d) => d.results) ?? [],
|
||||
hasMore: hasMore ?? true,
|
||||
tableData,
|
||||
resultHeader,
|
||||
totalResults,
|
||||
totalHiddenResults,
|
||||
onDeleteResults,
|
||||
fetchMore,
|
||||
mutate,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</resultsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useResults = () => useContext(resultsContext)
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
LogicBlockType,
|
||||
PublicTypebot,
|
||||
ResultsTablePreferences,
|
||||
Settings,
|
||||
Theme,
|
||||
Typebot,
|
||||
@ -53,6 +54,7 @@ type UpdateTypebotPayload = Partial<{
|
||||
publishedTypebotId: string
|
||||
icon: string
|
||||
customDomain: string
|
||||
resultsTablePreferences: ResultsTablePreferences
|
||||
}>
|
||||
|
||||
export type SetTypebot = (
|
||||
|
@ -1,93 +0,0 @@
|
||||
import { Button, Flex, HStack, Tag, Text } from '@chakra-ui/react'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { useToast } from 'components/shared/hooks/useToast'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useStats } from 'services/analytics'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
import { AnalyticsContent } from './AnalyticsContent'
|
||||
import { SubmissionsContent } from './SubmissionContent'
|
||||
|
||||
export const ResultsContent = () => {
|
||||
const router = useRouter()
|
||||
const { workspace } = useWorkspace()
|
||||
const { typebot, publishedTypebot } = useTypebot()
|
||||
const isAnalytics = useMemo(
|
||||
() => router.pathname.endsWith('analytics'),
|
||||
[router.pathname]
|
||||
)
|
||||
const { showToast } = useToast()
|
||||
|
||||
const { stats, mutate } = useStats({
|
||||
typebotId: publishedTypebot?.typebotId,
|
||||
onError: (err) => showToast({ title: err.name, description: err.message }),
|
||||
})
|
||||
|
||||
const handleDeletedResults = (total: number) => {
|
||||
if (!stats) return
|
||||
mutate({
|
||||
stats: { ...stats, totalStarts: stats.totalStarts - total },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex h="full" w="full">
|
||||
<Flex
|
||||
pos="absolute"
|
||||
zIndex={2}
|
||||
bgColor="white"
|
||||
w="full"
|
||||
justifyContent="center"
|
||||
h="60px"
|
||||
display={['none', 'flex']}
|
||||
>
|
||||
<HStack maxW="1200px" w="full">
|
||||
<Button
|
||||
as={NextChakraLink}
|
||||
colorScheme={!isAnalytics ? 'blue' : 'gray'}
|
||||
variant={!isAnalytics ? 'outline' : 'ghost'}
|
||||
size="sm"
|
||||
href={`/typebots/${typebot?.id}/results`}
|
||||
>
|
||||
<Text>Submissions</Text>
|
||||
{(stats?.totalStarts ?? 0) > 0 && (
|
||||
<Tag size="sm" colorScheme="blue" ml="1">
|
||||
{stats?.totalStarts}
|
||||
</Tag>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
as={NextChakraLink}
|
||||
colorScheme={isAnalytics ? 'blue' : 'gray'}
|
||||
variant={isAnalytics ? 'outline' : 'ghost'}
|
||||
href={`/typebots/${typebot?.id}/results/analytics`}
|
||||
size="sm"
|
||||
>
|
||||
Analytics
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
<Flex pt={['10px', '60px']} w="full" justify="center">
|
||||
{workspace &&
|
||||
publishedTypebot &&
|
||||
(isAnalytics ? (
|
||||
<AnalyticsContent stats={stats} />
|
||||
) : (
|
||||
<SubmissionsContent
|
||||
workspaceId={workspace.id}
|
||||
typebotId={publishedTypebot.typebotId}
|
||||
onDeleteResults={handleDeletedResults}
|
||||
totalResults={stats?.totalStarts ?? 0}
|
||||
totalHiddenResults={
|
||||
isFreePlan(workspace)
|
||||
? (stats?.totalStarts ?? 0) - (stats?.totalCompleted ?? 0)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
@ -1,193 +0,0 @@
|
||||
import { Stack, Flex } from '@chakra-ui/react'
|
||||
import { ResultsActionButtons } from 'components/results/ResultsActionButtons'
|
||||
import { SubmissionsTable } from 'components/results/SubmissionsTable'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
convertResultsToTableData,
|
||||
deleteResults,
|
||||
getAllResults,
|
||||
useResults,
|
||||
} from 'services/typebots'
|
||||
import { unparse } from 'papaparse'
|
||||
import { UnlockPlanInfo } from 'components/shared/Info'
|
||||
import { LogsModal } from './LogsModal'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { isDefined, parseResultHeader } from 'utils'
|
||||
import { Plan } from 'db'
|
||||
import { useToast } from 'components/shared/hooks/useToast'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
typebotId: string
|
||||
totalResults: number
|
||||
totalHiddenResults?: number
|
||||
onDeleteResults: (total: number) => void
|
||||
}
|
||||
export const SubmissionsContent = ({
|
||||
workspaceId,
|
||||
typebotId,
|
||||
totalResults,
|
||||
totalHiddenResults,
|
||||
onDeleteResults,
|
||||
}: Props) => {
|
||||
const { publishedTypebot, linkedTypebots } = useTypebot()
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false)
|
||||
const [isExportLoading, setIsExportLoading] = useState(false)
|
||||
const [inspectingLogsResultId, setInspectingLogsResultId] = useState<string>()
|
||||
|
||||
const { showToast } = useToast()
|
||||
|
||||
const groupsAndVariables = {
|
||||
groups: [
|
||||
...(publishedTypebot?.groups ?? []),
|
||||
...(linkedTypebots?.flatMap((t) => t.groups) ?? []),
|
||||
].filter(isDefined),
|
||||
variables: [
|
||||
...(publishedTypebot?.variables ?? []),
|
||||
...(linkedTypebots?.flatMap((t) => t.variables) ?? []),
|
||||
].filter(isDefined),
|
||||
}
|
||||
|
||||
const resultHeader = parseResultHeader(groupsAndVariables)
|
||||
|
||||
const { data, mutate, setSize, hasMore } = useResults({
|
||||
workspaceId,
|
||||
typebotId,
|
||||
onError: (err) => showToast({ title: err.name, description: err.message }),
|
||||
})
|
||||
|
||||
const results = useMemo(() => data?.flatMap((d) => d.results), [data])
|
||||
|
||||
const handleNewSelection = (newSelectionIndices: number[]) => {
|
||||
if (newSelectionIndices.length === selectedIndices.length) return
|
||||
setSelectedIndices(newSelectionIndices)
|
||||
}
|
||||
|
||||
const handleDeleteSelection = async () => {
|
||||
setIsDeleteLoading(true)
|
||||
const selectedIds = (results ?? [])
|
||||
.filter((_, idx) => selectedIndices.includes(idx))
|
||||
.map((result) => result.id)
|
||||
const { error } = await deleteResults(
|
||||
workspaceId,
|
||||
typebotId,
|
||||
totalSelected === totalResults ? [] : selectedIds
|
||||
)
|
||||
if (error) showToast({ description: error.message, title: error.name })
|
||||
else {
|
||||
mutate(
|
||||
totalSelected === totalResults
|
||||
? []
|
||||
: data?.map((d) => ({
|
||||
results: d.results.filter((r) => !selectedIds.includes(r.id)),
|
||||
}))
|
||||
)
|
||||
onDeleteResults(totalSelected)
|
||||
}
|
||||
setIsDeleteLoading(false)
|
||||
}
|
||||
|
||||
const totalSelected =
|
||||
selectedIndices.length > 0 && selectedIndices.length === results?.length
|
||||
? totalResults - (totalHiddenResults ?? 0)
|
||||
: selectedIndices.length
|
||||
|
||||
const handleScrolledToBottom = useCallback(
|
||||
() => setSize((state) => state + 1),
|
||||
[setSize]
|
||||
)
|
||||
|
||||
const handleExportSelection = async () => {
|
||||
setIsExportLoading(true)
|
||||
const isSelectAll =
|
||||
totalSelected === totalResults - (totalHiddenResults ?? 0)
|
||||
const dataToUnparse = isSelectAll
|
||||
? await getAllTableData()
|
||||
: tableData.filter((_, idx) => selectedIndices.includes(idx))
|
||||
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].plainText
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
const getAllTableData = async () => {
|
||||
if (!publishedTypebot) return []
|
||||
const results = await getAllResults(workspaceId, typebotId)
|
||||
return convertResultsToTableData(results, resultHeader)
|
||||
}
|
||||
|
||||
const tableData: {
|
||||
[key: string]: { plainText: string; element?: JSX.Element }
|
||||
}[] = useMemo(
|
||||
() =>
|
||||
publishedTypebot ? convertResultsToTableData(results, resultHeader) : [],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[publishedTypebot?.id, resultHeader.length, results]
|
||||
)
|
||||
|
||||
const handleLogsModalClose = () => setInspectingLogsResultId(undefined)
|
||||
|
||||
const handleLogOpenIndex = (index: number) => () => {
|
||||
if (!results) return
|
||||
setInspectingLogsResultId(results[index].id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack maxW="1200px" w="full" pb="28" px={['4', '0']} spacing="4">
|
||||
{totalHiddenResults && (
|
||||
<UnlockPlanInfo
|
||||
buttonLabel={`Unlock ${totalHiddenResults} results`}
|
||||
contentLabel="You are seeing complete submissions only."
|
||||
plan={Plan.PRO}
|
||||
/>
|
||||
)}
|
||||
{publishedTypebot && (
|
||||
<LogsModal
|
||||
typebotId={publishedTypebot?.typebotId}
|
||||
resultId={inspectingLogsResultId}
|
||||
onClose={handleLogsModalClose}
|
||||
/>
|
||||
)}
|
||||
<Flex w="full" justifyContent="flex-end">
|
||||
<ResultsActionButtons
|
||||
isDeleteLoading={isDeleteLoading}
|
||||
isExportLoading={isExportLoading}
|
||||
totalSelected={totalSelected}
|
||||
onDeleteClick={handleDeleteSelection}
|
||||
onExportClick={handleExportSelection}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<SubmissionsTable
|
||||
resultHeader={resultHeader}
|
||||
data={tableData}
|
||||
onNewSelection={handleNewSelection}
|
||||
onScrollToBottom={handleScrolledToBottom}
|
||||
hasMore={hasMore}
|
||||
onLogOpenIndex={handleLogOpenIndex}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -23,6 +23,8 @@
|
||||
"@codemirror/lang-javascript": "^0.20.0",
|
||||
"@codemirror/lang-json": "^0.20.0",
|
||||
"@codemirror/text": "^0.19.6",
|
||||
"@dnd-kit/core": "^6.0.5",
|
||||
"@dnd-kit/sortable": "^7.0.1",
|
||||
"@emotion/react": "^11.9.0",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@giphy/js-fetch-api": "^4.1.2",
|
||||
@ -31,6 +33,7 @@
|
||||
"@googleapis/drive": "^2.3.0",
|
||||
"@sentry/nextjs": "^6.19.7",
|
||||
"@stripe/stripe-js": "^1.29.0",
|
||||
"@tanstack/react-table": "^8.0.13",
|
||||
"@udecode/plate-basic-marks": "^13.1.0",
|
||||
"@udecode/plate-common": "^7.0.2",
|
||||
"@udecode/plate-core": "^13.1.0",
|
||||
@ -70,7 +73,6 @@
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-draggable": "^4.4.5",
|
||||
"react-table": "^7.7.0",
|
||||
"slate": "^0.81.1",
|
||||
"slate-history": "^0.66.0",
|
||||
"slate-hyperscript": "^0.77.0",
|
||||
|
@ -48,6 +48,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
...data,
|
||||
theme: data.theme ?? undefined,
|
||||
settings: data.settings ?? undefined,
|
||||
resultsTablePreferences: data.resultsTablePreferences ?? undefined,
|
||||
},
|
||||
})
|
||||
return res.send({ typebots })
|
||||
|
@ -1,15 +1,106 @@
|
||||
import { Flex } from '@chakra-ui/layout'
|
||||
import { ResultsContent } from 'layouts/results/ResultsContent'
|
||||
import { Flex, Text } from '@chakra-ui/layout'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { HStack, Button, Tag } from '@chakra-ui/react'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { ResultsContent } from 'components/results/ResultsContent'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { AnalyticsContent } from 'components/analytics/AnalyticsContent'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useStats } from 'services/analytics'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
import { useToast } from 'components/shared/hooks/useToast'
|
||||
import { ResultsProvider } from 'contexts/ResultsProvider'
|
||||
|
||||
const ResultsPage = () => (
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<Seo title="Share" />
|
||||
<TypebotHeader />
|
||||
<ResultsContent />
|
||||
</Flex>
|
||||
)
|
||||
const ResultsPage = () => {
|
||||
const router = useRouter()
|
||||
const { workspace } = useWorkspace()
|
||||
const { typebot, publishedTypebot } = useTypebot()
|
||||
const isAnalytics = useMemo(
|
||||
() => router.pathname.endsWith('analytics'),
|
||||
[router.pathname]
|
||||
)
|
||||
const { showToast } = useToast()
|
||||
|
||||
const { stats, mutate } = useStats({
|
||||
typebotId: publishedTypebot?.typebotId,
|
||||
onError: (err) => showToast({ title: err.name, description: err.message }),
|
||||
})
|
||||
|
||||
const handleDeletedResults = (total: number) => {
|
||||
if (!stats) return
|
||||
mutate({
|
||||
stats: { ...stats, totalStarts: stats.totalStarts - total },
|
||||
})
|
||||
}
|
||||
return (
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<Seo
|
||||
title={router.pathname.endsWith('analytics') ? 'Analytics' : 'Results'}
|
||||
/>
|
||||
<TypebotHeader />
|
||||
<Flex h="full" w="full">
|
||||
<Flex
|
||||
pos="absolute"
|
||||
zIndex={2}
|
||||
bgColor="white"
|
||||
w="full"
|
||||
justifyContent="center"
|
||||
h="60px"
|
||||
display={['none', 'flex']}
|
||||
>
|
||||
<HStack maxW="1600px" w="full" px="4">
|
||||
<Button
|
||||
as={NextChakraLink}
|
||||
colorScheme={!isAnalytics ? 'blue' : 'gray'}
|
||||
variant={!isAnalytics ? 'outline' : 'ghost'}
|
||||
size="sm"
|
||||
href={`/typebots/${typebot?.id}/results`}
|
||||
>
|
||||
<Text>Submissions</Text>
|
||||
{(stats?.totalStarts ?? 0) > 0 && (
|
||||
<Tag size="sm" colorScheme="blue" ml="1">
|
||||
{stats?.totalStarts}
|
||||
</Tag>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
as={NextChakraLink}
|
||||
colorScheme={isAnalytics ? 'blue' : 'gray'}
|
||||
variant={isAnalytics ? 'outline' : 'ghost'}
|
||||
href={`/typebots/${typebot?.id}/results/analytics`}
|
||||
size="sm"
|
||||
>
|
||||
Analytics
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
<Flex pt={['10px', '60px']} w="full" justify="center">
|
||||
{workspace &&
|
||||
publishedTypebot &&
|
||||
(isAnalytics ? (
|
||||
<AnalyticsContent stats={stats} />
|
||||
) : (
|
||||
<ResultsProvider
|
||||
workspaceId={workspace.id}
|
||||
typebotId={publishedTypebot.typebotId}
|
||||
totalResults={stats?.totalStarts ?? 0}
|
||||
totalHiddenResults={
|
||||
isFreePlan(workspace)
|
||||
? (stats?.totalStarts ?? 0) - (stats?.totalCompleted ?? 0)
|
||||
: undefined
|
||||
}
|
||||
onDeleteResults={handleDeletedResults}
|
||||
>
|
||||
<ResultsContent />
|
||||
</ResultsProvider>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultsPage
|
||||
|
@ -1,15 +1,5 @@
|
||||
import { Flex } from '@chakra-ui/layout'
|
||||
import { ResultsContent } from 'layouts/results/ResultsContent'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
||||
import React from 'react'
|
||||
import ResultsPage from '../results'
|
||||
|
||||
const AnalyticsPage = () => (
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<Seo title="Analytics" />
|
||||
<TypebotHeader />
|
||||
<ResultsContent />
|
||||
</Flex>
|
||||
)
|
||||
const AnalyticsPage = ResultsPage
|
||||
|
||||
export default AnalyticsPage
|
||||
|
@ -72,7 +72,7 @@ test.describe('Send email block', () => {
|
||||
await page.click('text=Preview')
|
||||
await typebotViewer(page).locator('text=Go').click()
|
||||
await expect(
|
||||
page.locator('text=Emails are not sent in preview mode')
|
||||
page.locator('text=Emails are not sent in preview mode >> nth=0')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
@ -16,121 +16,172 @@ import { deleteButtonInConfirmDialog } from '../services/selectorUtils'
|
||||
|
||||
const typebotId = cuid()
|
||||
|
||||
test.describe('Results page', () => {
|
||||
test('Submission table header should be parsed correctly', async ({
|
||||
page,
|
||||
}) => {
|
||||
const typebotId = cuid()
|
||||
await importTypebotInDatabase(
|
||||
path.join(
|
||||
__dirname,
|
||||
'../fixtures/typebots/results/submissionHeader.json'
|
||||
),
|
||||
{
|
||||
id: typebotId,
|
||||
}
|
||||
)
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await expect(page.locator('text=Submitted at')).toBeVisible()
|
||||
await expect(page.locator('text=Welcome')).toBeVisible()
|
||||
await expect(page.locator('text=Email')).toBeVisible()
|
||||
await expect(page.locator('text=Name')).toBeVisible()
|
||||
await expect(page.locator('text=Services')).toBeVisible()
|
||||
await expect(page.locator('text=Additional information')).toBeVisible()
|
||||
await expect(page.locator('text=utm_source')).toBeVisible()
|
||||
await expect(page.locator('text=utm_userid')).toBeVisible()
|
||||
})
|
||||
test('Submission table header should be parsed correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await importTypebotInDatabase(
|
||||
path.join(__dirname, '../fixtures/typebots/results/submissionHeader.json'),
|
||||
{
|
||||
id: typebotId,
|
||||
}
|
||||
)
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await expect(page.locator('text=Submitted at')).toBeVisible()
|
||||
await expect(page.locator('text=Welcome')).toBeVisible()
|
||||
await expect(page.locator('text=Email')).toBeVisible()
|
||||
await expect(page.locator('text=Name')).toBeVisible()
|
||||
await expect(page.locator('text=Services')).toBeVisible()
|
||||
await expect(page.locator('text=Additional information')).toBeVisible()
|
||||
await expect(page.locator('text=utm_source')).toBeVisible()
|
||||
await expect(page.locator('text=utm_userid')).toBeVisible()
|
||||
})
|
||||
|
||||
test('results should be deletable', async ({ page }) => {
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: InputBlockType.TEXT,
|
||||
options: defaultTextInputOptions,
|
||||
}),
|
||||
},
|
||||
])
|
||||
await createResults({ typebotId })
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await selectFirstResults(page)
|
||||
await page.click('button:has-text("Delete2")')
|
||||
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('button:has-text("Delete198")')
|
||||
await deleteButtonInConfirmDialog(page).click()
|
||||
await page.waitForTimeout(1000)
|
||||
expect(await page.locator('tr').count()).toBe(1)
|
||||
})
|
||||
test('results should be deletable', async ({ page }) => {
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: InputBlockType.TEXT,
|
||||
options: defaultTextInputOptions,
|
||||
}),
|
||||
},
|
||||
])
|
||||
await createResults({ typebotId })
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await selectFirstResults(page)
|
||||
await page.click('text="Delete"')
|
||||
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 deleteButtonInConfirmDialog(page).click()
|
||||
await page.waitForTimeout(1000)
|
||||
expect(await page.locator('tr').count()).toBe(1)
|
||||
await expect(page.locator('text="Delete"')).toBeHidden()
|
||||
})
|
||||
|
||||
test('submissions table should have infinite scroll', async ({ page }) => {
|
||||
const scrollToBottom = () =>
|
||||
page.evaluate(() => {
|
||||
const tableWrapper = document.querySelector('.table-wrapper')
|
||||
if (!tableWrapper) return
|
||||
tableWrapper.scrollTo(0, tableWrapper.scrollHeight)
|
||||
})
|
||||
|
||||
await createResults({ typebotId })
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await expect(page.locator('text=content199')).toBeVisible()
|
||||
|
||||
await expect(page.locator('text=content149')).toBeHidden()
|
||||
await scrollToBottom()
|
||||
await expect(page.locator('text=content149')).toBeVisible()
|
||||
|
||||
await expect(page.locator('text=content99')).toBeHidden()
|
||||
await scrollToBottom()
|
||||
await expect(page.locator('text=content99')).toBeVisible()
|
||||
|
||||
await expect(page.locator('text=content49')).toBeHidden()
|
||||
await scrollToBottom()
|
||||
await expect(page.locator('text=content49')).toBeVisible()
|
||||
await expect(page.locator('text=content0')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should correctly export selection in CSV', async ({ page }) => {
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await selectFirstResults(page)
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('button:has-text("Export2")').click(),
|
||||
])
|
||||
const path = await download.path()
|
||||
expect(path).toBeDefined()
|
||||
const file = readFileSync(path as string).toString()
|
||||
const { data } = parse(file)
|
||||
validateExportSelection(data)
|
||||
|
||||
await page.click('[data-testid="checkbox"] >> nth=0')
|
||||
const [downloadAll] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('button:has-text("Export200")').click(),
|
||||
])
|
||||
const pathAll = await downloadAll.path()
|
||||
expect(pathAll).toBeDefined()
|
||||
const fileAll = readFileSync(pathAll as string).toString()
|
||||
const { data: dataAll } = parse(fileAll)
|
||||
validateExportAll(dataAll)
|
||||
})
|
||||
|
||||
test.describe('Free user', async () => {
|
||||
test.use({
|
||||
storageState: path.join(__dirname, '../freeUser.json'),
|
||||
})
|
||||
test("Incomplete results shouldn't be displayed", async ({ page }) => {
|
||||
await prisma.typebot.update({
|
||||
where: { id: typebotId },
|
||||
data: { workspaceId: freeWorkspaceId },
|
||||
})
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await page.click('text=Unlock')
|
||||
await expect(page.locator('text=For solo creator')).toBeVisible()
|
||||
test('submissions table should have infinite scroll', async ({ page }) => {
|
||||
const scrollToBottom = () =>
|
||||
page.evaluate(() => {
|
||||
const tableWrapper = document.querySelector('.table-wrapper')
|
||||
if (!tableWrapper) return
|
||||
tableWrapper.scrollTo(0, tableWrapper.scrollHeight)
|
||||
})
|
||||
|
||||
await createResults({ typebotId })
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await expect(page.locator('text=content199')).toBeVisible()
|
||||
|
||||
await expect(page.locator('text=content149')).toBeHidden()
|
||||
await scrollToBottom()
|
||||
await expect(page.locator('text=content149')).toBeVisible()
|
||||
|
||||
await expect(page.locator('text=content99')).toBeHidden()
|
||||
await scrollToBottom()
|
||||
await expect(page.locator('text=content99')).toBeVisible()
|
||||
|
||||
await expect(page.locator('text=content49')).toBeHidden()
|
||||
await scrollToBottom()
|
||||
await expect(page.locator('text=content49')).toBeVisible()
|
||||
await expect(page.locator('text=content0')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should correctly export selection in CSV', async ({ page }) => {
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await selectFirstResults(page)
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('text="Export"').click(),
|
||||
])
|
||||
const path = await download.path()
|
||||
expect(path).toBeDefined()
|
||||
const file = readFileSync(path as string).toString()
|
||||
const { data } = parse(file)
|
||||
validateExportSelection(data)
|
||||
|
||||
await page.click('[data-testid="checkbox"] >> nth=0')
|
||||
const [downloadAll] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('text="Export"').click(),
|
||||
])
|
||||
const pathAll = await downloadAll.path()
|
||||
expect(pathAll).toBeDefined()
|
||||
const fileAll = readFileSync(pathAll as string).toString()
|
||||
const { data: dataAll } = parse(fileAll)
|
||||
validateExportAll(dataAll)
|
||||
})
|
||||
|
||||
test.describe('Free user', async () => {
|
||||
test.use({
|
||||
storageState: path.join(__dirname, '../freeUser.json'),
|
||||
})
|
||||
test("Incomplete results shouldn't be displayed", async ({ page }) => {
|
||||
await prisma.typebot.update({
|
||||
where: { id: typebotId },
|
||||
data: { workspaceId: freeWorkspaceId },
|
||||
})
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await page.click('text=Unlock')
|
||||
await expect(page.locator('text=For solo creator')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Can resize, hide and reorder columns', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await importTypebotInDatabase(
|
||||
path.join(__dirname, '../fixtures/typebots/results/submissionHeader.json'),
|
||||
{
|
||||
id: typebotId,
|
||||
}
|
||||
)
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
|
||||
// Resize
|
||||
expect((await page.locator('th >> nth=4').boundingBox())?.width).toBe(200)
|
||||
await page.waitForTimeout(500)
|
||||
await page.dragAndDrop(
|
||||
'[data-testid="resize-handle"] >> nth=3',
|
||||
'[data-testid="resize-handle"] >> nth=3',
|
||||
{ targetPosition: { x: 150, y: 0 }, force: true }
|
||||
)
|
||||
await page.waitForTimeout(500)
|
||||
expect((await page.locator('th >> nth=4').boundingBox())?.width).toBe(345)
|
||||
|
||||
// Hide
|
||||
await expect(
|
||||
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.click('[aria-label="Hide column"] >> nth=0')
|
||||
await page.click('[aria-label="Hide column"] >> nth=1')
|
||||
await expect(page.locator('[data-testid="Submitted at header"]')).toBeHidden()
|
||||
await expect(page.locator('[data-testid="Email header"]')).toBeHidden()
|
||||
|
||||
// Reorder
|
||||
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',
|
||||
{ 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')
|
||||
|
||||
// Preferences should be persisted
|
||||
const saveAndReload = async (page: Page) => {
|
||||
await page.click('text="Theme"')
|
||||
await page.waitForTimeout(2000)
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
}
|
||||
await saveAndReload(page)
|
||||
expect((await page.locator('th >> nth=1').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')
|
||||
})
|
||||
|
||||
const validateExportSelection = (data: unknown[]) => {
|
||||
|
@ -26,7 +26,7 @@ test.describe.parallel('Settings page', () => {
|
||||
typebotViewer(page).locator('a:has-text("Made with Typebot")')
|
||||
).toBeHidden()
|
||||
|
||||
await page.click('text=Create new session on page refresh')
|
||||
await page.click('text="Remember session"')
|
||||
await expect(
|
||||
page.locator('input[type="checkbox"] >> nth=-1')
|
||||
).toHaveAttribute('checked', '')
|
||||
|
@ -39,7 +39,7 @@ export const useResults = ({
|
||||
}: {
|
||||
workspaceId: string
|
||||
typebotId: string
|
||||
onError: (error: Error) => void
|
||||
onError?: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate, setSize, size, isValidating } = useSWRInfinite<
|
||||
{ results: ResultWithAnswers[] },
|
||||
@ -58,7 +58,7 @@ export const useResults = ({
|
||||
}
|
||||
)
|
||||
|
||||
if (error) onError(error)
|
||||
if (error && onError) onError(error)
|
||||
return {
|
||||
data,
|
||||
isLoading: !error && !data,
|
||||
@ -150,8 +150,12 @@ const HeaderIcon = ({ header }: { header: ResultHeaderCell }) =>
|
||||
export const convertResultsToTableData = (
|
||||
results: ResultWithAnswers[] | undefined,
|
||||
headerCells: ResultHeaderCell[]
|
||||
): { [key: string]: { element?: JSX.Element; plainText: string } }[] =>
|
||||
): {
|
||||
id: string
|
||||
[key: string]: { element?: JSX.Element; plainText: string } | string
|
||||
}[] =>
|
||||
(results ?? []).map((result) => ({
|
||||
id: result.id,
|
||||
'Submitted at': {
|
||||
plainText: parseDateToReadable(result.createdAt),
|
||||
},
|
||||
|
@ -43,7 +43,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('button:has-text("Export1")').click(),
|
||||
page.locator('text="Export"').click(),
|
||||
])
|
||||
const downloadPath = await download.path()
|
||||
expect(path).toBeDefined()
|
||||
|
@ -20,4 +20,9 @@ test('should work as expected', async ({ page }) => {
|
||||
await expect(page.locator('text="Baptiste"')).toBeVisible()
|
||||
await expect(page.locator('text="26"')).toBeVisible()
|
||||
await expect(page.locator('text="Yes"')).toBeVisible()
|
||||
await page.hover('tbody > tr')
|
||||
await page.click('button >> text="Open"')
|
||||
await expect(page.locator('text="Baptiste" >> nth=1')).toBeVisible()
|
||||
await expect(page.locator('text="26" >> nth=1')).toBeVisible()
|
||||
await expect(page.locator('text="Yes" >> nth=1')).toBeVisible()
|
||||
})
|
||||
|
@ -159,28 +159,29 @@ model DashboardFolder {
|
||||
}
|
||||
|
||||
model Typebot {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
icon String?
|
||||
name String
|
||||
publishedTypebotId String?
|
||||
publishedTypebot PublicTypebot?
|
||||
results Result[]
|
||||
folderId String?
|
||||
folder DashboardFolder? @relation(fields: [folderId], references: [id])
|
||||
groups Json
|
||||
variables Json[]
|
||||
edges Json
|
||||
theme Json
|
||||
settings Json
|
||||
publicId String? @unique
|
||||
customDomain String? @unique
|
||||
collaborators CollaboratorsOnTypebots[]
|
||||
invitations Invitation[]
|
||||
webhooks Webhook[]
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
icon String?
|
||||
name String
|
||||
publishedTypebotId String?
|
||||
publishedTypebot PublicTypebot?
|
||||
results Result[]
|
||||
folderId String?
|
||||
folder DashboardFolder? @relation(fields: [folderId], references: [id])
|
||||
groups Json
|
||||
variables Json[]
|
||||
edges Json
|
||||
theme Json
|
||||
settings Json
|
||||
publicId String? @unique
|
||||
customDomain String? @unique
|
||||
collaborators CollaboratorsOnTypebots[]
|
||||
invitations Invitation[]
|
||||
webhooks Webhook[]
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
resultsTablePreferences Json?
|
||||
}
|
||||
|
||||
model Invitation {
|
||||
@ -249,13 +250,13 @@ model Log {
|
||||
}
|
||||
|
||||
model Answer {
|
||||
createdAt DateTime @default(now())
|
||||
resultId String
|
||||
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
||||
blockId String
|
||||
groupId String
|
||||
variableId String?
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
resultId String
|
||||
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
||||
blockId String
|
||||
groupId String
|
||||
variableId String?
|
||||
content String
|
||||
storageUsed Int?
|
||||
|
||||
@@unique([resultId, blockId, groupId])
|
||||
|
@ -15,6 +15,7 @@ export type ResultValues = Pick<
|
||||
>
|
||||
|
||||
export type ResultHeaderCell = {
|
||||
id: string
|
||||
label: string
|
||||
blockId?: string
|
||||
blockType?: InputBlockType
|
||||
|
@ -31,6 +31,12 @@ const edgeSchema = z.object({
|
||||
to: targetSchema,
|
||||
})
|
||||
|
||||
const resultsTablePreferencesSchema = z.object({
|
||||
columnsOrder: z.array(z.string()),
|
||||
columnsVisibility: z.record(z.string(), z.boolean()),
|
||||
columnsWidth: z.record(z.string(), z.number()),
|
||||
})
|
||||
|
||||
const typebotSchema = z.object({
|
||||
version: z.enum(['2']).optional(),
|
||||
id: z.string(),
|
||||
@ -48,6 +54,7 @@ const typebotSchema = z.object({
|
||||
publicId: z.string().nullable(),
|
||||
customDomain: z.string().nullable(),
|
||||
workspaceId: z.string(),
|
||||
resultsTablePreferences: resultsTablePreferencesSchema.optional(),
|
||||
})
|
||||
|
||||
export type Typebot = z.infer<typeof typebotSchema>
|
||||
@ -55,3 +62,6 @@ export type Target = z.infer<typeof targetSchema>
|
||||
export type Source = z.infer<typeof sourceSchema>
|
||||
export type Edge = z.infer<typeof edgeSchema>
|
||||
export type Group = z.infer<typeof groupSchema>
|
||||
export type ResultsTablePreferences = z.infer<
|
||||
typeof resultsTablePreferencesSchema
|
||||
>
|
||||
|
@ -18,7 +18,7 @@ export const parseResultHeader = ({
|
||||
}): ResultHeaderCell[] => {
|
||||
const parsedGroups = parseInputsResultHeader({ groups, variables })
|
||||
return [
|
||||
{ label: 'Submitted at' },
|
||||
{ label: 'Submitted at', id: 'date' },
|
||||
...parsedGroups,
|
||||
...parseVariablesHeaders(variables, parsedGroups),
|
||||
]
|
||||
@ -62,6 +62,7 @@ const parseInputsResultHeader = ({
|
||||
return [
|
||||
...headers,
|
||||
{
|
||||
id: inputBlock.id,
|
||||
blockType: inputBlock.type,
|
||||
blockId: inputBlock.id,
|
||||
variableId: inputBlock.options.variableId,
|
||||
@ -80,6 +81,7 @@ const parseVariablesHeaders = (
|
||||
return [
|
||||
...headers,
|
||||
{
|
||||
id: v.id,
|
||||
label: v.name,
|
||||
variableId: v.id,
|
||||
},
|
||||
|
48
yarn.lock
48
yarn.lock
@ -1885,6 +1885,37 @@
|
||||
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.4.1.tgz#75b4c27948c81e88ccd3a8902047bcd797f38d32"
|
||||
integrity sha512-ej5oVy6lykXsvieQtqZxCOaLT+xD4+QNarq78cIYISHmZXshCvROLudpQN3lfL8G0NL7plMSSK+zlyvCaIJ4Iw==
|
||||
|
||||
"@dnd-kit/accessibility@^3.0.0":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c"
|
||||
integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@dnd-kit/core@^6.0.5":
|
||||
version "6.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.5.tgz#5670ad0dcc83cd51dbf2fa8c6a5c8af4ac0c1989"
|
||||
integrity sha512-3nL+Zy5cT+1XwsWdlXIvGIFvbuocMyB4NBxTN74DeBaBqeWdH9JsnKwQv7buZQgAHmAH+eIENfS1ginkvW6bCw==
|
||||
dependencies:
|
||||
"@dnd-kit/accessibility" "^3.0.0"
|
||||
"@dnd-kit/utilities" "^3.2.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@dnd-kit/sortable@^7.0.1":
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb"
|
||||
integrity sha512-n77qAzJQtMMywu25sJzhz3gsHnDOUlEjTtnRl8A87rWIhnu32zuP+7zmFjwGgvqfXmRufqiHOSlH7JPC/tnJ8Q==
|
||||
dependencies:
|
||||
"@dnd-kit/utilities" "^3.2.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@dnd-kit/utilities@^3.2.0":
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.0.tgz#b3e956ea63a1347c9d0e1316b037ddcc6140acda"
|
||||
integrity sha512-h65/pn2IPCCIWwdlR2BMLqRkDxpTEONA+HQW3n765HBijLYGyrnTCLa2YQt8VVjjSQD6EfFlTE6aS2Q/b6nb2g==
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@docsearch/css@3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.1.0.tgz#6781cad43fc2e034d012ee44beddf8f93ba21f19"
|
||||
@ -3620,6 +3651,18 @@
|
||||
dependencies:
|
||||
defer-to-connect "^2.0.1"
|
||||
|
||||
"@tanstack/react-table@^8.0.13":
|
||||
version "8.0.13"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.0.13.tgz#47020beeaddac0c64d215a3463fc536a9589410b"
|
||||
integrity sha512-4arYr+cGvpLiGSeAeLSTSlzuIB884pCFGzKI/MAX8/aFz7QZvkdu9AyWt9owCs/CYuPjd9iQoSwjfe3VK4yNPg==
|
||||
dependencies:
|
||||
"@tanstack/table-core" "8.0.13"
|
||||
|
||||
"@tanstack/table-core@8.0.13":
|
||||
version "8.0.13"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.0.13.tgz#e5dc7ab9a5ca8224e128251b55749a13b81bd421"
|
||||
integrity sha512-2G9DVpeIarsCkWNFe4ZPDDQQHK6XEqGr8C+G8eoSiPvM077LRg+oK150get3kRjbNUaHUT6RUEjc0lXNC6V83w==
|
||||
|
||||
"@tippyjs/react@^4.2.6":
|
||||
version "4.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@tippyjs/react/-/react-4.2.6.tgz#971677a599bf663f20bb1c60a62b9555b749cc71"
|
||||
@ -12353,11 +12396,6 @@ react-style-singleton@^2.2.1:
|
||||
invariant "^2.2.4"
|
||||
tslib "^2.0.0"
|
||||
|
||||
react-table@^7.7.0:
|
||||
version "7.8.0"
|
||||
resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.8.0.tgz#07858c01c1718c09f7f1aed7034fcfd7bda907d2"
|
||||
integrity sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA==
|
||||
|
||||
react-textarea-autosize@^8.3.2:
|
||||
version "8.3.4"
|
||||
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524"
|
||||
|
Reference in New Issue
Block a user