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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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 1b516319f55276aca1fbfb42ff77075a1444f074 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 10:37:29 +0200 Subject: [PATCH 16/28] chore(lint): enforce useHeadlessForm over direct createHeadlessForm imports Adds a no-restricted-imports rule that bans importing createHeadlessForm from the kit or the common wrapper. Legacy call sites are allowlisted in an override; each migration PR removes its file from the list. Rewrites the CLAUDE.md forms guidance, the json-schema-form-usage Cursor rule and the BUGBOT.md forms section around the useHeadlessForm pattern, and moves the lint rule out of the rollout doc's phase 3. Co-Authored-By: Claude Opus 5.5 (1M context) --- .cursor/BUGBOT.md | 6 ++- .cursor/rules/json-schema-form-usage.mdc | 66 ++++++++++++------------ .oxlintrc.json | 45 +++++++++++++++- CLAUDE.md | 2 +- docs/USE_HEADLESS_FORM_ROLLOUT.md | 5 +- 5 files changed, 86 insertions(+), 38 deletions(-) diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index ea31288c8..51da9515f 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -230,12 +230,14 @@ Forms are central to this library - ensure consistency: - **Yup schemas** - Validation schemas defined with Yup - **JSON Schema Form** - Dynamic fields use `@remoteoss/json-schema-form` - **Field mapping** - Custom field components mapped in `src/components/form/fields/fieldsMapping.tsx` -- **Validation timing** - Use `handleValidation` for combined static + dynamic validation -- **Parse before submit** - Use `parseJSFToValidate()` to transform form values +- **useHeadlessForm** - Schema-backed forms are built with `useHeadlessForm` (`src/common/useHeadlessForm.ts`): fetch the schema, then pass `schema`, `values`, `options` and `strategy: 'buildOnce'` +- **Validation and parsing** - Use the hook's `handleValidation` / `parseFormValues` instead of calling `parseJSFToValidate()` by hand - **Required vs optional** - Match field requirements to JSON schema **Check:** Are validation errors user-friendly? Do they display with proper field labels? +**Flag:** Files added to the `no-restricted-imports` allowlist in `.oxlintrc.json`, new `strategy: 'rebuild'` usage, `createHeadlessForm` reached through a re-export or a wrapper that gets around the lint rule, and `transformMoneyFields` passed in new code. + ### 5. API Client Management OpenAPI-driven development - never manually write client code: diff --git a/.cursor/rules/json-schema-form-usage.mdc b/.cursor/rules/json-schema-form-usage.mdc index 916ff75a9..afb2e8a18 100644 --- a/.cursor/rules/json-schema-form-usage.mdc +++ b/.cursor/rules/json-schema-form-usage.mdc @@ -1,57 +1,57 @@ --- alwaysApply: true -description: Call createHeadlessForm once and recompute via handleValidation, not by re-calling createHeadlessForm on every value change +description: Build JSON Schema forms with useHeadlessForm, never by calling createHeadlessForm directly glob: '{src,example/e2e}/**/*.{ts,tsx}' --- -# createHeadlessForm / handleValidation Rule +# useHeadlessForm Rule -## Philosophy: Recompute Incrementally, Don't Re-Derive From Scratch +## Philosophy: One Hook Owns the Form Engine -`createHeadlessForm` (from `@remoteoss/remote-json-schema-form-kit`, wrapping either -`@remoteoss/json-schema-form` or the deprecated v0 package) is designed to be called **once** -per schema. When values change and conditional fields (`if`/`then`, visibility, available -`options`) need to be re-evaluated, call the `handleValidation` function it returns instead of -calling `createHeadlessForm` again. +`useHeadlessForm` (`src/common/useHeadlessForm.ts`) is the only place app code builds a form from a JSON Schema. It builds the form once per schema and options, then recomputes conditionals through `handleValidation`, which mutates the same `fields` array in place. Calling `createHeadlessForm` again on every value change throws that array away and rebuilds the whole form from the raw schema. -**Both jsf versions implement `handleValidation` by mutating the same `fields` array `createHeadlessForm` -returned, in place** — it does not return a new one. Re-invoking `createHeadlessForm` from scratch every -time values change throws away that array and re-derives the entire form (types, options, conditionals) -from the raw JSON Schema for no reason. +Importing `createHeadlessForm` is an oxlint error (`no-restricted-imports` in `.oxlintrc.json`). ## Pattern to Follow -### ❌ DON'T: Re-call createHeadlessForm on every value change +### ❌ DON'T: Call createHeadlessForm yourself ```typescript -function recompute(values: Record) { - // Wasteful: re-parses the schema and rebuilds every field from scratch each time. - const { fields } = createHeadlessForm(schema, { initialValues: values }); - return fields; -} +const form = useMemo( + () => (schema ? createHeadlessForm(schema, fieldValues, options) : null), + [schema, fieldValues, options], +); ``` -### ✅ DO: Call createHeadlessForm once, then drive updates through handleValidation +### ✅ DO: Fetch the schema, hand it to useHeadlessForm ```typescript -const { fields, handleValidation } = createHeadlessForm(schema, { - initialValues: values, +const { data: schema, isLoading } = useQuery({ + queryKey: ['my-form-schema', countryCode], + queryFn: fetchMySchema, + select: ({ data }) => data?.data, }); -function recompute(values: Record) { - handleValidation(values); // mutates `fields` in place — re-read it, don't re-derive it - return fields; -} +const { form, handleValidation, parseFormValues, onValuesChange } = + useHeadlessForm({ + schema, + values: fieldValues, + options: { jsfModify: options?.jsfModify }, + strategy: 'buildOnce', + }); ``` -**Reference:** `example/e2e/helpers/benefits.ts` (`fillOnboardingBenefitsStepDynamically`) calls -`createHeadlessForm` once and loops on `handleValidation(values)` to discover fields that only -become visible once a sibling field (e.g. a benefit's "value" after its "filter") has a value. +Expose `handleValidation` and `parseFormValues` from the hook on the flow bag rather than calling `parseJSFToValidate` by hand, and call `onValuesChange` when the form values change. + +**Reference:** `useContractDetailsSchema` in `src/flows/Onboarding/api.ts`. + +## Strategies + +- **`buildOnce`**: use this for all new code. +- **`rebuild`**: only for moving a legacy call site onto the hook without changing behaviour. It will be removed (see `docs/USE_HEADLESS_FORM_ROLLOUT.md`). ## Known Exception: Legacy Call Sites -Some existing code (e.g. `useBenefitOffersSchema` and similar hooks in `src/flows/*/api.ts`) calls -`createHeadlessForm` fresh inside a React Query `select` that re-runs whenever `fieldValues` -changes, instead of using `handleValidation`. This is accepted technical debt, not the pattern to -copy — new code and refactors of these call sites should move to the `handleValidation` pattern -above. +Files that still call `createHeadlessForm` directly are listed in the `overrides` allowlist of the lint rule and tracked in `docs/USE_HEADLESS_FORM_ROLLOUT.md`. That's debt, not a pattern to copy. Moving a file onto the hook removes it from the allowlist, and new files never get added to it. + +Tests, `scripts/`, and `example/e2e` helpers (e.g. `fillOnboardingBenefitsStepDynamically` in `example/e2e/helpers/benefits.ts`) may still use the engine directly. diff --git a/.oxlintrc.json b/.oxlintrc.json index 7048f1de2..9351360cc 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -15,7 +15,26 @@ "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "typescript/no-explicit-any": "error", - "typescript/no-deprecated": "error" + "typescript/no-deprecated": "error", + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@remoteoss/remote-json-schema-form-kit", + "importNames": ["createHeadlessForm"], + "message": "Build JSON Schema forms with useHeadlessForm from @/src/common/useHeadlessForm instead of calling createHeadlessForm directly. See docs/USE_HEADLESS_FORM_ROLLOUT.md." + } + ], + "patterns": [ + { + "group": ["**/common/createHeadlessForm"], + "importNames": ["createHeadlessForm"], + "message": "Build JSON Schema forms with useHeadlessForm from @/src/common/useHeadlessForm instead of calling createHeadlessForm directly. See docs/USE_HEADLESS_FORM_ROLLOUT.md." + } + ] + } + ] }, "overrides": [ { @@ -23,6 +42,30 @@ "rules": { "typescript/no-deprecated": "off" } + }, + { + "files": [ + "scripts/**", + "src/common/createHeadlessForm.tsx", + "src/common/useHeadlessForm.ts", + "src/**/tests/**", + "src/common/api/contractor-contract-details.ts", + "src/common/invoice-schedules/api.ts", + "src/flows/ContractAmendment/hooks.ts", + "src/flows/ContractorOnboarding/api.ts", + "src/flows/CostCalculator/api.ts", + "src/flows/CostCalculator/hooks.tsx", + "src/flows/CreateCompany/api.ts", + "src/flows/JsonSchemaPlayground/hooks.ts", + "src/flows/Onboarding/api.ts", + "src/flows/PayrollAdminOnboarding/api.ts", + "src/flows/PayrollEmployeeOnboarding/api.ts", + "src/flows/Termination/api.ts", + "src/flows/Termination/hooks.tsx" + ], + "rules": { + "no-restricted-imports": "off" + } } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 7f27dff0d..a724febdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ Flows are exposed two ways: the prebuilt `` component Forms use **React Hook Form + Yup + `@remoteoss/remote-json-schema-form-kit`**. Field rendering is delegated to the `FormFieldsContext` component map; consumers override per-type renderers via the `` prop, and built-ins come from [src/lazy-default-components.ts](src/lazy-default-components.ts) (lazy-loaded to keep the bundle small). The `flowBag` exposes `handleValidation` and `parseFormValues` — both are **async** (changed in v1.0.0, see [MIGRATION.md](MIGRATION.md)). -Call `createHeadlessForm` once per schema and recompute conditionals via the `handleValidation` it returns (both jsf v0 and v1 mutate the same `fields` array in place) rather than calling `createHeadlessForm` again on every value change — some existing `src/flows/*/api.ts` hooks do the latter, which is accepted technical debt, not the pattern to copy. Detailed guidance lives in [.cursor/rules/json-schema-form-usage.mdc](.cursor/rules/json-schema-form-usage.mdc). +Build every schema-backed form with `useHeadlessForm` ([src/common/useHeadlessForm.ts](src/common/useHeadlessForm.ts)), never by calling `createHeadlessForm` directly. For a new schema: fetch it with React Query (`select` the raw schema), pass `schema`, the current `values`, `options` (`{ jsfModify }`) and `strategy: 'buildOnce'` to the hook, and expose its `handleValidation` / `parseFormValues` on the flow bag. `strategy: 'rebuild'` only exists to migrate legacy call sites and is going away. oxlint's `no-restricted-imports` fails on any new `createHeadlessForm` import. The legacy files in that rule's `overrides` allowlist are debt tracked in [docs/USE_HEADLESS_FORM_ROLLOUT.md](docs/USE_HEADLESS_FORM_ROLLOUT.md). Don't copy them, and don't add files to the allowlist. Detailed guidance lives in [.cursor/rules/json-schema-form-usage.mdc](.cursor/rules/json-schema-form-usage.mdc). ### React Query patterns diff --git a/docs/USE_HEADLESS_FORM_ROLLOUT.md b/docs/USE_HEADLESS_FORM_ROLLOUT.md index 1aa5ece07..c7b903f61 100644 --- a/docs/USE_HEADLESS_FORM_ROLLOUT.md +++ b/docs/USE_HEADLESS_FORM_ROLLOUT.md @@ -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. +Importing `createHeadlessForm` is banned by `no-restricted-imports` in `.oxlintrc.json`. The call sites below that haven't moved yet are listed in that rule's `overrides` allowlist. A PR that moves a file onto the hook also removes the file from the allowlist. + ### Phase 2: switch `rebuild` steps to `buildOnce`, one step per PR For each step, test all of these before switching (they come from #1430): @@ -36,7 +38,8 @@ For each step, test all of these before switching (they come from #1430): - [ ] 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/**` +- [x] Add an oxlint `no-restricted-imports` rule that bans importing `createHeadlessForm` +- [ ] Empty the rule's allowlist down to `createHeadlessForm.tsx`, `useHeadlessForm.ts`, tests and `scripts/` ## Call sites From 0a896326fddedf18f222e742a48ec3d57c588605 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 10:44:09 +0200 Subject: [PATCH 17/28] chore(lint): mark legacy createHeadlessForm imports inline instead of an allowlist The rule now always fails. Each legacy import carries an oxlint-disable-next-line comment with a TODO, so the remaining debt sits next to the code and is greppable. The overrides only keep permanent exceptions: the wrapper, the hook, tests and scripts. Lint now reports unused disable directives as errors, so migrating a file forces removing its comment. That surfaced three directives that no longer suppressed anything, which are removed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .cursor/BUGBOT.md | 2 +- .cursor/rules/json-schema-form-usage.mdc | 2 +- .oxlintrc.json | 15 +-------------- CLAUDE.md | 2 +- docs/USE_HEADLESS_FORM_ROLLOUT.md | 4 ++-- package.json | 2 +- src/common/api/contractor-contract-details.ts | 1 + src/common/createHeadlessForm.tsx | 1 - src/common/invoice-schedules/api.ts | 1 + src/components/ui/file-uploader.tsx | 1 - src/components/ui/form.tsx | 1 - src/flows/ContractAmendment/hooks.ts | 1 + src/flows/ContractorOnboarding/api.ts | 1 + src/flows/CostCalculator/api.ts | 1 + src/flows/CostCalculator/hooks.tsx | 1 + src/flows/CreateCompany/api.ts | 1 + src/flows/JsonSchemaPlayground/hooks.ts | 1 + src/flows/Onboarding/api.ts | 1 + src/flows/PayrollAdminOnboarding/api.ts | 1 + src/flows/PayrollEmployeeOnboarding/api.ts | 1 + src/flows/Termination/api.ts | 1 + src/flows/Termination/hooks.tsx | 1 + 22 files changed, 20 insertions(+), 23 deletions(-) diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 51da9515f..c41ac3cb7 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -236,7 +236,7 @@ Forms are central to this library - ensure consistency: **Check:** Are validation errors user-friendly? Do they display with proper field labels? -**Flag:** Files added to the `no-restricted-imports` allowlist in `.oxlintrc.json`, new `strategy: 'rebuild'` usage, `createHeadlessForm` reached through a re-export or a wrapper that gets around the lint rule, and `transformMoneyFields` passed in new code. +**Flag:** New `oxlint-disable` comments for `no-restricted-imports`, files added to that rule's `overrides` in `.oxlintrc.json`, new `strategy: 'rebuild'` usage, `createHeadlessForm` reached through a re-export or a wrapper that gets around the lint rule, and `transformMoneyFields` passed in new code. ### 5. API Client Management diff --git a/.cursor/rules/json-schema-form-usage.mdc b/.cursor/rules/json-schema-form-usage.mdc index afb2e8a18..98a4ee3d9 100644 --- a/.cursor/rules/json-schema-form-usage.mdc +++ b/.cursor/rules/json-schema-form-usage.mdc @@ -52,6 +52,6 @@ Expose `handleValidation` and `parseFormValues` from the hook on the flow bag ra ## Known Exception: Legacy Call Sites -Files that still call `createHeadlessForm` directly are listed in the `overrides` allowlist of the lint rule and tracked in `docs/USE_HEADLESS_FORM_ROLLOUT.md`. That's debt, not a pattern to copy. Moving a file onto the hook removes it from the allowlist, and new files never get added to it. +Files that still call `createHeadlessForm` directly carry a `// oxlint-disable-next-line no-restricted-imports -- TODO` comment on the import and are tracked in `docs/USE_HEADLESS_FORM_ROLLOUT.md`. That's debt, not a pattern to copy. Moving a file onto the hook removes its comment (lint fails on unused ones). New code never adds one. Tests, `scripts/`, and `example/e2e` helpers (e.g. `fillOnboardingBenefitsStepDynamically` in `example/e2e/helpers/benefits.ts`) may still use the engine directly. diff --git a/.oxlintrc.json b/.oxlintrc.json index 9351360cc..aac828f30 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -48,20 +48,7 @@ "scripts/**", "src/common/createHeadlessForm.tsx", "src/common/useHeadlessForm.ts", - "src/**/tests/**", - "src/common/api/contractor-contract-details.ts", - "src/common/invoice-schedules/api.ts", - "src/flows/ContractAmendment/hooks.ts", - "src/flows/ContractorOnboarding/api.ts", - "src/flows/CostCalculator/api.ts", - "src/flows/CostCalculator/hooks.tsx", - "src/flows/CreateCompany/api.ts", - "src/flows/JsonSchemaPlayground/hooks.ts", - "src/flows/Onboarding/api.ts", - "src/flows/PayrollAdminOnboarding/api.ts", - "src/flows/PayrollEmployeeOnboarding/api.ts", - "src/flows/Termination/api.ts", - "src/flows/Termination/hooks.tsx" + "src/**/tests/**" ], "rules": { "no-restricted-imports": "off" diff --git a/CLAUDE.md b/CLAUDE.md index a724febdb..a0fc2d0ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ Flows are exposed two ways: the prebuilt `` component Forms use **React Hook Form + Yup + `@remoteoss/remote-json-schema-form-kit`**. Field rendering is delegated to the `FormFieldsContext` component map; consumers override per-type renderers via the `` prop, and built-ins come from [src/lazy-default-components.ts](src/lazy-default-components.ts) (lazy-loaded to keep the bundle small). The `flowBag` exposes `handleValidation` and `parseFormValues` — both are **async** (changed in v1.0.0, see [MIGRATION.md](MIGRATION.md)). -Build every schema-backed form with `useHeadlessForm` ([src/common/useHeadlessForm.ts](src/common/useHeadlessForm.ts)), never by calling `createHeadlessForm` directly. For a new schema: fetch it with React Query (`select` the raw schema), pass `schema`, the current `values`, `options` (`{ jsfModify }`) and `strategy: 'buildOnce'` to the hook, and expose its `handleValidation` / `parseFormValues` on the flow bag. `strategy: 'rebuild'` only exists to migrate legacy call sites and is going away. oxlint's `no-restricted-imports` fails on any new `createHeadlessForm` import. The legacy files in that rule's `overrides` allowlist are debt tracked in [docs/USE_HEADLESS_FORM_ROLLOUT.md](docs/USE_HEADLESS_FORM_ROLLOUT.md). Don't copy them, and don't add files to the allowlist. Detailed guidance lives in [.cursor/rules/json-schema-form-usage.mdc](.cursor/rules/json-schema-form-usage.mdc). +Build every schema-backed form with `useHeadlessForm` ([src/common/useHeadlessForm.ts](src/common/useHeadlessForm.ts)), never by calling `createHeadlessForm` directly. For a new schema: fetch it with React Query (`select` the raw schema), pass `schema`, the current `values`, `options` (`{ jsfModify }`) and `strategy: 'buildOnce'` to the hook, and expose its `handleValidation` / `parseFormValues` on the flow bag. `strategy: 'rebuild'` only exists to migrate legacy call sites and is going away. oxlint's `no-restricted-imports` fails on any new `createHeadlessForm` import. Legacy files that still import it carry a `no-restricted-imports -- TODO` disable comment. They're debt tracked in [docs/USE_HEADLESS_FORM_ROLLOUT.md](docs/USE_HEADLESS_FORM_ROLLOUT.md). Don't copy them, and never add a new disable comment for this rule. Detailed guidance lives in [.cursor/rules/json-schema-form-usage.mdc](.cursor/rules/json-schema-form-usage.mdc). ### React Query patterns diff --git a/docs/USE_HEADLESS_FORM_ROLLOUT.md b/docs/USE_HEADLESS_FORM_ROLLOUT.md index c7b903f61..ac5ed97af 100644 --- a/docs/USE_HEADLESS_FORM_ROLLOUT.md +++ b/docs/USE_HEADLESS_FORM_ROLLOUT.md @@ -21,7 +21,7 @@ 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. -Importing `createHeadlessForm` is banned by `no-restricted-imports` in `.oxlintrc.json`. The call sites below that haven't moved yet are listed in that rule's `overrides` allowlist. A PR that moves a file onto the hook also removes the file from the allowlist. +Importing `createHeadlessForm` is banned by `no-restricted-imports` in `.oxlintrc.json`. Call sites that haven't moved yet carry a `// oxlint-disable-next-line no-restricted-imports -- TODO` comment on the import, so `grep -rn "no-restricted-imports -- TODO" src` lists what's left. Lint also fails on unused disable comments, so a PR that moves a file onto the hook has to delete that file's comment too. ### Phase 2: switch `rebuild` steps to `buildOnce`, one step per PR @@ -39,7 +39,7 @@ For each step, test all of these before switching (they come from #1430): - [ ] Remove the `rebuild` strategy from `useHeadlessForm` - [ ] Remove `transformMoneyFields` and the pre-fill money conversion from `createHeadlessForm` - [x] Add an oxlint `no-restricted-imports` rule that bans importing `createHeadlessForm` -- [ ] Empty the rule's allowlist down to `createHeadlessForm.tsx`, `useHeadlessForm.ts`, tests and `scripts/` +- [ ] Remove the last `no-restricted-imports -- TODO` disable comment ## Call sites diff --git a/package.json b/package.json index cba2224d6..4fab773fc 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "check-format": "oxfmt --check", "dev": "NODE_ENV=development tsdown --config-loader unrun --watch", "build:reset": "postcss src/styles/reset.css -o ./example/src/css/reset.css", - "lint": "oxlint --type-aware --ignore-pattern 'example/**' .", + "lint": "oxlint --type-aware --report-unused-disable-directives-severity=error --ignore-pattern 'example/**' .", "lint:workflows": "zizmor .github/workflows/", "lint:workflows:fix": "zizmor --fix .github/workflows/", "format": "oxfmt", diff --git a/src/common/api/contractor-contract-details.ts b/src/common/api/contractor-contract-details.ts index 553dea431..82d046da5 100644 --- a/src/common/api/contractor-contract-details.ts +++ b/src/common/api/contractor-contract-details.ts @@ -7,6 +7,7 @@ import { } from '@/src/client'; import { useClient } from '@/src/context'; import { Client } from '@/src/client/client'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { FlowOptions, diff --git a/src/common/createHeadlessForm.tsx b/src/common/createHeadlessForm.tsx index f0eef45da..e9da8d145 100644 --- a/src/common/createHeadlessForm.tsx +++ b/src/common/createHeadlessForm.tsx @@ -47,7 +47,6 @@ export const createHeadlessForm = ( actionableWarnings.length > 0 && process.env.RF_INTERNAL_DEV === 'true' ) { - // eslint-disable-next-line no-console console.warn('jsfModify warnings:', actionableWarnings); } } diff --git a/src/common/invoice-schedules/api.ts b/src/common/invoice-schedules/api.ts index cf8665b6c..76d214ecd 100644 --- a/src/common/invoice-schedules/api.ts +++ b/src/common/invoice-schedules/api.ts @@ -13,6 +13,7 @@ import { } from '@/src/client'; import { Client } from '@/src/client/client'; import { useClient } from '@/src/context'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { useContractorCurrencies } from '@/src/common/api/contractor-contract-details'; import { INVOICE_SCHEDULE_STATUS } from '@/src/common/invoice-schedules/constants'; diff --git a/src/components/ui/file-uploader.tsx b/src/components/ui/file-uploader.tsx index b80577441..fd25333d1 100644 --- a/src/components/ui/file-uploader.tsx +++ b/src/components/ui/file-uploader.tsx @@ -38,7 +38,6 @@ export function FileUploader({ if (externalFiles && externalFiles !== syncedRef.current) { syncedRef.current = externalFiles; // effect is okay here, we just track with a ref to avoid unnecessary re-renders - // oxlint-disable-next-line react-hooks/set-state-in-effect setFiles(externalFiles); } }, [externalFiles]); diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 6a7c98f28..bbfdb0532 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -147,7 +147,6 @@ const FormControl = React.forwardRef< return React.cloneElement( children as React.ReactElement>, // WE NEED TO FIX: react-hooks/refs - Passing a ref to a function may read its value during render - // oxlint-disable-next-line react-hooks/refs { ...(children.props as object), ...controlProps, ref }, ); }); diff --git a/src/flows/ContractAmendment/hooks.ts b/src/flows/ContractAmendment/hooks.ts index 3a74cae41..4d1a69030 100644 --- a/src/flows/ContractAmendment/hooks.ts +++ b/src/flows/ContractAmendment/hooks.ts @@ -10,6 +10,7 @@ import { import { parseJSFToValidate } from '@/src/components/form/utils'; import { mutationToPromise } from '@/src/lib/mutations'; import { Client } from '@/src/client/client'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { useEmploymentQuery } from '@/src/common/api/employment'; import { useClient } from '@/src/context'; diff --git a/src/flows/ContractorOnboarding/api.ts b/src/flows/ContractorOnboarding/api.ts index 280aca99a..7783cc5d9 100644 --- a/src/flows/ContractorOnboarding/api.ts +++ b/src/flows/ContractorOnboarding/api.ts @@ -32,6 +32,7 @@ import { } from '@/src/flows/types'; import { clearBase64Data } from '@/src/lib/utils'; import { Client } from '@/src/client/client'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { useMutation, useQuery } from '@tanstack/react-query'; import { FieldValues } from 'react-hook-form'; diff --git a/src/flows/CostCalculator/api.ts b/src/flows/CostCalculator/api.ts index c4a73c226..d11fcd347 100644 --- a/src/flows/CostCalculator/api.ts +++ b/src/flows/CostCalculator/api.ts @@ -1,3 +1,4 @@ +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { CostCalculatorEstimateParams, diff --git a/src/flows/CostCalculator/hooks.tsx b/src/flows/CostCalculator/hooks.tsx index 7bd59a406..67188856d 100644 --- a/src/flows/CostCalculator/hooks.tsx +++ b/src/flows/CostCalculator/hooks.tsx @@ -10,6 +10,7 @@ import type { JSFModify } from '@/src/flows/types'; import { parseJSFToValidate } from '@/src/components/form/utils'; import { iterateErrors } from '@/src/components/form/validationResolver'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { string, ValidationError } from 'yup'; diff --git a/src/flows/CreateCompany/api.ts b/src/flows/CreateCompany/api.ts index ec13858ef..e7a6699f0 100644 --- a/src/flows/CreateCompany/api.ts +++ b/src/flows/CreateCompany/api.ts @@ -3,6 +3,7 @@ import { useClient } from '@/src/context'; import { Client } from '@/src/client/client'; import { FieldValues } from 'react-hook-form'; import { FlowOptions } from '@/src/flows/types'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { companyBasicInformationStepSchema } from '@/src/flows/CreateCompany/json-schemas/companyBasicInformationStep'; import { useQuery } from '@tanstack/react-query'; diff --git a/src/flows/JsonSchemaPlayground/hooks.ts b/src/flows/JsonSchemaPlayground/hooks.ts index 76d859d7c..a0ad03325 100644 --- a/src/flows/JsonSchemaPlayground/hooks.ts +++ b/src/flows/JsonSchemaPlayground/hooks.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; import { FieldValues } from 'react-hook-form'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { getInitialValues, diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index c8a920bdc..5392453d7 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -53,6 +53,7 @@ import { getBasicInformationSchemaVersion, getBenefitOffersSchemaVersion, } from '@/src/flows/Onboarding/utils'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { HeadlessForm, useHeadlessForm } from '@/src/common/useHeadlessForm'; import { countriesOptions } from '@/src/common/api/countries'; diff --git a/src/flows/PayrollAdminOnboarding/api.ts b/src/flows/PayrollAdminOnboarding/api.ts index 65de42047..e850a817d 100644 --- a/src/flows/PayrollAdminOnboarding/api.ts +++ b/src/flows/PayrollAdminOnboarding/api.ts @@ -10,6 +10,7 @@ import { } from '@/src/client'; import { Client } from '@/src/client/client'; import { useClient } from '@/src/context'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { JSONSchemaFormResultWithFieldsets } from '@/src/flows/types'; import { countriesOptions } from '@/src/common/api/countries'; diff --git a/src/flows/PayrollEmployeeOnboarding/api.ts b/src/flows/PayrollEmployeeOnboarding/api.ts index 149e89fe5..7f12b3aed 100644 --- a/src/flows/PayrollEmployeeOnboarding/api.ts +++ b/src/flows/PayrollEmployeeOnboarding/api.ts @@ -10,6 +10,7 @@ import { } from '@/src/client'; import { Client } from '@/src/client/client'; import { useClient } from '@/src/context'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import type { JSONSchemaFormResultWithFieldsets, diff --git a/src/flows/Termination/api.ts b/src/flows/Termination/api.ts index 91c123834..7db9c7b25 100644 --- a/src/flows/Termination/api.ts +++ b/src/flows/Termination/api.ts @@ -20,6 +20,7 @@ import { formatAsDecimal, } from '@/src/lib/time'; import { Client } from '@/src/client/client'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { useMutation, useQuery } from '@tanstack/react-query'; import { schema } from '@/src/flows/Termination/json-schemas/schema'; diff --git a/src/flows/Termination/hooks.tsx b/src/flows/Termination/hooks.tsx index 2f5b93045..91540be90 100644 --- a/src/flows/Termination/hooks.tsx +++ b/src/flows/Termination/hooks.tsx @@ -1,4 +1,5 @@ import { $TSFixMe } from '@/src/types/remoteFlows'; +// oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import omitBy from 'lodash.omitby'; import isNull from 'lodash.isnull'; From d7a65d316b7938c6fec57d1336161c2ffa877594 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 10:52:31 +0200 Subject: [PATCH 18/28] docs(bugbot): drop unclear useHeadlessForm flag line Co-Authored-By: Claude Opus 5.5 (1M context) --- .cursor/BUGBOT.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index c41ac3cb7..6c25974b7 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -236,8 +236,6 @@ Forms are central to this library - ensure consistency: **Check:** Are validation errors user-friendly? Do they display with proper field labels? -**Flag:** New `oxlint-disable` comments for `no-restricted-imports`, files added to that rule's `overrides` in `.oxlintrc.json`, new `strategy: 'rebuild'` usage, `createHeadlessForm` reached through a re-export or a wrapper that gets around the lint rule, and `transformMoneyFields` passed in new code. - ### 5. API Client Management OpenAPI-driven development - never manually write client code: From 7af4ea8009e33832fdda5e24d2944e724bcca8eb Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 10:54:16 +0200 Subject: [PATCH 19/28] remove no effect --- src/components/ui/form.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index bbfdb0532..661790317 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -146,7 +146,6 @@ const FormControl = React.forwardRef< return React.cloneElement( children as React.ReactElement>, - // WE NEED TO FIX: react-hooks/refs - Passing a ref to a function may read its value during render { ...(children.props as object), ...controlProps, ref }, ); }); From 120c985721f319308d5420b3d19f9bd167d00173 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 10:59:25 +0200 Subject: [PATCH 20/28] chore(lint): fail example lint on unused disable directives Co-Authored-By: Claude Opus 5.5 (1M context) --- example/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/package.json b/example/package.json index a51900a59..1e299c115 100644 --- a/example/package.json +++ b/example/package.json @@ -7,7 +7,7 @@ "audit": "npm audit --audit-level=high", "dev": "nodemon ./dev_server.js", "build": "tsc -b && vite build", - "lint": "oxlint --type-aware .", + "lint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", "preview": "vite preview", "postinstall": "react-flagpack", "test:e2e": "playwright test", From ede61208cf6b7a30aa2b744ab213ffc16e866b46 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 11:02:06 +0200 Subject: [PATCH 21/28] fix(lint): catch relative createHeadlessForm imports Co-Authored-By: Claude Opus 5.5 (1M context) --- .oxlintrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index aac828f30..955931b39 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -28,7 +28,7 @@ ], "patterns": [ { - "group": ["**/common/createHeadlessForm"], + "group": ["**/createHeadlessForm"], "importNames": ["createHeadlessForm"], "message": "Build JSON Schema forms with useHeadlessForm from @/src/common/useHeadlessForm instead of calling createHeadlessForm directly. See docs/USE_HEADLESS_FORM_ROLLOUT.md." } From b718cf65ec39a48f13a8e8324b5b38a428c61bd8 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 12:35:09 +0200 Subject: [PATCH 22/28] 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 23/28] 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 24/28] 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 25/28] 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 1571e236e3bde76bfbdc0c9ec7ed74cc71bc9b8b Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 12:49:16 +0200 Subject: [PATCH 26/28] docs(form): pass saved initialValues to buildOnce in the useHeadlessForm guidance buildOnce now takes initialValues instead of values (#1433), so the CLAUDE.md, Cursor rule and Bugbot examples would have produced a type error. Co-Authored-By: Claude Opus 5.5 (1M context) --- .cursor/BUGBOT.md | 2 +- .cursor/rules/json-schema-form-usage.mdc | 6 ++++-- CLAUDE.md | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 6c25974b7..91c45367b 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -230,7 +230,7 @@ Forms are central to this library - ensure consistency: - **Yup schemas** - Validation schemas defined with Yup - **JSON Schema Form** - Dynamic fields use `@remoteoss/json-schema-form` - **Field mapping** - Custom field components mapped in `src/components/form/fields/fieldsMapping.tsx` -- **useHeadlessForm** - Schema-backed forms are built with `useHeadlessForm` (`src/common/useHeadlessForm.ts`): fetch the schema, then pass `schema`, `values`, `options` and `strategy: 'buildOnce'` +- **useHeadlessForm** - Schema-backed forms are built with `useHeadlessForm` (`src/common/useHeadlessForm.ts`): fetch the schema, then pass `schema`, saved `initialValues` (not the live form values), `options` and `strategy: 'buildOnce'` - **Validation and parsing** - Use the hook's `handleValidation` / `parseFormValues` instead of calling `parseJSFToValidate()` by hand - **Required vs optional** - Match field requirements to JSON schema diff --git a/.cursor/rules/json-schema-form-usage.mdc b/.cursor/rules/json-schema-form-usage.mdc index 98a4ee3d9..fd696f5ed 100644 --- a/.cursor/rules/json-schema-form-usage.mdc +++ b/.cursor/rules/json-schema-form-usage.mdc @@ -35,15 +35,17 @@ const { data: schema, isLoading } = useQuery({ const { form, handleValidation, parseFormValues, onValuesChange } = useHeadlessForm({ schema, - values: fieldValues, + initialValues: savedValues, options: { jsfModify: options?.jsfModify }, strategy: 'buildOnce', }); ``` +`initialValues` are the saved values in API units (partner initial values merged with the employment's saved step data), not the live form values. They seed the first build so conditional fields are visible before the step mounts; after that, visibility follows `handleValidation`, and a rebuild caused by a `jsfModify` change keeps the last validated values. + Expose `handleValidation` and `parseFormValues` from the hook on the flow bag rather than calling `parseJSFToValidate` by hand, and call `onValuesChange` when the form values change. -**Reference:** `useContractDetailsSchema` in `src/flows/Onboarding/api.ts`. +**Reference:** `useBasicInformationSchema` and `useContractDetailsSchema` in `src/flows/Onboarding/api.ts`. ## Strategies diff --git a/CLAUDE.md b/CLAUDE.md index a0fc2d0ca..5eb3bb454 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ Flows are exposed two ways: the prebuilt `` component Forms use **React Hook Form + Yup + `@remoteoss/remote-json-schema-form-kit`**. Field rendering is delegated to the `FormFieldsContext` component map; consumers override per-type renderers via the `` prop, and built-ins come from [src/lazy-default-components.ts](src/lazy-default-components.ts) (lazy-loaded to keep the bundle small). The `flowBag` exposes `handleValidation` and `parseFormValues` — both are **async** (changed in v1.0.0, see [MIGRATION.md](MIGRATION.md)). -Build every schema-backed form with `useHeadlessForm` ([src/common/useHeadlessForm.ts](src/common/useHeadlessForm.ts)), never by calling `createHeadlessForm` directly. For a new schema: fetch it with React Query (`select` the raw schema), pass `schema`, the current `values`, `options` (`{ jsfModify }`) and `strategy: 'buildOnce'` to the hook, and expose its `handleValidation` / `parseFormValues` on the flow bag. `strategy: 'rebuild'` only exists to migrate legacy call sites and is going away. oxlint's `no-restricted-imports` fails on any new `createHeadlessForm` import. Legacy files that still import it carry a `no-restricted-imports -- TODO` disable comment. They're debt tracked in [docs/USE_HEADLESS_FORM_ROLLOUT.md](docs/USE_HEADLESS_FORM_ROLLOUT.md). Don't copy them, and never add a new disable comment for this rule. Detailed guidance lives in [.cursor/rules/json-schema-form-usage.mdc](.cursor/rules/json-schema-form-usage.mdc). +Build every schema-backed form with `useHeadlessForm` ([src/common/useHeadlessForm.ts](src/common/useHeadlessForm.ts)), never by calling `createHeadlessForm` directly. For a new schema: fetch it with React Query (`select` the raw schema), pass `schema`, the saved `initialValues` (API units, e.g. partner initial values merged with the employment's saved step data), `options` (`{ jsfModify }`) and `strategy: 'buildOnce'` to the hook, expose its `handleValidation` / `parseFormValues` on the flow bag, and call its `onValuesChange` when the form values change. Don't pass the live form values; the hook tracks them through validation. `strategy: 'rebuild'` only exists to migrate legacy call sites and is going away. oxlint's `no-restricted-imports` fails on any new `createHeadlessForm` import. Legacy files that still import it carry a `no-restricted-imports -- TODO` disable comment. They're debt tracked in [docs/USE_HEADLESS_FORM_ROLLOUT.md](docs/USE_HEADLESS_FORM_ROLLOUT.md). Don't copy them, and never add a new disable comment for this rule. Detailed guidance lives in [.cursor/rules/json-schema-form-usage.mdc](.cursor/rules/json-schema-form-usage.mdc). ### React Query patterns From a4acf1a6f49e21ad8183d356395e2b016768d2e0 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 5 Oct 2026 13:35:14 +0200 Subject: [PATCH 27/28] chore(lint): exempt __tests__ and colocated test files from the createHeadlessForm ban Tests also live in __tests__ folders and next to source as *.test.ts(x), not only under tests/, so the override now covers them too, matching the guidance that tests may use the engine directly. Co-Authored-By: Claude Opus 5.5 (1M context) --- .oxlintrc.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 955931b39..071992833 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -48,7 +48,9 @@ "scripts/**", "src/common/createHeadlessForm.tsx", "src/common/useHeadlessForm.ts", - "src/**/tests/**" + "src/**/tests/**", + "src/**/__tests__/**", + "src/**/*.test.{ts,tsx}" ], "rules": { "no-restricted-imports": "off" From 759774c7de608bcf7f70ab259f23608feefcc96c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Garc=C3=ADa=20Seco?= Date: Mon, 5 Oct 2026 18:30:46 +0200 Subject: [PATCH 28/28] refactor(onboarding): build benefits and engagement agreement details once through useHeadlessForm (#1442) * refactor(onboarding): build benefits and engagement agreement details once through useHeadlessForm Both schema hooks now fetch the raw schema and hand it to useHeadlessForm with the buildOnce strategy, seeded with the saved values (partner initialValues plus the saved benefit offers / engagement agreement details). Validation, submit parsing and value changes for both steps go through the hook instead of the hand-written parseJSFToValidate branches in hooks.tsx, and the forms are no longer rebuilt on every keystroke from the live field values. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(form): mark benefits and engagement agreement details as migrated to buildOnce Co-Authored-By: Claude Opus 5.5 (1M context) * add tests to verify values --------- Co-authored-by: Claude Opus 5.5 (1M context) --- docs/USE_HEADLESS_FORM_ROLLOUT.md | 6 +- src/flows/Onboarding/api.ts | 66 ++++++--- src/flows/Onboarding/hooks.tsx | 95 ++++++------- ...OnboardingFlowGermanyDynamicSteps.test.tsx | 134 ++++++++++++++++++ 4 files changed, 229 insertions(+), 72 deletions(-) diff --git a/docs/USE_HEADLESS_FORM_ROLLOUT.md b/docs/USE_HEADLESS_FORM_ROLLOUT.md index 7414728d7..fe07d9581 100644 --- a/docs/USE_HEADLESS_FORM_ROLLOUT.md +++ b/docs/USE_HEADLESS_FORM_ROLLOUT.md @@ -21,7 +21,7 @@ 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. +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, benefits and engagement agreement details in #1442. Importing `createHeadlessForm` is banned by `no-restricted-imports` in `.oxlintrc.json`. Call sites that haven't moved yet carry a `// oxlint-disable-next-line no-restricted-imports -- TODO` comment on the import, so `grep -rn "no-restricted-imports -- TODO" src` lists what's left. Lint also fails on unused disable comments, so a PR that moves a file onto the hook has to delete that file's comment too. @@ -54,8 +54,8 @@ Status: `todo`, `phase 1` (on the hook, old behaviour), `done` (on the hook with | Onboarding `useLegacyContractDetailsSchema` | phase 1 | #1433. Only building moved; validation and submit parsing still go through the hand-written branches in `Onboarding/hooks.tsx` | | 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 | | +| Onboarding `useBenefitOffersSchema` | done | #1442. Went straight to `buildOnce`, seeded with the saved benefit offers | +| Onboarding `useEngagementAgreementDetailsSchema` | done | #1442. Went straight to `buildOnce`, seeded with the saved engagement agreement details | | Contractor contract details `useContractorOnboardingDetailsSchema` | todo | Money-sensitive | | ContractorOnboarding `useGetContractDocumentSignatureSchema` | todo | | | ContractorOnboarding `useGetEligibilityQuestionnaire` | todo | | diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index 36e9e7b30..dc6b9dcf1 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -458,15 +458,18 @@ export const useBasicInformationSchema = ({ export const useBenefitOffersSchema = ( employmentId: string, - fieldValues: FieldValues, + initialValues: FieldValues | undefined, options: OnboardingFlowProps['options'], -) => { +): Omit & { + data: JSONSchemaFormResultWithFieldsets | null; + isLoading: boolean; +} => { const { client } = useClient(); const jsonSchemaQueryParam = { json_schema_version: getBenefitOffersSchemaVersion(options), }; - return useQuery({ + const { data: schema, isLoading } = useQuery({ queryKey: ['benefit-offers-schema', employmentId], retry: false, enabled: !!employmentId, @@ -486,14 +489,24 @@ export const useBenefitOffersSchema = ( return response; }, - select: ({ data }) => { - const jsfSchema = data?.data?.schema || {}; - - return createHeadlessForm(jsfSchema, fieldValues, { - jsfModify: options?.jsfModify?.benefits, - }); - }, + select: ({ data }) => data?.data?.schema || {}, }); + + const { form, handleValidation, onValuesChange, parseFormValues } = + useHeadlessForm({ + schema, + initialValues, + options: { jsfModify: options?.jsfModify?.benefits }, + strategy: 'buildOnce', + }); + + return { + data: form, + isLoading, + handleValidation, + onValuesChange, + parseFormValues, + }; }; /** @@ -741,13 +754,16 @@ export const useEmploymentOnboardingReservesStatus = ( export const useEngagementAgreementDetailsSchema = ( countryCode: string, - fieldValues: FieldValues, + initialValues: FieldValues | undefined, options?: { jsfModify?: OnboardingJsfModify; queryOptions?: { enabled?: boolean }; jsonSchemaVersion?: number | 'latest'; }, -) => { +): Omit & { + data: JSONSchemaFormResultWithFieldsets | null; + isLoading: boolean; +} => { const { client } = useClient(); const jsonSchemaQueryParam = options?.jsonSchemaVersion ? { @@ -755,7 +771,7 @@ export const useEngagementAgreementDetailsSchema = ( } : {}; - return useQuery({ + const { data: schema, isLoading } = useQuery({ queryKey: [ 'engagement-agreement-details', countryCode, @@ -782,14 +798,26 @@ export const useEngagementAgreementDetailsSchema = ( return response; }, - select: ({ data }) => { - const jsfSchema = data?.data || {}; + select: ({ data }) => data?.data || {}, + }); - return createHeadlessForm(jsfSchema, fieldValues, { + const { form, handleValidation, onValuesChange, parseFormValues } = + useHeadlessForm({ + schema, + initialValues, + options: { jsfModify: options?.jsfModify?.engagement_agreement_details, - }); - }, - }); + }, + strategy: 'buildOnce', + }); + + return { + data: form, + isLoading, + handleValidation, + onValuesChange, + parseFormValues, + }; }; /** diff --git a/src/flows/Onboarding/hooks.tsx b/src/flows/Onboarding/hooks.tsx index 58da81dc0..4634de6d6 100644 --- a/src/flows/Onboarding/hooks.tsx +++ b/src/flows/Onboarding/hooks.tsx @@ -286,26 +286,23 @@ export const useOnboarding = ({ ), }); - const engagementAgreementDetailsFieldValues = useMemo(() => { - return { + const engagementAgreementDetailsSavedValues = useMemo( + () => ({ ...onboardingInitialValues, ...employmentEngagementAgreementDetails, - ...stepState.values?.engagement_agreement_details, - ...fieldValues, - }; - }, [ - employmentEngagementAgreementDetails, - onboardingInitialValues, - stepState.values?.engagement_agreement_details, - fieldValues, - ]); + }), + [onboardingInitialValues, employmentEngagementAgreementDetails], + ); const { data: engagementAgreementDetailsSchema, isLoading: isLoadingEngagementAgreementDetails, + handleValidation: handleEngagementAgreementDetailsValidation, + onValuesChange: onEngagementAgreementDetailsValuesChange, + parseFormValues: parseEngagementAgreementDetailsValues, } = useEngagementAgreementDetailsSchema( internalCountryCode as string, - engagementAgreementDetailsFieldValues, + engagementAgreementDetailsSavedValues, { jsfModify: options?.jsfModify, queryOptions: { @@ -745,12 +742,23 @@ export const useOnboarding = ({ jsfModify: jsfV1Modify, }); + const benefitsSavedValues = useMemo( + () => ({ + ...onboardingInitialValues, + ...benefitOffers, + }), + [onboardingInitialValues, benefitOffers], + ); + const { data: benefitOffersSchema, isLoading: isLoadingBenefitsOffersSchema, + handleValidation: handleBenefitsValidation, + onValuesChange: onBenefitsValuesChange, + parseFormValues: parseBenefitsValues, } = useBenefitOffersSchema( internalEmploymentId as string, - fieldValues, + benefitsSavedValues, options, ); @@ -868,21 +876,17 @@ export const useOnboarding = ({ [stepFields.basic_information, basicInformationSavedValues], ); - const engagementAgreementDetailsInitialValues = useMemo(() => { - const initialValues = { - ...onboardingInitialValues, - ...employmentEngagementAgreementDetails, - }; - - return getInitialValues( + const engagementAgreementDetailsInitialValues = useMemo( + () => + getInitialValues( + stepFields.engagement_agreement_details, + engagementAgreementDetailsSavedValues, + ), + [ stepFields.engagement_agreement_details, - initialValues, - ); - }, [ - stepFields.engagement_agreement_details, - onboardingInitialValues, - employmentEngagementAgreementDetails, - ]); + engagementAgreementDetailsSavedValues, + ], + ); const contractDetailsInitialValues = useMemo( () => @@ -1068,13 +1072,7 @@ export const useOnboarding = ({ engagementAgreementDetailsSchema && stepState.currentStep.name === 'engagement_agreement_details' ) { - return await parseJSFToValidate( - values, - engagementAgreementDetailsSchema?.fields, - { - isPartialValidation: false, - }, - ); + return await parseEngagementAgreementDetailsValues(values); } if ( @@ -1096,9 +1094,7 @@ export const useOnboarding = ({ } if (benefitOffersSchema && stepState.currentStep.name === 'benefits') { - return await parseJSFToValidate(values, benefitOffersSchema?.fields, { - isPartialValidation: false, - }); + return await parseBenefitsValues(values); } return {}; @@ -1230,13 +1226,7 @@ export const useOnboarding = ({ return selectCountryForm.handleValidation(parsedValues); } if (stepState.currentStep.name === 'benefits' && benefitOffersSchema) { - const parsedValues = await parseJSFToValidate( - values, - benefitOffersSchema?.fields, - { isPartialValidation: false }, - ); - - return benefitOffersSchema?.handleValidation(parsedValues); + return handleBenefitsValidation(values); } if ( basicInformationForm && @@ -1249,12 +1239,7 @@ export const useOnboarding = ({ engagementAgreementDetailsSchema && stepState.currentStep.name === 'engagement_agreement_details' ) { - const parsedValues = await parseJSFToValidate( - values, - engagementAgreementDetailsSchema?.fields, - { isPartialValidation: false }, - ); - return engagementAgreementDetailsSchema?.handleValidation(parsedValues); + return handleEngagementAgreementDetailsValidation(values); } if ( @@ -1284,9 +1269,11 @@ export const useOnboarding = ({ stepState, selectCountryForm, benefitOffersSchema, + handleBenefitsValidation, basicInformationForm, handleBasicInformationValidation, engagementAgreementDetailsSchema, + handleEngagementAgreementDetailsValidation, contractDetailsForm, contractDetailsFormV1, handleContractDetailsV1Validation, @@ -1300,6 +1287,12 @@ export const useOnboarding = ({ if (stepState.currentStep.name === 'basic_information') { await onBasicInformationValuesChange(values); } + if (stepState.currentStep.name === 'engagement_agreement_details') { + await onEngagementAgreementDetailsValuesChange(values); + } + if (stepState.currentStep.name === 'benefits') { + await onBenefitsValuesChange(values); + } if ( isJsfV1ContractDetailsEnabled && stepState.currentStep.name === 'contract_details' @@ -1313,6 +1306,8 @@ export const useOnboarding = ({ stepState, handleValidation, onBasicInformationValuesChange, + onEngagementAgreementDetailsValuesChange, + onBenefitsValuesChange, ], ); diff --git a/src/flows/Onboarding/tests/OnboardingFlowGermanyDynamicSteps.test.tsx b/src/flows/Onboarding/tests/OnboardingFlowGermanyDynamicSteps.test.tsx index 056a5123b..2b15422e8 100644 --- a/src/flows/Onboarding/tests/OnboardingFlowGermanyDynamicSteps.test.tsx +++ b/src/flows/Onboarding/tests/OnboardingFlowGermanyDynamicSteps.test.tsx @@ -6,6 +6,7 @@ import { waitForElementToBeRemoved, } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { $TSFixMe } from '@/src/types/remoteFlows'; import { OnboardingFlow } from '@/src/flows/Onboarding/OnboardingFlow'; @@ -29,6 +30,7 @@ const mockOnSuccess = vi.fn(); const mockOnError = vi.fn(); const phoneNumberMock = '1701234567'; + describe('OnboardingFlow - Germany with dynamic_steps', () => { const MultiStepFormGermany = ({ components, onboardingBag }: $TSFixMe) => { const { @@ -165,6 +167,69 @@ describe('OnboardingFlow - Germany with dynamic_steps', () => { render: mockRender, }; + const savedSimilarRolesDetails = { + ...engagementAgreementDetailsDefaultResponseGermany.data.details, + has_business_presence: 'no', + has_similar_roles: 'yes', + similar_roles: 'Backend engineers', + has_similar_work_conditions: 'no', + has_illness_remuneration: 'no', + min_annual_gross_salary: 3500000, + max_annual_gross_salary: 5000000, + has_signing_bonus: 'no', + has_bonus: 'no', + has_commissions: 'no', + has_allowances: 'no', + has_business_expenses: 'no', + has_pension_scheme: 'no', + work_hours_per_week: 40, + working_days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + break_time_per_day: 30, + available_pto: 25, + has_overtime_compensation: 'no', + has_covenants: 'no', + }; + + const goToEngagementAgreementDetailsWithSavedSalaries = async () => { + server.use( + http.get('*/v2/employments/:id/engagement-agreement-details', () => + HttpResponse.json({ + ...engagementAgreementDetailsDefaultResponseGermany, + data: { + ...engagementAgreementDetailsDefaultResponseGermany.data, + details: savedSimilarRolesDetails, + }, + }), + ), + ); + + render( + , + { wrapper: TestProviders }, + ); + await waitForElementToBeRemoved(() => screen.getByTestId('spinner')); + await screen.findByText(/Step: Basic Information/i); + screen.getByText(/Next Step/i).click(); + await screen.findByText(/Step: Engagement Agreement Details/i); + + const minSalaryInput = await screen.findByRole('textbox', { + name: /Minimum average salary/i, + }); + const maxSalaryInput = screen.getByRole('textbox', { + name: /Maximum average salary/i, + }); + await waitFor(() => { + expect(minSalaryInput).toHaveValue('35000'); + expect(maxSalaryInput).toHaveValue('50000'); + }); + + return { minSalaryInput, maxSalaryInput }; + }; + beforeEach(() => { vi.clearAllMocks(); mockRender.mockReset(); @@ -355,4 +420,73 @@ describe('OnboardingFlow - Germany with dynamic_steps', () => { expect(mockOnSubmit).toHaveBeenCalled(); }); }); + + it('should validate the salary range against the computed minimums', async () => { + const user = userEvent.setup(); + const { minSalaryInput, maxSalaryInput } = + await goToEngagementAgreementDetailsWithSavedSalaries(); + + await user.clear(minSalaryInput); + await user.type(minSalaryInput, '30000'); + screen.getByText(/Next Step/i).click(); + expect( + await screen.findByText('Must be 30222.4 EUR or greater.'), + ).toBeInTheDocument(); + + await user.clear(minSalaryInput); + await user.type(minSalaryInput, '40000'); + await user.clear(maxSalaryInput); + await user.type(maxSalaryInput, '39000'); + screen.getByText(/Next Step/i).click(); + expect( + await screen.findByText('Must be 40000 EUR or greater.'), + ).toBeInTheDocument(); + expect( + screen.queryByText('Must be 30222.4 EUR or greater.'), + ).not.toBeInTheDocument(); + expect( + screen.getByText(/Step: Engagement Agreement Details/i), + ).toBeInTheDocument(); + }); + + it('should submit the saved salary range in cents', async () => { + let requestBody: unknown; + server.use( + http.post( + '*/v2/employments/:id/engagement-agreement-details', + async ({ request }) => { + requestBody = await request.json(); + return HttpResponse.json(mockBaseResponse); + }, + ), + ); + await goToEngagementAgreementDetailsWithSavedSalaries(); + + screen.getByText(/Next Step/i).click(); + await screen.findByText(/Step: Contract Details/i); + + expect(requestBody).toEqual({ + engagement_agreement_details: { + available_pto: 25, + break_time_per_day: 30, + has_allowances: 'no', + has_bonus: 'no', + has_business_expenses: 'no', + has_business_presence: 'no', + has_commissions: 'no', + has_covenants: 'no', + has_illness_remuneration: 'no', + has_overtime_compensation: 'no', + has_pension_scheme: 'no', + has_signing_bonus: 'no', + has_similar_roles: 'yes', + has_similar_work_conditions: 'no', + max_annual_gross_salary: 5000000, + min_annual_gross_salary: 3500000, + similar_roles: 'Backend engineers', + work_hours_per_week: 40, + working_days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + }, + }); + }); });