diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..8b250f038d --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,142 @@ +# Paper Forms Tracking + +How FormSG records whether a form replaced a paper (or other non-digital) process, and whether that form has become live, so the team can measure paper-to-digital conversion impact without manual stocktakes. + +## Language + +**Form Origin**: +The source(s) a form replaced, captured once during form creation. A form can have **one or more** origins (multi-select), each being paper or a specific digital medium. +_Avoid_: source, form type, `isFormOrigin` (misleading `is` prefix for a non-boolean). + +**Paper form**: +A form whose **Form Origins** include paper — i.e. the admin indicated the process was (at least partly) on paper before FormSG. A paper form may _also_ carry digital origins. +_Avoid_: physical form. + +**Digital origin**: +A **Form Origin** that is not paper (new process, email, document, spreadsheet, other form builder, or other). In-house/vendor systems are intentionally not a separate option — admins are expected to capture them under "other". + +**Liveliness**: +Whether a form is considered actively in use, derived automatically from submission volume rather than self-reported by the admin. +_Avoid_: active, published (publishing is a separate, admin-driven status). + +## Relationships + +- Every form created while paper tracking is enabled has **one or more Form Origins**, captured once (at set-up, or at publish in the publish-step experiment). +- **Paper** and **Digital** origins are **not** mutually exclusive — a single form can be tagged with both (e.g. paper + spreadsheet). +- **Liveliness** is independent of **Form Origin** — any form can become live; the team cares most about live **Paper forms**. + +## Example dialogue + +> **Dev:** "If the admin ticks both 'Paper form' and 'Spreadsheets', what's the **Form Origin**?" +> **PM:** "Both — it's multi-select. The form is a **Paper form** _and_ carries a spreadsheet **Digital origin**. We count it as paper for conversion, and the spreadsheet tag lets us break impact down by medium." +> **Dev:** "And does choosing an origin make the form live?" +> **PM:** "No — **Liveliness** is separate. A form only counts as live once it crosses the submission threshold, regardless of origin." + +## Flagged ambiguities + +- **Storage location** — decided: **Form Origins** are stored in the form's `metadata` (as an array of origin entries), not as a top-level field. The team accepts the weaker query ergonomics (dashboards do array intersections) in exchange for not extending the core schema. +- **"Other" free text** — decided: embedded in its own `formOrigins` entry as `digital-others: `, following the platform's checkbox-Others convention ("Others: "), instead of a companion field. Accepted trade-off: dashboards count "Other" selections by prefix match. +- **Mutual exclusivity** — earlier modelled as a single exclusive value; corrected to **multi-select**: paper and digital origins can co-exist on one form. +- **Duplication & templates** — decided (revised): **Form Origins** are never copied from a source form; **both** the "use template" and the duplicate flow **re-ask** the origin question and write the freshly captured origins to the new form's metadata. (Earlier the duplicate flow was deliberately left un-asked and carried no origins — overturned: a duplicate is a new form and should report its own origin.) Both flows reuse the shared origin step, gated by the same `enablePaperTrackingSetUpPage` flag. +- **Coupling to MRF cutover** — decided: the origin set-up step is gated by `enablePaperTrackingSetUpPage` **alone**, independent of `mrfCutover`. Decoupled so the page can roll out before cutover completes (cutover may slip), targeted by admin email domain via GrowthBook. Consequence: the origin step now fronts **both** storage- and multirespondent-mode creates, not just the post-cutover MRF flow. +- **Storage-mode capture** — decided: origins are captured for storage-mode (one-respondent) creates too, not only MRF. The backend `createForm` path already persists `metadata.formOrigins` for storage forms (part-1 widened the DTO), so this is frontend-only. +- **Escape-hatch gap** — known/accepted: post-cutover, the storage-mode _escape hatch_ (`StorageModeDetails`) creates directly and bypasses the origin step, so those forms carry no origins. Pre-existing in the shipped flow; not closed in Part 2. +- **Email mode** — n/a: email-mode creation is no longer reachable in the create modal, so the origin step needs no email special-casing. + +--- + +# MRF Workflow + +How a Multi-Respondent Form (MRF) routes a single submission through an ordered sequence of respondents, each editing or approving a defined set of fields, until the submission is complete. + +## Language + +**Workflow**: +The ordered sequence of steps an MRF submission passes through. A form may have **zero** steps (no routing) or many. It is positional — a step's position _is_ its number. +_Avoid_: "process", "flow" (overloaded with conditional-logic routing within a single page). + +**Workflow step**: +One stage of the workflow. Identifies its **respondent** (a fixed email, an email read from a form field, or an email chosen by a dropdown mapping), the **field assignments** it may edit, an optional **approval field**, and an optional name. + +**Step 1 / First step**: +The initial respondent — the person who opens the form link and starts the submission. It is special: it may **not** be an approval step, it is the **only** step allowed to edit MyInfo fields, and it sources the **respondent-copy email**. Position-based: whichever step sits at the front is "step 1". +_Avoid_: "the creator", "the owner" (the first respondent is whoever fills the form, not the admin). + +**Step promotion**: +When step 1 is deleted while later steps remain, the next step slides forward to **become** the new step 1 and inherits step-1 rules. Deleting a step is the only way steps renumber. + +**Empty workflow**: +A valid end-state with zero steps, reachable by deleting the last (step 1) step. The form still works: the first (only) respondent may edit **all** fields and submit — no routing, no approvers. Used when an admin trialled a workflow and decided they don't need one. +_Avoid_: "deleted form", "disabled workflow" — the form is unchanged; only its routing is gone. + +**Field assignment**: +The set of fields a step's respondent may edit (its "edit set"). Deleting a step **drops** its assignments — those fields become editable in no step (locked/uncollected) until the admin re-assigns them. Assignments are never auto-migrated between steps. + +**Respondent-copy email**: +The email field, collected in step 1, used to send the first respondent a copy of their submission (`stepOneEmailNotificationFieldId`). Cleared automatically if step-1 deletion leaves no step collecting that field. + +## Relationships + +- A **workflow** is an ordered list of **workflow steps**; **step 1** is simply the step at position 0. +- Submissions store a **snapshot** of the workflow at creation, so editing or emptying a form's workflow never disturbs in-progress submissions — only new ones. +- **Step promotion** is the only renumbering event; appending a new step never reorders existing ones. +- An **empty workflow** is independent of form status — a public form may have its workflow emptied; the change applies to new responses immediately. + +## Flagged ambiguities + +- **"Delete step 1" intent** — decided: serves **both** "fully remove an accidentally-created workflow" (delete down to empty) and "delete a wrongly-configured first step, keep the rest" (promote step 2). Not a mode switch — the form stays in Multirespondent mode. +- **Empty-workflow validity** — decided: an MRF form with zero steps is **valid and publishable**, behaving like a one-respondent form. Matches existing code (first respondent edits all fields); no publish-time guard added. +- **Promotion auto-fix** — decided: promoting a step to step 1 auto-strips its **approval field**, clears the **respondent-copy email** if the field is no longer collected, and prunes the deleted step from notifications — all surfaced in the delete-confirmation modal. Field/MyInfo assignments are deliberately **not** auto-migrated (admin re-assigns). +- **Live-form editing** — decided: step-1 deletion is allowed while the form is public, consistent with existing workflow edits. Blocking the builder for published forms is deferred, not part of this work. +- **Backend already permits it** — known: the `DELETE /workflow/:stepNumber` endpoint already deletes any step (including step 1) and already allows an empty workflow; the only block today is the frontend hiding step 1's delete button. This work adds the auto-fix semantics and unhides the button. + +--- + +# MyInfo Children + +How FormSG prefills a respondent's children's birth records from MyInfo into a single compound field, and how v2 widens that to sponsored children while moving storage to the answerObject v4 shape. + +## Language + +**Children field**: +The compound `BasicField.Children` field that prefills children's birth records from MyInfo. One field configures a set of **child sub-fields** and produces, per child, a value for each. It is whitelist-gated and Encrypt-mode only. +_Avoid_: "children_v2" as a field type — v2 is a **version** of this field, not a new type (see ADR-0001). `children_v2` is only the project/branch name. + +**Children version**: +A discriminator on the **Children field** selecting its behaviour. **Version 1** (legacy) stores responses in the exploded v3 shape, offers Secondary Race + Allow-Multiple, and numbers children (`[MyInfo] Child 1 …`). **Version 2** stores the **answerObject v4** shape, is single-child, drops Secondary Race + Allow-Multiple, carries a new description, dedups by the **unified identifier**, and stamps each child's **record type**. +_Avoid_: "new field type", "children_v2 field". + +**Child sub-field**: +One MyInfo attribute collected per child (e.g. `childname`, `childdateofbirth`, `childgender`, `childvaxxstatus`). Version 2 exposes the common-denominator set plus the **unified identifier** and omits Secondary Race. + +**Local registered birth record**: +A child whose birth is registered in Singapore (born here, or re-registered after a Singapore court adoption order). Identified by `birthcertno`. Re-registered adopted children appear here, **not** as sponsored. +_Avoid_: "nuclear child" in prose — though `nuclear` is the stored **record type** value for these (see below). + +**Sponsored child record**: +A child born overseas and sponsored by the parents. Identified by `nric` (not a birth certificate number). Not returned by today's field — the headline v2 gap (PRD slice 03). + +**Unified identifier**: +The single sub-field that resolves to `birthcertno` for a **local registered birth record** or `nric` for a **sponsored child record**, so both kinds appear and validate without an empty-mandatory error. The picker dedups by this, **not** by name. +_Avoid_: deduping by child name (the legacy behaviour). + +**Record type**: +The per-child attribute (`nuclear` for local, `sponsored`) stamped onto each answer and surfaced as its own output column. Stored as `ChildEntryV4.type`. + +**answerObject v4**: +The nested, normalised submission shape (`packages/sdk/types-v4.ts`) replacing the legacy exploded responses. For children: field → `answer` → `child0` → `value` → `{ sub-field: { value, myInfo } }`, plus `type`. No `child-1/child-2` numbering. Version-2 Children fields write this; version 1 stays exploded. + +## Relationships + +- A **Children field** is either version 1 or version 2; **version 2** is whitelist-gated and writes **answerObject v4**, version 1 writes exploded v3. +- Each child carries exactly one **unified identifier** and one **record type**; the picker dedups by the identifier. +- **Local registered birth record** ↔ `nuclear` / `birthcertno`; **sponsored child record** ↔ `sponsored` / `nric`. +- Migration bumps an existing whitelisted form's Children field from version 1 to version 2 **in place**, preserving the field `_id`. + +## Flagged ambiguities + +- **v2 representation** — decided (ADR-0001): a **version discriminator on the existing `children` field**, not a new `BasicField`. The PRD's "after" JSON (`fieldType: "children"`) reflects this; the PRD prose / early README calling it a "new field type" is superseded. +- **Legacy responses** — decided: version-1 (exploded) responses remain **decrypt-only** and must keep rendering after v2 ships (PRD slice 05); no server-side mutation of past encrypted responses. +- **Edge cases (provisional, gated by PRD slice 01)** — deceased (`lifestatus = DECEASED`) and ≥21 children are filtered out of the picker by default; if Singpass compliance forbids hiding, switch to shown-disabled. An empty sub-field renders blank and never blocks submission. +- **Sponsored-only fields** — decided: v2.0 surfaces only the common-denominator sub-fields + unified identifier + record type. Sponsored-only immigration fields (`nationality`, `residentialstatus`, `scprgrantdate`, `birthcountry`) are **not** exposed. +- **Provenance / verified-vs-unverified** — out of scope: a separate platform PRD across all MyInfo fields. Children v2 consumes the answerObject slot; does not block on it. diff --git a/apps/backend/src/app/models/field/__tests__/childrenCompoundField.spec.ts b/apps/backend/src/app/models/field/__tests__/childrenCompoundField.spec.ts new file mode 100644 index 0000000000..f9c8085b6d --- /dev/null +++ b/apps/backend/src/app/models/field/__tests__/childrenCompoundField.spec.ts @@ -0,0 +1,57 @@ +import dbHandler from '__tests__/unit/backend/helpers/jest-db' +import { + ChildrenFieldVersion, + FormResponseMode, + MyInfoChildAttributes, +} from 'formsg-shared/types' +import { Model, Schema } from 'mongoose' + +import { IChildrenCompoundFieldSchema } from 'src/types' + +import createChildrenCompoundFieldSchema from '../childrenCompoundField' + +describe('models.fields.childrenCompoundField', () => { + let MockParent: Model<{ + responseMode: FormResponseMode + field: IChildrenCompoundFieldSchema + }> + + beforeAll(async () => { + const db = await dbHandler.connect() + MockParent = db.model( + 'mockChildrenParent', + new Schema({ + responseMode: { + type: String, + enum: Object.values(FormResponseMode), + }, + field: createChildrenCompoundFieldSchema(), + }), + ) + }) + beforeEach(async () => await dbHandler.clearDatabase()) + afterAll(async () => await dbHandler.closeDatabase()) + + const baseField = { + childrenSubFields: [MyInfoChildAttributes.ChildName], + allowMultiple: false, + } + + it('persists the v2 schema version', async () => { + const actual = await MockParent.create({ + responseMode: FormResponseMode.Encrypt, + field: { ...baseField, version: ChildrenFieldVersion.V2 }, + }) + + expect(actual.field.toObject().version).toBe(ChildrenFieldVersion.V2) + }) + + it('leaves version unset for a legacy field', async () => { + const actual = await MockParent.create({ + responseMode: FormResponseMode.Encrypt, + field: baseField, + }) + + expect(actual.field.toObject().version).toBeUndefined() + }) +}) diff --git a/apps/backend/src/app/models/field/childrenCompoundField.ts b/apps/backend/src/app/models/field/childrenCompoundField.ts index d59cf5ecaa..d65c6e9872 100644 --- a/apps/backend/src/app/models/field/childrenCompoundField.ts +++ b/apps/backend/src/app/models/field/childrenCompoundField.ts @@ -1,3 +1,4 @@ +import { ChildrenFieldVersion } from 'formsg-shared/types' import { Schema } from 'mongoose' import { IChildrenCompoundFieldSchema } from '../../../types' @@ -8,6 +9,15 @@ const createchildrenCompoundFieldSchema = () => { return new Schema({ childrenSubFields: [String], allowMultiple: Boolean, + // Children schema version (ADR-0001). 2 = v2 (answerObject v4 behaviour); + // absent/1 = legacy. Without this path, strict mode drops the v2 stamp on + // save and ignores it on read. Constrained to the known versions so a + // garbage number can't be persisted (matches the enum pattern other + // field-schema properties use). + version: { + type: Number, + enum: [ChildrenFieldVersion.Legacy, ChildrenFieldVersion.V2], + }, myInfo: MyInfoSchema, }) } diff --git a/apps/backend/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/apps/backend/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index 4b7c0236bd..56cc18e65f 100644 --- a/apps/backend/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/apps/backend/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -6991,6 +6991,7 @@ describe('admin-form.controller', () => { MOCK_FORM, MOCK_CREATE_FIELD_BODY, undefined, + { childrenV2Enabled: false }, ) }) @@ -7028,6 +7029,7 @@ describe('admin-form.controller', () => { MOCK_CREATE_FIELD_BODY, // Should pass in position query expectedPosition, + { childrenV2Enabled: false }, ) }) @@ -7124,6 +7126,7 @@ describe('admin-form.controller', () => { MOCK_FORM, MOCK_CREATE_FIELD_BODY, undefined, + { childrenV2Enabled: false }, ) }) @@ -7151,6 +7154,7 @@ describe('admin-form.controller', () => { MOCK_FORM, MOCK_CREATE_FIELD_BODY, undefined, + { childrenV2Enabled: false }, ) }) @@ -7257,6 +7261,7 @@ describe('admin-form.controller', () => { MOCK_FORM, MOCK_CREATE_FIELD_BODY, undefined, + { childrenV2Enabled: false }, ) }) }) 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 96a52d7326..fbd5464a53 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 @@ -1,4 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ +// `canvas` (pulled in transitively via convert-vector-array-to-png) ships a +// native binding that fails to load in some sandboxes; it is not exercised by +// these service tests, so stub it out to let the suite load. import { generateDefaultField } from '__tests__/unit/backend/helpers/generate-form-data' import { PresignedPost } from 'aws-sdk/clients/s3' import { ObjectId } from 'bson' @@ -13,6 +16,7 @@ import { VALID_UPLOAD_FILE_TYPES } from 'formsg-shared/constants/file' import { AdminDashboardFormMetaDto, BasicField, + ChildrenFieldVersion, CustomFormLogo, DropdownFieldBase, DuplicateFormBodyDto, @@ -29,6 +33,7 @@ import { LogicDto, LogicType, MyInfoAttribute, + MyInfoChildAttributes, PaymentChannel, PaymentType, SettingsUpdateDto, @@ -100,6 +105,11 @@ import * as AdminFormService from '../admin-form.service' import { OverrideProps } from '../admin-form.types' import * as AdminFormUtils from '../admin-form.utils' +jest.mock('canvas', () => ({ + createCanvas: jest.fn(), + CanvasRenderingContext2D: class {}, +})) + const FormModel = getFormModel(mongoose) const EmailFormModel = getEmailFormModel(mongoose) const EncryptFormModel = getEncryptedFormModel(mongoose) @@ -2137,6 +2147,87 @@ describe('admin-form.service', () => { // Assert expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) }) + + it('strips Secondary Race and Allow-Multiple when updating a v2 children field', async () => { + // Arrange + const fieldToUpdate = generateDefaultField(BasicField.Children) + const mockNewField = { + ...fieldToUpdate, + version: ChildrenFieldVersion.V2, + allowMultiple: true, + childrenSubFields: [ + MyInfoChildAttributes.ChildName, + MyInfoChildAttributes.ChildSecondaryRace, + MyInfoChildAttributes.ChildGender, + ], + } as unknown as FieldUpdateDto + const mockForm = { + form_fields: [fieldToUpdate], + responseMode: FormResponseMode.Encrypt, + 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, + MyInfoChildAttributes.ChildGender, + ], + }), + ) + }) + + it('leaves a legacy children field untouched on update', async () => { + // Arrange + const fieldToUpdate = generateDefaultField(BasicField.Children) + const mockNewField = { + ...fieldToUpdate, + allowMultiple: true, + childrenSubFields: [ + MyInfoChildAttributes.ChildName, + MyInfoChildAttributes.ChildSecondaryRace, + ], + } as unknown as FieldUpdateDto + const mockForm = { + form_fields: [fieldToUpdate], + responseMode: FormResponseMode.Encrypt, + 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({ + allowMultiple: true, + childrenSubFields: [ + MyInfoChildAttributes.ChildName, + MyInfoChildAttributes.ChildSecondaryRace, + ], + }), + ) + }) }) describe('createFormField', () => { @@ -2240,6 +2331,152 @@ describe('admin-form.service', () => { // Assert expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) }) + + const createChildrenFieldWith = async (opts?: { + childrenV2Enabled?: boolean + requestedVersion?: ChildrenFieldVersion + responseMode?: FormResponseMode + }) => { + const insertFormField = jest.fn().mockResolvedValue({ + form_fields: [generateDefaultField(BasicField.Children)], + }) + const mockForm = { + form_fields: [], + responseMode: opts?.responseMode ?? FormResponseMode.Encrypt, + insertFormField, + } as unknown as IPopulatedForm + const formCreateParams = { + fieldType: BasicField.Children, + title: 'Children', + ...(opts?.requestedVersion !== undefined && { + version: opts.requestedVersion, + }), + } as FieldCreateDto + + await AdminFormService.createFormField( + mockForm, + formCreateParams, + undefined, + { + childrenV2Enabled: opts?.childrenV2Enabled ?? false, + }, + ) + + return insertFormField + } + + it('stamps version 2 on a children field when children-v2 is enabled', async () => { + const insertFormField = await createChildrenFieldWith({ + childrenV2Enabled: true, + }) + + expect(insertFormField).toHaveBeenCalledWith( + expect.objectContaining({ version: ChildrenFieldVersion.V2 }), + undefined, + ) + }) + + it('stamps the legacy version when children-v2 is disabled', async () => { + const insertFormField = await createChildrenFieldWith({ + childrenV2Enabled: false, + }) + + expect(insertFormField).toHaveBeenCalledWith( + expect.objectContaining({ version: ChildrenFieldVersion.Legacy }), + 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, + insertFormField, + } as unknown as IPopulatedForm + const formCreateParams = { + fieldType: BasicField.Children, + title: 'Children', + allowMultiple: true, + childrenSubFields: [ + MyInfoChildAttributes.ChildName, + MyInfoChildAttributes.ChildSecondaryRace, + MyInfoChildAttributes.ChildGender, + ], + } as unknown as FieldCreateDto + + await AdminFormService.createFormField( + mockForm, + formCreateParams, + undefined, + { childrenV2Enabled: true }, + ) + + expect(insertFormField).toHaveBeenCalledWith( + expect.objectContaining({ + version: ChildrenFieldVersion.V2, + allowMultiple: false, + childrenSubFields: [ + MyInfoChildAttributes.ChildName, + MyInfoChildAttributes.ChildGender, + ], + }), + undefined, + ) + }) + + it('keeps Secondary Race and Allow-Multiple for a legacy field', async () => { + const insertFormField = jest.fn().mockResolvedValue({ + form_fields: [generateDefaultField(BasicField.Children)], + }) + const mockForm = { + form_fields: [], + responseMode: FormResponseMode.Encrypt, + insertFormField, + } as unknown as IPopulatedForm + const formCreateParams = { + fieldType: BasicField.Children, + title: 'Children', + allowMultiple: true, + childrenSubFields: [ + MyInfoChildAttributes.ChildName, + MyInfoChildAttributes.ChildSecondaryRace, + ], + } as unknown as FieldCreateDto + + await AdminFormService.createFormField( + mockForm, + formCreateParams, + undefined, + { childrenV2Enabled: false }, + ) + + expect(insertFormField).toHaveBeenCalledWith( + expect.objectContaining({ + version: ChildrenFieldVersion.Legacy, + allowMultiple: true, + childrenSubFields: [ + MyInfoChildAttributes.ChildName, + MyInfoChildAttributes.ChildSecondaryRace, + ], + }), + undefined, + ) + }) + + it('ignores a client-supplied version 2 when children-v2 is disabled', async () => { + const insertFormField = await createChildrenFieldWith({ + childrenV2Enabled: false, + requestedVersion: ChildrenFieldVersion.V2, + }) + + expect(insertFormField).toHaveBeenCalledWith( + expect.objectContaining({ version: ChildrenFieldVersion.Legacy }), + undefined, + ) + }) }) describe('deleteFormLogic', () => { diff --git a/apps/backend/src/app/modules/form/admin-form/admin-form.controller.ts b/apps/backend/src/app/modules/form/admin-form/admin-form.controller.ts index 5d6b4db8a5..89a67d9deb 100644 --- a/apps/backend/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/apps/backend/src/app/modules/form/admin-form/admin-form.controller.ts @@ -4,6 +4,7 @@ import { ObjectId } from 'bson' import { celebrate, Joi as BaseJoi, Segments } from 'celebrate' import { AuthedSessionData } from 'express-session' import { FORM_ORIGIN_OTHER_DETAIL_MAX_LENGTH } from 'formsg-shared/constants' +import { featureFlags } from 'formsg-shared/constants/feature-flags' import { KB, MAX_UPLOAD_FILE_SIZE, @@ -2302,7 +2303,10 @@ export const _handleCreateFormField: ControllerHandler< ) // Step 3: User has permissions, proceed to create form field with provided body. .andThen((form) => - AdminFormService.createFormField(form, formFieldToCreate, to), + AdminFormService.createFormField(form, formFieldToCreate, to, { + childrenV2Enabled: + req.growthbook?.isOn(featureFlags.childrenV2) ?? false, + }), ) .map((createdFormField) => res.status(StatusCodes.OK).json(createdFormField as FormFieldDto), 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 c4c375d2be..f4d337cd07 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 @@ -14,6 +14,8 @@ import { MYINFO_ATTRIBUTE_MAP } from 'formsg-shared/constants/field/myinfo' import { AdminDashboardFormMetaDto, BasicField, + ChildrenCompoundFieldBase, + ChildrenFieldVersion, DropdownFieldBase, DuplicateFormOverwriteDto, EndPageUpdateDto, @@ -29,6 +31,7 @@ import { FormWorkflowDto, FormWorkflowStepDto, LogicDto, + MyInfoChildAttributes, PaymentChannel, SettingsUpdateDto, StartPageUpdateDto, @@ -919,6 +922,18 @@ export const updateFormField = ( ): ResultAsync => { 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. + if (_newField.fieldType === BasicField.Children) { + const childrenField = _newField as ChildrenCompoundFieldBase + const isV2 = childrenField.version === ChildrenFieldVersion.V2 + if (isV2) { + enforceChildrenV2Invariants(childrenField) + } + } + return ResultAsync.fromPromise( form.updateFormFieldById(fieldId, _newField), (error) => { @@ -998,6 +1013,23 @@ export const duplicateFormField = ( }) } +/** + * 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 + * edit). Cleaning at the data layer (rather than filtering at render) matters + * because the render index maps to childFields/answerArray positions. + */ +const enforceChildrenV2Invariants = ( + childrenField: ChildrenCompoundFieldBase, +): void => { + childrenField.allowMultiple = false + childrenField.childrenSubFields = childrenField.childrenSubFields?.filter( + (subField) => subField !== MyInfoChildAttributes.ChildSecondaryRace, + ) +} + /** * Inserts a new form field into given form's fields with the field provided * @param form the form to insert the new field into @@ -1010,6 +1042,7 @@ export const createFormField = ( form: IPopulatedForm, newField: FieldCreateDto, to?: number, + opts?: { childrenV2Enabled?: boolean }, ): ResultAsync< FormFieldSchema, PossibleDatabaseError | FormNotFoundError | FieldNotFoundError @@ -1020,6 +1053,20 @@ export const createFormField = ( MYINFO_ATTRIBUTE_MAP[newField.myInfo.attr]?.value ?? newField.title } + // 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. + if (newField.fieldType === BasicField.Children) { + const childrenField = newField as ChildrenCompoundFieldBase + const isV2 = !!opts?.childrenV2Enabled + childrenField.version = isV2 + ? ChildrenFieldVersion.V2 + : ChildrenFieldVersion.Legacy + if (isV2) { + enforceChildrenV2Invariants(childrenField) + } + } + return ResultAsync.fromPromise( form.insertFormField(newField, to), (error) => { diff --git a/apps/backend/src/app/utils/field-validation/validators/__tests__/children-validation.spec.ts b/apps/backend/src/app/utils/field-validation/validators/__tests__/children-validation.spec.ts new file mode 100644 index 0000000000..e15aa9a7a6 --- /dev/null +++ b/apps/backend/src/app/utils/field-validation/validators/__tests__/children-validation.spec.ts @@ -0,0 +1,139 @@ +import { + generateDefaultField, + generateDefaultFieldV3, +} from '__tests__/unit/backend/helpers/generate-form-data' +import { + BasicField, + ChildBirthRecordsResponseV3, + ChildrenFieldVersion, + MyInfoChildAttributes, +} from 'formsg-shared/types' +import { mongo as mongodb } from 'mongoose' + +import { ProcessedChildrenResponse } from 'src/app/modules/submission/submission.types' +import { validateField, validateFieldV3 } from 'src/app/utils/field-validation' + +const { ObjectId } = mongodb + +const processedChildrenResponse = ( + answerArray: string[][], +): ProcessedChildrenResponse => ({ + _id: new ObjectId().toHexString(), + question: 'Children', + fieldType: BasicField.Children, + isVisible: true, + answerArray, + childSubFieldsArray: SUBFIELDS, +}) + +const SUBFIELDS = [ + MyInfoChildAttributes.ChildName, + MyInfoChildAttributes.ChildBirthCertNo, + MyInfoChildAttributes.ChildGender, + MyInfoChildAttributes.ChildRace, +] + +const childrenResponse = (child: string[][]): ChildBirthRecordsResponseV3 => ({ + fieldType: BasicField.Children, + answer: { child, childFields: SUBFIELDS }, +}) + +describe('Children validation V3', () => { + const formId = new ObjectId().toHexString() + + describe('empty optional sub-field', () => { + // A selected child whose optional sub-field (here: race) MyInfo returns + // empty. This is the original empty-mandatory-sub-field error. + const selectedChildWithEmptyRace = childrenResponse([ + ['Tan Wen Jie', 'S1234567A', 'MALE', ''], + ]) + + it('allows an empty optional sub-field for a version-2 field', () => { + const formField = generateDefaultFieldV3(BasicField.Children, { + childrenSubFields: SUBFIELDS, + version: ChildrenFieldVersion.V2, + }) + + const result = validateFieldV3({ + formId, + formField, + response: selectedChildWithEmptyRace, + isVisible: true, + }) + + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual(true) + }) + + it('still rejects an empty sub-field for a legacy (unversioned) field', () => { + const formField = generateDefaultFieldV3(BasicField.Children, { + childrenSubFields: SUBFIELDS, + }) + + const result = validateFieldV3({ + formId, + formField, + response: selectedChildWithEmptyRace, + isVisible: true, + }) + + expect(result.isErr()).toBe(true) + }) + }) + + it('accepts a fully populated version-2 child', () => { + const formField = generateDefaultFieldV3(BasicField.Children, { + childrenSubFields: SUBFIELDS, + version: ChildrenFieldVersion.V2, + }) + + const result = validateFieldV3({ + formId, + formField, + response: childrenResponse([ + ['Tan Wen Jie', 'S1234567A', 'MALE', 'CHINESE'], + ]), + isVisible: true, + }) + + expect(result.isOk()).toBe(true) + }) + + // Storage-mode (Encrypt) submissions are validated through the non-V3 + // `validateField` path, so the empty-optional fix must hold here too. + describe('non-V3 path (storage submissions)', () => { + const selectedChildWithEmptyRace = processedChildrenResponse([ + ['Tan Wen Jie', 'S1234567A', 'MALE', ''], + ]) + + it('allows an empty optional sub-field for a version-2 field', () => { + const formField = generateDefaultField(BasicField.Children, { + childrenSubFields: SUBFIELDS, + version: ChildrenFieldVersion.V2, + }) + + const result = validateField( + formId, + formField, + selectedChildWithEmptyRace, + ) + + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual(true) + }) + + it('still rejects an empty sub-field for a legacy (unversioned) field', () => { + const formField = generateDefaultField(BasicField.Children, { + childrenSubFields: SUBFIELDS, + }) + + const result = validateField( + formId, + formField, + selectedChildWithEmptyRace, + ) + + expect(result.isErr()).toBe(true) + }) + }) +}) diff --git a/apps/backend/src/app/utils/field-validation/validators/childrenValidator.ts b/apps/backend/src/app/utils/field-validation/validators/childrenValidator.ts index 530c5c54b3..b2eaf44613 100644 --- a/apps/backend/src/app/utils/field-validation/validators/childrenValidator.ts +++ b/apps/backend/src/app/utils/field-validation/validators/childrenValidator.ts @@ -2,6 +2,7 @@ import { BasicField, ChildBirthRecordsResponseV3, ChildrenCompoundFieldBase, + isChildrenV2Field, MyInfoChildAttributes, } from 'formsg-shared/types' import { chain, left, right } from 'fp-ts/lib/Either' @@ -70,30 +71,44 @@ const validChildAnswerConsistency: ChildrenValidator = (response) => { * Returns a validation function to check if * all the answers are non-empty if first answer subarray has length > 0 and a child is selected */ -const validChildAnswersNonEmpty: ChildrenValidator = (response) => { - const { childSubFieldsArray, answerArray } = response - - const first = answerArray[0] - - // Account for the case where no child is selected - const noOfChildrenSubFields = childSubFieldsArray?.length ?? 1 - const noChildSelectedAnswerArray = Array(noOfChildrenSubFields).fill('') - // Similar to transformToChildOutput in inputTransformation, this is a string of empty strings (which represents number of children subfields). - const noChildSelectedAnswer = noChildSelectedAnswerArray[0] - - return Array.isArray(first) && - first.length > 0 && - // Check that at least 1 child is selected - first[0] !== noChildSelectedAnswer - ? answerArray.every((subArr) => - subArr.every((val) => typeof val === 'string' && !!val.trim()), - ) +const validChildAnswersNonEmpty: ChildrenValidatorConstructor = + (childrenField) => (response) => { + const { childSubFieldsArray, answerArray } = response + + const first = answerArray[0] + + // Account for the case where no child is selected + const noOfChildrenSubFields = childSubFieldsArray?.length ?? 1 + const noChildSelectedAnswerArray = Array(noOfChildrenSubFields).fill('') + // Similar to transformToChildOutput in inputTransformation, this is a string of empty strings (which represents number of children subfields). + const noChildSelectedAnswer = noChildSelectedAnswerArray[0] + + const isChildSelected = + Array.isArray(first) && + first.length > 0 && + // Check that at least 1 child is selected + first[0] !== noChildSelectedAnswer + + if (!isChildSelected) { + return right(response) + } + + // children-v2 (ADR-0001): once a child is selected, an optional sub-field + // that MyInfo returns empty must not block submission — the fix for the + // legacy empty-mandatory-sub-field error. Legacy (v1) fields keep the + // stricter all-sub-fields-non-empty rule. + if (isChildrenV2Field(childrenField)) { + return right(response) + } + + return answerArray.every((subArr) => + subArr.every((val) => typeof val === 'string' && !!val.trim()), + ) ? right(response) : left( `ChildrenValidator (validChildAnswersNonEmpty):\t inconsistent answer array subarrays`, ) - : right(response) -} + } /** * Returns a validation function to check if the @@ -168,7 +183,7 @@ export const constructChildrenValidator: ChildrenValidatorConstructor = ( childrenAnswerValidator, chain(validChildAnswerFirstArray), chain(validChildAnswerConsistency), - chain(validChildAnswersNonEmpty), + chain(validChildAnswersNonEmpty(childrenField)), chain(validChildAnswerAndSubFields), chain(validChildSubFieldsValidator(childrenField)), chain(validChildSubFieldsAndResponseSubFieldsMatch(childrenField)), @@ -239,9 +254,10 @@ const isChildElementsSameLengthV3: ResponseValidator< * Returns a validation function to check if * all the answers are non-empty if the child answer does not represent no child being selected. */ -const isValidChildAnswersNonEmpty: ResponseValidator< +const isValidChildAnswersNonEmpty: ResponseValidatorConstructor< + ChildrenCompoundFieldBase, ChildBirthRecordsResponseV3 -> = (response) => { +> = (childrenField) => (response) => { const { childFields, child } = response.answer const numChildrenSubFields = childFields?.length ?? 1 @@ -251,18 +267,27 @@ const isValidChildAnswersNonEmpty: ResponseValidator< child.length === 0 || (child.length === 1 && _.isEqual(child[0], noChildSelectedAnswer)) - return isNoChildSelected + if (isNoChildSelected) { + return right(response) + } + + // children-v2 (ADR-0001): once a child is selected, an optional sub-field + // that MyInfo returns empty (e.g. an absent race) must not block submission. + // This is the fix for the legacy empty-mandatory-sub-field error. Legacy + // (v1) fields keep the stricter all-sub-fields-non-empty rule. + if (isChildrenV2Field(childrenField)) { + return right(response) + } + + return child.every((childAns) => + childAns.every( + (subFieldAns) => typeof subFieldAns === 'string' && !!subFieldAns.trim(), + ), + ) ? right(response) - : child.every((childAns) => - childAns.every( - (subFieldAns) => - typeof subFieldAns === 'string' && !!subFieldAns.trim(), - ), - ) - ? right(response) - : left( - `ChildrenValidatorV3.validChildAnswersNonEmpty:\t inconsistent answer array subarrays`, - ) + : left( + `ChildrenValidatorV3.validChildAnswersNonEmpty:\t inconsistent answer array subarrays`, + ) } /** @@ -344,7 +369,7 @@ export const constructChildrenValidatorV3: ResponseValidatorConstructor< chain(isChildAnswerNonEmptyV3), chain(isChildFirstElementLengthGtZeroV3), chain(isChildElementsSameLengthV3), - chain(isValidChildAnswersNonEmpty), + chain(isValidChildAnswersNonEmpty(childrenField)), chain(isChildrenAnswerAndSubfieldSameLengthV3), chain(isChildFieldValidAttributeV3), chain(isSubfieldsValidAttributeV3(childrenField)), 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 c5d9892adf..7fb027a351 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 { MyInfoChildAttributes } from 'formsg-shared/types' +import { isChildrenV2Field, MyInfoChildAttributes } from 'formsg-shared/types' import { SINGPASS_FAQ } from '~constants/links' import { MultiSelect } from '~components/Dropdown' @@ -33,6 +33,12 @@ const VerifiedIcon = ({ isVerified }: { isVerified: boolean }): JSX.Element => { const EDIT_MYINFO_CHILDREN = ['allowMultiple', 'childrenSubFields'] as const +// New field description for children-v2 (ADR-0001). Surfaced in place of the +// MyInfo metadata details so admins see the v2 scope (sponsored children, +// below-21 only) and verification sources. +const CHILDREN_V2_FIELD_DESCRIPTION = + "The children birth records of the respondent's children or sponsored children. Only data of children below 21 years old will be available. Vaccination status is verified by HPB. All other data is verified by ICA." + type EditMyInfoChildrenProps = EditFieldProps type EditMyInfoChildrenInputs = Pick< ChildrenCompoundFieldMyInfo, @@ -43,6 +49,14 @@ 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 subFieldOptions = isV2 + ? CREATE_MYINFO_CHILDREN_SUBFIELDS_OPTIONS.filter( + (option) => option.value !== MyInfoChildAttributes.ChildSecondaryRace, + ) + : CREATE_MYINFO_CHILDREN_SUBFIELDS_OPTIONS const { control, register, @@ -105,7 +119,7 @@ export const EditMyInfoChildren = ({ name="childrenSubFields" render={({ field: { value, onChange, ...rest } }) => ( - - - - - + {isV2 ? null : ( + + + + + + )} Field details - {extendedField.details} + + {isV2 ? CHILDREN_V2_FIELD_DESCRIPTION : extendedField.details} + { const { user } = useUser() + // children-v2 (slice 08): the field is also offered in Multi-respondent forms, + // where v2 is the default (the definitive children field lives in unified + // modes). Encrypt-mode availability is unchanged. + const showChildrenSection = canAddChildrenField({ + hasChildrenBetaFlag: !!user?.betaFlags?.children, + responseMode: form?.responseMode, + }) + /** * If sgID is used, checks if the corresponding * MyInfo field is supported by sgID. @@ -233,8 +240,7 @@ export const MyInfoFieldPanel = ({ searchValue }: { searchValue: string }) => { )} - {user?.betaFlags?.children && - form?.responseMode === FormResponseMode.Encrypt ? ( + {showChildrenSection ? ( {(provided) => ( diff --git a/apps/frontend/src/features/myinfo/utils/canAddChildrenField.test.ts b/apps/frontend/src/features/myinfo/utils/canAddChildrenField.test.ts new file mode 100644 index 0000000000..82ce5b0893 --- /dev/null +++ b/apps/frontend/src/features/myinfo/utils/canAddChildrenField.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { FormResponseMode } from 'formsg-shared/types' + +import { canAddChildrenField } from './canAddChildrenField' + +describe('canAddChildrenField', () => { + it('allows children in Encrypt mode with the beta flag', () => { + expect( + canAddChildrenField({ + hasChildrenBetaFlag: true, + responseMode: FormResponseMode.Encrypt, + }), + ).toBe(true) + }) + + it('hides children without the beta flag', () => { + expect( + canAddChildrenField({ + hasChildrenBetaFlag: false, + responseMode: FormResponseMode.Encrypt, + }), + ).toBe(false) + }) + + it('hides children in Multi-respondent mode (arrives with MRF support)', () => { + expect( + canAddChildrenField({ + hasChildrenBetaFlag: true, + responseMode: FormResponseMode.Multirespondent, + }), + ).toBe(false) + }) + + it('hides children in unsupported modes (e.g. Email)', () => { + expect( + canAddChildrenField({ + hasChildrenBetaFlag: true, + responseMode: FormResponseMode.Email, + }), + ).toBe(false) + }) +}) diff --git a/apps/frontend/src/features/myinfo/utils/canAddChildrenField.ts b/apps/frontend/src/features/myinfo/utils/canAddChildrenField.ts new file mode 100644 index 0000000000..91f641c846 --- /dev/null +++ b/apps/frontend/src/features/myinfo/utils/canAddChildrenField.ts @@ -0,0 +1,22 @@ +import { FormResponseMode } from 'formsg-shared/types' + +/** + * Whether the MyInfo Children field may be offered in the builder. + * + * - **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. + * - All other modes (e.g. Email): not supported. + */ +export const canAddChildrenField = ({ + hasChildrenBetaFlag, + responseMode, +}: { + hasChildrenBetaFlag: boolean + responseMode?: FormResponseMode +}): boolean => { + if (!hasChildrenBetaFlag) { + return false + } + return responseMode === FormResponseMode.Encrypt +} diff --git a/apps/frontend/src/features/myinfo/utils/index.ts b/apps/frontend/src/features/myinfo/utils/index.ts index abac29f085..c9def73649 100644 --- a/apps/frontend/src/features/myinfo/utils/index.ts +++ b/apps/frontend/src/features/myinfo/utils/index.ts @@ -1,5 +1,6 @@ export * from './augmentWithMyInfo' export * from './augmentWithMyInfoDisplayValue' +export * from './canAddChildrenField' export * from './extractPreviewValue' export * from './hasExistingFieldValue' export * from './isMyInfo' diff --git a/apps/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.stories.tsx b/apps/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.stories.tsx index e55d60e100..6c882fac83 100644 --- a/apps/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.stories.tsx +++ b/apps/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.stories.tsx @@ -6,6 +6,7 @@ import { merge } from 'lodash' import { BasicField, + ChildrenFieldVersion, MyInfoChildAttributes, MyInfoChildData, } from 'formsg-shared/types/field' @@ -90,3 +91,27 @@ export const AllowMultipleChildren = Template.bind({}) AllowMultipleChildren.args = { schema: merge({}, baseSchema, { allowMultiple: true }), } + +// children-v2 (ADR-0001): a single local child, common-denominator sub-fields, +// no add-multiple affordance, Secondary Race omitted. +const childrenV2BirthRecords: MyInfoChildData = { + [MyInfoChildAttributes.ChildName]: ['Tan Wen Jie'], + [MyInfoChildAttributes.ChildBirthCertNo]: ['S1234567A'], + [MyInfoChildAttributes.ChildGender]: ['MALE'], + [MyInfoChildAttributes.ChildRace]: ['CHINESE'], +} + +export const ChildrenV2SingleLocalChild = Template.bind({}) +ChildrenV2SingleLocalChild.args = { + schema: merge({}, baseSchema, { + version: ChildrenFieldVersion.V2, + allowMultiple: false, + childrenSubFields: [ + MyInfoChildAttributes.ChildName, + MyInfoChildAttributes.ChildBirthCertNo, + MyInfoChildAttributes.ChildGender, + MyInfoChildAttributes.ChildRace, + ], + }), + childrenBirthRecords: childrenV2BirthRecords, +} diff --git a/apps/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx b/apps/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx index 1f04b88218..bad3d705b9 100644 --- a/apps/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx +++ b/apps/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx @@ -28,6 +28,7 @@ import simplur from 'simplur' import { DATE_DISPLAY_FORMAT } from 'formsg-shared/constants/dates' import { MYINFO_ATTRIBUTE_MAP } from 'formsg-shared/constants/field/myinfo' import { + ChildrenFieldVersion, FormColorTheme, MyInfoAttribute, MyInfoChildAttributes, @@ -77,6 +78,11 @@ export const ChildrenCompoundField = ({ [schema._id], ) + // children-v2 has no Allow-Multiple: a v2 field is always single-child here, + // regardless of any stale `allowMultiple` left on the field data. + const allowAddMultiple = + schema.version !== ChildrenFieldVersion.V2 && !!schema.allowMultiple + const formContext = useFormContext() const { isSubmitting, errors } = useFormState({ name: schema._id, @@ -113,12 +119,12 @@ export const ChildrenCompoundField = ({ let description = simplur`This is a children field. There [is|are] ${fields.length} child[|ren].` description += `Each child has multiple fields to fill.` description += `You can fill the child by selecting the child's name from the child name dropdown.` - if (schema.allowMultiple) { + if (allowAddMultiple) { description += ` You can add another child if you'd like by clicking the "Add another child" button below` } return description - }, [fields.length, schema.allowMultiple]) + }, [fields.length, allowAddMultiple]) const numChild = fields.length ?? 0 @@ -160,7 +166,7 @@ export const ChildrenCompoundField = ({ ))} - {schema.allowMultiple ? ( + {allowAddMultiple ? (