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==