2
0

⚗️ Add infinite scroll in results table

This commit is contained in:
Baptiste Arnaud
2022-01-04 12:25:48 +01:00
parent 8ddf608c9e
commit 72454c0f68
9 changed files with 200 additions and 98 deletions

View File

@ -17,7 +17,7 @@ export const LoadingRows = ({ totalColumns }: LoadingRowsProps) => {
border="1px"
as="td"
borderColor="gray.200"
flex="0"
width="50px"
>
<Checkbox isDisabled />
</Flex>
@ -30,7 +30,7 @@ export const LoadingRows = ({ totalColumns }: LoadingRowsProps) => {
border="1px"
as="td"
borderColor="gray.200"
flex="1"
width="180px"
align="center"
>
<Skeleton height="5px" w="full" />

View File

@ -3,7 +3,7 @@
import { Box, Checkbox, Flex } from '@chakra-ui/react'
import { Answer, Result } from 'bot-engine'
import { useTypebot } from 'contexts/TypebotContext'
import React, { useEffect } from 'react'
import React, { useEffect, useRef } from 'react'
import { Hooks, useFlexLayout, useRowSelect, useTable } from 'react-table'
import { parseSubmissionsColumns } from 'services/publicTypebot'
import { parseDateToReadable } from 'services/results'
@ -13,12 +13,16 @@ const defaultCellWidth = 180
type SubmissionsTableProps = {
results?: (Result & { answers: Answer[] })[]
hasMore?: boolean
onNewSelection: (selection: string[]) => void
onScrollToBottom: () => void
}
export const SubmissionsTable = ({
results,
hasMore,
onNewSelection,
onScrollToBottom,
}: SubmissionsTableProps) => {
const { publishedTypebot } = useTypebot()
const columns: any = React.useMemo(
@ -34,8 +38,11 @@ export const SubmissionsTable = ({
{}
),
})),
[results]
// eslint-disable-next-line react-hooks/exhaustive-deps
[results?.length]
)
const bottomElement = useRef<HTMLDivElement | null>(null)
const tableWrapper = useRef<HTMLDivElement | null>(null)
const {
getTableProps,
@ -57,10 +64,35 @@ export const SubmissionsTable = ({
// 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 overflowX="scroll" maxW="full" className="table-wrapper" rounded="md">
<Box as="table" rounded="md" {...getTableProps()} w="full">
<Box as="thead">
<Flex
overflow="scroll"
maxW="full"
maxH="full"
className="table-wrapper"
rounded="md"
data-testid="table-wrapper"
pb="20"
ref={tableWrapper}
>
<Box as="table" rounded="md" {...getTableProps()} w="full" h="full">
<Box as="thead" pos="sticky" top="0" zIndex={2}>
{headerGroups.map((headerGroup: any) => {
return (
<Flex as="tr" {...headerGroup.getHeaderGroupProps()}>
@ -75,6 +107,7 @@ export const SubmissionsTable = ({
color="gray.500"
fontWeight="normal"
textAlign="left"
bgColor={'white'}
{...column.getHeaderProps()}
style={{
width: idx === 0 ? '50px' : `${defaultCellWidth}px`,
@ -90,13 +123,17 @@ export const SubmissionsTable = ({
</Box>
<Box as="tbody" {...getTableBodyProps()}>
{results === undefined && (
<LoadingRows totalColumns={columns.length} />
)}
{rows.map((row: any) => {
{rows.map((row: any, idx: number) => {
prepareRow(row)
return (
<Flex as="tr" {...row.getRowProps()}>
<Flex
as="tr"
{...row.getRowProps()}
ref={(ref) => {
if (results && idx === results.length - 10)
bottomElement.current = ref
}}
>
{row.cells.map((cell: any, idx: number) => {
return (
<Flex
@ -105,6 +142,7 @@ export const SubmissionsTable = ({
border="1px"
as="td"
borderColor="gray.200"
bgColor={'white'}
{...cell.getCellProps()}
style={{
width: idx === 0 ? '50px' : `${defaultCellWidth}px`,
@ -117,6 +155,9 @@ export const SubmissionsTable = ({
</Flex>
)
})}
{(results === undefined || hasMore === true) && (
<LoadingRows totalColumns={columns.length} />
)}
</Box>
</Box>
</Flex>

View File

@ -85,24 +85,16 @@ const createTypebots = async () => {
const createResults = () => {
return prisma.result.createMany({
data: [
{
typebotId: 'typebot1',
},
{
typebotId: 'typebot1',
},
{
id: 'result1',
typebotId: 'typebot2',
},
{
id: 'result2',
typebotId: 'typebot2',
},
{
id: 'result3',
typebotId: 'typebot2',
},
...Array.from(Array(200)).map((_, idx) => {
const today = new Date()
return {
id: `result${idx}`,
typebotId: 'typebot2',
createdAt: new Date(
today.setTime(today.getTime() + 1000 * 60 * 60 * 24 * idx)
),
}
}),
],
})
}
@ -110,24 +102,12 @@ const createResults = () => {
const createAnswers = () => {
return prisma.answer.createMany({
data: [
{
resultId: 'result1',
content: 'content 1',
...Array.from(Array(200)).map((_, idx) => ({
resultId: `result${idx}`,
content: `content${idx}`,
stepId: 'step1',
blockId: 'block1',
},
{
resultId: 'result2',
content: 'content 2',
stepId: 'step1',
blockId: 'block1',
},
{
resultId: 'result3',
content: 'content 3',
stepId: 'step1',
blockId: 'block1',
},
})),
],
})
}

View File

@ -1,6 +1,9 @@
describe('ResultsPage', () => {
before(() => {
cy.intercept({ url: '/api/typebots/typebot2/results?', method: 'GET' }).as(
cy.intercept({ url: '/api/typebots/typebot2/results*', method: 'GET' }).as(
'getResults'
)
cy.intercept({ url: '/api/typebots/typebot2/results*', method: 'GET' }).as(
'getResults'
)
})
@ -13,19 +16,32 @@ describe('ResultsPage', () => {
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot2/results')
cy.wait('@getResults')
cy.findByText('content 2').should('exist')
cy.findByText('content 3').should('exist')
cy.findByText('content198').should('exist')
cy.findByText('content197').should('exist')
cy.findAllByRole('checkbox').eq(2).check({ force: true })
cy.findAllByRole('checkbox').eq(3).check({ force: true })
cy.findByRole('button', { name: 'Delete 2' }).click()
cy.findByRole('button', { name: 'Delete 2' }).click({ force: true })
cy.findByRole('button', { name: 'Delete' }).click()
cy.findByText('content 2').should('not.exist')
cy.findByText('content 3').should('not.exist')
cy.findByText('content198').should('not.exist')
cy.findByText('content197').should('not.exist')
cy.wait(200)
cy.findAllByRole('checkbox').first().check({ force: true })
cy.findByRole('button', { name: 'Delete 198' }).click({ force: true })
cy.findByRole('button', { name: 'Delete' }).click()
cy.findAllByRole('row').should('have.length', 1)
})
it.only('submissions table should have infinite scroll', () => {
it('submissions table should have infinite scroll', () => {
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot2/results')
cy.wait('@getResults')
cy.findByText('content50').should('not.exist')
cy.findByText('content199').should('exist')
cy.findByTestId('table-wrapper').scrollTo('bottom')
cy.findByText('content149').should('exist')
cy.findByTestId('table-wrapper').scrollTo('bottom')
cy.findByText('content99').should('exist')
cy.findByTestId('table-wrapper').scrollTo('bottom')
cy.findByText('content50').should('exist')
cy.findByText('content0').should('exist')
})
})

View File

@ -19,10 +19,18 @@ export const ResultsContent = () => {
status: 'error',
})
const { stats } = useStats({
const { stats, mutate } = useStats({
typebotId: typebot?.id,
onError: (err) => toast({ 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
@ -66,6 +74,7 @@ export const ResultsContent = () => {
) : (
<SubmissionsContent
typebotId={typebot.id}
onDeleteResults={handleDeletedResults}
totalResults={stats?.totalStarts ?? 0}
/>
))}

View File

@ -12,12 +12,19 @@ import {
import { DownloadIcon, TrashIcon } from 'assets/icons'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { SubmissionsTable } from 'components/results/SubmissionsTable'
import React, { useMemo, useState } from 'react'
import { deleteResults, useResults } from 'services/results'
import React, { useCallback, useMemo, useState } from 'react'
import { deleteAllResults, deleteResults, useResults } from 'services/results'
type Props = { typebotId: string; totalResults: number }
export const SubmissionsContent = ({ typebotId, totalResults }: Props) => {
const [lastResultId, setLastResultId] = useState<string>()
type Props = {
typebotId: string
totalResults: number
onDeleteResults: (total: number) => void
}
export const SubmissionsContent = ({
typebotId,
totalResults,
onDeleteResults,
}: Props) => {
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [isDeleteLoading, setIsDeleteLoading] = useState(false)
@ -28,12 +35,13 @@ export const SubmissionsContent = ({ typebotId, totalResults }: Props) => {
status: 'error',
})
const { results, mutate } = useResults({
lastResultId,
const { data, mutate, setSize, hasMore } = useResults({
typebotId,
onError: (err) => toast({ title: err.name, description: err.message }),
})
const results = useMemo(() => data?.flatMap((d) => d.results), [data])
const handleNewSelection = (newSelection: string[]) => {
if (newSelection.length === selectedIds.length) return
setSelectedIds(newSelection)
@ -41,14 +49,21 @@ export const SubmissionsContent = ({ typebotId, totalResults }: Props) => {
const handleDeleteSelection = async () => {
setIsDeleteLoading(true)
const { error } = await deleteResults(typebotId, selectedIds)
const { error } =
totalSelected === totalResults
? await deleteAllResults(typebotId)
: await deleteResults(typebotId, selectedIds)
if (error) toast({ description: error.message, title: error.name })
else
mutate({
results: (results ?? []).filter((result) =>
selectedIds.includes(result.id)
),
})
else {
mutate(
totalSelected === totalResults
? []
: data?.map((d) => ({
results: d.results.filter((r) => !selectedIds.includes(r.id)),
}))
)
onDeleteResults(totalSelected)
}
setIsDeleteLoading(false)
}
@ -60,6 +75,11 @@ export const SubmissionsContent = ({ typebotId, totalResults }: Props) => {
[results?.length, selectedIds.length, totalResults]
)
const handleScrolledToBottom = useCallback(
() => setSize((state) => state + 1),
[setSize]
)
return (
<Stack maxW="1200px" w="full">
<Flex w="full" justifyContent="flex-end">
@ -90,28 +110,33 @@ export const SubmissionsContent = ({ typebotId, totalResults }: Props) => {
{totalSelected}
</Tag>
)}
<ConfirmModal
isOpen={isOpen}
onConfirm={handleDeleteSelection}
onClose={onClose}
message={
<Text>
You are about to delete{' '}
<strong>
{totalSelected} submission
{totalSelected > 0 ? 's' : ''}
</strong>
. Are you sure you wish to continue?
</Text>
}
confirmButtonLabel={'Delete'}
/>
</HStack>
<ConfirmModal
isOpen={isOpen}
onConfirm={handleDeleteSelection}
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>
</Flex>
<SubmissionsTable results={results} onNewSelection={handleNewSelection} />
<SubmissionsTable
results={results}
onNewSelection={handleNewSelection}
onScrollToBottom={handleScrolledToBottom}
hasMore={hasMore}
/>
</Stack>
)
}

View File

@ -51,7 +51,7 @@
"stripe": "^8.195.0",
"styled-components": "^5.3.3",
"svg-round-corners": "^0.3.0",
"swr": "^1.1.1",
"swr": "^1.1.2",
"use-debounce": "^7.0.1",
"utils": "*"
},

View File

@ -1,30 +1,55 @@
import { Result } from 'bot-engine'
import useSWR from 'swr'
import useSWRInfinite from 'swr/infinite'
import { fetcher, sendRequest } from './utils'
import { stringify } from 'qs'
import { Answer } from 'db'
const getKey = (
typebotId: string,
pageIndex: number,
previousPageData: {
results: ResultWithAnswers[]
}
) => {
if (previousPageData && previousPageData.results.length === 0) return null
if (pageIndex === 0) return `/api/typebots/${typebotId}/results`
console.log(previousPageData.results)
return `/api/typebots/${typebotId}/results?lastResultId=${
previousPageData.results[previousPageData.results.length - 1].id
}`
}
type ResultWithAnswers = Result & { answers: Answer[] }
export const useResults = ({
lastResultId,
typebotId,
onError,
}: {
lastResultId?: string
typebotId: string
onError: (error: Error) => void
}) => {
const params = stringify({
lastResultId,
})
const { data, error, mutate } = useSWR<
{ results: (Result & { answers: Answer[] })[] },
const { data, error, mutate, setSize, size } = useSWRInfinite<
{ results: ResultWithAnswers[] },
Error
>(`/api/typebots/${typebotId}/results?${params}`, fetcher)
>(
(
pageIndex: number,
previousPageData: {
results: ResultWithAnswers[]
}
) => getKey(typebotId, pageIndex, previousPageData),
fetcher,
{ revalidateAll: true }
)
if (error) onError(error)
return {
results: data?.results,
data,
isLoading: !error && !data,
mutate,
setSize,
size,
hasMore:
data && data.length > 0 && data[data.length - 1].results.length > 0,
}
}
@ -41,6 +66,12 @@ export const deleteResults = async (typebotId: string, ids: string[]) => {
})
}
export const deleteAllResults = async (typebotId: string) =>
sendRequest({
url: `/api/typebots/${typebotId}/results`,
method: 'DELETE',
})
export const parseDateToReadable = (dateStr: string): string => {
const date = new Date(dateStr)
return (

View File

@ -7103,7 +7103,7 @@ svgo@^2.7.0:
picocolors "^1.0.0"
stable "^0.1.8"
swr@^1.1.1:
swr@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/swr/-/swr-1.1.2.tgz#9f3de2541931fccf03c48f322f1fc935a7551612"
integrity sha512-UsM0eo5T+kRPyWFZtWRx2XR5qzohs/LS4lDC0GCyLpCYFmsfTk28UCVDbOE9+KtoXY4FnwHYiF+ZYEU3hnJ1lQ==