⚗️ Add export results
This commit is contained in:
3
apps/builder/.gitignore
vendored
3
apps/builder/.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
cypress/screenshots
|
||||
cypress/downloads
|
81
apps/builder/components/results/ResultsActionButtons.tsx
Normal file
81
apps/builder/components/results/ResultsActionButtons.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -1,46 +1,33 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react/jsx-key */
|
||||
import { Box, Checkbox, Flex } from '@chakra-ui/react'
|
||||
import { Answer, Result } from 'bot-engine'
|
||||
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 { parseSubmissionsColumns } from 'services/publicTypebot'
|
||||
import { parseDateToReadable } from 'services/results'
|
||||
import { LoadingRows } from './LoadingRows'
|
||||
|
||||
const defaultCellWidth = 180
|
||||
|
||||
type SubmissionsTableProps = {
|
||||
results?: (Result & { answers: Answer[] })[]
|
||||
data?: any
|
||||
hasMore?: boolean
|
||||
onNewSelection: (selection: string[]) => void
|
||||
onNewSelection: (indices: number[]) => void
|
||||
onScrollToBottom: () => void
|
||||
}
|
||||
|
||||
export const SubmissionsTable = ({
|
||||
results,
|
||||
data,
|
||||
hasMore,
|
||||
onNewSelection,
|
||||
onScrollToBottom,
|
||||
}: SubmissionsTableProps) => {
|
||||
const { publishedTypebot } = useTypebot()
|
||||
const columns: any = React.useMemo(
|
||||
const columns: any = useMemo(
|
||||
() => parseSubmissionsColumns(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 tableWrapper = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
@ -59,8 +46,7 @@ export const SubmissionsTable = ({
|
||||
) as any
|
||||
|
||||
useEffect(() => {
|
||||
if (!results) return
|
||||
onNewSelection(selectedFlatRows.map((row: any) => results[row.index].id))
|
||||
onNewSelection(selectedFlatRows.map((row: any) => row.index))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedFlatRows])
|
||||
|
||||
@ -130,8 +116,7 @@ export const SubmissionsTable = ({
|
||||
as="tr"
|
||||
{...row.getRowProps()}
|
||||
ref={(ref) => {
|
||||
if (results && idx === results.length - 10)
|
||||
bottomElement.current = ref
|
||||
if (idx === data.length - 10) bottomElement.current = ref
|
||||
}}
|
||||
>
|
||||
{row.cells.map((cell: any, idx: number) => {
|
||||
@ -155,9 +140,7 @@ export const SubmissionsTable = ({
|
||||
</Flex>
|
||||
)
|
||||
})}
|
||||
{(results === undefined || hasMore === true) && (
|
||||
<LoadingRows totalColumns={columns.length} />
|
||||
)}
|
||||
{hasMore === true && <LoadingRows totalColumns={columns.length} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
@ -33,6 +33,7 @@ export const TypebotHeader = () => {
|
||||
h={`${headerHeight}px`}
|
||||
zIndex={2}
|
||||
bgColor="white"
|
||||
flexShrink={0}
|
||||
>
|
||||
<HStack>
|
||||
<Button
|
||||
|
@ -1,3 +1,6 @@
|
||||
import path from 'path'
|
||||
import { parse } from 'papaparse'
|
||||
|
||||
describe('ResultsPage', () => {
|
||||
before(() => {
|
||||
cy.intercept({ url: '/api/typebots/typebot2/results*', method: 'GET' }).as(
|
||||
@ -44,4 +47,48 @@ describe('ResultsPage', () => {
|
||||
cy.findByText('content50').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')
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"types": ["cypress", "@testing-library/cypress", "cypress-file-upload"],
|
||||
"lib": ["es2015", "dom"],
|
||||
"lib": ["es2015", "dom", "ES2021.String"],
|
||||
"target": "es5",
|
||||
"isolatedModules": false,
|
||||
"allowJs": true,
|
||||
|
@ -1,19 +1,15 @@
|
||||
import {
|
||||
Button,
|
||||
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 { Stack, useToast, Flex } from '@chakra-ui/react'
|
||||
import { ResultsActionButtons } from 'components/results/ResultsActionButtons'
|
||||
import { SubmissionsTable } from 'components/results/SubmissionsTable'
|
||||
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 = {
|
||||
typebotId: string
|
||||
@ -25,10 +21,9 @@ export const SubmissionsContent = ({
|
||||
totalResults,
|
||||
onDeleteResults,
|
||||
}: Props) => {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false)
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const [isExportLoading, setIsExportLoading] = useState(false)
|
||||
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
@ -42,13 +37,16 @@ export const SubmissionsContent = ({
|
||||
|
||||
const results = useMemo(() => data?.flatMap((d) => d.results), [data])
|
||||
|
||||
const handleNewSelection = (newSelection: string[]) => {
|
||||
if (newSelection.length === selectedIds.length) return
|
||||
setSelectedIds(newSelection)
|
||||
const handleNewSelection = (newSelectionIndices: number[]) => {
|
||||
if (newSelectionIndices.length === selectedIndices.length) return
|
||||
setSelectedIndices(newSelectionIndices)
|
||||
}
|
||||
|
||||
const handleDeleteSelection = async () => {
|
||||
setIsDeleteLoading(true)
|
||||
const selectedIds = (results ?? [])
|
||||
.filter((_, idx) => selectedIndices.includes(idx))
|
||||
.map((result) => result.id)
|
||||
const { error } =
|
||||
totalSelected === totalResults
|
||||
? await deleteAllResults(typebotId)
|
||||
@ -69,10 +67,10 @@ export const SubmissionsContent = ({
|
||||
|
||||
const totalSelected = useMemo(
|
||||
() =>
|
||||
selectedIds.length === results?.length
|
||||
selectedIndices.length === results?.length
|
||||
? totalResults
|
||||
: selectedIds.length,
|
||||
[results?.length, selectedIds.length, totalResults]
|
||||
: selectedIndices.length,
|
||||
[results?.length, selectedIndices.length, totalResults]
|
||||
)
|
||||
|
||||
const handleScrolledToBottom = useCallback(
|
||||
@ -80,59 +78,51 @@ export const SubmissionsContent = ({
|
||||
[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 (
|
||||
<Stack maxW="1200px" w="full">
|
||||
<Flex w="full" justifyContent="flex-end">
|
||||
<HStack>
|
||||
<HStack as={Button} colorScheme="blue">
|
||||
<DownloadIcon />
|
||||
<Text>Export</Text>
|
||||
<Fade
|
||||
in={totalSelected > 0 && (results ?? []).length > 0}
|
||||
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>
|
||||
<ResultsActionButtons
|
||||
isDeleteLoading={isDeleteLoading}
|
||||
isExportLoading={isExportLoading}
|
||||
totalSelected={totalSelected}
|
||||
onDeleteClick={handleDeleteSelection}
|
||||
onExportClick={handleExportSelection}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<SubmissionsTable
|
||||
results={results}
|
||||
data={tableData}
|
||||
onNewSelection={handleNewSelection}
|
||||
onScrollToBottom={handleScrolledToBottom}
|
||||
hasMore={hasMore}
|
||||
|
@ -38,6 +38,7 @@
|
||||
"next-auth": "beta",
|
||||
"nodemailer": "^6.7.2",
|
||||
"nprogress": "^0.2.0",
|
||||
"papaparse": "^5.3.1",
|
||||
"qs": "^6.10.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
@ -60,6 +61,7 @@
|
||||
"@types/micro-cors": "^0.1.2",
|
||||
"@types/node": "^16.11.9",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/papaparse": "^5.3.1",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/react": "^17.0.37",
|
||||
"@types/react-table": "^7.7.9",
|
||||
|
@ -14,8 +14,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === 'GET') {
|
||||
const typebotId = req.query.typebotId.toString()
|
||||
const lastResultId = req.query.lastResultId?.toString()
|
||||
const take = parseInt(req.query.limit?.toString())
|
||||
const results = await prisma.result.findMany({
|
||||
take: 50,
|
||||
take: isNaN(take) ? undefined : take,
|
||||
skip: lastResultId ? 1 : 0,
|
||||
cursor: lastResultId
|
||||
? {
|
||||
|
@ -12,14 +12,13 @@ const getKey = (
|
||||
}
|
||||
) => {
|
||||
if (previousPageData && previousPageData.results.length === 0) return null
|
||||
if (pageIndex === 0) return `/api/typebots/${typebotId}/results`
|
||||
console.log(previousPageData.results)
|
||||
if (pageIndex === 0) return `/api/typebots/${typebotId}/results?limit=50`
|
||||
return `/api/typebots/${typebotId}/results?lastResultId=${
|
||||
previousPageData.results[previousPageData.results.length - 1].id
|
||||
}`
|
||||
}&limit=50`
|
||||
}
|
||||
|
||||
type ResultWithAnswers = Result & { answers: Answer[] }
|
||||
export type ResultWithAnswers = Result & { answers: Answer[] }
|
||||
export const useResults = ({
|
||||
typebotId,
|
||||
onError,
|
||||
@ -72,6 +71,12 @@ export const deleteAllResults = async (typebotId: string) =>
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
export const getAllResults = async (typebotId: string) =>
|
||||
sendRequest<{ results: ResultWithAnswers[] }>({
|
||||
url: `/api/typebots/${typebotId}/results`,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
export const parseDateToReadable = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
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 }),
|
||||
{}
|
||||
),
|
||||
}))
|
||||
|
Reference in New Issue
Block a user