diff --git a/apps/backend/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/apps/backend/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index fbd5464a53..100191f454 100644 --- a/apps/backend/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/apps/backend/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -2190,6 +2190,44 @@ describe('admin-form.service', () => { ) }) + it('defaults to v2 and strips disallowed options when updating a children field in a Multi-respondent form', async () => { + // Arrange + const fieldToUpdate = generateDefaultField(BasicField.Children) + const mockNewField = { + ...fieldToUpdate, + version: undefined, + allowMultiple: true, + childrenSubFields: [ + MyInfoChildAttributes.ChildName, + MyInfoChildAttributes.ChildSecondaryRace, + ], + } as unknown as FieldUpdateDto + const mockForm = { + form_fields: [fieldToUpdate], + responseMode: FormResponseMode.Multirespondent, + updateFormFieldById: jest + .fn() + .mockResolvedValue({ form_fields: [mockNewField] }), + } as unknown as IPopulatedForm + + // Act + await AdminFormService.updateFormField( + mockForm, + fieldToUpdate._id, + mockNewField, + ) + + // Assert + expect(mockForm.updateFormFieldById).toHaveBeenCalledWith( + fieldToUpdate._id, + expect.objectContaining({ + version: ChildrenFieldVersion.V2, + allowMultiple: false, + childrenSubFields: [MyInfoChildAttributes.ChildName], + }), + ) + }) + it('leaves a legacy children field untouched on update', async () => { // Arrange const fieldToUpdate = generateDefaultField(BasicField.Children) @@ -2387,13 +2425,25 @@ describe('admin-form.service', () => { ) }) + it('defaults to version 2 for a Multi-respondent form even when children-v2 is disabled', async () => { + const insertFormField = await createChildrenFieldWith({ + childrenV2Enabled: false, + responseMode: FormResponseMode.Multirespondent, + }) + + expect(insertFormField).toHaveBeenCalledWith( + expect.objectContaining({ version: ChildrenFieldVersion.V2 }), + undefined, + ) + }) + it('strips Secondary Race and Allow-Multiple when stamping v2', async () => { const insertFormField = jest.fn().mockResolvedValue({ form_fields: [generateDefaultField(BasicField.Children)], }) const mockForm = { form_fields: [], - responseMode: FormResponseMode.Encrypt, + responseMode: FormResponseMode.Multirespondent, insertFormField, } as unknown as IPopulatedForm const formCreateParams = { @@ -2411,7 +2461,7 @@ describe('admin-form.service', () => { mockForm, formCreateParams, undefined, - { childrenV2Enabled: true }, + { childrenV2Enabled: false }, ) expect(insertFormField).toHaveBeenCalledWith( diff --git a/apps/backend/src/app/modules/form/admin-form/admin-form.service.ts b/apps/backend/src/app/modules/form/admin-form/admin-form.service.ts index b40ecc27ad..985c98e138 100644 --- a/apps/backend/src/app/modules/form/admin-form/admin-form.service.ts +++ b/apps/backend/src/app/modules/form/admin-form/admin-form.service.ts @@ -923,13 +923,21 @@ export const updateFormField = ( const _newField = insertTableShortTextColumnDefaultValidationOptions(newField) // children-v2 (ADR-0001): the v2 invariants must hold on edits too, not only - // on create. If the payload is stamped v2, strip Secondary Race / - // Allow-Multiple so a crafted update payload can't reintroduce them. We never - // downgrade an existing version here. + // on create. A children field is v2 if the form is Multi-respondent (where v2 + // is the default) or the payload is already stamped v2; in that case strip + // Secondary Race / Allow-Multiple so a crafted update payload can't + // reintroduce them. Note the v2 test differs from createFormField on purpose: + // create ignores any payload `version` (the flag/MRF is the authoritative + // gate, so a crafted create can't opt in), whereas update trusts the + // already-stamped `version` and never re-derives v2-ness from the flag. We + // never downgrade an existing version here. if (_newField.fieldType === BasicField.Children) { const childrenField = _newField as ChildrenCompoundFieldBase - const isV2 = childrenField.version === ChildrenFieldVersion.V2 + const isV2 = + form.responseMode === FormResponseMode.Multirespondent || + childrenField.version === ChildrenFieldVersion.V2 if (isV2) { + childrenField.version = ChildrenFieldVersion.V2 enforceChildrenV2Invariants(childrenField) } } @@ -1014,7 +1022,7 @@ export const duplicateFormField = ( } /** - * Enforces the children-v2 data invariants (ADR-0001/0002) on a children field + * Enforces the children-v2 data invariants (ADR-0001) on a children field * before it is written: v2 has neither Secondary Race nor Allow-Multiple, so we * drop them from the field data — not just from the builder UI — so the * invariant holds however the field became v2 (fresh create, MRF default, or @@ -1054,11 +1062,14 @@ export const createFormField = ( } // Children version is stamped server-side (ADR-0001), so the gate is - // authoritative — a hand-crafted payload can't opt in. In Storage/Encrypt - // mode v2 is whitelist-gated by the children-v2 flag. + // authoritative — a hand-crafted payload can't opt in. v2 is the **default + // in Multi-respondent forms** (the definitive children field lives in unified + // modes); in Storage/Encrypt mode it stays whitelist-gated by children-v2. if (newField.fieldType === BasicField.Children) { const childrenField = newField as ChildrenCompoundFieldBase - const isV2 = !!opts?.childrenV2Enabled + const isMultirespondent = + form.responseMode === FormResponseMode.Multirespondent + const isV2 = isMultirespondent || !!opts?.childrenV2Enabled childrenField.version = isV2 ? ChildrenFieldVersion.V2 : ChildrenFieldVersion.Legacy diff --git a/apps/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/EditMyInfoChildren.tsx b/apps/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/EditMyInfoChildren.tsx index 7fb027a351..75b68de5d7 100644 --- a/apps/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/EditMyInfoChildren.tsx +++ b/apps/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/EditMyInfoChildren.tsx @@ -3,7 +3,7 @@ import { BiCheck, BiData, BiX } from 'react-icons/bi' import { Box, FormControl, HStack, Icon, Text, VStack } from '@chakra-ui/react' import { extend } from 'lodash' -import { isChildrenV2Field, MyInfoChildAttributes } from 'formsg-shared/types' +import { MyInfoChildAttributes } from 'formsg-shared/types' import { SINGPASS_FAQ } from '~constants/links' import { MultiSelect } from '~components/Dropdown' @@ -12,6 +12,8 @@ import Link from '~components/Link' import { Toggle } from '~components/Toggle/Toggle' import { CREATE_MYINFO_CHILDREN_SUBFIELDS_OPTIONS } from '~features/admin-form/create/builder-and-design/constants' +import { useCreateTabForm } from '~features/admin-form/create/builder-and-design/useCreateTabForm' +import { isChildrenV2InBuilder } from '~features/myinfo/utils' import { CreatePageDrawerContentContainer } from '../../../../../common' import { FormFieldDrawerActions } from '../common/FormFieldDrawerActions' @@ -49,9 +51,12 @@ export const EditMyInfoChildren = ({ field, }: EditMyInfoChildrenProps): JSX.Element => { const extendedField = extendWithMyInfo(field) - // children-v2 (ADR-0001): drop Secondary Race + Allow-Multiple and show the - // new description when the field is stamped v2. - const isV2 = isChildrenV2Field(field) + const { data: form } = useCreateTabForm() + // children-v2 (ADR-0001): drop Secondary Race + Allow-Multiple and show + // the new description. v2 applies when the field is stamped v2 OR the form is + // Multi-respondent (v2 is the default there, stamped on save) — so the editor + // reflects v2 immediately, not only after the field is persisted. + const isV2 = isChildrenV2InBuilder(field, form?.responseMode) const subFieldOptions = isV2 ? CREATE_MYINFO_CHILDREN_SUBFIELDS_OPTIONS.filter( (option) => option.value !== MyInfoChildAttributes.ChildSecondaryRace, diff --git a/apps/frontend/src/features/myinfo/utils/canAddChildrenField.test.ts b/apps/frontend/src/features/myinfo/utils/canAddChildrenField.test.ts index 82ce5b0893..be355baea8 100644 --- a/apps/frontend/src/features/myinfo/utils/canAddChildrenField.test.ts +++ b/apps/frontend/src/features/myinfo/utils/canAddChildrenField.test.ts @@ -14,19 +14,19 @@ describe('canAddChildrenField', () => { ).toBe(true) }) - it('hides children without the beta flag', () => { + it('allows children in Multi-respondent mode with the beta flag (v2 is the default there)', () => { expect( canAddChildrenField({ - hasChildrenBetaFlag: false, - responseMode: FormResponseMode.Encrypt, + hasChildrenBetaFlag: true, + responseMode: FormResponseMode.Multirespondent, }), - ).toBe(false) + ).toBe(true) }) - it('hides children in Multi-respondent mode (arrives with MRF support)', () => { + it('hides children without the beta flag, regardless of mode', () => { expect( canAddChildrenField({ - hasChildrenBetaFlag: true, + hasChildrenBetaFlag: false, responseMode: FormResponseMode.Multirespondent, }), ).toBe(false) diff --git a/apps/frontend/src/features/myinfo/utils/canAddChildrenField.ts b/apps/frontend/src/features/myinfo/utils/canAddChildrenField.ts index 91f641c846..692e4547f6 100644 --- a/apps/frontend/src/features/myinfo/utils/canAddChildrenField.ts +++ b/apps/frontend/src/features/myinfo/utils/canAddChildrenField.ts @@ -6,6 +6,9 @@ import { FormResponseMode } from 'formsg-shared/types' * - **Encrypt (Storage)** mode: available with the children beta flag (today's * behaviour). v1 vs v2 there is governed separately by the `children-v2` * GrowthBook flag at create time. + * - **Multi-respondent** mode: available with the children beta flag — + * children-v2 is the **default** here (the definitive children field lives in + * the unified-modes / answerObject-v4 world), so no extra flag is needed. * - All other modes (e.g. Email): not supported. */ export const canAddChildrenField = ({ @@ -18,5 +21,8 @@ export const canAddChildrenField = ({ if (!hasChildrenBetaFlag) { return false } - return responseMode === FormResponseMode.Encrypt + return ( + responseMode === FormResponseMode.Encrypt || + responseMode === FormResponseMode.Multirespondent + ) } diff --git a/apps/frontend/src/features/myinfo/utils/index.ts b/apps/frontend/src/features/myinfo/utils/index.ts index c9def73649..18cc3cc359 100644 --- a/apps/frontend/src/features/myinfo/utils/index.ts +++ b/apps/frontend/src/features/myinfo/utils/index.ts @@ -3,4 +3,5 @@ export * from './augmentWithMyInfoDisplayValue' export * from './canAddChildrenField' export * from './extractPreviewValue' export * from './hasExistingFieldValue' +export * from './isChildrenV2InBuilder' export * from './isMyInfo' diff --git a/apps/frontend/src/features/myinfo/utils/isChildrenV2InBuilder.test.ts b/apps/frontend/src/features/myinfo/utils/isChildrenV2InBuilder.test.ts new file mode 100644 index 0000000000..5a4e60da7c --- /dev/null +++ b/apps/frontend/src/features/myinfo/utils/isChildrenV2InBuilder.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { + BasicField, + ChildrenFieldVersion, + FormResponseMode, +} from 'formsg-shared/types' + +import { isChildrenV2InBuilder } from './isChildrenV2InBuilder' + +describe('isChildrenV2InBuilder', () => { + it('is true for a children field already stamped v2 (Encrypt)', () => { + expect( + isChildrenV2InBuilder( + { fieldType: BasicField.Children, version: ChildrenFieldVersion.V2 }, + FormResponseMode.Encrypt, + ), + ).toBe(true) + }) + + it('is true for a children field on a Multi-respondent form even before it is stamped', () => { + expect( + isChildrenV2InBuilder( + { fieldType: BasicField.Children }, + FormResponseMode.Multirespondent, + ), + ).toBe(true) + }) + + it('is false for an unstamped children field on Encrypt', () => { + expect( + isChildrenV2InBuilder( + { fieldType: BasicField.Children }, + FormResponseMode.Encrypt, + ), + ).toBe(false) + }) + + it('is false for a non-children field, even on Multi-respondent', () => { + expect( + isChildrenV2InBuilder( + { fieldType: BasicField.ShortText }, + FormResponseMode.Multirespondent, + ), + ).toBe(false) + }) +}) diff --git a/apps/frontend/src/features/myinfo/utils/isChildrenV2InBuilder.ts b/apps/frontend/src/features/myinfo/utils/isChildrenV2InBuilder.ts new file mode 100644 index 0000000000..5c74173e0c --- /dev/null +++ b/apps/frontend/src/features/myinfo/utils/isChildrenV2InBuilder.ts @@ -0,0 +1,28 @@ +import { + BasicField, + ChildrenFieldVersion, + FormResponseMode, +} from 'formsg-shared/types' + +/** + * Whether a children field should behave as v2 in the builder UI for a form of + * the given response mode. + * + * Mirrors the server-side rule in `createFormField`: a children field is v2 if + * it is already stamped `version: 2`, **or** the form is Multi-respondent + * (where v2 is the default). The mode check matters because an MRF children + * field is only stamped on save — without it the editor would briefly show the + * v1 options (Secondary Race, Allow-Multiple) for a field that is really v2. + */ +export const isChildrenV2InBuilder = ( + field: { fieldType: BasicField; version?: ChildrenFieldVersion }, + responseMode?: FormResponseMode, +): boolean => { + if (field.fieldType !== BasicField.Children) { + return false + } + return ( + field.version === ChildrenFieldVersion.V2 || + responseMode === FormResponseMode.Multirespondent + ) +} diff --git a/packages/sdk/spec/adaptor/adapt-v3-to-v4.spec.ts b/packages/sdk/spec/adaptor/adapt-v3-to-v4.spec.ts index 8555bd3cf3..63fca27f6f 100644 --- a/packages/sdk/spec/adaptor/adapt-v3-to-v4.spec.ts +++ b/packages/sdk/spec/adaptor/adapt-v3-to-v4.spec.ts @@ -298,6 +298,79 @@ describe('adaptV3ToV4', () => { }, }) }) + + // Characterisation test for the children-v2 storage shape (ADR-0001). + // The v4 answerObject already produces exactly what v2 needs for a local + // child: a single un-numbered `[Myinfo] Children` field whose answer nests + // each sub-field under one child key — no `Child 1 / Child 2` numbering in + // the question label or the sub-field keys. This locks that shape so it is + // ready to flip on when storage-mode v4 lands; the per-child `type` + // (nuclear/sponsored) stamp is added with sponsored support (slice 03). + it('produces the children-v2 shape: one un-numbered local child, nested per sub-field', () => { + const v3: FormFieldsV3 = { + childrenField: { + fieldType: 'children', + answer: { + child: [['Tan Wen Jie', 'S1234567A', 'MALE']], + childFields: ['childname', 'childbirthcertno', 'childgender'], + }, + }, + } + + const result = adaptV3ToV4(v3, { + formFields: { + childrenField: { + question: 'Children', + myInfo: { attr: 'childrenbirthrecords' }, + }, + }, + }) + + expect(result.childrenField.question).toBe('[Myinfo] Children') + expect(result.childrenField.question).not.toMatch(/Child\s*\d/) + expect(Object.keys(result.childrenField.answer)).toEqual(['child0']) + expect(result.childrenField.answer).toEqual({ + child0: { + value: { + childname: { + value: 'Tan Wen Jie', + myInfo: { attr: 'childname' }, + }, + childbirthcertno: { + value: 'S1234567A', + myInfo: { attr: 'childbirthcertno' }, + }, + childgender: { value: 'MALE', myInfo: { attr: 'childgender' } }, + }, + }, + }) + }) + + // children-v2 sponsored support (ADR-0002): a per-child record type + // (nuclear/sponsored) is threaded into ChildEntryV4.type when the V3 + // answer carries a parallel `childrenTypes` array. + it('stamps per-child record type from childrenTypes', () => { + const v3: FormFieldsV3 = { + field1: { + fieldType: 'children', + answer: { + child: [ + ['Local Tan', 'S1111111A'], + ['Sponsored Lee', 'T2222222B'], + ], + childFields: ['childname', 'childbirthcertno'], + childrenTypes: ['nuclear', 'sponsored'], + }, + }, + } + + const result = adaptV3ToV4(v3) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const answer = result.field1.answer as any + expect(answer.child0.type).toBe('nuclear') + expect(answer.child1.type).toBe('sponsored') + }) }) describe('address field', () => { diff --git a/packages/sdk/src/adapt-v3-to-v4.ts b/packages/sdk/src/adapt-v3-to-v4.ts index ec77f51b1d..b256cacf78 100644 --- a/packages/sdk/src/adapt-v3-to-v4.ts +++ b/packages/sdk/src/adapt-v3-to-v4.ts @@ -85,6 +85,8 @@ const convertTableAnswer = (answer: Record[]): TableAnswerV4 => const convertChildrenAnswer = (answer: { child: string[][] childFields: string[] + // children-v2: per-child record type (nuclear/sponsored), parallel to `child`. + childrenTypes?: string[] }): ChildrenAnswerV4 => { const result: ChildrenAnswerV4 = {} for (let i = 0; i < answer.child.length; i++) { @@ -98,7 +100,8 @@ const convertChildrenAnswer = (answer: { myInfo: { attr }, } } - result[childKey] = { value } + const type = answer.childrenTypes?.[i] + result[childKey] = { value, ...(type !== undefined && { type }) } } return result } diff --git a/packages/sdk/src/adapt-v4-to-v3.ts b/packages/sdk/src/adapt-v4-to-v3.ts index c412b2202e..25c79ba90f 100644 --- a/packages/sdk/src/adapt-v4-to-v3.ts +++ b/packages/sdk/src/adapt-v4-to-v3.ts @@ -1,4 +1,7 @@ -import { ADDRESS_SUBFIELD_KEYS, GENERIC_STRING_FIELD_TYPES } from './constants-v4' +import { + ADDRESS_SUBFIELD_KEYS, + GENERIC_STRING_FIELD_TYPES, +} from './constants-v4' import { FieldType, FormFieldsV3 } from './types' import { AddressAnswerV4, @@ -104,6 +107,10 @@ function convertChildrenAnswerToV3(answer: ChildrenAnswerV4): { child.push(childValues) } + // children-v2 (ADR-0001): the per-child record `type` that adaptV3ToV4 stamps + // onto ChildEntryV4 is intentionally NOT reconstructed here — the v3 answer + // shape has no slot for it, and this reverse path is legacy/decrypt-only, so a + // v4→v3 conversion deliberately drops `type` rather than round-tripping it. return { child, childFields } }