diff --git a/apps/builder/components/results/SubmissionsTable/LoadingRows.tsx b/apps/builder/components/results/SubmissionsTable/LoadingRows.tsx index a0851539e..be29c52fc 100644 --- a/apps/builder/components/results/SubmissionsTable/LoadingRows.tsx +++ b/apps/builder/components/results/SubmissionsTable/LoadingRows.tsx @@ -17,7 +17,7 @@ export const LoadingRows = ({ totalColumns }: LoadingRowsProps) => { border="1px" as="td" borderColor="gray.200" - flex="0" + width="50px" > @@ -30,7 +30,7 @@ export const LoadingRows = ({ totalColumns }: LoadingRowsProps) => { border="1px" as="td" borderColor="gray.200" - flex="1" + width="180px" align="center" > diff --git a/apps/builder/components/results/SubmissionsTable/SubmissionsTable.tsx b/apps/builder/components/results/SubmissionsTable/SubmissionsTable.tsx index d7c1956c7..5de3b747f 100644 --- a/apps/builder/components/results/SubmissionsTable/SubmissionsTable.tsx +++ b/apps/builder/components/results/SubmissionsTable/SubmissionsTable.tsx @@ -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(null) + const tableWrapper = useRef(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 ( - - - + + + {headerGroups.map((headerGroup: any) => { return ( @@ -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 = ({ - {results === undefined && ( - - )} - {rows.map((row: any) => { + {rows.map((row: any, idx: number) => { prepareRow(row) return ( - + { + if (results && idx === results.length - 10) + bottomElement.current = ref + }} + > {row.cells.map((cell: any, idx: number) => { return ( ) })} + {(results === undefined || hasMore === true) && ( + + )} diff --git a/apps/builder/cypress/plugins/database.ts b/apps/builder/cypress/plugins/database.ts index c8162dfcf..52e3a1f7c 100644 --- a/apps/builder/cypress/plugins/database.ts +++ b/apps/builder/cypress/plugins/database.ts @@ -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', - }, + })), ], }) } diff --git a/apps/builder/cypress/tests/results.ts b/apps/builder/cypress/tests/results.ts index e330ff9b1..97fcf9441 100644 --- a/apps/builder/cypress/tests/results.ts +++ b/apps/builder/cypress/tests/results.ts @@ -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') }) }) diff --git a/apps/builder/layouts/results/ResultsContent.tsx b/apps/builder/layouts/results/ResultsContent.tsx index 6bfa0e2dd..29042702e 100644 --- a/apps/builder/layouts/results/ResultsContent.tsx +++ b/apps/builder/layouts/results/ResultsContent.tsx @@ -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 ( { ) : ( ))} diff --git a/apps/builder/layouts/results/SubmissionContent.tsx b/apps/builder/layouts/results/SubmissionContent.tsx index 5a2b2fd60..6b532d5de 100644 --- a/apps/builder/layouts/results/SubmissionContent.tsx +++ b/apps/builder/layouts/results/SubmissionContent.tsx @@ -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() +type Props = { + typebotId: string + totalResults: number + onDeleteResults: (total: number) => void +} +export const SubmissionsContent = ({ + typebotId, + totalResults, + onDeleteResults, +}: Props) => { const [selectedIds, setSelectedIds] = useState([]) 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 ( @@ -90,28 +110,33 @@ export const SubmissionsContent = ({ typebotId, totalResults }: Props) => { {totalSelected} )} - - You are about to delete{' '} - - {totalSelected} submission - {totalSelected > 0 ? 's' : ''} - - . Are you sure you wish to continue? - - } - confirmButtonLabel={'Delete'} - /> + + You are about to delete{' '} + + {totalSelected} submission + {totalSelected > 1 ? 's' : ''} + + . Are you sure you wish to continue? + + } + confirmButtonLabel={'Delete'} + /> - + ) } diff --git a/apps/builder/package.json b/apps/builder/package.json index 3e2e541f2..72bcc5913 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -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": "*" }, diff --git a/apps/builder/services/results.ts b/apps/builder/services/results.ts index 65e0c9d9d..a0eb18f87 100644 --- a/apps/builder/services/results.ts +++ b/apps/builder/services/results.ts @@ -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 ( diff --git a/yarn.lock b/yarn.lock index cbf3ea8c5..e12e0c9d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==