2
0

fix(results): 🐛 Display results for blocks w/ multiple inputs

This commit is contained in:
Baptiste Arnaud
2022-03-10 09:25:44 +01:00
parent 7e61ab19eb
commit 4767cdc542
7 changed files with 437 additions and 231 deletions

View File

@ -2,14 +2,13 @@
/* eslint-disable react/jsx-key */
import { Button, chakra, Checkbox, Flex, HStack, Text } from '@chakra-ui/react'
import { AlignLeftTextIcon } from 'assets/icons'
import { PublicTypebot } from 'models'
import React, { useEffect, useMemo, useRef } from 'react'
import { Hooks, useRowSelect, useTable } from 'react-table'
import { parseSubmissionsColumns } from 'services/publicTypebot'
import { parseSubmissionsColumns, ResultHeaderCell } from 'services/typebots'
import { LoadingRows } from './LoadingRows'
type SubmissionsTableProps = {
blocksAndVariables: Pick<PublicTypebot, 'blocks' | 'variables'>
resultHeader: ResultHeaderCell[]
data?: any
hasMore?: boolean
onNewSelection: (indices: number[]) => void
@ -18,7 +17,7 @@ type SubmissionsTableProps = {
}
export const SubmissionsTable = ({
blocksAndVariables,
resultHeader,
data,
hasMore,
onNewSelection,
@ -26,8 +25,8 @@ export const SubmissionsTable = ({
onLogOpenIndex,
}: SubmissionsTableProps) => {
const columns: any = useMemo(
() => parseSubmissionsColumns(blocksAndVariables),
[blocksAndVariables]
() => parseSubmissionsColumns(resultHeader),
[resultHeader]
)
const bottomElement = useRef<HTMLDivElement | null>(null)
const tableWrapper = useRef<HTMLDivElement | null>(null)

View File

@ -7,6 +7,7 @@ import {
deleteAllResults,
deleteResults,
getAllResults,
parseResultHeader,
useResults,
} from 'services/typebots'
import { unparse } from 'papaparse'
@ -49,6 +50,8 @@ export const SubmissionsContent = ({
].filter(isDefined),
}
const resultHeader = parseResultHeader(blocksAndVariables)
const { data, mutate, setSize, hasMore } = useResults({
typebotId,
onError: (err) => toast({ title: err.name, description: err.message }),
@ -117,14 +120,12 @@ export const SubmissionsContent = ({
if (!publishedTypebot) return []
const { data, error } = await getAllResults(typebotId)
if (error) toast({ description: error.message, title: error.name })
return convertResultsToTableData(blocksAndVariables)(data?.results)
return convertResultsToTableData(data?.results, resultHeader)
}
const tableData: { [key: string]: string }[] = useMemo(
() =>
publishedTypebot
? convertResultsToTableData(blocksAndVariables)(results)
: [],
publishedTypebot ? convertResultsToTableData(results, resultHeader) : [],
// eslint-disable-next-line react-hooks/exhaustive-deps
[results]
)
@ -159,7 +160,7 @@ export const SubmissionsContent = ({
</Flex>
<SubmissionsTable
blocksAndVariables={blocksAndVariables}
resultHeader={resultHeader}
data={tableData}
onNewSelection={handleNewSelection}
onScrollToBottom={handleScrolledToBottom}

View File

@ -1,9 +1,6 @@
import { Block, PublicTypebot, Typebot, Variable } from 'models'
import { PublicTypebot, Typebot } from 'models'
import shortId from 'short-uuid'
import { HStack, Text } from '@chakra-ui/react'
import { CalendarIcon, CodeIcon } from 'assets/icons'
import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
import { byId, isInputStep, sendRequest } from 'utils'
import { sendRequest } from 'utils'
export const parseTypebotToPublicTypebot = (
typebot: Typebot
@ -58,96 +55,3 @@ export const updatePublishedTypebot = async (
method: 'PUT',
body: typebot,
})
type HeaderCell = {
Header: JSX.Element
accessor: string
}
export const parseSubmissionsColumns = (blocksAndVariables: {
blocks: Block[]
variables: Variable[]
}): HeaderCell[] => {
const parsedBlocks = parseBlocksHeaders(blocksAndVariables)
return [
{
Header: (
<HStack>
<CalendarIcon />
<Text>Submitted at</Text>
</HStack>
),
accessor: 'Submitted at',
},
...parsedBlocks,
...parseVariablesHeaders(blocksAndVariables.variables, parsedBlocks),
]
}
const parseBlocksHeaders = ({
blocks,
variables,
}: {
blocks: Block[]
variables: Variable[]
}) =>
blocks
.filter((block) => block.steps.some((step) => isInputStep(step)))
.reduce<HeaderCell[]>((headers, block) => {
const inputStep = block.steps.find((step) => isInputStep(step))
if (
!inputStep ||
!isInputStep(inputStep) ||
headers.find(
(h) =>
h.accessor ===
variables.find(byId(inputStep.options.variableId))?.name
)
)
return headers
const matchedVariableName =
inputStep.options.variableId &&
variables.find(byId(inputStep.options.variableId))?.name
return [
...headers,
{
Header: (
<HStack
minW={
'isLong' in inputStep.options && inputStep.options.isLong
? '400px'
: '150px'
}
maxW="500px"
>
<StepIcon type={inputStep.type} />
<Text>{matchedVariableName ?? block.title}</Text>
</HStack>
),
accessor: matchedVariableName ?? block.title,
},
]
}, [])
const parseVariablesHeaders = (
variables: Variable[],
parsedBlocks: {
Header: JSX.Element
accessor: string
}[]
) =>
variables.reduce<HeaderCell[]>((headers, v) => {
if (parsedBlocks.find((b) => b.accessor === v.name)) return headers
return [
...headers,
{
Header: (
<HStack minW={'150px'} maxW="500px">
<CodeIcon />
<Text>{v.name}</Text>
</HStack>
),
accessor: v.name,
},
]
}, [])

View File

@ -1,122 +0,0 @@
import { Block, ResultWithAnswers, Variable, VariableWithValue } from 'models'
import useSWRInfinite from 'swr/infinite'
import { stringify } from 'qs'
import { Answer } from 'db'
import { byId, isDefined, sendRequest } from 'utils'
import { fetcher } from 'services/utils'
const paginationLimit = 50
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?limit=50`
return `/api/typebots/${typebotId}/results?lastResultId=${
previousPageData.results[previousPageData.results.length - 1].id
}&limit=${paginationLimit}`
}
export const useResults = ({
typebotId,
onError,
}: {
typebotId: string
onError: (error: Error) => void
}) => {
const { data, error, mutate, setSize, size, isValidating } = useSWRInfinite<
{ results: ResultWithAnswers[] },
Error
>(
(
pageIndex: number,
previousPageData: {
results: ResultWithAnswers[]
}
) => getKey(typebotId, pageIndex, previousPageData),
fetcher,
{ revalidateAll: true }
)
if (error) onError(error)
return {
data,
isLoading: !error && !data,
mutate,
setSize,
size,
hasMore:
isValidating ||
(data &&
data.length > 0 &&
data[data.length - 1].results.length > 0 &&
data.length === paginationLimit),
}
}
export const deleteResults = async (typebotId: string, ids: string[]) => {
const params = stringify(
{
ids,
},
{ indices: false }
)
return sendRequest({
url: `/api/typebots/${typebotId}/results?${params}`,
method: 'DELETE',
})
}
export const deleteAllResults = async (typebotId: string) =>
sendRequest({
url: `/api/typebots/${typebotId}/results`,
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 (
date.toDateString().split(' ').slice(1, 3).join(' ') +
', ' +
date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
)
}
export const convertResultsToTableData =
({ variables, blocks }: { variables: Variable[]; blocks: Block[] }) =>
(results: ResultWithAnswers[] | undefined) =>
(results ?? []).map((result) => ({
'Submitted at': parseDateToReadable(result.createdAt),
...[...result.answers, ...result.prefilledVariables].reduce<{
[key: string]: string
}>((o, answerOrVariable) => {
if ('blockId' in answerOrVariable) {
const answer = answerOrVariable as Answer
const key =
(answer.variableId
? variables.find(byId(answer.variableId))?.name
: blocks.find(byId(answer.blockId))?.title) ?? ''
return {
...o,
[key]: answer.content,
}
}
const variable = answerOrVariable as VariableWithValue
if (isDefined(o[variable.id])) return o
const key = variables.find(byId(variable.id))?.name ?? ''
return { ...o, [key]: variable.value }
}, {}),
}))

View File

@ -0,0 +1,246 @@
import {
Block,
InputStep,
InputStepType,
ResultWithAnswers,
Variable,
VariableWithValue,
} from 'models'
import useSWRInfinite from 'swr/infinite'
import { stringify } from 'qs'
import { Answer } from 'db'
import { byId, isDefined, isInputStep, sendRequest } from 'utils'
import { fetcher } from 'services/utils'
import { HStack, Text } from '@chakra-ui/react'
import { CodeIcon, CalendarIcon } from 'assets/icons'
import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
const paginationLimit = 50
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?limit=50`
return `/api/typebots/${typebotId}/results?lastResultId=${
previousPageData.results[previousPageData.results.length - 1].id
}&limit=${paginationLimit}`
}
export const useResults = ({
typebotId,
onError,
}: {
typebotId: string
onError: (error: Error) => void
}) => {
const { data, error, mutate, setSize, size, isValidating } = useSWRInfinite<
{ results: ResultWithAnswers[] },
Error
>(
(
pageIndex: number,
previousPageData: {
results: ResultWithAnswers[]
}
) => getKey(typebotId, pageIndex, previousPageData),
fetcher,
{ revalidateAll: true }
)
if (error) onError(error)
return {
data,
isLoading: !error && !data,
mutate,
setSize,
size,
hasMore:
isValidating ||
(data &&
data.length > 0 &&
data[data.length - 1].results.length > 0 &&
data.length === paginationLimit),
}
}
export const deleteResults = async (typebotId: string, ids: string[]) => {
const params = stringify(
{
ids,
},
{ indices: false }
)
return sendRequest({
url: `/api/typebots/${typebotId}/results?${params}`,
method: 'DELETE',
})
}
export const deleteAllResults = async (typebotId: string) =>
sendRequest({
url: `/api/typebots/${typebotId}/results`,
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 (
date.toDateString().split(' ').slice(1, 3).join(' ') +
', ' +
date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
)
}
type HeaderCell = {
Header: JSX.Element
accessor: string
}
export type ResultHeaderCell = {
label: string
stepId?: string
stepType?: InputStepType
isLong?: boolean
variableId?: string
}
export const parseSubmissionsColumns = (
resultHeader: ResultHeaderCell[]
): HeaderCell[] =>
resultHeader.map((header) => ({
Header: (
<HStack minW={header.isLong ? '400px' : '150px'} maxW="500px">
<HeaderIcon header={header} />
<Text>{header.label}</Text>
</HStack>
),
accessor: header.label,
}))
const HeaderIcon = ({ header }: { header: ResultHeaderCell }) =>
header.stepType ? (
<StepIcon type={header.stepType} />
) : header.variableId ? (
<CodeIcon />
) : (
<CalendarIcon />
)
export const parseResultHeader = ({
blocks,
variables,
}: {
blocks: Block[]
variables: Variable[]
}): ResultHeaderCell[] => {
const parsedBlocks = parseInputsResultHeader({ blocks, variables })
return [
{ label: 'Submitted at' },
...parsedBlocks,
...parseVariablesHeaders(variables, parsedBlocks),
]
}
const parseInputsResultHeader = ({
blocks,
variables,
}: {
blocks: Block[]
variables: Variable[]
}): ResultHeaderCell[] =>
(
blocks
.flatMap((b) =>
b.steps.map((s) => ({
...s,
blockTitle: b.title,
}))
)
.filter((step) => isInputStep(step)) as (InputStep & {
blockTitle: string
})[]
).reduce<ResultHeaderCell[]>((headers, inputStep) => {
if (
headers.find(
(h) =>
isDefined(h.variableId) &&
h.variableId ===
variables.find(byId(inputStep.options.variableId))?.id
)
)
return headers
const matchedVariableName =
inputStep.options.variableId &&
variables.find(byId(inputStep.options.variableId))?.name
let label = matchedVariableName ?? inputStep.blockTitle
const totalPrevious = headers.filter((h) => h.label.includes(label)).length
if (totalPrevious > 0) label = label + ` (${totalPrevious})`
return [
...headers,
{
stepType: inputStep.type,
stepId: inputStep.id,
variableId: inputStep.options.variableId,
label,
isLong: 'isLong' in inputStep.options && inputStep.options.isLong,
},
]
}, [])
const parseVariablesHeaders = (
variables: Variable[],
stepResultHeader: ResultHeaderCell[]
) =>
variables.reduce<ResultHeaderCell[]>((headers, v) => {
if (stepResultHeader.find((h) => h.variableId === v.id)) return headers
return [
...headers,
{
label: v.name,
variableId: v.id,
},
]
}, [])
export const convertResultsToTableData = (
results: ResultWithAnswers[] | undefined,
header: ResultHeaderCell[]
): { [key: string]: string }[] =>
(results ?? []).map((result) => ({
'Submitted at': parseDateToReadable(result.createdAt),
...[...result.answers, ...result.prefilledVariables].reduce<{
[key: string]: string
}>((o, answerOrVariable) => {
if ('blockId' in answerOrVariable) {
const answer = answerOrVariable as Answer
const key = answer.variableId
? header.find((h) => h.variableId === answer.variableId)?.label
: header.find((h) => h.stepId === answer.stepId)?.label
if (!key) return o
return {
...o,
[key]: answer.content,
}
}
const variable = answerOrVariable as VariableWithValue
if (isDefined(o[variable.id])) return o
const key = header.find((h) => h.variableId === variable.id)?.label
if (!key) return o
return { ...o, [key]: variable.value }
}, {}),
}))

View File

@ -0,0 +1,155 @@
{
"id": "cl0jrltqx0037601agzjiy7t4",
"createdAt": "2022-03-09T16:17:51.321Z",
"updatedAt": "2022-03-09T16:19:07.037Z",
"name": "My typebot",
"ownerId": "cl0cfi60r0000381a2bft9yis",
"publishedTypebotId": "dm12bh6hmEQemywn86osJD",
"folderId": null,
"blocks": [
{
"id": "dqork4dJJZk3RgKYavBpRE",
"steps": [
{
"id": "u7Px8eD9MWXNJEBwxQwJCF",
"type": "start",
"label": "Start",
"blockId": "dqork4dJJZk3RgKYavBpRE",
"outgoingEdgeId": "b3XsreaqtWt4CrZZmCKDpa"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "2Vrpgk5VP9BUo3vKtM5kws",
"steps": [
{
"id": "s6GezsfD612D1naKwvhDFgA",
"type": "text",
"blockId": "2Vrpgk5VP9BUo3vKtM5kws",
"content": {
"html": "<div>Hi what&#x27;s your name?</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Hi what's your name?" }] }
],
"plainText": "Hi what's your name?"
}
},
{
"id": "sq3mPXUrugs5t6FoME3T4t4",
"type": "text input",
"blockId": "2Vrpgk5VP9BUo3vKtM5kws",
"options": {
"isLong": false,
"labels": {
"button": "Send",
"placeholder": "Type your answer..."
},
"variableId": "93BD32WVM5JEQ1nmWtr2S5"
}
},
{
"id": "s57hbzfpG2sVvXefznVhbVB",
"type": "text",
"blockId": "2Vrpgk5VP9BUo3vKtM5kws",
"content": {
"html": "<div>How old are you?</div>",
"richText": [
{ "type": "p", "children": [{ "text": "How old are you?" }] }
],
"plainText": "How old are you?"
}
},
{
"id": "s4fFn3s7nouQk88iJ3oLgx6",
"type": "number input",
"blockId": "2Vrpgk5VP9BUo3vKtM5kws",
"options": {
"labels": { "button": "Send", "placeholder": "Type a number..." }
}
},
{
"id": "scR6MewJwkPNJzABYG8NEA4",
"type": "text",
"blockId": "2Vrpgk5VP9BUo3vKtM5kws",
"content": {
"html": "<div>Cool!</div>",
"richText": [{ "type": "p", "children": [{ "text": "Cool!" }] }],
"plainText": "Cool!"
}
},
{
"id": "s5fo1s8UTyHQ7CfqC6MRxyW",
"type": "text",
"blockId": "2Vrpgk5VP9BUo3vKtM5kws",
"content": {
"html": "<div>Do you eat pizza?</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Do you eat pizza?" }] }
],
"plainText": "Do you eat pizza?"
}
},
{
"id": "sxn8UjQ2MjEMuRjhkh7LWws",
"type": "choice input",
"items": [
{
"id": "wGKXMr4mfySw1HNThND2Xd",
"type": 0,
"stepId": "sxn8UjQ2MjEMuRjhkh7LWws",
"content": "Yes"
},
{
"id": "qzqzVqMeo6TDUdMYckLZmf",
"type": 0,
"stepId": "sxn8UjQ2MjEMuRjhkh7LWws",
"content": "No"
}
],
"blockId": "2Vrpgk5VP9BUo3vKtM5kws",
"options": { "buttonLabel": "Send", "isMultipleChoice": false }
}
],
"title": "Block #1",
"graphCoordinates": { "x": 386, "y": 108 }
}
],
"variables": [{ "id": "93BD32WVM5JEQ1nmWtr2S5", "name": "Name" }],
"edges": [
{
"id": "b3XsreaqtWt4CrZZmCKDpa",
"to": { "blockId": "2Vrpgk5VP9BUo3vKtM5kws" },
"from": {
"stepId": "u7Px8eD9MWXNJEBwxQwJCF",
"blockId": "dqork4dJJZk3RgKYavBpRE"
}
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": "my-typebot-zjiy7t4",
"customDomain": null
}

View File

@ -0,0 +1,23 @@
import test, { expect } from '@playwright/test'
import path from 'path'
import { importTypebotInDatabase } from '../services/database'
import { typebotViewer } from '../services/selectorUtils'
import { generate } from 'short-uuid'
test('should work as expected', async ({ page }) => {
const typebotId = generate()
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/hugeBlock.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('input').fill('Baptiste')
await typebotViewer(page).locator('input').press('Enter')
await typebotViewer(page).locator('input').fill('26')
await typebotViewer(page).locator('input').press('Enter')
await typebotViewer(page).locator('button >> text=Yes').click()
await page.goto(`http://localhost:3000/typebots/${typebotId}/results`)
await expect(page.locator('text=Baptiste')).toBeVisible()
await expect(page.locator('text=26')).toBeVisible()
await expect(page.locator('text=Yes')).toBeVisible()
})