2
0

⚗️ Add export results

This commit is contained in:
Baptiste Arnaud
2022-01-04 15:50:56 +01:00
parent 72454c0f68
commit 6c1e0fd345
11 changed files with 235 additions and 103 deletions

View File

@@ -1,2 +1,3 @@
cypress/videos cypress/videos
cypress/screenshots cypress/screenshots
cypress/downloads

View File

@@ -0,0 +1,81 @@
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>
)
}

View File

@@ -1,46 +1,33 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-key */ /* eslint-disable react/jsx-key */
import { Box, Checkbox, Flex } from '@chakra-ui/react' import { Box, Checkbox, Flex } from '@chakra-ui/react'
import { Answer, Result } from 'bot-engine'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import React, { useEffect, useRef } from 'react' import React, { useEffect, useMemo, 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 { LoadingRows } from './LoadingRows' import { LoadingRows } from './LoadingRows'
const defaultCellWidth = 180 const defaultCellWidth = 180
type SubmissionsTableProps = { type SubmissionsTableProps = {
results?: (Result & { answers: Answer[] })[] data?: any
hasMore?: boolean hasMore?: boolean
onNewSelection: (selection: string[]) => void onNewSelection: (indices: number[]) => void
onScrollToBottom: () => void onScrollToBottom: () => void
} }
export const SubmissionsTable = ({ export const SubmissionsTable = ({
results, data,
hasMore, hasMore,
onNewSelection, onNewSelection,
onScrollToBottom, onScrollToBottom,
}: SubmissionsTableProps) => { }: SubmissionsTableProps) => {
const { publishedTypebot } = useTypebot() const { publishedTypebot } = useTypebot()
const columns: any = React.useMemo( const columns: any = useMemo(
() => parseSubmissionsColumns(publishedTypebot), () => parseSubmissionsColumns(publishedTypebot),
[publishedTypebot] [publishedTypebot]
) )
const data = React.useMemo(
() =>
(results ?? []).map((result) => ({
createdAt: parseDateToReadable(result.createdAt),
...result.answers.reduce(
(o, answer) => ({ ...o, [answer.blockId]: answer.content }),
{}
),
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[results?.length]
)
const bottomElement = useRef<HTMLDivElement | null>(null) const bottomElement = useRef<HTMLDivElement | null>(null)
const tableWrapper = useRef<HTMLDivElement | null>(null) const tableWrapper = useRef<HTMLDivElement | null>(null)
@@ -59,8 +46,7 @@ export const SubmissionsTable = ({
) as any ) as any
useEffect(() => { useEffect(() => {
if (!results) return onNewSelection(selectedFlatRows.map((row: any) => row.index))
onNewSelection(selectedFlatRows.map((row: any) => results[row.index].id))
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFlatRows]) }, [selectedFlatRows])
@@ -130,8 +116,7 @@ export const SubmissionsTable = ({
as="tr" as="tr"
{...row.getRowProps()} {...row.getRowProps()}
ref={(ref) => { ref={(ref) => {
if (results && idx === results.length - 10) if (idx === data.length - 10) bottomElement.current = ref
bottomElement.current = ref
}} }}
> >
{row.cells.map((cell: any, idx: number) => { {row.cells.map((cell: any, idx: number) => {
@@ -155,9 +140,7 @@ export const SubmissionsTable = ({
</Flex> </Flex>
) )
})} })}
{(results === undefined || hasMore === true) && ( {hasMore === true && <LoadingRows totalColumns={columns.length} />}
<LoadingRows totalColumns={columns.length} />
)}
</Box> </Box>
</Box> </Box>
</Flex> </Flex>

View File

@@ -33,6 +33,7 @@ export const TypebotHeader = () => {
h={`${headerHeight}px`} h={`${headerHeight}px`}
zIndex={2} zIndex={2}
bgColor="white" bgColor="white"
flexShrink={0}
> >
<HStack> <HStack>
<Button <Button

View File

@@ -1,3 +1,6 @@
import path from 'path'
import { parse } from 'papaparse'
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(
@@ -44,4 +47,48 @@ describe('ResultsPage', () => {
cy.findByText('content50').should('exist') cy.findByText('content50').should('exist')
cy.findByText('content0').should('exist') cy.findByText('content0').should('exist')
}) })
it.only('should correctly export selection in CSV', () => {
const downloadsFolder = Cypress.config('downloadsFolder')
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot2/results')
cy.wait('@getResults')
cy.findByRole('button', { name: 'Export' }).should('not.exist')
cy.findByText('content199').should('exist')
cy.findAllByRole('checkbox').eq(2).check({ force: true })
cy.findAllByRole('checkbox').eq(3).check({ force: true })
cy.findByRole('button', { name: 'Export 2' }).click({ force: true })
const filename = path.join(
downloadsFolder,
`typebot-export_${new Date()
.toLocaleDateString()
.replaceAll('/', '-')}.csv`
)
cy.readFile(filename, { timeout: 15000 })
.then(parse)
.then(validateExportSelection as any)
cy.findAllByRole('checkbox').first().check({ force: true })
cy.findByRole('button', { name: 'Export 200' }).click({ force: true })
const filenameAll = path.join(
downloadsFolder,
`typebot-export_${new Date()
.toLocaleDateString()
.replaceAll('/', '-')}_all.csv`
)
cy.readFile(filenameAll, { timeout: 15000 })
.then(parse)
.then(validateExportAll as any)
})
}) })
const validateExportSelection = (list: { data: unknown[][] }) => {
expect(list.data, 'number of records').to.have.length(3)
expect(list.data[1][1], 'first record').to.equal('content198')
expect(list.data[2][1], 'second record').to.equal('content197')
}
const validateExportAll = (list: { data: unknown[][] }) => {
expect(list.data, 'number of records').to.have.length(201)
expect(list.data[1][1], 'first record').to.equal('content199')
expect(list.data[200][1], 'second record').to.equal('content0')
}

View File

@@ -4,7 +4,7 @@
"exclude": [], "exclude": [],
"compilerOptions": { "compilerOptions": {
"types": ["cypress", "@testing-library/cypress", "cypress-file-upload"], "types": ["cypress", "@testing-library/cypress", "cypress-file-upload"],
"lib": ["es2015", "dom"], "lib": ["es2015", "dom", "ES2021.String"],
"target": "es5", "target": "es5",
"isolatedModules": false, "isolatedModules": false,
"allowJs": true, "allowJs": true,

View File

@@ -1,19 +1,15 @@
import { import { Stack, useToast, Flex } from '@chakra-ui/react'
Button, import { ResultsActionButtons } from 'components/results/ResultsActionButtons'
HStack,
Stack,
Tag,
useToast,
Text,
Fade,
Flex,
useDisclosure,
} from '@chakra-ui/react'
import { DownloadIcon, TrashIcon } from 'assets/icons'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { SubmissionsTable } from 'components/results/SubmissionsTable' import { SubmissionsTable } from 'components/results/SubmissionsTable'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { deleteAllResults, deleteResults, useResults } from 'services/results' import {
convertResultsToTableData,
deleteAllResults,
deleteResults,
getAllResults,
useResults,
} from 'services/results'
import { unparse } from 'papaparse'
type Props = { type Props = {
typebotId: string typebotId: string
@@ -25,10 +21,9 @@ export const SubmissionsContent = ({
totalResults, totalResults,
onDeleteResults, onDeleteResults,
}: Props) => { }: Props) => {
const [selectedIds, setSelectedIds] = useState<string[]>([]) const [selectedIndices, setSelectedIndices] = useState<number[]>([])
const [isDeleteLoading, setIsDeleteLoading] = useState(false) const [isDeleteLoading, setIsDeleteLoading] = useState(false)
const [isExportLoading, setIsExportLoading] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
@@ -42,13 +37,16 @@ export const SubmissionsContent = ({
const results = useMemo(() => data?.flatMap((d) => d.results), [data]) const results = useMemo(() => data?.flatMap((d) => d.results), [data])
const handleNewSelection = (newSelection: string[]) => { const handleNewSelection = (newSelectionIndices: number[]) => {
if (newSelection.length === selectedIds.length) return if (newSelectionIndices.length === selectedIndices.length) return
setSelectedIds(newSelection) setSelectedIndices(newSelectionIndices)
} }
const handleDeleteSelection = async () => { const handleDeleteSelection = async () => {
setIsDeleteLoading(true) setIsDeleteLoading(true)
const selectedIds = (results ?? [])
.filter((_, idx) => selectedIndices.includes(idx))
.map((result) => result.id)
const { error } = const { error } =
totalSelected === totalResults totalSelected === totalResults
? await deleteAllResults(typebotId) ? await deleteAllResults(typebotId)
@@ -69,10 +67,10 @@ export const SubmissionsContent = ({
const totalSelected = useMemo( const totalSelected = useMemo(
() => () =>
selectedIds.length === results?.length selectedIndices.length === results?.length
? totalResults ? totalResults
: selectedIds.length, : selectedIndices.length,
[results?.length, selectedIds.length, totalResults] [results?.length, selectedIndices.length, totalResults]
) )
const handleScrolledToBottom = useCallback( const handleScrolledToBottom = useCallback(
@@ -80,59 +78,51 @@ export const SubmissionsContent = ({
[setSize] [setSize]
) )
const handleExportSelection = async () => {
setIsExportLoading(true)
const isSelectAll = totalSelected === totalResults
const dataToUnparse = isSelectAll
? await getAllTableData()
: tableData.filter((_, idx) => selectedIndices.includes(idx))
const csvData = new Blob([unparse(dataToUnparse)], {
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 () => {
const { data, error } = await getAllResults(typebotId)
if (error) toast({ description: error.message, title: error.name })
return convertResultsToTableData(data?.results)
}
const tableData: { [key: string]: string }[] = useMemo(
() => convertResultsToTableData(results),
// eslint-disable-next-line react-hooks/exhaustive-deps
[results?.length]
)
return ( return (
<Stack maxW="1200px" w="full"> <Stack maxW="1200px" w="full">
<Flex w="full" justifyContent="flex-end"> <Flex w="full" justifyContent="flex-end">
<HStack> <ResultsActionButtons
<HStack as={Button} colorScheme="blue"> isDeleteLoading={isDeleteLoading}
<DownloadIcon /> isExportLoading={isExportLoading}
<Text>Export</Text> totalSelected={totalSelected}
<Fade onDeleteClick={handleDeleteSelection}
in={totalSelected > 0 && (results ?? []).length > 0} onExportClick={handleExportSelection}
unmountOnExit />
>
<Tag colorScheme="blue" variant="subtle" size="sm">
{totalSelected}
</Tag>
</Fade>
</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={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> </Flex>
<SubmissionsTable <SubmissionsTable
results={results} data={tableData}
onNewSelection={handleNewSelection} onNewSelection={handleNewSelection}
onScrollToBottom={handleScrolledToBottom} onScrollToBottom={handleScrolledToBottom}
hasMore={hasMore} hasMore={hasMore}

View File

@@ -38,6 +38,7 @@
"next-auth": "beta", "next-auth": "beta",
"nodemailer": "^6.7.2", "nodemailer": "^6.7.2",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"papaparse": "^5.3.1",
"qs": "^6.10.2", "qs": "^6.10.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
@@ -60,6 +61,7 @@
"@types/micro-cors": "^0.1.2", "@types/micro-cors": "^0.1.2",
"@types/node": "^16.11.9", "@types/node": "^16.11.9",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.0",
"@types/papaparse": "^5.3.1",
"@types/qs": "^6.9.7", "@types/qs": "^6.9.7",
"@types/react": "^17.0.37", "@types/react": "^17.0.37",
"@types/react-table": "^7.7.9", "@types/react-table": "^7.7.9",

View File

@@ -14,8 +14,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') { if (req.method === 'GET') {
const typebotId = req.query.typebotId.toString() const typebotId = req.query.typebotId.toString()
const lastResultId = req.query.lastResultId?.toString() const lastResultId = req.query.lastResultId?.toString()
const take = parseInt(req.query.limit?.toString())
const results = await prisma.result.findMany({ const results = await prisma.result.findMany({
take: 50, take: isNaN(take) ? undefined : take,
skip: lastResultId ? 1 : 0, skip: lastResultId ? 1 : 0,
cursor: lastResultId cursor: lastResultId
? { ? {

View File

@@ -12,14 +12,13 @@ const getKey = (
} }
) => { ) => {
if (previousPageData && previousPageData.results.length === 0) return null if (previousPageData && previousPageData.results.length === 0) return null
if (pageIndex === 0) return `/api/typebots/${typebotId}/results` if (pageIndex === 0) return `/api/typebots/${typebotId}/results?limit=50`
console.log(previousPageData.results)
return `/api/typebots/${typebotId}/results?lastResultId=${ return `/api/typebots/${typebotId}/results?lastResultId=${
previousPageData.results[previousPageData.results.length - 1].id previousPageData.results[previousPageData.results.length - 1].id
}` }&limit=50`
} }
type ResultWithAnswers = Result & { answers: Answer[] } export type ResultWithAnswers = Result & { answers: Answer[] }
export const useResults = ({ export const useResults = ({
typebotId, typebotId,
onError, onError,
@@ -72,6 +71,12 @@ export const deleteAllResults = async (typebotId: string) =>
method: 'DELETE', method: 'DELETE',
}) })
export const getAllResults = async (typebotId: string) =>
sendRequest<{ results: ResultWithAnswers[] }>({
url: `/api/typebots/${typebotId}/results`,
method: 'GET',
})
export const parseDateToReadable = (dateStr: string): string => { export const parseDateToReadable = (dateStr: string): string => {
const date = new Date(dateStr) const date = new Date(dateStr)
return ( return (
@@ -83,3 +88,12 @@ export const parseDateToReadable = (dateStr: string): string => {
}) })
) )
} }
export const convertResultsToTableData = (results?: ResultWithAnswers[]) =>
(results ?? []).map((result) => ({
createdAt: parseDateToReadable(result.createdAt),
...result.answers.reduce(
(o, answer) => ({ ...o, [answer.blockId]: answer.content }),
{}
),
}))

View File

@@ -1472,6 +1472,13 @@
resolved "https://registry.yarnpkg.com/@types/nprogress/-/nprogress-0.2.0.tgz#86c593682d4199212a0509cc3c4d562bbbd6e45f" resolved "https://registry.yarnpkg.com/@types/nprogress/-/nprogress-0.2.0.tgz#86c593682d4199212a0509cc3c4d562bbbd6e45f"
integrity sha512-1cYJrqq9GezNFPsWTZpFut/d4CjpZqA0vhqDUPFWYKF1oIyBz5qnoYMzR+0C/T96t3ebLAC1SSnwrVOm5/j74A== integrity sha512-1cYJrqq9GezNFPsWTZpFut/d4CjpZqA0vhqDUPFWYKF1oIyBz5qnoYMzR+0C/T96t3ebLAC1SSnwrVOm5/j74A==
"@types/papaparse@^5.3.1":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.1.tgz#fb5c613a64473c33b08fb9bc2a5ddbf25e54784e"
integrity sha512-1lbngk9wty2kCyQB42LjqSa12SEop3t9wcEC7/xYr3ujTSTmv7HWKjKYXly0GkMfQ42PRb2lFPFEibDOiMXS0g==
dependencies:
"@types/node" "*"
"@types/parse-json@^4.0.0": "@types/parse-json@^4.0.0":
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
@@ -5432,6 +5439,11 @@ pako@~1.0.5:
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
papaparse@^5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.3.1.tgz#770b7a9124d821d4b2132132b7bd7dce7194b5b1"
integrity sha512-Dbt2yjLJrCwH2sRqKFFJaN5XgIASO9YOFeFP8rIBRG2Ain8mqk5r1M6DkfvqEVozVcz3r3HaUGw253hA1nLIcA==
parent-module@^1.0.0: parent-module@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"