From 8e4776f9b7e0fc905549353442c38de3d466ba96 Mon Sep 17 00:00:00 2001 From: Andrea Maria Piana Date: Tue, 29 Sep 2026 18:32:29 +0100 Subject: [PATCH 01/21] fix(onboarding): submit computed money forced values in the right unit Portugal's extended work hours allowance is a forced money value computed from the salary. Onboarding built its render-time form without converting money fields to cents, so the allowance was computed from the salary in major units (100x too small), and the forced value was then written into the form state in cents while the form keeps money in major units. Validation recomputed the correct value and rejected the submission on a field with no input to show the error, blocking the step. - Onboarding's useJSONSchemaForm passes transformMoneyFields: true, since createHeadlessForm only defaults it on when no options are passed. - Forced money values are converted from cents before being set in the form. Co-Authored-By: Claude Opus 5.5 --- src/components/form/JSONSchemaForm.tsx | 8 +- src/components/form/fields/FieldSetField.tsx | 7 +- src/components/form/tests/utils.test.ts | 14 +++ src/components/form/utils.ts | 13 +++ src/flows/Onboarding/api.ts | 9 +- .../Onboarding/tests/OnboardingFlow.test.tsx | 88 +++++++++++++++++++ 6 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 src/components/form/tests/utils.test.ts diff --git a/src/components/form/JSONSchemaForm.tsx b/src/components/form/JSONSchemaForm.tsx index 515a73752..f2e8f2dc1 100644 --- a/src/components/form/JSONSchemaForm.tsx +++ b/src/components/form/JSONSchemaForm.tsx @@ -12,7 +12,11 @@ import { $TSFixMe, } from '@/src/types/remoteFlows'; import { StatementComponentProps } from '@/src/types/fields'; -import { checkFieldHasForcedValue, getFieldsWithFlatFieldsets } from './utils'; +import { + checkFieldHasForcedValue, + getFieldsWithFlatFieldsets, + getForcedValue, +} from './utils'; type JSONSchemaFormFieldsProps = { fields: JSFFields; @@ -75,7 +79,7 @@ export const JSONSchemaFormFields = ({ { + it('converts a money const from cents to the major units kept in the form', () => { + expect(getForcedValue({ type: 'money', const: 81257 })).toBe(812.57); + }); + + it('returns the const unchanged for other field types', () => { + expect(getForcedValue({ type: 'number', const: 40 })).toBe(40); + expect(getForcedValue({ type: 'text', const: 'acknowledged' })).toBe( + 'acknowledged', + ); + }); +}); diff --git a/src/components/form/utils.ts b/src/components/form/utils.ts index e4fca9ffc..78ba42ad4 100644 --- a/src/components/form/utils.ts +++ b/src/components/form/utils.ts @@ -871,3 +871,16 @@ export function checkFieldHasForcedValue(field: $TSFixMe) { field.type !== 'hidden' // Because hidden inputs shouldn't be visible ); } + +/** + * The value a forced value field should hold in the form state. + * The schema's `const` is in API units, but money fields are kept in major units in the + * form and converted to cents on submit, so a money `const` has to be converted back. + */ +export function getForcedValue(field: $TSFixMe) { + if (field.type === supportedTypes.MONEY) { + return convertFromCents(field.const); + } + + return field.const; +} diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index 99ea7fc77..823283564 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -247,7 +247,14 @@ export const useJSONSchemaForm = ({ enabled: options?.queryOptions?.enabled, select: ({ data }) => { const jsfSchema = data?.data || {}; - return createHeadlessForm(jsfSchema, fieldValues, options); + // `transformMoneyFields` has to be explicit: `createHeadlessForm` only defaults it on + // when no options object is passed at all. Without it the schema's conditionals and + // computed values see money fields in major units instead of the cents they are written + // for, e.g. PRT's extended work hours allowance came out 100x too small. + return createHeadlessForm(jsfSchema, fieldValues, { + ...options, + transformMoneyFields: true, + }); }, }); }; diff --git a/src/flows/Onboarding/tests/OnboardingFlow.test.tsx b/src/flows/Onboarding/tests/OnboardingFlow.test.tsx index 0442d12b8..3328b96cb 100644 --- a/src/flows/Onboarding/tests/OnboardingFlow.test.tsx +++ b/src/flows/Onboarding/tests/OnboardingFlow.test.tsx @@ -761,6 +761,94 @@ describe('OnboardingFlow', () => { await screen.findByText(/Step: Benefits/i); }); + it.each([ + { + maximumWorkingHoursRegime: 'yes', + allowanceInCents: 81257, + allowanceLabel: '812.57', + }, + { + maximumWorkingHoursRegime: 'no', + allowanceInCents: 29548, + allowanceLabel: '295.48', + }, + ])( + 'should submit the computed extended work hours allowance in cents (maximum_working_hours_regime: $maximumWorkingHoursRegime)', + async ({ maximumWorkingHoursRegime, allowanceInCents, allowanceLabel }) => { + // Served without x-rmt-meta, like PRT contract details v3, so the kit evaluates it with + // the jsf v1 engine. The allowance is a forced money value computed from the salary. + const { 'x-rmt-meta': _meta, ...schemaWithoutMeta } = + contractDetailsSchemaV1Portugal.data; + + server.use( + http.get('*/v1/countries/PRT/contract_details*', () => { + return HttpResponse.json({ data: schemaWithoutMeta }); + }), + http.get('*/v1/employments/:id', ({ params }) => { + return HttpResponse.json({ + ...employmentDefaultResponse, + data: { + ...employmentDefaultResponse.data, + employment: { + ...employmentDefaultResponse.data.employment, + id: params.id, + contract_details: { + ...employmentDefaultResponse.data.employment.contract_details, + annual_gross_salary: 7170377, + working_hours_exemption: 'yes', + maximum_working_hours_regime: maximumWorkingHoursRegime, + }, + }, + }, + }); + }), + ); + + render( + , + { + wrapper: TestProviders, + }, + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('spinner')); + + await fillCountry('PRT'); + + await waitFor(() => { + expect(screen.getByLabelText(/Personal email/i)).toBeInTheDocument(); + }); + + screen.getByText(/Next Step/i).click(); + + await screen.findByText(/Step: Contract Details/i); + + await screen.findByText( + new RegExp(`additional ${allowanceLabel} EUR monthly`, 'i'), + ); + + screen.getByText(/Next Step/i).click(); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledTimes(3); + }); + + expect(mockOnSubmit.mock.calls[2][0]).toEqual( + expect.objectContaining({ + annual_gross_salary: 7170377, + working_hours_exemption: 'yes', + maximum_working_hours_regime: maximumWorkingHoursRegime, + working_hours_exemption_allowance: allowanceInCents, + }), + ); + + await screen.findByText(/Step: Benefits/i); + }, + ); + it('should show validation errors when submitting benefits step without selecting any benefits', async () => { server.use( http.get('*/v1/employments/*/benefit-offers', () => { From ccf0275c44669d2898f29f3eef1bc45275e37140 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 20:20:39 +0200 Subject: [PATCH 02/21] refactor(forms): default transformMoneyFields per key Default transformMoneyFields to true whenever it's not explicitly set, instead of only when the whole options object is missing. Callers that pass options without transformMoneyFields (e.g. only jsfModify) now get money fields converted to cents too. Co-Authored-By: Claude Opus 5.5 --- src/common/createHeadlessForm.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/common/createHeadlessForm.tsx b/src/common/createHeadlessForm.tsx index 5ade854ff..f0eef45da 100644 --- a/src/common/createHeadlessForm.tsx +++ b/src/common/createHeadlessForm.tsx @@ -23,9 +23,7 @@ export const createHeadlessForm = ( fieldValues?: FieldValues, options?: { jsfModify?: JSFModify; transformMoneyFields?: boolean }, ): JSONSchemaFormResultWithFieldsets => { - const { transformMoneyFields } = options || { - transformMoneyFields: true, - }; + const transformMoneyFields = options?.transformMoneyFields ?? true; if (options && options.jsfModify) { const { required, allOf, ...modifyConfig } = options.jsfModify; // muteLogging: true suppresses the generic library log; we surface the From 77136fcb56305df1f645993a0d3b05bec88e2d3b Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 20:21:15 +0200 Subject: [PATCH 03/21] refactor(invoice-schedules): drop redundant money flag Co-Authored-By: Claude Opus 5.5 --- src/common/invoice-schedules/api.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/common/invoice-schedules/api.ts b/src/common/invoice-schedules/api.ts index 1732f461c..cf8665b6c 100644 --- a/src/common/invoice-schedules/api.ts +++ b/src/common/invoice-schedules/api.ts @@ -86,15 +86,7 @@ export const useGetCreateInvoiceScheduleSchema = ({ includeCustomDays, }); - // `transformMoneyFields` has to be explicit: `createHeadlessForm` only defaults it on - // when no options object is passed at all, so passing `jsfModify` alone silently opted - // out. Without it the item amounts stay in the money input's major units — a decimal as - // soon as an amount has cents — and never satisfy the `integer` the row-reveal - // conditional matches on, which froze the form at a single item for any such amount. - return createHeadlessForm(schema, fieldValues, { - jsfModify, - transformMoneyFields: true, - }); + return createHeadlessForm(schema, fieldValues, { jsfModify }); }, [ enabled, currencies, From c28068dfae7020358aebe96f0a7230fa71782ebf Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 21:59:28 +0200 Subject: [PATCH 04/21] test(onboarding): cover money forced values on the legacy contract details path The flow regression test only covers the legacy path while PRT is outside JSF_V1_CONTRACT_DETAILS_COUNTRIES. These test the Onboarding useJSONSchemaForm hook and JSONSchemaFormFields directly, so they keep covering the fix whichever countries are on v1. Co-Authored-By: Claude Opus 5.5 --- .../tests/JSONSchemaFormForcedValue.test.tsx | 66 +++++++++++++++++++ ...useJSONSchemaFormMoneyForcedValue.test.tsx | 64 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/components/form/tests/JSONSchemaFormForcedValue.test.tsx create mode 100644 src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx diff --git a/src/components/form/tests/JSONSchemaFormForcedValue.test.tsx b/src/components/form/tests/JSONSchemaFormForcedValue.test.tsx new file mode 100644 index 000000000..a870812b3 --- /dev/null +++ b/src/components/form/tests/JSONSchemaFormForcedValue.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { createHeadlessForm } from '@/src/common/createHeadlessForm'; +import { JSONSchemaFormFields } from '@/src/components/form/JSONSchemaForm'; +import { ForcedValueComponentProps } from '@/src/types/fields'; + +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(() => ({ + components: { + forcedValue: ({ fieldData }: ForcedValueComponentProps) => ( +

{`Forced value: ${fieldData.value}`}

+ ), + }, + })), + }; +}); + +const schema = { + type: 'object', + properties: { + allowance: { + title: 'Allowance', + type: 'integer', + const: 81257, + default: 81257, + 'x-jsf-presentation': { + inputType: 'money', + currency: 'EUR', + statement: { + title: 'Allowance', + description: 'You need to pay an allowance', + }, + }, + }, + }, + 'x-jsf-order': ['allowance'], +}; + +describe('JSONSchemaForm - money forced value', () => { + it('holds a money forced value in major units, like any other money field', async () => { + const form = createHeadlessForm(schema, {}); + let latestValues: Record = {}; + + const TestComponent = () => { + const methods = useForm(); + latestValues = methods.watch(); + + return ( + + + + ); + }; + + render(); + + await screen.findByText('Forced value: 812.57'); + + await waitFor(() => { + expect(latestValues).toEqual({ allowance: 812.57 }); + }); + }); +}); diff --git a/src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx b/src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx new file mode 100644 index 000000000..408c96eb7 --- /dev/null +++ b/src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx @@ -0,0 +1,64 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { useJSONSchemaForm } from '@/src/flows/Onboarding/api'; +import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; +import { server } from '@/src/tests/server'; +import { queryClient, TestProviders } from '@/src/tests/testHelpers'; +import { $TSFixMe } from '@/src/types/remoteFlows'; + +const findField = (fields: $TSFixMe[] = [], name: string): $TSFixMe => + fields.reduce<$TSFixMe>( + (found, field) => + found ?? (field.name === name ? field : findField(field.fields, name)), + undefined, + ); + +describe('Onboarding useJSONSchemaForm money forced values', () => { + beforeEach(() => { + queryClient.clear(); + + const { 'x-rmt-meta': _meta, ...schemaWithoutMeta } = + contractDetailsSchemaV1Portugal.data; + + server.use( + http.get('*/v1/countries/PRT/contract_details*', () => { + return HttpResponse.json({ data: schemaWithoutMeta }); + }), + ); + }); + + it.each([ + { maximumWorkingHoursRegime: 'yes', allowanceInCents: 81257 }, + { maximumWorkingHoursRegime: 'no', allowanceInCents: 29548 }, + ])( + 'computes the allowance from the salary in cents (maximum_working_hours_regime: $maximumWorkingHoursRegime)', + async ({ maximumWorkingHoursRegime, allowanceInCents }) => { + const { result } = renderHook( + () => + useJSONSchemaForm({ + countryCode: 'PRT', + form: 'contract_details', + fieldValues: { + annual_gross_salary: 71703.77, + work_hours_per_week: 40, + working_hours_exemption: 'yes', + maximum_working_hours_regime: maximumWorkingHoursRegime, + }, + options: { queryOptions: { enabled: true } }, + }), + { wrapper: TestProviders }, + ); + + await waitFor(() => { + expect(result.current.data).toBeDefined(); + }); + + const allowance = findField( + result.current.data?.fields, + 'working_hours_exemption_allowance', + ); + + expect(allowance?.const).toBe(allowanceInCents); + }, + ); +}); From 6896273a5b95ac9db85183904450e1c1d537f77a Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 22:07:00 +0200 Subject: [PATCH 05/21] refactor(form): drop getForcedValue Submitting a forced value already uses the field's `const` in cents (parseFormValuesToAPI overwrites the form value with it), and the default forced value component doesn't render `value`. The `transformMoneyFields` change alone fixes the PRT allowance; getForcedValue would only change what custom forcedValue components receive in `fieldData.value`. Co-Authored-By: Claude Opus 5.5 --- src/components/form/JSONSchemaForm.tsx | 8 +-- src/components/form/fields/FieldSetField.tsx | 7 +- .../tests/JSONSchemaFormForcedValue.test.tsx | 66 ------------------- src/components/form/tests/utils.test.ts | 14 ---- src/components/form/utils.ts | 13 ---- 5 files changed, 4 insertions(+), 104 deletions(-) delete mode 100644 src/components/form/tests/JSONSchemaFormForcedValue.test.tsx delete mode 100644 src/components/form/tests/utils.test.ts diff --git a/src/components/form/JSONSchemaForm.tsx b/src/components/form/JSONSchemaForm.tsx index f2e8f2dc1..515a73752 100644 --- a/src/components/form/JSONSchemaForm.tsx +++ b/src/components/form/JSONSchemaForm.tsx @@ -12,11 +12,7 @@ import { $TSFixMe, } from '@/src/types/remoteFlows'; import { StatementComponentProps } from '@/src/types/fields'; -import { - checkFieldHasForcedValue, - getFieldsWithFlatFieldsets, - getForcedValue, -} from './utils'; +import { checkFieldHasForcedValue, getFieldsWithFlatFieldsets } from './utils'; type JSONSchemaFormFieldsProps = { fields: JSFFields; @@ -79,7 +75,7 @@ export const JSONSchemaFormFields = ({ { - const actual = await importOriginal(); - return { - ...actual, - useFormFields: vi.fn(() => ({ - components: { - forcedValue: ({ fieldData }: ForcedValueComponentProps) => ( -

{`Forced value: ${fieldData.value}`}

- ), - }, - })), - }; -}); - -const schema = { - type: 'object', - properties: { - allowance: { - title: 'Allowance', - type: 'integer', - const: 81257, - default: 81257, - 'x-jsf-presentation': { - inputType: 'money', - currency: 'EUR', - statement: { - title: 'Allowance', - description: 'You need to pay an allowance', - }, - }, - }, - }, - 'x-jsf-order': ['allowance'], -}; - -describe('JSONSchemaForm - money forced value', () => { - it('holds a money forced value in major units, like any other money field', async () => { - const form = createHeadlessForm(schema, {}); - let latestValues: Record = {}; - - const TestComponent = () => { - const methods = useForm(); - latestValues = methods.watch(); - - return ( - - - - ); - }; - - render(); - - await screen.findByText('Forced value: 812.57'); - - await waitFor(() => { - expect(latestValues).toEqual({ allowance: 812.57 }); - }); - }); -}); diff --git a/src/components/form/tests/utils.test.ts b/src/components/form/tests/utils.test.ts deleted file mode 100644 index 5ffdd1ef4..000000000 --- a/src/components/form/tests/utils.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getForcedValue } from '@/src/components/form/utils'; - -describe('getForcedValue', () => { - it('converts a money const from cents to the major units kept in the form', () => { - expect(getForcedValue({ type: 'money', const: 81257 })).toBe(812.57); - }); - - it('returns the const unchanged for other field types', () => { - expect(getForcedValue({ type: 'number', const: 40 })).toBe(40); - expect(getForcedValue({ type: 'text', const: 'acknowledged' })).toBe( - 'acknowledged', - ); - }); -}); diff --git a/src/components/form/utils.ts b/src/components/form/utils.ts index 78ba42ad4..e4fca9ffc 100644 --- a/src/components/form/utils.ts +++ b/src/components/form/utils.ts @@ -871,16 +871,3 @@ export function checkFieldHasForcedValue(field: $TSFixMe) { field.type !== 'hidden' // Because hidden inputs shouldn't be visible ); } - -/** - * The value a forced value field should hold in the form state. - * The schema's `const` is in API units, but money fields are kept in major units in the - * form and converted to cents on submit, so a money `const` has to be converted back. - */ -export function getForcedValue(field: $TSFixMe) { - if (field.type === supportedTypes.MONEY) { - return convertFromCents(field.const); - } - - return field.const; -} From 2d2809d17acb042ea8fc68917d691a8311da8f8d Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 22:11:23 +0200 Subject: [PATCH 06/21] test(form): pin that forced values are submitted as their const Correct submission of forced money values relies on parseFormValuesToAPI overwriting the form value with the field's const. Covers a top-level field and one nested in a fieldset; both fail with 100x the amount without it. Co-Authored-By: Claude Opus 5.5 --- .../form/tests/parseSubmitValues.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/components/form/tests/parseSubmitValues.test.ts diff --git a/src/components/form/tests/parseSubmitValues.test.ts b/src/components/form/tests/parseSubmitValues.test.ts new file mode 100644 index 000000000..2c287dde9 --- /dev/null +++ b/src/components/form/tests/parseSubmitValues.test.ts @@ -0,0 +1,50 @@ +import { createHeadlessForm } from '@/src/common/createHeadlessForm'; +import { parseSubmitValues } from '@/src/components/form/utils'; + +const forcedAllowance = { + title: 'Allowance', + type: 'integer', + const: 81257, + default: 81257, + 'x-jsf-presentation': { inputType: 'money', currency: 'EUR' }, +}; + +describe('parseSubmitValues', () => { + it('submits a forced money value as its const, even though the form holds it unconverted', async () => { + const { fields } = createHeadlessForm( + { + type: 'object', + properties: { allowance: forcedAllowance }, + 'x-jsf-order': ['allowance'], + }, + {}, + ); + + expect(await parseSubmitValues({ allowance: 81257 }, fields)).toEqual({ + allowance: 81257, + }); + }); + + it('submits a forced money value nested in a fieldset as its const', async () => { + const { fields } = createHeadlessForm( + { + type: 'object', + properties: { + compensation: { + title: 'Compensation', + type: 'object', + properties: { allowance: forcedAllowance }, + 'x-jsf-order': ['allowance'], + 'x-jsf-presentation': { inputType: 'fieldset' }, + }, + }, + 'x-jsf-order': ['compensation'], + }, + {}, + ); + + expect( + await parseSubmitValues({ compensation: { allowance: 81257 } }, fields), + ).toEqual({ compensation: { allowance: 81257 } }); + }); +}); From 8ac5bcbdba427522942d09b9bb345e5f4749f863 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 22:15:47 +0200 Subject: [PATCH 07/21] refactor(onboarding): drop explicit transformMoneyFields createHeadlessForm now defaults transformMoneyFields per key (#1426), so the explicit override in useJSONSchemaForm is redundant. Co-Authored-By: Claude Opus 5.5 --- src/flows/Onboarding/api.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index 823283564..99ea7fc77 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -247,14 +247,7 @@ export const useJSONSchemaForm = ({ enabled: options?.queryOptions?.enabled, select: ({ data }) => { const jsfSchema = data?.data || {}; - // `transformMoneyFields` has to be explicit: `createHeadlessForm` only defaults it on - // when no options object is passed at all. Without it the schema's conditionals and - // computed values see money fields in major units instead of the cents they are written - // for, e.g. PRT's extended work hours allowance came out 100x too small. - return createHeadlessForm(jsfSchema, fieldValues, { - ...options, - transformMoneyFields: true, - }); + return createHeadlessForm(jsfSchema, fieldValues, options); }, }); }; From 39b2adcb49f6d89c3c10d2b220e20fd7b6e491c1 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 22:21:48 +0200 Subject: [PATCH 08/21] test(form): drop parseSubmitValues forced value test Co-Authored-By: Claude Opus 5.5 --- .../form/tests/parseSubmitValues.test.ts | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 src/components/form/tests/parseSubmitValues.test.ts diff --git a/src/components/form/tests/parseSubmitValues.test.ts b/src/components/form/tests/parseSubmitValues.test.ts deleted file mode 100644 index 2c287dde9..000000000 --- a/src/components/form/tests/parseSubmitValues.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createHeadlessForm } from '@/src/common/createHeadlessForm'; -import { parseSubmitValues } from '@/src/components/form/utils'; - -const forcedAllowance = { - title: 'Allowance', - type: 'integer', - const: 81257, - default: 81257, - 'x-jsf-presentation': { inputType: 'money', currency: 'EUR' }, -}; - -describe('parseSubmitValues', () => { - it('submits a forced money value as its const, even though the form holds it unconverted', async () => { - const { fields } = createHeadlessForm( - { - type: 'object', - properties: { allowance: forcedAllowance }, - 'x-jsf-order': ['allowance'], - }, - {}, - ); - - expect(await parseSubmitValues({ allowance: 81257 }, fields)).toEqual({ - allowance: 81257, - }); - }); - - it('submits a forced money value nested in a fieldset as its const', async () => { - const { fields } = createHeadlessForm( - { - type: 'object', - properties: { - compensation: { - title: 'Compensation', - type: 'object', - properties: { allowance: forcedAllowance }, - 'x-jsf-order': ['allowance'], - 'x-jsf-presentation': { inputType: 'fieldset' }, - }, - }, - 'x-jsf-order': ['compensation'], - }, - {}, - ); - - expect( - await parseSubmitValues({ compensation: { allowance: 81257 } }, fields), - ).toEqual({ compensation: { allowance: 81257 } }); - }); -}); From 8e1421d77c6019c53027a5b2f163f7039b31b9f3 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Wed, 30 Sep 2026 10:30:01 +0200 Subject: [PATCH 09/21] refactor(form): add useHeadlessForm with rebuild and buildOnce strategies Every flow wires createHeadlessForm, parseJSFToValidate and handleValidation by hand, and each copy can drift on its own (the money unit regression lived in one of them). useHeadlessForm puts that wiring behind one interface with the two strategies the codebase uses today: - rebuild: rebuild the form whenever values change (current behaviour of most call sites). - buildOnce: build once per schema/options and resolve conditionals through handleValidation. It validates once after every build, so visibility is right without a mounted step and an inline jsfModify reference does not reset it; a replaced form's pending validation is cancelled before it mutates the old fields. The engine contract test renders the real useJSONSchemaForm + JSONSchemaFormFields (so ForcedValueField's setValue loop runs) and runs each schema situation across jsf v0, jsf v1, no meta and both strategies. New situations are one row. Co-Authored-By: Claude Opus 5.5 --- src/common/tests/jsfEngineContract.test.tsx | 155 ++++++++++++++++++++ src/common/tests/useHeadlessForm.test.tsx | 35 +++++ src/common/useHeadlessForm.ts | 95 ++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 src/common/tests/jsfEngineContract.test.tsx create mode 100644 src/common/tests/useHeadlessForm.test.tsx create mode 100644 src/common/useHeadlessForm.ts diff --git a/src/common/tests/jsfEngineContract.test.tsx b/src/common/tests/jsfEngineContract.test.tsx new file mode 100644 index 000000000..a836418c3 --- /dev/null +++ b/src/common/tests/jsfEngineContract.test.tsx @@ -0,0 +1,155 @@ +import { useState } from 'react'; +import { FieldValues } from 'react-hook-form'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent, { UserEvent } from '@testing-library/user-event'; +import { + HeadlessFormStrategy, + useHeadlessForm, +} from '@/src/common/useHeadlessForm'; +import { JSONSchemaFormFields } from '@/src/components/form/JSONSchemaForm'; +import { useJSONSchemaForm } from '@/src/components/form/useJSONSchemaForm'; +import { Form } from '@/src/components/ui/form'; +import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; +import { queryClient, TestProviders } from '@/src/tests/testHelpers'; + +type Schema = Record; + +type Situation = { + situation: string; + schema: Schema; + fill: (user: UserEvent) => Promise; + assert: (submitted: FieldValues) => Promise; +}; + +const ENGINES = [ + { engine: 'jsf v0', meta: { jsfOldVersion: true } }, + { engine: 'jsf v1', meta: { jsfVersion: '1' } }, + { engine: 'no meta', meta: undefined }, +]; + +const STRATEGIES: { strategy: HeadlessFormStrategy }[] = [ + { strategy: 'rebuild' }, + { strategy: 'buildOnce' }, +]; + +const withoutMeta = ({ 'x-rmt-meta': _meta, ...schema }: Schema) => schema; + +const portugal = withoutMeta(contractDetailsSchemaV1Portugal.data); + +const chooseInGroup = async ( + user: UserEvent, + group: RegExp, + option: string, +) => { + const radioGroup = await screen.findByRole('radiogroup', { name: group }); + await user.click(within(radioGroup).getByRole('radio', { name: option })); +}; + +const SITUATIONS: Situation[] = [ + { + situation: 'forced money value computed from another money field', + schema: portugal, + fill: async (user) => { + await user.type( + await screen.findByLabelText('Annual gross salary'), + '71703.77', + ); + await chooseInGroup(user, /Type of employee/i, 'Full-time'); + await chooseInGroup(user, /work outside regular work hours/i, 'Yes'); + await chooseInGroup(user, /more than 8 hours a day/i, 'Yes'); + }, + assert: async (submitted) => { + expect( + await screen.findByText(/additional 812.57 EUR monthly/), + ).toBeInTheDocument(); + expect(submitted.working_hours_exemption_allowance).toBe(81257); + }, + }, + { + situation: 'radio reveals a conditional money field', + schema: portugal, + fill: async (user) => { + await chooseInGroup(user, /Offer a signing bonus/i, 'Yes'); + await user.type( + await screen.findByLabelText('Signing bonus amount'), + '1000.50', + ); + }, + assert: async (submitted) => { + expect(screen.getByLabelText('Signing bonus amount')).toBeVisible(); + expect(submitted.signing_bonus_amount).toBe(100050); + }, + }, +]; + +function Harness({ + schema, + strategy, + onCapture, +}: { + schema: Schema; + strategy: HeadlessFormStrategy; + onCapture: (submitted: FieldValues) => void; +}) { + const [values, setValues] = useState({}); + const headless = useHeadlessForm({ schema, values, strategy }); + const form = useJSONSchemaForm({ + handleValidation: headless.handleValidation, + defaultValues: {}, + checkFieldUpdates: async (nextValues) => { + setValues(nextValues); + await headless.onValuesChange(nextValues); + }, + }); + + if (!headless.form) return null; + + return ( +
+ + + + + + ); +} + +beforeEach(() => { + queryClient.clear(); +}); + +describe.each(ENGINES)('jsf engine contract on $engine', ({ meta }) => { + describe.each(STRATEGIES)('$strategy', ({ strategy }) => { + it.each(SITUATIONS)('$situation', async ({ schema, fill, assert }) => { + const user = userEvent.setup(); + const onCapture = vi.fn(); + render( + , + { wrapper: TestProviders }, + ); + + await fill(user); + await user.click(screen.getByRole('button', { name: 'Capture' })); + await waitFor(() => expect(onCapture).toHaveBeenCalled()); + + await assert(onCapture.mock.lastCall?.[0]); + }); + }); +}); diff --git a/src/common/tests/useHeadlessForm.test.tsx b/src/common/tests/useHeadlessForm.test.tsx new file mode 100644 index 000000000..a0e1a3019 --- /dev/null +++ b/src/common/tests/useHeadlessForm.test.tsx @@ -0,0 +1,35 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useHeadlessForm } from '@/src/common/useHeadlessForm'; +import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; +import { $TSFixMe } from '@/src/types/remoteFlows'; + +const { 'x-rmt-meta': _meta, ...schema } = contractDetailsSchemaV1Portugal.data; + +const isVisible = (fields: $TSFixMe[] | undefined, name: string) => + fields?.find((field) => field.name === name)?.isVisible; + +describe('useHeadlessForm buildOnce', () => { + it('replays the latest values into a rebuilt form and leaves the replaced one untouched', async () => { + const { result, rerender } = renderHook( + ({ options }) => + useHeadlessForm({ + schema, + values: { has_signing_bonus: 'yes' }, + options, + strategy: 'buildOnce', + }), + { initialProps: { options: { jsfModify: {} } } }, + ); + const replacedForm = result.current.form; + + rerender({ options: { jsfModify: {} } }); + + await waitFor(() => + expect( + isVisible(result.current.form?.fields, 'signing_bonus_amount'), + ).toBe(true), + ); + expect(result.current.form).not.toBe(replacedForm); + expect(isVisible(replacedForm?.fields, 'signing_bonus_amount')).toBe(false); + }); +}); diff --git a/src/common/useHeadlessForm.ts b/src/common/useHeadlessForm.ts new file mode 100644 index 000000000..b96fa6060 --- /dev/null +++ b/src/common/useHeadlessForm.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FieldValues } from 'react-hook-form'; +import { ValidationResult } from '@remoteoss/remote-json-schema-form-kit'; +import { createHeadlessForm } from '@/src/common/createHeadlessForm'; +import { parseJSFToValidate } from '@/src/components/form/utils'; +import { + JSFModify, + JSONSchemaFormResultWithFieldsets, +} from '@/src/flows/types'; + +export type HeadlessFormStrategy = 'rebuild' | 'buildOnce'; + +type UseHeadlessFormArgs = { + schema: Record | undefined; + values: FieldValues; + options?: { jsfModify?: JSFModify }; + strategy: HeadlessFormStrategy; +}; + +export type HeadlessForm = { + form: JSONSchemaFormResultWithFieldsets | null; + handleValidation: (values: FieldValues) => Promise; + onValuesChange: (values: FieldValues) => Promise; + parseFormValues: (values: FieldValues) => Promise; +}; + +export function useHeadlessForm({ + schema, + values, + options, + strategy, +}: UseHeadlessFormArgs): HeadlessForm { + const isBuildOnce = strategy === 'buildOnce'; + const [, setRevision] = useState(0); + const latestValues = useRef(values); + useEffect(() => { + latestValues.current = values; + }); + + const buildValues = isBuildOnce ? undefined : values; + + const form = useMemo(() => { + if (!schema) return null; + return isBuildOnce + ? createHeadlessForm( + schema, + {}, + { ...options, transformMoneyFields: false }, + ) + : createHeadlessForm(schema, buildValues, options); + }, [schema, buildValues, options, isBuildOnce]); + + const validate = useCallback( + async (nextValues: FieldValues, isCancelled: () => boolean) => { + if (!form) return null; + const parsedValues = await parseJSFToValidate(nextValues, form.fields, { + isPartialValidation: isBuildOnce, + }); + if (isCancelled()) return null; + const result = form.handleValidation(parsedValues); + if (isBuildOnce) setRevision((revision) => revision + 1); + return result; + }, + [form, isBuildOnce], + ); + + const handleValidation = useCallback( + (nextValues: FieldValues) => validate(nextValues, () => false), + [validate], + ); + + const onValuesChange = useCallback( + async (nextValues: FieldValues) => { + if (isBuildOnce) await handleValidation(nextValues); + }, + [handleValidation, isBuildOnce], + ); + + useEffect(() => { + if (!isBuildOnce) return; + let cancelled = false; + void validate(latestValues.current, () => cancelled); + return () => { + cancelled = true; + }; + }, [validate, isBuildOnce]); + + const parseFormValues = useCallback( + async (nextValues: FieldValues) => + form ? parseJSFToValidate(nextValues, form.fields) : {}, + [form], + ); + + return { form, handleValidation, onValuesChange, parseFormValues }; +} From c3cc3b1aecd0152db070594c639e0796743273ab Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Wed, 30 Sep 2026 10:30:01 +0200 Subject: [PATCH 10/21] refactor(onboarding): build legacy contract details through useHeadlessForm First call site on the shared hook, with the rebuild strategy so behaviour is unchanged. useLegacyContractDetailsSchema fetches the raw schema and leaves building to the hook; the shared useJSONSchema stays as it is for the other steps. mergedFormValues moves out of that wrapper so both can use it. The existing PRT extended hours allowance flow test covers this step: it fails when the hook's rebuild stops converting money to cents. The jsonSchemaVersion test's mocked schema gains x-jsf-presentation, like every real schema has: the hook builds during render instead of inside a React Query select, so the money conversion's lookup of it now surfaces instead of turning into a silent query error. Co-Authored-By: Claude Opus 5.5 --- src/flows/Onboarding/api.ts | 64 +++++++++++++++++++ src/flows/Onboarding/hooks.tsx | 36 ++++++----- .../useOnboardingJsonSchemaVersion.test.tsx | 12 +++- 3 files changed, 93 insertions(+), 19 deletions(-) diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index 99ea7fc77..b2b2add0b 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -54,6 +54,7 @@ import { getBenefitOffersSchemaVersion, } from '@/src/flows/Onboarding/utils'; import { createHeadlessForm } from '@/src/common/createHeadlessForm'; +import { useHeadlessForm } from '@/src/common/useHeadlessForm'; import { countriesOptions } from '@/src/common/api/countries'; import { useMemo } from 'react'; @@ -252,6 +253,69 @@ export const useJSONSchemaForm = ({ }); }; +export const useLegacyContractDetailsSchema = ({ + countryCode, + fieldValues, + options, + query = {}, + jsonSchemaVersion, +}: { + countryCode: string; + fieldValues: FieldValues; + options?: FlowOptions & { queryOptions?: { enabled?: boolean } }; + query?: Record; + jsonSchemaVersion?: number | 'latest'; +}): { data: JSONSchemaFormResultWithFieldsets | null; isLoading: boolean } => { + const { client } = useClient(); + const jsonSchemaQueryParam = jsonSchemaVersion + ? { + json_schema_version: jsonSchemaVersion, + } + : {}; + const { data: schema, isLoading } = useQuery({ + queryKey: [ + 'onboarding-legacy-contract-details-schema', + countryCode, + jsonSchemaVersion, + ], + retry: false, + queryFn: async () => { + const response = await getV1CountriesCountryCodeForm({ + client: client as Client, + headers: { + Authorization: ``, + }, + path: { + country_code: countryCode, + form: 'contract_details', + }, + query: { + skip_benefits: true, + ...query, + ...jsonSchemaQueryParam, + }, + }); + + if (response.error || !response.data) { + throw new Error('Failed to fetch onboarding schema'); + } + + return response; + }, + enabled: options?.queryOptions?.enabled, + select: ({ data }) => data?.data || {}, + }); + + const { form } = useHeadlessForm({ + schema, + values: fieldValues, + options: { jsfModify: options?.jsfModify }, + strategy: 'rebuild', + }); + + return { data: form, isLoading }; +}; + export const useContractDetailsSchema = ({ countryCode, options, diff --git a/src/flows/Onboarding/hooks.tsx b/src/flows/Onboarding/hooks.tsx index fba199db6..1b893368b 100644 --- a/src/flows/Onboarding/hooks.tsx +++ b/src/flows/Onboarding/hooks.tsx @@ -40,6 +40,7 @@ import { useEngagementAgreementDetailsSchema, useGetPreOnboardingRequirements, useJSONSchemaForm, + useLegacyContractDetailsSchema, useUpdateBenefitsOffers, useUpdateEmployment, useUpdateEmploymentEngagementAgreementDetails, @@ -513,6 +514,21 @@ export const useOnboarding = ({ unknown >; + const hasUserEnteredAnyValues = Object.keys(fieldValues).length > 0; + // when you write on the fields, the values are stored in the fieldValues state + // when values are stored in the stepState is when the user has navigated to the step + // and then we have the values from the server and the onboardingInitialValues that the user can inject, + const mergedFormValues = hasUserEnteredAnyValues + ? { + ...onboardingInitialValues, + ...stepState.values?.[stepState.currentStep.name], // Restore values for the current step + ...fieldValues, + } + : { + ...onboardingInitialValues, + ...serverEmploymentData, + }; + const useJSONSchema = ({ form, options: jsonSchemaOptions = {}, @@ -527,21 +543,6 @@ export const useOnboarding = ({ query?: Record; jsonSchemaVersion?: number | 'latest'; }) => { - const hasUserEnteredAnyValues = Object.keys(fieldValues).length > 0; - // when you write on the fields, the values are stored in the fieldValues state - // when values are stored in the stepState is when the user has navigated to the step - // and then we have the values from the server and the onboardingInitialValues that the user can inject, - const mergedFormValues = hasUserEnteredAnyValues - ? { - ...onboardingInitialValues, - ...stepState.values?.[stepState.currentStep.name], // Restore values for the current step - ...fieldValues, - } - : { - ...onboardingInitialValues, - ...serverEmploymentData, - }; - return useJSONSchemaForm({ countryCode: internalCountryCode as string, form: form, @@ -712,8 +713,9 @@ export const useOnboarding = ({ getContractDetailsSchemaVersion(options, internalCountryCode); const { data: contractDetailsForm, isLoading: isLoadingContractDetailsForm } = - useJSONSchema({ - form: 'contract_details', + useLegacyContractDetailsSchema({ + countryCode: internalCountryCode as string, + fieldValues: mergedFormValues, query: { employment_id: internalEmploymentId as string, }, diff --git a/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx b/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx index d2e39c3ee..d61244f5f 100644 --- a/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx +++ b/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx @@ -44,8 +44,16 @@ describe('useOnboarding jsonSchemaVersion', () => { data: { data: { properties: { - name: { type: 'string', title: 'Name' }, - email: { type: 'string', title: 'Email' }, + name: { + type: 'string', + title: 'Name', + 'x-jsf-presentation': { inputType: 'text' }, + }, + email: { + type: 'string', + title: 'Email', + 'x-jsf-presentation': { inputType: 'email' }, + }, }, }, }, From fbdf5a6361fe329222c7d9a6d1f931820066e85b Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Wed, 30 Sep 2026 10:42:56 +0200 Subject: [PATCH 11/21] fix(form): stop buildOnce from rebuilding on every render with inline options buildOnce rebuilt the form whenever the options object changed identity, and every build validates and re-renders. A caller that creates options during render (as the Onboarding contract details hooks do) looped forever: 132 renders in 500ms in a probe, "Too many re-renders" in the test. Options are now compared by value with fast-deep-equal, the helper useJSONSchemaForm already uses. Functions and components inside jsfModify keep their identity across our own renders, so they compare equal by reference. React elements cannot reach jsfModify: the jsf engine deep-clones it and rejects them, so the React-aware variant is not needed. Co-Authored-By: Claude Opus 5.5 --- src/common/tests/useHeadlessForm.test.tsx | 41 ++++++++++++++++++++--- src/common/useHeadlessForm.ts | 17 ++++++++-- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/common/tests/useHeadlessForm.test.tsx b/src/common/tests/useHeadlessForm.test.tsx index a0e1a3019..f0b2b5dbb 100644 --- a/src/common/tests/useHeadlessForm.test.tsx +++ b/src/common/tests/useHeadlessForm.test.tsx @@ -1,5 +1,6 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useHeadlessForm } from '@/src/common/useHeadlessForm'; +import { JSFModify } from '@/src/flows/types'; import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; import { $TSFixMe } from '@/src/types/remoteFlows'; @@ -8,21 +9,25 @@ const { 'x-rmt-meta': _meta, ...schema } = contractDetailsSchemaV1Portugal.data; const isVisible = (fields: $TSFixMe[] | undefined, name: string) => fields?.find((field) => field.name === name)?.isVisible; +const renamedBonus = (title: string): JSFModify => ({ + fields: { signing_bonus_amount: { title } }, +}); + describe('useHeadlessForm buildOnce', () => { it('replays the latest values into a rebuilt form and leaves the replaced one untouched', async () => { const { result, rerender } = renderHook( - ({ options }) => + ({ jsfModify }) => useHeadlessForm({ schema, values: { has_signing_bonus: 'yes' }, - options, + options: { jsfModify }, strategy: 'buildOnce', }), - { initialProps: { options: { jsfModify: {} } } }, + { initialProps: { jsfModify: renamedBonus('Bonus') } }, ); const replacedForm = result.current.form; - rerender({ options: { jsfModify: {} } }); + rerender({ jsfModify: renamedBonus('Welcome bonus') }); await waitFor(() => expect( @@ -32,4 +37,32 @@ describe('useHeadlessForm buildOnce', () => { expect(result.current.form).not.toBe(replacedForm); expect(isVisible(replacedForm?.fields, 'signing_bonus_amount')).toBe(false); }); + + it('keeps the form when options are recreated with the same content on every render', async () => { + let renders = 0; + const { result } = renderHook(() => { + renders += 1; + return useHeadlessForm({ + schema, + values: { has_signing_bonus: 'yes' }, + options: { + jsfModify: { + fields: { signing_bonus_amount: { title: 'Bonus' } }, + }, + }, + strategy: 'buildOnce', + }); + }); + const builtForm = result.current.form; + + await waitFor(() => + expect( + isVisible(result.current.form?.fields, 'signing_bonus_amount'), + ).toBe(true), + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(result.current.form).toBe(builtForm); + expect(renders).toBeLessThan(5); + }); }); diff --git a/src/common/useHeadlessForm.ts b/src/common/useHeadlessForm.ts index b96fa6060..4b54061ee 100644 --- a/src/common/useHeadlessForm.ts +++ b/src/common/useHeadlessForm.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import equal from 'fast-deep-equal'; import { FieldValues } from 'react-hook-form'; import { ValidationResult } from '@remoteoss/remote-json-schema-form-kit'; import { createHeadlessForm } from '@/src/common/createHeadlessForm'; @@ -32,6 +33,10 @@ export function useHeadlessForm({ }: UseHeadlessFormArgs): HeadlessForm { const isBuildOnce = strategy === 'buildOnce'; const [, setRevision] = useState(0); + const [stableOptions, setStableOptions] = useState(options); + if (!equal(stableOptions, options)) { + setStableOptions(options); + } const latestValues = useRef(values); useEffect(() => { latestValues.current = values; @@ -45,14 +50,20 @@ export function useHeadlessForm({ ? createHeadlessForm( schema, {}, - { ...options, transformMoneyFields: false }, + { ...stableOptions, transformMoneyFields: false }, ) - : createHeadlessForm(schema, buildValues, options); - }, [schema, buildValues, options, isBuildOnce]); + : createHeadlessForm(schema, buildValues, stableOptions); + }, [schema, buildValues, stableOptions, isBuildOnce]); const validate = useCallback( async (nextValues: FieldValues, isCancelled: () => boolean) => { if (!form) return null; + // buildOnce keeps invisible values on purpose. The fields still hold the + // visibility of the previous change, so dropping their values would hide + // what a field that is about to become visible needs to compute itself: + // a hidden fieldset coming back would lose the values driving its own + // children. handleValidation resolves the visibility first and nulls + // whatever it considers hidden afterwards, which is the right order. const parsedValues = await parseJSFToValidate(nextValues, form.fields, { isPartialValidation: isBuildOnce, }); From f1e90345bacde1c1935ba9f15fbbf2d744de914d Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Wed, 30 Sep 2026 10:42:56 +0200 Subject: [PATCH 12/21] refactor(onboarding): build jsf v1 contract details through useHeadlessForm The v1 contract details (FRA, ITA, DEU, ESP) already built the form once and resolved conditionals through handleValidation with invisible values kept, which is the hook's buildOnce strategy. useContractDetailsSchema now hands building, validation and submit parsing to the hook, so contract details covers both strategies: the legacy step with rebuild, v1 with buildOnce. The fieldsCount state that forced a re-render after each v1 validation goes away, since the hook re-renders itself. The comment on why invisible values are kept moves with that decision into the hook. The replay after a rebuild uses the step's form values, not the server employment data, so money is never converted from cents a second time on this path. France's flow tests and useOnboardingJsfV1ContractDetails fail when the hook's buildOnce validation is broken, so they cover this step. Co-Authored-By: Claude Opus 5.5 --- src/flows/Onboarding/api.ts | 34 +++++++++---------- src/flows/Onboarding/hooks.tsx | 61 ++++++++++++---------------------- 2 files changed, 38 insertions(+), 57 deletions(-) diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index b2b2add0b..dc10ef1b7 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -54,7 +54,7 @@ import { getBenefitOffersSchemaVersion, } from '@/src/flows/Onboarding/utils'; import { createHeadlessForm } from '@/src/common/createHeadlessForm'; -import { useHeadlessForm } from '@/src/common/useHeadlessForm'; +import { HeadlessForm, useHeadlessForm } from '@/src/common/useHeadlessForm'; import { countriesOptions } from '@/src/common/api/countries'; import { useMemo } from 'react'; @@ -318,25 +318,27 @@ export const useLegacyContractDetailsSchema = ({ export const useContractDetailsSchema = ({ countryCode, + fieldValues, options, query = {}, jsonSchemaVersion, }: { countryCode: string; - options?: FlowOptions & { - queryOptions?: { enabled?: boolean }; - transformMoneyFields?: boolean; - }; + fieldValues: FieldValues; + options?: FlowOptions & { queryOptions?: { enabled?: boolean } }; query?: Record; jsonSchemaVersion?: number | 'latest'; -}): { data: JSONSchemaFormResultWithFieldsets | null; isLoading: boolean } => { +}): Omit & { + data: JSONSchemaFormResultWithFieldsets | null; + isLoading: boolean; +} => { const { client } = useClient(); const jsonSchemaQueryParam = jsonSchemaVersion ? { json_schema_version: jsonSchemaVersion, } : {}; - const { data: response, ...reactQueryResult } = useQuery({ + const { data: schema, isLoading } = useQuery({ queryKey: [ 'onboarding-contract-details-schema', countryCode, @@ -368,19 +370,17 @@ export const useContractDetailsSchema = ({ return response; }, enabled: options?.queryOptions?.enabled, + select: ({ data }) => data?.data, }); - const contractDetailsFormFrance: JSONSchemaFormResultWithFieldsets | null = - useMemo(() => { - const schemaData = response?.data.data; - if (!schemaData) return null; - return createHeadlessForm(schemaData, {}, options); - }, [options, response?.data]); + const { form, handleValidation, parseFormValues } = useHeadlessForm({ + schema, + values: fieldValues, + options: { jsfModify: options?.jsfModify }, + strategy: 'buildOnce', + }); - return { - data: contractDetailsFormFrance, - isLoading: reactQueryResult.isLoading, - }; + return { data: form, isLoading, handleValidation, parseFormValues }; }; export const useBenefitOffersSchema = ( diff --git a/src/flows/Onboarding/hooks.tsx b/src/flows/Onboarding/hooks.tsx index 1b893368b..2b45ff247 100644 --- a/src/flows/Onboarding/hooks.tsx +++ b/src/flows/Onboarding/hooks.tsx @@ -168,39 +168,33 @@ const getLoadingStates = ({ const useJsfV1ContractDetails = ({ countryCode, + fieldValues, query, enabled, jsonSchemaVersion, jsfModify, }: { countryCode: string; + fieldValues: FieldValues; query: Record; enabled: boolean; jsonSchemaVersion: number | 'latest'; jsfModify?: JSFModify; }) => { - const options = useMemo( - () => ({ - queryOptions: { - enabled, - }, - transformMoneyFields: false, - jsfModify, - }), - [enabled, jsfModify], - ); - const { data: form, isLoading: isLoadingContractDetails } = - useContractDetailsSchema({ - countryCode, - query, - options, - jsonSchemaVersion, - }); + const { + data: form, + isLoading, + handleValidation, + parseFormValues, + } = useContractDetailsSchema({ + countryCode, + fieldValues, + query, + options: { queryOptions: { enabled }, jsfModify }, + jsonSchemaVersion, + }); - return { - form, - isLoading: isLoadingContractDetails, - }; + return { form, isLoading, handleValidation, parseFormValues }; }; export const useOnboarding = ({ @@ -223,7 +217,6 @@ export const useOnboarding = ({ }, }); - const [, setFieldsCount] = useState(0); const [internalEmploymentId, setInternalEmploymentId] = useState< string | undefined >(employmentId); @@ -748,8 +741,11 @@ export const useOnboarding = ({ const { form: contractDetailsFormV1, isLoading: isLoadingContractDetailsFormV1, + handleValidation: handleContractDetailsV1Validation, + parseFormValues: parseContractDetailsV1Values, } = useJsfV1ContractDetails({ countryCode: internalCountryCode as string, + fieldValues, query: { employment_id: internalEmploymentId as string, }, @@ -1123,9 +1119,7 @@ export const useOnboarding = ({ stepState.currentStep.name === 'contract_details' && isJsfV1ContractDetailsEnabled ) { - return await parseJSFToValidate(values, contractDetailsFormV1?.fields, { - isPartialValidation: false, - }); + return await parseContractDetailsV1Values(values); } if (benefitOffersSchema && stepState.currentStep.name === 'benefits') { @@ -1313,20 +1307,7 @@ export const useOnboarding = ({ stepState.currentStep.name === 'contract_details' && isJsfV1ContractDetailsEnabled ) { - // Invisible values are kept on purpose here. The fields still hold the - // visibility of the previous change, so dropping their values would hide - // what a field that is about to become visible needs to compute itself: - // a hidden fieldset coming back would lose the values driving its own - // children. handleValidation resolves the visibility first and nulls - // whatever it considers hidden afterwards, which is the right order. - const parsedValues = await parseJSFToValidate( - values, - contractDetailsFormV1?.fields, - { isPartialValidation: true }, - ); - const result = contractDetailsFormV1?.handleValidation(parsedValues); - setFieldsCount((prev) => prev + 1); - return result; + return handleContractDetailsV1Validation(values); } return null; @@ -1339,8 +1320,8 @@ export const useOnboarding = ({ engagementAgreementDetailsSchema, contractDetailsForm, contractDetailsFormV1, + handleContractDetailsV1Validation, isJsfV1ContractDetailsEnabled, - setFieldsCount, ], ); From 60b4ecb65073be0f2050f90819b4200854c2b380 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Wed, 30 Sep 2026 10:49:03 +0200 Subject: [PATCH 13/21] fix(onboarding): refetch the contract details schema per employment Both contract details schema requests send employment_id, but their query keys only held the country and schema version, so React Query served the first employment's schema to every other employment of the same country in the session, and never refetched once an employment was created mid-flow. The keys now include the request query. Co-Authored-By: Claude Opus 5.5 --- src/flows/Onboarding/api.ts | 2 + ...contractDetailsSchemaEmploymentId.test.tsx | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/flows/Onboarding/tests/contractDetailsSchemaEmploymentId.test.tsx diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index dc10ef1b7..c8a920bdc 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -277,6 +277,7 @@ export const useLegacyContractDetailsSchema = ({ 'onboarding-legacy-contract-details-schema', countryCode, jsonSchemaVersion, + query, ], retry: false, queryFn: async () => { @@ -343,6 +344,7 @@ export const useContractDetailsSchema = ({ 'onboarding-contract-details-schema', countryCode, jsonSchemaVersion, + query, ], retry: false, queryFn: async () => { diff --git a/src/flows/Onboarding/tests/contractDetailsSchemaEmploymentId.test.tsx b/src/flows/Onboarding/tests/contractDetailsSchemaEmploymentId.test.tsx new file mode 100644 index 000000000..f39279f81 --- /dev/null +++ b/src/flows/Onboarding/tests/contractDetailsSchemaEmploymentId.test.tsx @@ -0,0 +1,55 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { + useContractDetailsSchema, + useLegacyContractDetailsSchema, +} from '@/src/flows/Onboarding/api'; +import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; +import { server } from '@/src/tests/server'; +import { queryClient, TestProviders } from '@/src/tests/testHelpers'; + +describe.each([ + { + hook: 'useLegacyContractDetailsSchema', + useSchema: useLegacyContractDetailsSchema, + }, + { hook: 'useContractDetailsSchema', useSchema: useContractDetailsSchema }, +])('$hook', ({ useSchema }) => { + let requestedEmploymentIds: (string | null)[]; + + beforeEach(() => { + queryClient.clear(); + requestedEmploymentIds = []; + server.use( + http.get('*/v1/countries/PRT/contract_details*', ({ request }) => { + requestedEmploymentIds.push( + new URL(request.url).searchParams.get('employment_id'), + ); + return HttpResponse.json(contractDetailsSchemaV1Portugal); + }), + ); + }); + + it('fetches the schema again for a different employment', async () => { + const { rerender } = renderHook( + ({ employmentId }) => + useSchema({ + countryCode: 'PRT', + fieldValues: {}, + query: { employment_id: employmentId }, + options: { queryOptions: { enabled: true } }, + }), + { + initialProps: { employmentId: 'employment-1' }, + wrapper: TestProviders, + }, + ); + await waitFor(() => expect(requestedEmploymentIds).toHaveLength(1)); + + rerender({ employmentId: 'employment-2' }); + + await waitFor(() => + expect(requestedEmploymentIds).toEqual(['employment-1', 'employment-2']), + ); + }); +}); From e5fc86586d5f79c75112b3455e73c216ac9a57eb Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Wed, 30 Sep 2026 11:14:33 +0200 Subject: [PATCH 14/21] test(form): cover options shapes in the jsf engine contract The contract only built forms without options, which is the one shape where createHeadlessForm's money default is irrelevant, so reverting that default to the old `options || { transformMoneyFields: true }` stayed green here and was only caught by flow and hook tests. Run every situation with no options, with an options object carrying no jsfModify (what Onboarding passes when the consumer gives none), and with a real jsfModify whose effect is asserted. Mutation checked: the old default fails the 6 rebuild money rows, and dropping options inside useHeadlessForm fails the 6 jsfModify rows. The Onboarding useJSONSchemaForm money forced value test is removed: its engine check is now in the contract and its wiring is covered end to end by the OnboardingFlow allowance-in-cents test. Co-Authored-By: Claude Opus 5.5 --- src/common/tests/jsfEngineContract.test.tsx | 67 +++++++++++++------ ...useJSONSchemaFormMoneyForcedValue.test.tsx | 64 ------------------ 2 files changed, 48 insertions(+), 83 deletions(-) delete mode 100644 src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx diff --git a/src/common/tests/jsfEngineContract.test.tsx b/src/common/tests/jsfEngineContract.test.tsx index a836418c3..3325b1c5e 100644 --- a/src/common/tests/jsfEngineContract.test.tsx +++ b/src/common/tests/jsfEngineContract.test.tsx @@ -9,6 +9,7 @@ import { import { JSONSchemaFormFields } from '@/src/components/form/JSONSchemaForm'; import { useJSONSchemaForm } from '@/src/components/form/useJSONSchemaForm'; import { Form } from '@/src/components/ui/form'; +import { JSFModify } from '@/src/flows/types'; import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; import { queryClient, TestProviders } from '@/src/tests/testHelpers'; @@ -32,6 +33,25 @@ const STRATEGIES: { strategy: HeadlessFormStrategy }[] = [ { strategy: 'buildOnce' }, ]; +const OPTIONS: { + options: string; + value: { jsfModify?: JSFModify } | undefined; + assertApplied?: () => void; +}[] = [ + { options: 'no options', value: undefined }, + { options: 'no jsfModify', value: { jsfModify: undefined } }, + { + options: 'a jsfModify', + value: { + jsfModify: { + fields: { annual_gross_salary: { description: 'Gross, per year' } }, + }, + }, + assertApplied: () => + expect(screen.getByText('Gross, per year')).toBeInTheDocument(), + }, +]; + const withoutMeta = ({ 'x-rmt-meta': _meta, ...schema }: Schema) => schema; const portugal = withoutMeta(contractDetailsSchemaV1Portugal.data); @@ -85,14 +105,16 @@ const SITUATIONS: Situation[] = [ function Harness({ schema, strategy, + options, onCapture, }: { schema: Schema; strategy: HeadlessFormStrategy; + options: { jsfModify?: JSFModify } | undefined; onCapture: (submitted: FieldValues) => void; }) { const [values, setValues] = useState({}); - const headless = useHeadlessForm({ schema, values, strategy }); + const headless = useHeadlessForm({ schema, values, options, strategy }); const form = useJSONSchemaForm({ handleValidation: headless.handleValidation, defaultValues: {}, @@ -131,25 +153,32 @@ beforeEach(() => { describe.each(ENGINES)('jsf engine contract on $engine', ({ meta }) => { describe.each(STRATEGIES)('$strategy', ({ strategy }) => { - it.each(SITUATIONS)('$situation', async ({ schema, fill, assert }) => { - const user = userEvent.setup(); - const onCapture = vi.fn(); - render( - , - { wrapper: TestProviders }, - ); + describe.each(OPTIONS)( + 'with $options', + ({ value: options, assertApplied }) => { + it.each(SITUATIONS)('$situation', async ({ schema, fill, assert }) => { + const user = userEvent.setup(); + const onCapture = vi.fn(); + render( + , + { wrapper: TestProviders }, + ); - await fill(user); - await user.click(screen.getByRole('button', { name: 'Capture' })); - await waitFor(() => expect(onCapture).toHaveBeenCalled()); + await fill(user); + await user.click(screen.getByRole('button', { name: 'Capture' })); + await waitFor(() => expect(onCapture).toHaveBeenCalled()); - await assert(onCapture.mock.lastCall?.[0]); - }); + await assert(onCapture.mock.lastCall?.[0]); + assertApplied?.(); + }); + }, + ); }); }); diff --git a/src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx b/src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx deleted file mode 100644 index 408c96eb7..000000000 --- a/src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { useJSONSchemaForm } from '@/src/flows/Onboarding/api'; -import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; -import { server } from '@/src/tests/server'; -import { queryClient, TestProviders } from '@/src/tests/testHelpers'; -import { $TSFixMe } from '@/src/types/remoteFlows'; - -const findField = (fields: $TSFixMe[] = [], name: string): $TSFixMe => - fields.reduce<$TSFixMe>( - (found, field) => - found ?? (field.name === name ? field : findField(field.fields, name)), - undefined, - ); - -describe('Onboarding useJSONSchemaForm money forced values', () => { - beforeEach(() => { - queryClient.clear(); - - const { 'x-rmt-meta': _meta, ...schemaWithoutMeta } = - contractDetailsSchemaV1Portugal.data; - - server.use( - http.get('*/v1/countries/PRT/contract_details*', () => { - return HttpResponse.json({ data: schemaWithoutMeta }); - }), - ); - }); - - it.each([ - { maximumWorkingHoursRegime: 'yes', allowanceInCents: 81257 }, - { maximumWorkingHoursRegime: 'no', allowanceInCents: 29548 }, - ])( - 'computes the allowance from the salary in cents (maximum_working_hours_regime: $maximumWorkingHoursRegime)', - async ({ maximumWorkingHoursRegime, allowanceInCents }) => { - const { result } = renderHook( - () => - useJSONSchemaForm({ - countryCode: 'PRT', - form: 'contract_details', - fieldValues: { - annual_gross_salary: 71703.77, - work_hours_per_week: 40, - working_hours_exemption: 'yes', - maximum_working_hours_regime: maximumWorkingHoursRegime, - }, - options: { queryOptions: { enabled: true } }, - }), - { wrapper: TestProviders }, - ); - - await waitFor(() => { - expect(result.current.data).toBeDefined(); - }); - - const allowance = findField( - result.current.data?.fields, - 'working_hours_exemption_allowance', - ); - - expect(allowance?.const).toBe(allowanceInCents); - }, - ); -}); From 5575dbcd1c5c68e4e5bb2f4011bea6cbd0782f4d Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Wed, 30 Sep 2026 11:22:22 +0200 Subject: [PATCH 15/21] docs(form): add useHeadlessForm rollout plan Co-Authored-By: Claude Opus 5.5 --- docs/USE_HEADLESS_FORM_ROLLOUT.md | 81 +++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/USE_HEADLESS_FORM_ROLLOUT.md diff --git a/docs/USE_HEADLESS_FORM_ROLLOUT.md b/docs/USE_HEADLESS_FORM_ROLLOUT.md new file mode 100644 index 000000000..1aa5ece07 --- /dev/null +++ b/docs/USE_HEADLESS_FORM_ROLLOUT.md @@ -0,0 +1,81 @@ +# useHeadlessForm rollout + +Every flow builds its JSON Schema forms through one shared hook, [`src/common/useHeadlessForm.ts`](../src/common/useHeadlessForm.ts), instead of calling `createHeadlessForm` directly. Once that's done, every form builds once and resolves conditional fields through validation. This doc tracks the migration so it can happen over many PRs. + +## Why + +- Each flow has its own hand-written copy of the build and validate logic. Each copy can break on its own (the money-in-cents bug fixed in #1429 is one example), and no shared test catches it. +- With one hook, [`jsfEngineContract.test.tsx`](../src/common/tests/jsfEngineContract.test.tsx) tests schema situations once, across engines and strategies. Each flow only needs one smoke test proving it goes through the hook. +- Rebuilding the form on every change is why the money pre-fill conversion and `transformMoneyFields` exist. Building once removes the need for both. + +## Strategies + +- **`rebuild`** (old): `createHeadlessForm(schema, values, options)` again whenever values change. Money values are converted to cents at build time. +- **`buildOnce`** (new): built once per `schema`/`options`. Conditionals are resolved by `handleValidation` with `isPartialValidation: true`, and the latest values are replayed after each build so visibility is right without a mounted step. + +## Phases + +### Phase 1: move every call site onto the hook, same behaviour + +Pure refactor. Each call site keeps the strategy it effectively has today, so nothing a user sees should change. Each PR adds one smoke test per flow. The test must fail when the hook is broken, not just pass with it. + +Call sites that don't pass values already behave like `buildOnce`, so they go straight onto it. + +### Phase 2: switch `rebuild` steps to `buildOnce`, one step per PR + +For each step, test all of these before switching (they come from #1430): + +- [ ] Starting from scratch +- [ ] `initialValues` provided by the partner +- [ ] Existing `employmentId` (server values already in cents, so they must not be converted twice) +- [ ] Read-only employment that jumps straight to review without mounting the step (`prettifyFormValues` drops invisible fields) +- [ ] Partner passes `options` inline, which creates a new `jsfModify` reference on every render +- [ ] Headless `use()` consumers with a custom UI, which don't mount our step components + +### Phase 3: cleanup + +- [ ] Remove the `rebuild` strategy from `useHeadlessForm` +- [ ] Remove `transformMoneyFields` and the pre-fill money conversion from `createHeadlessForm` +- [ ] Add an oxlint `no-restricted-imports` rule that bans importing `createHeadlessForm` in `src/flows/**` + +## Call sites + +Status: `todo`, `phase 1` (on the hook, old behaviour), `done` (on the hook with `buildOnce`). + +### Pass values (rebuild today) + +| Call site | Status | Notes | +| ------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Onboarding `useLegacyContractDetailsSchema` | phase 1 | #1433. Only building moved; validation and submit parsing still go through the hand-written branches in `Onboarding/hooks.tsx` | +| Onboarding `useJSONSchemaForm` (basic information and other steps) | todo | #1430 tried `buildOnce` here and was closed | +| Onboarding `useBenefitOffersSchema` | todo | | +| Onboarding `useEngagementAgreementDetailsSchema` | todo | | +| Contractor contract details `useContractorOnboardingDetailsSchema` | todo | Money-sensitive | +| ContractorOnboarding `useGetContractDocumentSignatureSchema` | todo | | +| ContractorOnboarding `useGetEligibilityQuestionnaire` | todo | | +| ContractorOnboarding `useGetContractOriginSchema` | todo | | +| Invoice schedules `useGetCreateInvoiceScheduleSchema` | todo | Money-sensitive | +| ContractAmendment `useContractAmendmentSchemaQuery` | todo | Money-sensitive | +| CreateCompany `useAddressDetailsSchema` | todo | | +| Termination `useTerminationSchema` | todo | | +| PayrollAdminOnboarding `useGPFormSchema` | todo | | +| PayrollAdminOnboarding `useGPCountrySelectSchema` | todo | | +| PayrollEmployeeOnboarding `useGPEmployeeFormSchema` | todo | | +| JsonSchemaPlayground `useJsonSchemaPlayground` | todo | Only exported from `internals`, so it's the lowest priority | + +### Already build once (no values) + +| Call site | Status | Notes | +| ----------------------------------------------------------- | ------ | --------------------------------------------------------- | +| Onboarding `useContractDetailsSchema` (jsf v1) | done | #1433 | +| Onboarding `useCountriesSchemaField` | todo | | +| ContractorOnboarding `useCountriesSchemaField` | todo | | +| ContractorOnboarding `useContractorSubscriptionSchemaField` | todo | | +| ContractorOnboarding `useGetInvoiceScheduleSchema` | todo | | +| CreateCompany `useCountriesSchemaField` (basic information) | todo | | +| CostCalculator `useStaticSchema` + `useRegionFields` | todo | Combines several forms; needs a design per sub-form first | +| Termination full schema in `useTermination` | todo | Combines several forms; needs a design per sub-form first | + +## Known behaviour until phase 2 + +On a prefilled legacy contract details step, the first render converts server values that are already in cents a second time. A probe on a PRT employment computed the allowance as `8125770` before it settled on `81257` once the step mounted. Users never see this. Anything that reads the form before the step mounts could, and that hasn't been verified. The problem goes away once that step switches to `buildOnce`. From b718cf65ec39a48f13a8e8324b5b38a428c61bd8 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 12:35:09 +0200 Subject: [PATCH 16/21] refactor(form): seed buildOnce forms from initialValues and move basic information onto useHeadlessForm useHeadlessForm's buildOnce strategy now takes initialValues and resolves conditional visibility at build time instead of replaying values in a post-mount effect. Rebuilds triggered by jsfModify reuse the last validated values for the same schema, while a new schema starts from initialValues. Onboarding's basic_information step now goes through useBasicInformationSchema (buildOnce) and contract details receives saved employment values as initialValues rather than the live fieldValues. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/common/tests/jsfEngineContract.test.tsx | 6 +- src/common/tests/useHeadlessForm.test.tsx | 126 +++++++++++++++----- src/common/useHeadlessForm.ts | 96 +++++++-------- src/flows/Onboarding/api.ts | 76 +++++++++++- src/flows/Onboarding/hooks.tsx | 123 ++++++++----------- 5 files changed, 269 insertions(+), 158 deletions(-) diff --git a/src/common/tests/jsfEngineContract.test.tsx b/src/common/tests/jsfEngineContract.test.tsx index 3325b1c5e..6b1faa8d2 100644 --- a/src/common/tests/jsfEngineContract.test.tsx +++ b/src/common/tests/jsfEngineContract.test.tsx @@ -114,7 +114,11 @@ function Harness({ onCapture: (submitted: FieldValues) => void; }) { const [values, setValues] = useState({}); - const headless = useHeadlessForm({ schema, values, options, strategy }); + const headless = useHeadlessForm( + strategy === 'rebuild' + ? { schema, options, strategy, values } + : { schema, options, strategy }, + ); const form = useJSONSchemaForm({ handleValidation: headless.handleValidation, defaultValues: {}, diff --git a/src/common/tests/useHeadlessForm.test.tsx b/src/common/tests/useHeadlessForm.test.tsx index f0b2b5dbb..9811b2fd5 100644 --- a/src/common/tests/useHeadlessForm.test.tsx +++ b/src/common/tests/useHeadlessForm.test.tsx @@ -1,4 +1,5 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; +import { FieldValues } from 'react-hook-form'; import { useHeadlessForm } from '@/src/common/useHeadlessForm'; import { JSFModify } from '@/src/flows/types'; import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; @@ -13,56 +14,119 @@ const renamedBonus = (title: string): JSFModify => ({ fields: { signing_bonus_amount: { title } }, }); +const renderBuildOnce = (initialProps: { + schema?: Record; + initialValues?: FieldValues; + jsfModify?: JSFModify; +}) => + renderHook( + (props) => + useHeadlessForm({ + schema: props.schema ?? schema, + initialValues: props.initialValues, + options: { jsfModify: props.jsfModify }, + strategy: 'buildOnce', + }), + { initialProps }, + ); + describe('useHeadlessForm buildOnce', () => { - it('replays the latest values into a rebuilt form and leaves the replaced one untouched', async () => { - const { result, rerender } = renderHook( - ({ jsfModify }) => - useHeadlessForm({ - schema, - values: { has_signing_bonus: 'yes' }, - options: { jsfModify }, - strategy: 'buildOnce', - }), - { initialProps: { jsfModify: renamedBonus('Bonus') } }, + it('resolves visibility from initialValues on the first render and keeps it', async () => { + const { result } = renderBuildOnce({ + initialValues: { has_signing_bonus: 'yes' }, + }); + + expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( + true, ); - const replacedForm = result.current.form; - rerender({ jsfModify: renamedBonus('Welcome bonus') }); + await new Promise((resolve) => setTimeout(resolve, 100)); - await waitFor(() => - expect( - isVisible(result.current.form?.fields, 'signing_bonus_amount'), - ).toBe(true), + expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( + true, ); - expect(result.current.form).not.toBe(replacedForm); - expect(isVisible(replacedForm?.fields, 'signing_bonus_amount')).toBe(false); }); - it('keeps the form when options are recreated with the same content on every render', async () => { + it('rebuilds a resolved form when initialValues arrive after the schema', () => { + const { result, rerender } = renderBuildOnce({ initialValues: {} }); + const formBeforeEmployment = result.current.form; + + rerender({ initialValues: { has_signing_bonus: 'yes' } }); + + expect(result.current.form).not.toBe(formBeforeEmployment); + expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( + true, + ); + }); + + it('keeps the form when initialValues and options are recreated with the same content on every render', async () => { let renders = 0; const { result } = renderHook(() => { renders += 1; return useHeadlessForm({ schema, - values: { has_signing_bonus: 'yes' }, - options: { - jsfModify: { - fields: { signing_bonus_amount: { title: 'Bonus' } }, - }, - }, + initialValues: { has_signing_bonus: 'yes' }, + options: { jsfModify: renamedBonus('Bonus') }, strategy: 'buildOnce', }); }); const builtForm = result.current.form; - await waitFor(() => - expect( - isVisible(result.current.form?.fields, 'signing_bonus_amount'), - ).toBe(true), - ); await new Promise((resolve) => setTimeout(resolve, 100)); expect(result.current.form).toBe(builtForm); expect(renders).toBeLessThan(5); }); + + it('rebuilds with the last validated values when jsfModify changes', async () => { + const { result, rerender } = renderBuildOnce({ + jsfModify: renamedBonus('Bonus'), + }); + + await act(async () => { + await result.current.handleValidation({ has_signing_bonus: 'yes' }); + }); + const replacedForm = result.current.form; + + rerender({ jsfModify: renamedBonus('Welcome bonus') }); + + expect(result.current.form).not.toBe(replacedForm); + expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( + true, + ); + }); + + it('lets the last validated values win over initialValues after a rebuild', async () => { + const { result, rerender } = renderBuildOnce({ + initialValues: { has_signing_bonus: 'yes' }, + jsfModify: renamedBonus('Bonus'), + }); + + await act(async () => { + await result.current.handleValidation({ has_signing_bonus: 'no' }); + }); + + rerender({ + initialValues: { has_signing_bonus: 'yes' }, + jsfModify: renamedBonus('Welcome bonus'), + }); + + expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( + false, + ); + }); + + it('builds a new schema from initialValues instead of the previous schema values', async () => { + const { result, rerender } = renderBuildOnce({ initialValues: {} }); + + await act(async () => { + await result.current.handleValidation({ has_signing_bonus: 'yes' }); + }); + + rerender({ schema: { ...schema }, initialValues: {} }); + + expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( + false, + ); + }); }); diff --git a/src/common/useHeadlessForm.ts b/src/common/useHeadlessForm.ts index 4b54061ee..c06709418 100644 --- a/src/common/useHeadlessForm.ts +++ b/src/common/useHeadlessForm.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import equal from 'fast-deep-equal'; import { FieldValues } from 'react-hook-form'; import { ValidationResult } from '@remoteoss/remote-json-schema-form-kit'; @@ -11,12 +11,15 @@ import { export type HeadlessFormStrategy = 'rebuild' | 'buildOnce'; +type Schema = Record; + type UseHeadlessFormArgs = { - schema: Record | undefined; - values: FieldValues; + schema: Schema | undefined; options?: { jsfModify?: JSFModify }; - strategy: HeadlessFormStrategy; -}; +} & ( + | { strategy: 'buildOnce'; initialValues?: FieldValues } + | { strategy: 'rebuild'; values: FieldValues } +); export type HeadlessForm = { form: JSONSchemaFormResultWithFieldsets | null; @@ -25,39 +28,45 @@ export type HeadlessForm = { parseFormValues: (values: FieldValues) => Promise; }; -export function useHeadlessForm({ - schema, - values, - options, - strategy, -}: UseHeadlessFormArgs): HeadlessForm { - const isBuildOnce = strategy === 'buildOnce'; - const [, setRevision] = useState(0); - const [stableOptions, setStableOptions] = useState(options); - if (!equal(stableOptions, options)) { - setStableOptions(options); +const useDeepStable = (value: T): T => { + const [stable, setStable] = useState(value); + if (!equal(stable, value)) { + setStable(value); } - const latestValues = useRef(values); - useEffect(() => { - latestValues.current = values; - }); + return stable; +}; - const buildValues = isBuildOnce ? undefined : values; +export function useHeadlessForm(args: UseHeadlessFormArgs): HeadlessForm { + const { schema, options, strategy } = args; + const isBuildOnce = strategy === 'buildOnce'; + const [, setRevision] = useState(0); + const stableOptions = useDeepStable(options); + const stableInitialValues = useDeepStable( + args.strategy === 'buildOnce' ? args.initialValues : undefined, + ); + const rebuildValues = args.strategy === 'rebuild' ? args.values : undefined; + const lastValidated = useRef<{ schema: Schema; values: FieldValues } | null>( + null, + ); const form = useMemo(() => { if (!schema) return null; - return isBuildOnce - ? createHeadlessForm( - schema, - {}, - { ...stableOptions, transformMoneyFields: false }, - ) - : createHeadlessForm(schema, buildValues, stableOptions); - }, [schema, buildValues, stableOptions, isBuildOnce]); + if (!isBuildOnce) { + return createHeadlessForm(schema, rebuildValues, stableOptions); + } + const buildValues = + lastValidated.current?.schema === schema + ? lastValidated.current.values + : stableInitialValues; + return createHeadlessForm(schema, buildValues ?? {}, { + ...stableOptions, + transformMoneyFields: false, + }); + }, [schema, rebuildValues, stableOptions, stableInitialValues, isBuildOnce]); - const validate = useCallback( - async (nextValues: FieldValues, isCancelled: () => boolean) => { - if (!form) return null; + const handleValidation = useCallback( + async (nextValues: FieldValues) => { + if (!form || !schema) return null; // buildOnce keeps invisible values on purpose. The fields still hold the // visibility of the previous change, so dropping their values would hide // what a field that is about to become visible needs to compute itself: @@ -67,17 +76,17 @@ export function useHeadlessForm({ const parsedValues = await parseJSFToValidate(nextValues, form.fields, { isPartialValidation: isBuildOnce, }); - if (isCancelled()) return null; + if (isBuildOnce) { + lastValidated.current = { + schema, + values: JSON.parse(JSON.stringify(parsedValues)), + }; + } const result = form.handleValidation(parsedValues); if (isBuildOnce) setRevision((revision) => revision + 1); return result; }, - [form, isBuildOnce], - ); - - const handleValidation = useCallback( - (nextValues: FieldValues) => validate(nextValues, () => false), - [validate], + [form, schema, isBuildOnce], ); const onValuesChange = useCallback( @@ -87,15 +96,6 @@ export function useHeadlessForm({ [handleValidation, isBuildOnce], ); - useEffect(() => { - if (!isBuildOnce) return; - let cancelled = false; - void validate(latestValues.current, () => cancelled); - return () => { - cancelled = true; - }; - }, [validate, isBuildOnce]); - const parseFormValues = useCallback( async (nextValues: FieldValues) => form ? parseJSFToValidate(nextValues, form.fields) : {}, diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index c8a920bdc..5b282201f 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -319,13 +319,13 @@ export const useLegacyContractDetailsSchema = ({ export const useContractDetailsSchema = ({ countryCode, - fieldValues, + initialValues, options, query = {}, jsonSchemaVersion, }: { countryCode: string; - fieldValues: FieldValues; + initialValues?: FieldValues; options?: FlowOptions & { queryOptions?: { enabled?: boolean } }; query?: Record; jsonSchemaVersion?: number | 'latest'; @@ -377,7 +377,7 @@ export const useContractDetailsSchema = ({ const { form, handleValidation, parseFormValues } = useHeadlessForm({ schema, - values: fieldValues, + initialValues, options: { jsfModify: options?.jsfModify }, strategy: 'buildOnce', }); @@ -385,6 +385,76 @@ export const useContractDetailsSchema = ({ return { data: form, isLoading, handleValidation, parseFormValues }; }; +export const useBasicInformationSchema = ({ + countryCode, + initialValues, + options, + jsonSchemaVersion, +}: { + countryCode: string; + initialValues?: FieldValues; + options?: FlowOptions & { queryOptions?: { enabled?: boolean } }; + jsonSchemaVersion?: number | 'latest'; +}): Omit & { + data: JSONSchemaFormResultWithFieldsets | null; + isLoading: boolean; +} => { + const { client } = useClient(); + const jsonSchemaQueryParam = jsonSchemaVersion + ? { + json_schema_version: jsonSchemaVersion, + } + : {}; + const { data: schema, isLoading } = useQuery({ + queryKey: [ + 'onboarding-basic-information-schema', + countryCode, + jsonSchemaVersion, + ], + retry: false, + queryFn: async () => { + const response = await getV1CountriesCountryCodeForm({ + client: client as Client, + headers: { + Authorization: ``, + }, + path: { + country_code: countryCode, + form: 'employment_basic_information', + }, + query: { + skip_benefits: true, + ...jsonSchemaQueryParam, + }, + }); + + if (response.error || !response.data) { + throw new Error('Failed to fetch onboarding schema'); + } + + return response; + }, + enabled: options?.queryOptions?.enabled, + select: ({ data }) => data?.data, + }); + + const { form, handleValidation, onValuesChange, parseFormValues } = + useHeadlessForm({ + schema, + initialValues, + options: { jsfModify: options?.jsfModify }, + strategy: 'buildOnce', + }); + + return { + data: form, + isLoading, + handleValidation, + onValuesChange, + parseFormValues, + }; +}; + export const useBenefitOffersSchema = ( employmentId: string, fieldValues: FieldValues, diff --git a/src/flows/Onboarding/hooks.tsx b/src/flows/Onboarding/hooks.tsx index 2b45ff247..58da81dc0 100644 --- a/src/flows/Onboarding/hooks.tsx +++ b/src/flows/Onboarding/hooks.tsx @@ -28,6 +28,7 @@ import { OnboardingFlowProps } from '@/src/flows/Onboarding/types'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import mergeWith from 'lodash.mergewith'; import { + useBasicInformationSchema, useBenefitOffers, useBenefitOffersSchema, useCompany, @@ -39,7 +40,6 @@ import { useEmploymentOnboardingReservesStatus, useEngagementAgreementDetailsSchema, useGetPreOnboardingRequirements, - useJSONSchemaForm, useLegacyContractDetailsSchema, useUpdateBenefitsOffers, useUpdateEmployment, @@ -168,14 +168,14 @@ const getLoadingStates = ({ const useJsfV1ContractDetails = ({ countryCode, - fieldValues, + initialValues, query, enabled, jsonSchemaVersion, jsfModify, }: { countryCode: string; - fieldValues: FieldValues; + initialValues: FieldValues; query: Record; enabled: boolean; jsonSchemaVersion: number | 'latest'; @@ -188,7 +188,7 @@ const useJsfV1ContractDetails = ({ parseFormValues, } = useContractDetailsSchema({ countryCode, - fieldValues, + initialValues, query, options: { queryOptions: { enabled }, jsfModify }, jsonSchemaVersion, @@ -522,35 +522,6 @@ export const useOnboarding = ({ ...serverEmploymentData, }; - const useJSONSchema = ({ - form, - options: jsonSchemaOptions = {}, - query = {}, - jsonSchemaVersion, - }: { - form: JSONSchemaFormType; - options?: { - jsfModify?: JSFModify; - queryOptions?: { enabled?: boolean }; - }; - query?: Record; - jsonSchemaVersion?: number | 'latest'; - }) => { - return useJSONSchemaForm({ - countryCode: internalCountryCode as string, - form: form, - fieldValues: mergedFormValues, - query, - options: { - ...jsonSchemaOptions, - queryOptions: { - enabled: jsonSchemaOptions.queryOptions?.enabled ?? true, - }, - }, - jsonSchemaVersion, - }); - }; - const isBasicInformationDetailsEnabled = Boolean( internalCountryCode && (stepState.currentStep.name === 'basic_information' || @@ -570,11 +541,23 @@ export const useOnboarding = ({ Boolean(employmentId)), ); + const basicInformationSavedValues = useMemo( + () => ({ + ...onboardingInitialValues, + ...employment?.basic_information, + }), + [onboardingInitialValues, employment?.basic_information], + ); + const { data: basicInformationForm, isLoading: isLoadingBasicInformationForm, - } = useJSONSchema({ - form: 'employment_basic_information', + handleValidation: handleBasicInformationValidation, + onValuesChange: onBasicInformationValuesChange, + parseFormValues: parseBasicInformationValues, + } = useBasicInformationSchema({ + countryCode: internalCountryCode as string, + initialValues: basicInformationSavedValues, options: { jsfModify: options?.jsfModify?.basic_information, queryOptions: { @@ -727,6 +710,14 @@ export const useOnboarding = ({ jsonSchemaVersion: effectiveContractDetailsJsonSchemaVersion, }); + const contractDetailsSavedValues = useMemo( + () => ({ + ...onboardingInitialValues, + ...employment?.contract_details, + }), + [onboardingInitialValues, employment?.contract_details], + ); + const jsfV1Modify = useMemo( () => ({ ...options?.jsfModify?.contract_details, @@ -745,7 +736,7 @@ export const useOnboarding = ({ parseFormValues: parseContractDetailsV1Values, } = useJsfV1ContractDetails({ countryCode: internalCountryCode as string, - fieldValues, + initialValues: contractDetailsSavedValues, query: { employment_id: internalEmploymentId as string, }, @@ -855,12 +846,7 @@ export const useOnboarding = ({ ], ); - const { - country, - basic_information: employmentBasicInformation = {}, - contract_details: employmentContractDetails = {}, - status: employmentStatus, - } = employment || {}; + const { country, status: employmentStatus } = employment || {}; const employmentCountryCode = country?.code; const currentStepName = stepState.currentStep.name; @@ -873,18 +859,14 @@ export const useOnboarding = ({ [stepFields.select_country, internalCountryCode, employmentCountryCode], ); - const basicInformationInitialValues = useMemo(() => { - const initialValues = { - ...onboardingInitialValues, - ...employmentBasicInformation, - }; - - return getInitialValues(stepFields.basic_information, initialValues); - }, [ - stepFields.basic_information, - employmentBasicInformation, - onboardingInitialValues, - ]); + const basicInformationInitialValues = useMemo( + () => + getInitialValues( + stepFields.basic_information, + basicInformationSavedValues, + ), + [stepFields.basic_information, basicInformationSavedValues], + ); const engagementAgreementDetailsInitialValues = useMemo(() => { const initialValues = { @@ -902,18 +884,11 @@ export const useOnboarding = ({ employmentEngagementAgreementDetails, ]); - const contractDetailsInitialValues = useMemo(() => { - const initialValues = { - ...onboardingInitialValues, - ...employmentContractDetails, - }; - - return getInitialValues(stepFields.contract_details, initialValues); - }, [ - stepFields.contract_details, - employmentContractDetails, - onboardingInitialValues, - ]); + const contractDetailsInitialValues = useMemo( + () => + getInitialValues(stepFields.contract_details, contractDetailsSavedValues), + [stepFields.contract_details, contractDetailsSavedValues], + ); const benefitsInitialValues = useMemo(() => { const initialValues = { @@ -1086,9 +1061,7 @@ export const useOnboarding = ({ basicInformationForm && stepState.currentStep.name === 'basic_information' ) { - return await parseJSFToValidate(values, basicInformationForm?.fields, { - isPartialValidation: false, - }); + return await parseBasicInformationValues(values); } if ( @@ -1269,12 +1242,7 @@ export const useOnboarding = ({ basicInformationForm && stepState.currentStep.name === 'basic_information' ) { - const parsedValues = await parseJSFToValidate( - values, - basicInformationForm?.fields, - { isPartialValidation: false }, - ); - return basicInformationForm?.handleValidation(parsedValues); + return handleBasicInformationValidation(values); } if ( @@ -1317,6 +1285,7 @@ export const useOnboarding = ({ selectCountryForm, benefitOffersSchema, basicInformationForm, + handleBasicInformationValidation, engagementAgreementDetailsSchema, contractDetailsForm, contractDetailsFormV1, @@ -1328,6 +1297,9 @@ export const useOnboarding = ({ const checkFieldUpdates = useCallback( async (values: FieldValues) => { setFieldValues(values); + if (stepState.currentStep.name === 'basic_information') { + await onBasicInformationValuesChange(values); + } if ( isJsfV1ContractDetailsEnabled && stepState.currentStep.name === 'contract_details' @@ -1340,6 +1312,7 @@ export const useOnboarding = ({ isJsfV1ContractDetailsEnabled, stepState, handleValidation, + onBasicInformationValuesChange, ], ); From 11dc6f576568b347ae3463e65e7dad0c5635641d Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 12:39:25 +0200 Subject: [PATCH 17/21] test(form): fold useHeadlessForm lifecycle cases into the jsf engine contract test The buildOnce rebuild/seed rules (initialValues on first render and when they arrive late, last validated values surviving a jsfModify change and beating initialValues, a new schema starting from initialValues) now run through the real form on jsf v0, jsf v1 and no meta, instead of a renderHook test on the jsf v1 fixture only. The render-count guard is dropped: the hook no longer validates after each build, so recreated options cannot cause a render loop anymore. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/common/tests/jsfEngineContract.test.tsx | 104 ++++++++++++++- src/common/tests/useHeadlessForm.test.tsx | 132 -------------------- 2 files changed, 100 insertions(+), 136 deletions(-) delete mode 100644 src/common/tests/useHeadlessForm.test.tsx diff --git a/src/common/tests/jsfEngineContract.test.tsx b/src/common/tests/jsfEngineContract.test.tsx index 6b1faa8d2..cccf7f292 100644 --- a/src/common/tests/jsfEngineContract.test.tsx +++ b/src/common/tests/jsfEngineContract.test.tsx @@ -106,18 +106,26 @@ function Harness({ schema, strategy, options, - onCapture, + initialValues, + onCapture = () => {}, }: { schema: Schema; strategy: HeadlessFormStrategy; options: { jsfModify?: JSFModify } | undefined; - onCapture: (submitted: FieldValues) => void; + initialValues?: FieldValues; + onCapture?: (submitted: FieldValues) => void; }) { const [values, setValues] = useState({}); + const inlineOptions = options && structuredClone(options); const headless = useHeadlessForm( strategy === 'rebuild' - ? { schema, options, strategy, values } - : { schema, options, strategy }, + ? { schema, options: inlineOptions, strategy, values } + : { + schema, + options: inlineOptions, + strategy, + initialValues: initialValues && structuredClone(initialValues), + }, ); const form = useJSONSchemaForm({ handleValidation: headless.handleValidation, @@ -186,3 +194,91 @@ describe.each(ENGINES)('jsf engine contract on $engine', ({ meta }) => { ); }); }); + +const renamedBonus = (title: string) => ({ + jsfModify: { fields: { signing_bonus_amount: { title } } }, +}); + +describe.each(ENGINES)('buildOnce lifecycle on $engine', ({ meta }) => { + const withMeta = (schema: Schema) => + structuredClone(meta ? { ...schema, 'x-rmt-meta': meta } : schema); + + const renderHarness = ( + props: Partial[0]> = {}, + ) => { + const schema = withMeta(portugal); + const harnessProps = { + schema, + strategy: 'buildOnce' as const, + options: undefined, + ...props, + }; + const view = render(, { + wrapper: TestProviders, + }); + return { + ...view, + rerenderWith: (next: Partial[0]>) => + view.rerender(), + }; + }; + + it('shows fields revealed by initialValues on the first render', async () => { + renderHarness({ initialValues: { has_signing_bonus: 'yes' } }); + + expect( + await screen.findByLabelText('Signing bonus amount'), + ).toBeInTheDocument(); + }); + + it('shows fields revealed by initialValues that arrive after the schema', async () => { + const { rerenderWith } = renderHarness({ initialValues: {} }); + await screen.findByLabelText('Annual gross salary'); + expect(screen.queryByLabelText('Signing bonus amount')).toBeNull(); + + rerenderWith({ initialValues: { has_signing_bonus: 'yes' } }); + + expect( + await screen.findByLabelText('Signing bonus amount'), + ).toBeInTheDocument(); + }); + + it('keeps fields the user revealed when jsfModify changes', async () => { + const user = userEvent.setup(); + const { rerenderWith } = renderHarness({ options: renamedBonus('Bonus') }); + await chooseInGroup(user, /Offer a signing bonus/i, 'Yes'); + await screen.findByLabelText('Bonus'); + + rerenderWith({ options: renamedBonus('Welcome bonus') }); + + expect(await screen.findByLabelText('Welcome bonus')).toBeInTheDocument(); + }); + + it('keeps fields the user hid hidden when jsfModify changes, over initialValues', async () => { + const user = userEvent.setup(); + const { rerenderWith } = renderHarness({ + initialValues: { has_signing_bonus: 'yes' }, + options: renamedBonus('Bonus'), + }); + await chooseInGroup(user, /Offer a signing bonus/i, 'No'); + await waitFor(() => expect(screen.queryByLabelText('Bonus')).toBeNull()); + + rerenderWith({ options: renamedBonus('Welcome bonus') }); + + await screen.findByText(/Offer a signing bonus/i); + expect(screen.queryByLabelText('Welcome bonus')).toBeNull(); + }); + + it('builds a new schema from initialValues, not the previous schema values', async () => { + const user = userEvent.setup(); + const { rerenderWith } = renderHarness({ initialValues: {} }); + await chooseInGroup(user, /Offer a signing bonus/i, 'Yes'); + await screen.findByLabelText('Signing bonus amount'); + + rerenderWith({ schema: withMeta(portugal) }); + + await waitFor(() => + expect(screen.queryByLabelText('Signing bonus amount')).toBeNull(), + ); + }); +}); diff --git a/src/common/tests/useHeadlessForm.test.tsx b/src/common/tests/useHeadlessForm.test.tsx deleted file mode 100644 index 9811b2fd5..000000000 --- a/src/common/tests/useHeadlessForm.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { FieldValues } from 'react-hook-form'; -import { useHeadlessForm } from '@/src/common/useHeadlessForm'; -import { JSFModify } from '@/src/flows/types'; -import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; -import { $TSFixMe } from '@/src/types/remoteFlows'; - -const { 'x-rmt-meta': _meta, ...schema } = contractDetailsSchemaV1Portugal.data; - -const isVisible = (fields: $TSFixMe[] | undefined, name: string) => - fields?.find((field) => field.name === name)?.isVisible; - -const renamedBonus = (title: string): JSFModify => ({ - fields: { signing_bonus_amount: { title } }, -}); - -const renderBuildOnce = (initialProps: { - schema?: Record; - initialValues?: FieldValues; - jsfModify?: JSFModify; -}) => - renderHook( - (props) => - useHeadlessForm({ - schema: props.schema ?? schema, - initialValues: props.initialValues, - options: { jsfModify: props.jsfModify }, - strategy: 'buildOnce', - }), - { initialProps }, - ); - -describe('useHeadlessForm buildOnce', () => { - it('resolves visibility from initialValues on the first render and keeps it', async () => { - const { result } = renderBuildOnce({ - initialValues: { has_signing_bonus: 'yes' }, - }); - - expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( - true, - ); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( - true, - ); - }); - - it('rebuilds a resolved form when initialValues arrive after the schema', () => { - const { result, rerender } = renderBuildOnce({ initialValues: {} }); - const formBeforeEmployment = result.current.form; - - rerender({ initialValues: { has_signing_bonus: 'yes' } }); - - expect(result.current.form).not.toBe(formBeforeEmployment); - expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( - true, - ); - }); - - it('keeps the form when initialValues and options are recreated with the same content on every render', async () => { - let renders = 0; - const { result } = renderHook(() => { - renders += 1; - return useHeadlessForm({ - schema, - initialValues: { has_signing_bonus: 'yes' }, - options: { jsfModify: renamedBonus('Bonus') }, - strategy: 'buildOnce', - }); - }); - const builtForm = result.current.form; - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(result.current.form).toBe(builtForm); - expect(renders).toBeLessThan(5); - }); - - it('rebuilds with the last validated values when jsfModify changes', async () => { - const { result, rerender } = renderBuildOnce({ - jsfModify: renamedBonus('Bonus'), - }); - - await act(async () => { - await result.current.handleValidation({ has_signing_bonus: 'yes' }); - }); - const replacedForm = result.current.form; - - rerender({ jsfModify: renamedBonus('Welcome bonus') }); - - expect(result.current.form).not.toBe(replacedForm); - expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( - true, - ); - }); - - it('lets the last validated values win over initialValues after a rebuild', async () => { - const { result, rerender } = renderBuildOnce({ - initialValues: { has_signing_bonus: 'yes' }, - jsfModify: renamedBonus('Bonus'), - }); - - await act(async () => { - await result.current.handleValidation({ has_signing_bonus: 'no' }); - }); - - rerender({ - initialValues: { has_signing_bonus: 'yes' }, - jsfModify: renamedBonus('Welcome bonus'), - }); - - expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( - false, - ); - }); - - it('builds a new schema from initialValues instead of the previous schema values', async () => { - const { result, rerender } = renderBuildOnce({ initialValues: {} }); - - await act(async () => { - await result.current.handleValidation({ has_signing_bonus: 'yes' }); - }); - - rerender({ schema: { ...schema }, initialValues: {} }); - - expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe( - false, - ); - }); -}); From d3e0a353ff785d6ab128adf55d9b4df6fdb9dc5c Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 12:44:36 +0200 Subject: [PATCH 18/21] docs(form): mark basic information as migrated to buildOnce in the rollout Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/USE_HEADLESS_FORM_ROLLOUT.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/USE_HEADLESS_FORM_ROLLOUT.md b/docs/USE_HEADLESS_FORM_ROLLOUT.md index 1aa5ece07..29805a5cd 100644 --- a/docs/USE_HEADLESS_FORM_ROLLOUT.md +++ b/docs/USE_HEADLESS_FORM_ROLLOUT.md @@ -11,7 +11,7 @@ Every flow builds its JSON Schema forms through one shared hook, [`src/common/us ## Strategies - **`rebuild`** (old): `createHeadlessForm(schema, values, options)` again whenever values change. Money values are converted to cents at build time. -- **`buildOnce`** (new): built once per `schema`/`options`. Conditionals are resolved by `handleValidation` with `isPartialValidation: true`, and the latest values are replayed after each build so visibility is right without a mounted step. +- **`buildOnce`** (new): built once per `schema`/`options`, with `transformMoneyFields: false`. Conditionals are resolved by `handleValidation` with `isPartialValidation: true`. The first build is seeded with `initialValues` (saved values in API units), so visibility is right without a mounted step. When `jsfModify` changes, the form is rebuilt from the last validated values; a new schema starts again from `initialValues`. ## Phases @@ -21,6 +21,8 @@ Pure refactor. Each call site keeps the strategy it effectively has today, so no Call sites that don't pass values already behave like `buildOnce`, so they go straight onto it. +A `rebuild` step can also skip this phase and go straight onto `buildOnce` when it passes the phase 2 checklist. Basic information did this in #1433. + ### Phase 2: switch `rebuild` steps to `buildOnce`, one step per PR For each step, test all of these before switching (they come from #1430): @@ -47,7 +49,8 @@ Status: `todo`, `phase 1` (on the hook, old behaviour), `done` (on the hook with | Call site | Status | Notes | | ------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------ | | Onboarding `useLegacyContractDetailsSchema` | phase 1 | #1433. Only building moved; validation and submit parsing still go through the hand-written branches in `Onboarding/hooks.tsx` | -| Onboarding `useJSONSchemaForm` (basic information and other steps) | todo | #1430 tried `buildOnce` here and was closed | +| Onboarding basic information `useBasicInformationSchema` | done | #1433. Went straight to `buildOnce`; building, validation and submit parsing all go through the hook | +| Onboarding `useJSONSchemaForm` | todo | No Onboarding step uses it anymore; only `JsonSchemaComparison` does | | Onboarding `useBenefitOffersSchema` | todo | | | Onboarding `useEngagementAgreementDetailsSchema` | todo | | | Contractor contract details `useContractorOnboardingDetailsSchema` | todo | Money-sensitive | From 53da62fffda636ab5808304e25e04a570ded2f98 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 12:46:21 +0200 Subject: [PATCH 19/21] test(onboarding): drop the contract details employment_id query key test Co-Authored-By: Claude Opus 5.5 (1M context) --- ...contractDetailsSchemaEmploymentId.test.tsx | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 src/flows/Onboarding/tests/contractDetailsSchemaEmploymentId.test.tsx diff --git a/src/flows/Onboarding/tests/contractDetailsSchemaEmploymentId.test.tsx b/src/flows/Onboarding/tests/contractDetailsSchemaEmploymentId.test.tsx deleted file mode 100644 index f39279f81..000000000 --- a/src/flows/Onboarding/tests/contractDetailsSchemaEmploymentId.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { - useContractDetailsSchema, - useLegacyContractDetailsSchema, -} from '@/src/flows/Onboarding/api'; -import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; -import { server } from '@/src/tests/server'; -import { queryClient, TestProviders } from '@/src/tests/testHelpers'; - -describe.each([ - { - hook: 'useLegacyContractDetailsSchema', - useSchema: useLegacyContractDetailsSchema, - }, - { hook: 'useContractDetailsSchema', useSchema: useContractDetailsSchema }, -])('$hook', ({ useSchema }) => { - let requestedEmploymentIds: (string | null)[]; - - beforeEach(() => { - queryClient.clear(); - requestedEmploymentIds = []; - server.use( - http.get('*/v1/countries/PRT/contract_details*', ({ request }) => { - requestedEmploymentIds.push( - new URL(request.url).searchParams.get('employment_id'), - ); - return HttpResponse.json(contractDetailsSchemaV1Portugal); - }), - ); - }); - - it('fetches the schema again for a different employment', async () => { - const { rerender } = renderHook( - ({ employmentId }) => - useSchema({ - countryCode: 'PRT', - fieldValues: {}, - query: { employment_id: employmentId }, - options: { queryOptions: { enabled: true } }, - }), - { - initialProps: { employmentId: 'employment-1' }, - wrapper: TestProviders, - }, - ); - await waitFor(() => expect(requestedEmploymentIds).toHaveLength(1)); - - rerender({ employmentId: 'employment-2' }); - - await waitFor(() => - expect(requestedEmploymentIds).toEqual(['employment-1', 'employment-2']), - ); - }); -}); From 587aa4cd87df794e8b95ed008f80c0d0826b9c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Garc=C3=ADa=20Seco?= Date: Mon, 5 Oct 2026 14:16:21 +0200 Subject: [PATCH 20/21] test(form): move jsf engine situations into their own file and document when to add one (#1443) * test(form): move jsf engine situations into their own file and document when to add one Form behaviour bugs (visibility, computed values, money conversion, initialValues, jsfModify) should be reproduced as a situation in the jsf engine contract test instead of a per-country integration test. The rule now lives in CLAUDE.md, the BUGBOT review guide and the useHeadlessForm rollout doc, and situations live in jsfEngineSituations.ts so adding one doesn't touch the test logic. Co-Authored-By: Claude Opus 5.5 (1M context) * test(form): replace the Portugal fixture with minimal inline schemas in the jsf engine situations Each situation now uses a schema that only contains the fields the behaviour needs, matching the guidance in CLAUDE.md and the rollout doc, and gives the next contributor an inline example to copy that works on jsf v0, v1 and schemas without meta. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .cursor/BUGBOT.md | 1 + CLAUDE.md | 1 + docs/USE_HEADLESS_FORM_ROLLOUT.md | 17 ++ src/common/tests/jsfEngineContract.test.tsx | 74 ++------- src/common/tests/jsfEngineSituations.ts | 167 ++++++++++++++++++++ 5 files changed, 196 insertions(+), 64 deletions(-) create mode 100644 src/common/tests/jsfEngineSituations.ts diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index ea31288c8..5b1fe1fda 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -468,6 +468,7 @@ flows/[FlowName]/ - Missing `waitFor` for async assertions - Not cleaning up React Query cache between tests - Testing implementation details instead of behavior +- A fix for JSON Schema form behaviour (conditional visibility, computed or forced values, money conversion, `initialValues`, `jsfModify`) without a new situation in `src/common/tests/jsfEngineSituations.ts`, or reproduced only through a flow integration test. The situation must fail without the fix ### 9. Build and Release Issues diff --git a/CLAUDE.md b/CLAUDE.md index 7f27dff0d..fab38da48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ Tailwind v4 (`@tailwindcss/postcss`) + CSS variables. Public component classes a - **Strict equality in mocks** — use `toHaveBeenCalledWith({ exact: 'shape' })`, not `expect.objectContaining()`. - **MSW for API mocking** in tests — never `vi.mock('@/src/client')`. Override endpoints with `server.use(http.get(...))` inside `beforeEach`; defaults live in [src/tests/handlers.ts](src/tests/handlers.ts) and are reset after each test by [src/tests/server.ts](src/tests/server.ts). This holds for headless-hook tests (`renderHook` + `useFlowName()`) exactly as much as for rendered-flow tests — [OnboardingFlowGermanyDynamicSteps.test.tsx](src/flows/Onboarding/tests/OnboardingFlowGermanyDynamicSteps.test.tsx) and [useOnboardingJsfV1ContractDetails.test.tsx](src/flows/Onboarding/tests/useOnboardingJsfV1ContractDetails.test.tsx) are the references. Mocking the generated client bypasses the real query/serialization path and silently drops any endpoint the flow starts calling (the server is set to `onUnhandledRequest: 'error'`, so MSW surfaces those instead). Reserve `vi.fn()` for consumer callbacks (`onSubmit`, `onSuccess`, `render`) and for the rare test that asserts on request _arguments_ rather than behaviour. - **Schema fixtures already include the response envelope** — a `contractDetailsSchemaV1` is `{ data: }`, so a handler is just `HttpResponse.json(fixture)`. Add new ones under `tests/fixtures/
/` and re-export from that folder's `index.ts`. +- **Form behaviour bugs get a situation, not an integration test** — when the bug is in how a JSON Schema form behaves (conditional visibility, computed or forced values, money conversion, `initialValues`, `jsfModify`), reproduce it first as a failing entry in [jsfEngineSituations.ts](src/common/tests/jsfEngineSituations.ts), then fix it. Each situation runs against every jsf engine and `useHeadlessForm` strategy in [jsfEngineContract.test.tsx](src/common/tests/jsfEngineContract.test.tsx). Use a minimal inline schema unless the bug depends on a specific country's fixture, and name the situation after the behaviour, not the country or ticket. Integration tests are for flow wiring (steps, API calls, submit payloads). How to write one: [docs/USE_HEADLESS_FORM_ROLLOUT.md](docs/USE_HEADLESS_FORM_ROLLOUT.md#adding-a-situation). - **Tests must wrap React Query consumers** in a `QueryClientProvider` and `queryClient.clear()` between tests. Use the shared `queryClient` + `TestProviders` from [src/tests/testHelpers.tsx](src/tests/testHelpers.tsx) rather than constructing a local `QueryClient`. - **`no-console: warn`** in oxlint — only `console.warn` / `console.error` permitted. - **No `any`** — `typescript/no-explicit-any: error`. Use the `$TSFixMe` type from `src/types/remoteFlows.ts` for genuine workarounds. diff --git a/docs/USE_HEADLESS_FORM_ROLLOUT.md b/docs/USE_HEADLESS_FORM_ROLLOUT.md index 29805a5cd..56c19f373 100644 --- a/docs/USE_HEADLESS_FORM_ROLLOUT.md +++ b/docs/USE_HEADLESS_FORM_ROLLOUT.md @@ -13,6 +13,23 @@ Every flow builds its JSON Schema forms through one shared hook, [`src/common/us - **`rebuild`** (old): `createHeadlessForm(schema, values, options)` again whenever values change. Money values are converted to cents at build time. - **`buildOnce`** (new): built once per `schema`/`options`, with `transformMoneyFields: false`. Conditionals are resolved by `handleValidation` with `isPartialValidation: true`. The first build is seeded with `initialValues` (saved values in API units), so visibility is right without a mounted step. When `jsfModify` changes, the form is rebuilt from the last validated values; a new schema starts again from `initialValues`. +## Adding a situation + +When a form misbehaves because of the schema or the hook (a field doesn't appear, a computed value is wrong, money is converted twice, `initialValues` or `jsfModify` are lost), reproduce it as a situation before fixing it. A situation runs against every engine and strategy in [`jsfEngineContract.test.tsx`](../src/common/tests/jsfEngineContract.test.tsx), so one entry covers what would otherwise take an integration test per flow and country. + +Add an entry to `SITUATIONS` in [`jsfEngineSituations.ts`](../src/common/tests/jsfEngineSituations.ts): + +- `situation`: the behaviour, e.g. "fieldset hidden by a radio keeps its children's values". Not the country, the flow or the ticket. +- `schema`: the smallest schema that shows the bug, written inline. Use a country fixture only when the bug depends on that country's schema. Leave out `x-rmt-meta`; the test adds one per engine. +- `fill`: what the user does, with `userEvent` and `chooseInGroup`. +- `assert`: what the user sees and what `parseFormValues` returns. + +Run it before the fix and check that it fails. A situation that passes either way doesn't protect anything. + +Behaviour that only exists in one strategy (rebuilding when `initialValues` or `jsfModify` change, falling back when the schema changes) goes in the `buildOnce lifecycle` block of the test instead. + +Keep integration tests for flow wiring: steps, API calls and submit payloads. + ## Phases ### Phase 1: move every call site onto the hook, same behaviour diff --git a/src/common/tests/jsfEngineContract.test.tsx b/src/common/tests/jsfEngineContract.test.tsx index cccf7f292..dcb873b03 100644 --- a/src/common/tests/jsfEngineContract.test.tsx +++ b/src/common/tests/jsfEngineContract.test.tsx @@ -1,7 +1,13 @@ import { useState } from 'react'; import { FieldValues } from 'react-hook-form'; -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent, { UserEvent } from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + chooseInGroup, + Schema, + signingBonusSchema, + SITUATIONS, +} from '@/src/common/tests/jsfEngineSituations'; import { HeadlessFormStrategy, useHeadlessForm, @@ -10,18 +16,8 @@ import { JSONSchemaFormFields } from '@/src/components/form/JSONSchemaForm'; import { useJSONSchemaForm } from '@/src/components/form/useJSONSchemaForm'; import { Form } from '@/src/components/ui/form'; import { JSFModify } from '@/src/flows/types'; -import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; import { queryClient, TestProviders } from '@/src/tests/testHelpers'; -type Schema = Record; - -type Situation = { - situation: string; - schema: Schema; - fill: (user: UserEvent) => Promise; - assert: (submitted: FieldValues) => Promise; -}; - const ENGINES = [ { engine: 'jsf v0', meta: { jsfOldVersion: true } }, { engine: 'jsf v1', meta: { jsfVersion: '1' } }, @@ -52,56 +48,6 @@ const OPTIONS: { }, ]; -const withoutMeta = ({ 'x-rmt-meta': _meta, ...schema }: Schema) => schema; - -const portugal = withoutMeta(contractDetailsSchemaV1Portugal.data); - -const chooseInGroup = async ( - user: UserEvent, - group: RegExp, - option: string, -) => { - const radioGroup = await screen.findByRole('radiogroup', { name: group }); - await user.click(within(radioGroup).getByRole('radio', { name: option })); -}; - -const SITUATIONS: Situation[] = [ - { - situation: 'forced money value computed from another money field', - schema: portugal, - fill: async (user) => { - await user.type( - await screen.findByLabelText('Annual gross salary'), - '71703.77', - ); - await chooseInGroup(user, /Type of employee/i, 'Full-time'); - await chooseInGroup(user, /work outside regular work hours/i, 'Yes'); - await chooseInGroup(user, /more than 8 hours a day/i, 'Yes'); - }, - assert: async (submitted) => { - expect( - await screen.findByText(/additional 812.57 EUR monthly/), - ).toBeInTheDocument(); - expect(submitted.working_hours_exemption_allowance).toBe(81257); - }, - }, - { - situation: 'radio reveals a conditional money field', - schema: portugal, - fill: async (user) => { - await chooseInGroup(user, /Offer a signing bonus/i, 'Yes'); - await user.type( - await screen.findByLabelText('Signing bonus amount'), - '1000.50', - ); - }, - assert: async (submitted) => { - expect(screen.getByLabelText('Signing bonus amount')).toBeVisible(); - expect(submitted.signing_bonus_amount).toBe(100050); - }, - }, -]; - function Harness({ schema, strategy, @@ -206,7 +152,7 @@ describe.each(ENGINES)('buildOnce lifecycle on $engine', ({ meta }) => { const renderHarness = ( props: Partial[0]> = {}, ) => { - const schema = withMeta(portugal); + const schema = withMeta(signingBonusSchema); const harnessProps = { schema, strategy: 'buildOnce' as const, @@ -275,7 +221,7 @@ describe.each(ENGINES)('buildOnce lifecycle on $engine', ({ meta }) => { await chooseInGroup(user, /Offer a signing bonus/i, 'Yes'); await screen.findByLabelText('Signing bonus amount'); - rerenderWith({ schema: withMeta(portugal) }); + rerenderWith({ schema: withMeta(signingBonusSchema) }); await waitFor(() => expect(screen.queryByLabelText('Signing bonus amount')).toBeNull(), diff --git a/src/common/tests/jsfEngineSituations.ts b/src/common/tests/jsfEngineSituations.ts new file mode 100644 index 000000000..399ced2b1 --- /dev/null +++ b/src/common/tests/jsfEngineSituations.ts @@ -0,0 +1,167 @@ +import { FieldValues } from 'react-hook-form'; +import { screen, within } from '@testing-library/react'; +import { UserEvent } from '@testing-library/user-event'; + +export type Schema = Record; + +export type Situation = { + situation: string; + schema: Schema; + fill: (user: UserEvent) => Promise; + assert: (submitted: FieldValues) => Promise; +}; + +const money = (title: string) => ({ + title, + type: ['integer', 'null'], + 'x-jsf-presentation': { inputType: 'money', currency: 'EUR' }, +}); + +const yesNo = (title: string) => ({ + title, + type: 'string', + oneOf: [ + { const: 'yes', title: 'Yes' }, + { const: 'no', title: 'No' }, + ], + 'x-jsf-presentation': { inputType: 'radio', direction: 'row' }, +}); + +const monthlySalaryInCents = { + '-': [ + { '/': [{ var: 'annual_gross_salary' }, 12] }, + { '%': [{ '/': [{ var: 'annual_gross_salary' }, 12] }, 1] }, + ], +}; + +export const signingBonusSchema: Schema = { + type: 'object', + additionalProperties: false, + properties: { + annual_gross_salary: money('Annual gross salary'), + has_signing_bonus: yesNo('Offer a signing bonus?'), + signing_bonus_amount: money('Signing bonus amount'), + }, + required: ['annual_gross_salary', 'has_signing_bonus'], + allOf: [ + { + if: { + properties: { has_signing_bonus: { const: 'yes' } }, + required: ['has_signing_bonus'], + }, + then: { required: ['signing_bonus_amount'] }, + else: { properties: { signing_bonus_amount: false } }, + }, + ], + 'x-jsf-order': [ + 'annual_gross_salary', + 'has_signing_bonus', + 'signing_bonus_amount', + ], +}; + +const monthlyAllowanceSchema: Schema = { + type: 'object', + additionalProperties: false, + properties: { + annual_gross_salary: money('Annual gross salary'), + pays_monthly_allowance: yesNo('Pay a monthly allowance?'), + monthly_allowance: money('Monthly allowance'), + }, + required: ['annual_gross_salary', 'pays_monthly_allowance'], + allOf: [ + { + if: { + properties: { + annual_gross_salary: { minimum: 1 }, + pays_monthly_allowance: { const: 'yes' }, + }, + required: ['annual_gross_salary', 'pays_monthly_allowance'], + }, + then: { + properties: { + monthly_allowance: { + 'x-jsf-logic-computedAttrs': { + const: 'monthly_allowance_in_cents', + default: 'monthly_allowance_in_cents', + 'x-jsf-presentation': { + statement: { + description: + 'You need to pay an additional {{monthly_allowance_value}} EUR monthly.', + severity: 'info', + }, + }, + }, + }, + }, + required: ['monthly_allowance'], + }, + else: { properties: { monthly_allowance: false } }, + }, + ], + 'x-jsf-order': [ + 'annual_gross_salary', + 'pays_monthly_allowance', + 'monthly_allowance', + ], + 'x-jsf-logic': { + computedValues: { + monthly_allowance_in_cents: { + rule: { if: [{ var: 'annual_gross_salary' }, monthlySalaryInCents, 0] }, + }, + monthly_allowance_value: { + rule: { + if: [ + { var: 'annual_gross_salary' }, + { '/': [monthlySalaryInCents, 100] }, + 0, + ], + }, + }, + }, + }, +}; + +export const chooseInGroup = async ( + user: UserEvent, + group: RegExp, + option: string, +) => { + const radioGroup = await screen.findByRole('radiogroup', { name: group }); + await user.click(within(radioGroup).getByRole('radio', { name: option })); +}; + +export const SITUATIONS: Situation[] = [ + { + situation: 'forced money value computed from another money field', + schema: monthlyAllowanceSchema, + fill: async (user) => { + await user.type( + await screen.findByLabelText('Annual gross salary'), + '1234.56', + ); + await chooseInGroup(user, /Pay a monthly allowance/i, 'Yes'); + }, + assert: async (submitted) => { + expect( + await screen.findByText(/additional 102.88 EUR monthly/), + ).toBeInTheDocument(); + expect(submitted.monthly_allowance).toBe(10288); + }, + }, + { + situation: 'radio reveals a conditional money field', + schema: signingBonusSchema, + fill: async (user) => { + await chooseInGroup(user, /Offer a signing bonus/i, 'Yes'); + await user.type( + await screen.findByLabelText('Signing bonus amount'), + '1000.50', + ); + }, + assert: async (submitted) => { + expect(screen.getByLabelText('Signing bonus amount')).toBeVisible(); + expect(submitted.signing_bonus_amount).toBe(100050); + }, + }, +]; From 0cafa5119d91ad49cd434764df199febeaa44613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Garc=C3=ADa=20Seco?= Date: Mon, 5 Oct 2026 14:35:27 +0200 Subject: [PATCH 21/21] docs(architecture): describe the four test layers and the planned smoke tests (#1444) Adds a Test Layers section to the testing chapter: what unit tests and schema situations, integration, E2E and smoke tests each catch, where a bug's test belongs, and how failures found by the slow layers turn into situations. Smoke tests are marked as planned (headless, scheduled, against sandbox), with what to copy from Platform's FE smoke tests and what to avoid. Co-authored-by: Claude Opus 5.5 (1M context) --- ARCHITECTURE.md | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ce68987fb..7cf0d99a5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1099,7 +1099,43 @@ Consumers can override text in JSON schemas without code changes: ## 7. Testing Philosophy -### 7.1 Vitest Globals +### 7.1 Test Layers + +Four layers, each catching a kind of failure the others can't. A layer earns its place only if it does. + +| Layer | Catches | Runs against | When | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ----------------- | +| **Unit (schema situations)** | How a JSON Schema form behaves: conditional fields, computed or forced values, money conversion, `initialValues`, `jsfModify` | Minimal inline schemas | Every PR | +| **Integration** | Flow wiring: submitting, going back, moving between steps, error handling | Fixture schemas, endpoints mocked with MSW | Every PR | +| **E2E** | Whether the SDK and the backend still work together on the happy path | Sandbox | Every PR, nightly | +| **Smoke** (planned) | Real country schemas using something no situation covers yet | Every country's real schema, in sandbox | Scheduled | + +**Unit.** [`jsfEngineSituations.ts`](src/common/tests/jsfEngineSituations.ts) lists situations: a small schema, what the user does, and what they should see and submit. [`jsfEngineContract.test.tsx`](src/common/tests/jsfEngineContract.test.tsx) runs each one against every jsf engine and every `useHeadlessForm` strategy. The idea comes from json-schema-form's own tests: schema in, behaviour out, no flow around it. How to add one: [Adding a situation](docs/USE_HEADLESS_FORM_ROLLOUT.md#adding-a-situation). Plain helper tests, such as [`dates.test.ts`](src/common/tests/dates.test.ts), sit at this layer too. + +**Integration.** Full flows rendered with the endpoints mocked (see [7.3](#73-msw-for-api-mocking)). This is where several things are tested together: a step submits, the next one loads, going back keeps the values. + +**E2E.** Playwright in [`example/e2e/`](example/e2e/), driving the deployed example app against the sandbox environment, on every PR and nightly ([`e2e-nightly.yml`](.github/workflows/e2e-nightly.yml)). Few tests, happy paths only. When a PR's own unit and integration tests pass but E2E fails, the backend has usually changed. + +**Smoke (planned, not built yet).** Every country's contract details schema, fetched from sandbox. For each one: generate values the schema accepts, run them through `useHeadlessForm` and `parseFormValues`, and check that sandbox accepts the payload. Headless, no UI. It runs on a schedule rather than on PRs: running every country is too slow for each PR, and one country's schema changing in sandbox shouldn't block unrelated work. A green run means sandbox schemas work with the SDK; sandbox can differ from production. + +Platform's FE already has smoke tests: Playwright, one test per country, run on merge requests against a real backend in CI. Ours would differ in running headless and on a schedule, but three things are worth copying: + +- Values are generated from the schema, not written per country: fill the fields, validate, and repeat until the schema accepts them. +- Each country is its own test, so one broken schema doesn't hide the others. +- On failure, the generator is re-run on the saved schema to tell an SDK regression apart from a backend schema regression. + +One thing to avoid: about 41 of the ~91 countries in Platform's FE smoke list are commented out, many with "flaky for some reason that is unknown". Skipped countries here should each have a reason and a ticket, so the list shrinks instead of growing. + +#### Where a bug's test goes + +- Reproducible with a schema alone → a unit situation. +- Needs steps, navigation or API calls → an integration test. +- Needs the real backend → E2E. +- Only some countries break and you don't know which → smoke finds it. + +Failures move down the layers. When E2E or smoke finds a schema bug, the fix comes with a situation that reproduces it on a minimal schema. That keeps the slow layers finding new problems instead of guarding old ones. + +### 7.2 Vitest Globals Vitest globals are **enabled** in `vitest.config.ts`: @@ -1132,7 +1168,7 @@ describe('MyComponent', () => { **Why:** Globals reduce boilerplate. This is a project convention - follow it. -### 7.2 MSW for API Mocking +### 7.3 MSW for API Mocking All API calls are mocked using MSW (Mock Service Worker): @@ -1184,7 +1220,7 @@ export const mockEmployment = { }; ``` -### 7.3 Strict Equality Assertions +### 7.4 Strict Equality Assertions Always use **exact object matching**, never `expect.objectContaining()`: @@ -1204,7 +1240,7 @@ expect(mockApiCall).toHaveBeenCalledWith({ **Why:** `objectContaining` lets tests pass even if we're passing extra unexpected fields. Strict equality catches bugs. -### 7.4 QueryClient Management +### 7.5 QueryClient Management React Query tests must wrap components in a provider: