Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {
Expand All @@ -2411,7 +2461,7 @@ describe('admin-form.service', () => {
mockForm,
formCreateParams,
undefined,
{ childrenV2Enabled: true },
{ childrenV2Enabled: false },
)

expect(insertFormField).toHaveBeenCalledWith(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
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'
import InlineMessage from '~components/InlineMessage'

Check warning on line 10 in apps/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/EditMyInfoChildren.tsx

View workflow job for this annotation

GitHub Actions / frontend_lint

'InlineMessage' is defined but never used
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'
Expand All @@ -31,7 +33,7 @@
)
}

const EDIT_MYINFO_CHILDREN = ['allowMultiple', 'childrenSubFields'] as const

Check warning on line 36 in apps/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/EditMyInfoChildren.tsx

View workflow job for this annotation

GitHub Actions / frontend_lint

'EDIT_MYINFO_CHILDREN' is assigned a value but only used as a type

// New field description for children-v2 (ADR-0001). Surfaced in place of the
// MyInfo metadata details so admins see the v2 scope (sponsored children,
Expand All @@ -49,9 +51,12 @@
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -18,5 +21,8 @@ export const canAddChildrenField = ({
if (!hasChildrenBetaFlag) {
return false
}
return responseMode === FormResponseMode.Encrypt
return (
responseMode === FormResponseMode.Encrypt ||
responseMode === FormResponseMode.Multirespondent
)
}
1 change: 1 addition & 0 deletions apps/frontend/src/features/myinfo/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export * from './augmentWithMyInfoDisplayValue'
export * from './canAddChildrenField'
export * from './extractPreviewValue'
export * from './hasExistingFieldValue'
export * from './isChildrenV2InBuilder'
export * from './isMyInfo'
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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
)
}
73 changes: 73 additions & 0 deletions packages/sdk/spec/adaptor/adapt-v3-to-v4.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk/src/adapt-v3-to-v4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ const convertTableAnswer = (answer: Record<string, string>[]): 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++) {
Expand All @@ -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
}
Expand Down
Loading
Loading