Skip to content

Commit 5f25880

Browse files
PLU-624: fix(formsg-frontend): table field preview (#1355)
## Problem * Forms that contain **Section** or **Image** fields cannot preview their table rows due to a mismatch in the Zod schema. The current schema allows `undefined`, but these field types return `null`, which causes validation errors. * If there is more than 1 table in the form, the preview always shows data from the first table ## Solution - Replace `.optional()` with `.nullish()` so that both undefined and null are accepted. - Fetch the table data using the variable id ## Tests - [x] All FormSG test results display correctly - [x] FormSG tables can be previewed correctly even when they contain section headers or images - [x] FormSG table previews display the correct data when there are two or more tables --------- Co-authored-by: Ian Chen <[email protected]>
1 parent 5e8ec44 commit 5f25880

File tree

6 files changed

+74
-40
lines changed

6 files changed

+74
-40
lines changed

packages/backend/src/apps/formsg/triggers/new-submission/get-data-out-metadata.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,9 @@ function buildTableMetadatum(fieldData: IJSONObject): IDataOutMetadata {
204204

205205
const tableObject = JSON.parse(fieldData.answer as string)
206206
return {
207-
label: `Response ${fieldData.order}`,
207+
label: fieldData.question
208+
? `${fieldData.order} ${fieldData.question}`
209+
: `Response ${fieldData.order}`,
208210
order: fieldData.order ? (fieldData.order as number) + 0.1 : null,
209211
type: 'table',
210212
displayedValue: `Preview ${tableObject.rows.length} row(s)`,

packages/backend/src/apps/formsg/triggers/new-submission/get-mock-data.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type FormField = {
1313
_id: string
1414
columns?: Array<{
1515
_id: string
16+
title?: string
1617
}>
1718
fieldType: string
1819
fieldOptions?: string[]
@@ -130,8 +131,9 @@ async function getMockData($: IGlobalVariable) {
130131
data.responses[formFields[i]._id].answer = 'Signature captured' // mock this to always be present regardless of whether the user has signed or not
131132
}
132133

133-
// formsg payload doesnt contain this anyways, so we dont return in mock data
134-
if (fieldType === 'statement') {
134+
// formsg payload doesnt contain these fields anyways, so we dont return in mock data
135+
// this ensures that the question numbering remains consistent with the actual submission
136+
if (fieldType === 'statement' || fieldType === 'image') {
135137
delete data.responses[formFields[i]._id]
136138
continue
137139
}
@@ -160,11 +162,21 @@ async function getMockData($: IGlobalVariable) {
160162
if (fieldType === 'table') {
161163
const answerArray = data.responses[formFields[i]._id]
162164
.answerArray as string[][]
163-
const question = data.responses[formFields[i]._id].question
165+
const question = `${
166+
data.responses[formFields[i]._id].question
167+
} (${formFields[i].columns
168+
?.map((column, index) => column?.title ?? `Col ${index + 1}`)
169+
.join(', ')})`
170+
171+
data.responses[formFields[i]._id].question = question
164172
data.responses[formFields[i]._id].answer =
165173
convertTableAnswerArrayToTableObject(question, answerArray)
166174
}
167175

176+
if (fieldType === 'section' || fieldType === 'image') {
177+
data.responses[formFields[i]._id].answer = ''
178+
}
179+
168180
data.responses[formFields[i]._id].order = i + 1
169181
data.responses[formFields[i]._id].id = undefined
170182
}

packages/frontend/src/components/VariablesList/TableVariableItem.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export default function TableVariableItem(props: TableVariableItemProps) {
3333
/>
3434
{!onClick && (
3535
<TableVariableModal
36+
variableId={variable.name}
3637
isOpen={isOpen}
3738
onClose={onClose}
3839
currentExecutionStep={currentTestExecutionStep}

packages/frontend/src/components/VariablesList/TableVariableModal.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ interface TableVariableModalProps {
2828
isOpen: boolean
2929
onClose: () => void
3030
currentExecutionStep?: IExecutionStep | null
31+
// variableName is the step variable: `step.<uuid>.xxxxx`
32+
variableId: string
3133
}
3234

3335
const TableHeader = ({ columns }: { columns: ProcessedColumn[] }) => (
@@ -107,12 +109,15 @@ const TableRow = ({
107109
)
108110

109111
export default function TableVariableModal(props: TableVariableModalProps) {
110-
const { isOpen, onClose, currentExecutionStep } = props
112+
const { isOpen, onClose, currentExecutionStep, variableId } = props
111113

112114
if (!currentExecutionStep) {
113115
return null
114116
}
115-
const { rowsFound, dataRows, columns } = processData(currentExecutionStep)
117+
const { rowsFound, dataRows, columns } = processData(
118+
currentExecutionStep,
119+
variableId,
120+
)
116121

117122
return (
118123
<Modal
Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { IJSONObject } from '@plumber/types'
22

3+
import get from 'lodash.get'
34
import { z } from 'zod'
45

56
const RowDataSchema = z.object({
@@ -32,26 +33,31 @@ const FormSgTableFieldSchema = z.object({
3233

3334
const FormSgFieldSchema = z.object({
3435
fieldType: z.string(),
35-
answer: z.union([z.string(), z.record(z.any())]).optional(),
36+
answer: z.union([z.string(), z.record(z.any())]).nullish(),
3637
})
3738

38-
const FormSgFieldsSchema = z.record(FormSgFieldSchema).transform((fields) => {
39-
const tableField = Object.values(fields).find(
40-
(field) => field?.fieldType === 'table',
41-
) as z.infer<typeof FormSgFieldSchema> | undefined
39+
const createFormSgFieldsSchema = (variableId: string) => {
40+
const regex = new RegExp(
41+
/step\.[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\.fields\.([^.]+)\.answer/,
42+
)
43+
const match = variableId.match(regex)
44+
const tableFieldId = match?.[1]
4245

43-
if (!tableField) {
44-
return {
45-
fieldType: 'table',
46-
answer: {
47-
rows: [],
48-
columns: [],
49-
},
46+
return z.record(FormSgFieldSchema).transform((fields) => {
47+
if (!tableFieldId) {
48+
return {
49+
fieldType: 'table',
50+
answer: {
51+
rows: [],
52+
columns: [],
53+
},
54+
}
5055
}
51-
}
5256

53-
return FormSgTableFieldSchema.parse(tableField)
54-
})
57+
const tableField = get(fields, tableFieldId)
58+
return FormSgTableFieldSchema.parse(tableField)
59+
})
60+
}
5561

5662
/**
5763
* FormSG has a different dataOut structure from our own apps.
@@ -60,26 +66,29 @@ const FormSgFieldsSchema = z.record(FormSgFieldSchema).transform((fields) => {
6066
* Note: FormSG implements check to have at least 1 row in the table field.
6167
* the cells may be empty, but there will always be at least 1 row.
6268
*/
63-
export const FormSgTableDataOutSchema = z
64-
.object({
65-
fields: FormSgFieldsSchema,
66-
})
67-
.transform((dataOut) => {
68-
const parsedData = dataOut.fields.answer as z.infer<typeof RowDataSchema>
69-
return {
70-
rowsFound:
71-
(parsedData.rows as z.infer<typeof RowDataSchema>['rows'])?.length ?? 0,
72-
data: parsedData,
73-
}
74-
})
69+
export const createFormSgTableDataOutSchema = (variableName: string) =>
70+
z
71+
.object({
72+
fields: createFormSgFieldsSchema(variableName),
73+
})
74+
.transform((dataOut) => {
75+
const parsedData = dataOut.fields.answer as z.infer<typeof RowDataSchema>
76+
return {
77+
rowsFound:
78+
(parsedData.rows as z.infer<typeof RowDataSchema>['rows'])?.length ??
79+
0,
80+
data: parsedData,
81+
}
82+
})
7583

7684
export const MultipleRowDataOutSchema = z.object({
7785
rowsFound: z.union([z.string(), z.number()]).default(0),
7886
data: RowDataSchema,
7987
})
8088

8189
// Enhanced schema that can handle both regular and FormSG data
82-
export const ExecutionStepDataOutSchema = z.union([
83-
MultipleRowDataOutSchema,
84-
FormSgTableDataOutSchema,
85-
])
90+
export const createExecutionStepDataOutSchema = (variableName: string) =>
91+
z.union([
92+
MultipleRowDataOutSchema,
93+
createFormSgTableDataOutSchema(variableName),
94+
])

packages/frontend/src/components/VariablesList/utils.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { IExecutionStep } from '@plumber/types'
22

3-
import { ExecutionStepDataOutSchema } from './schema'
3+
import { createExecutionStepDataOutSchema } from './schema'
44

55
export interface RawColumn {
66
id: string
@@ -42,9 +42,14 @@ const processColumns = (rawColumns: RawColumn[]): ProcessedColumn[] => {
4242
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
4343
}
4444

45-
export const processData = (executionStep: IExecutionStep): ProcessedData => {
45+
export const processData = (
46+
executionStep: IExecutionStep,
47+
variableId: string,
48+
): ProcessedData => {
4649
try {
47-
const dataOut = ExecutionStepDataOutSchema.parse(executionStep.dataOut)
50+
const dataOut = createExecutionStepDataOutSchema(variableId).parse(
51+
executionStep.dataOut,
52+
)
4853
const rowsFound = String(dataOut.rowsFound)
4954

5055
return {

0 commit comments

Comments
 (0)