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

View File

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

View File

@ -85,24 +85,16 @@ const createTypebots = async () => {
const createResults = () => { const createResults = () => {
return prisma.result.createMany({ return prisma.result.createMany({
data: [ data: [
{ ...Array.from(Array(200)).map((_, idx) => {
typebotId: 'typebot1', const today = new Date()
}, return {
{ id: `result${idx}`,
typebotId: 'typebot1',
},
{
id: 'result1',
typebotId: 'typebot2', typebotId: 'typebot2',
}, createdAt: new Date(
{ today.setTime(today.getTime() + 1000 * 60 * 60 * 24 * idx)
id: 'result2', ),
typebotId: 'typebot2', }
}, }),
{
id: 'result3',
typebotId: 'typebot2',
},
], ],
}) })
} }
@ -110,24 +102,12 @@ const createResults = () => {
const createAnswers = () => { const createAnswers = () => {
return prisma.answer.createMany({ return prisma.answer.createMany({
data: [ data: [
{ ...Array.from(Array(200)).map((_, idx) => ({
resultId: 'result1', resultId: `result${idx}`,
content: 'content 1', content: `content${idx}`,
stepId: 'step1', stepId: 'step1',
blockId: 'block1', 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', () => { describe('ResultsPage', () => {
before(() => { 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' 'getResults'
) )
}) })
@ -13,19 +16,32 @@ describe('ResultsPage', () => {
cy.signIn('test2@gmail.com') cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot2/results') cy.visit('/typebots/typebot2/results')
cy.wait('@getResults') cy.wait('@getResults')
cy.findByText('content 2').should('exist') cy.findByText('content198').should('exist')
cy.findByText('content 3').should('exist') cy.findByText('content197').should('exist')
cy.findAllByRole('checkbox').eq(2).check({ force: true }) cy.findAllByRole('checkbox').eq(2).check({ force: true })
cy.findAllByRole('checkbox').eq(3).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.findByRole('button', { name: 'Delete' }).click()
cy.findByText('content 2').should('not.exist') cy.findByText('content198').should('not.exist')
cy.findByText('content 3').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.signIn('test2@gmail.com')
cy.visit('/typebots/typebot2/results') 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', status: 'error',
}) })
const { stats } = useStats({ const { stats, mutate } = useStats({
typebotId: typebot?.id, typebotId: typebot?.id,
onError: (err) => toast({ title: err.name, description: err.message }), onError: (err) => toast({ title: err.name, description: err.message }),
}) })
const handleDeletedResults = (total: number) => {
if (!stats) return
mutate({
stats: { ...stats, totalStarts: stats.totalStarts - total },
})
}
return ( return (
<Flex h="full" w="full"> <Flex h="full" w="full">
<Flex <Flex
@ -66,6 +74,7 @@ export const ResultsContent = () => {
) : ( ) : (
<SubmissionsContent <SubmissionsContent
typebotId={typebot.id} typebotId={typebot.id}
onDeleteResults={handleDeletedResults}
totalResults={stats?.totalStarts ?? 0} totalResults={stats?.totalStarts ?? 0}
/> />
))} ))}

View File

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

View File

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

View File

@ -1,30 +1,55 @@
import { Result } from 'bot-engine' import { Result } from 'bot-engine'
import useSWR from 'swr' import useSWRInfinite from 'swr/infinite'
import { fetcher, sendRequest } from './utils' import { fetcher, sendRequest } from './utils'
import { stringify } from 'qs' import { stringify } from 'qs'
import { Answer } from 'db' 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 = ({ export const useResults = ({
lastResultId,
typebotId, typebotId,
onError, onError,
}: { }: {
lastResultId?: string
typebotId: string typebotId: string
onError: (error: Error) => void onError: (error: Error) => void
}) => { }) => {
const params = stringify({ const { data, error, mutate, setSize, size } = useSWRInfinite<
lastResultId, { results: ResultWithAnswers[] },
})
const { data, error, mutate } = useSWR<
{ results: (Result & { answers: Answer[] })[] },
Error Error
>(`/api/typebots/${typebotId}/results?${params}`, fetcher) >(
(
pageIndex: number,
previousPageData: {
results: ResultWithAnswers[]
}
) => getKey(typebotId, pageIndex, previousPageData),
fetcher,
{ revalidateAll: true }
)
if (error) onError(error) if (error) onError(error)
return { return {
results: data?.results, data,
isLoading: !error && !data, isLoading: !error && !data,
mutate, 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 => { export const parseDateToReadable = (dateStr: string): string => {
const date = new Date(dateStr) const date = new Date(dateStr)
return ( return (

View File

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