diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index ea31288c8..5b1fe1fda 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -468,6 +468,7 @@ flows/[FlowName]/ - Missing `waitFor` for async assertions - Not cleaning up React Query cache between tests - Testing implementation details instead of behavior +- A fix for JSON Schema form behaviour (conditional visibility, computed or forced values, money conversion, `initialValues`, `jsfModify`) without a new situation in `src/common/tests/jsfEngineSituations.ts`, or reproduced only through a flow integration test. The situation must fail without the fix ### 9. Build and Release Issues diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ce68987fb..7cf0d99a5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1099,7 +1099,43 @@ Consumers can override text in JSON schemas without code changes: ## 7. Testing Philosophy -### 7.1 Vitest Globals +### 7.1 Test Layers + +Four layers, each catching a kind of failure the others can't. A layer earns its place only if it does. + +| Layer | Catches | Runs against | When | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ----------------- | +| **Unit (schema situations)** | How a JSON Schema form behaves: conditional fields, computed or forced values, money conversion, `initialValues`, `jsfModify` | Minimal inline schemas | Every PR | +| **Integration** | Flow wiring: submitting, going back, moving between steps, error handling | Fixture schemas, endpoints mocked with MSW | Every PR | +| **E2E** | Whether the SDK and the backend still work together on the happy path | Sandbox | Every PR, nightly | +| **Smoke** (planned) | Real country schemas using something no situation covers yet | Every country's real schema, in sandbox | Scheduled | + +**Unit.** [`jsfEngineSituations.ts`](src/common/tests/jsfEngineSituations.ts) lists situations: a small schema, what the user does, and what they should see and submit. [`jsfEngineContract.test.tsx`](src/common/tests/jsfEngineContract.test.tsx) runs each one against every jsf engine and every `useHeadlessForm` strategy. The idea comes from json-schema-form's own tests: schema in, behaviour out, no flow around it. How to add one: [Adding a situation](docs/USE_HEADLESS_FORM_ROLLOUT.md#adding-a-situation). Plain helper tests, such as [`dates.test.ts`](src/common/tests/dates.test.ts), sit at this layer too. + +**Integration.** Full flows rendered with the endpoints mocked (see [7.3](#73-msw-for-api-mocking)). This is where several things are tested together: a step submits, the next one loads, going back keeps the values. + +**E2E.** Playwright in [`example/e2e/`](example/e2e/), driving the deployed example app against the sandbox environment, on every PR and nightly ([`e2e-nightly.yml`](.github/workflows/e2e-nightly.yml)). Few tests, happy paths only. When a PR's own unit and integration tests pass but E2E fails, the backend has usually changed. + +**Smoke (planned, not built yet).** Every country's contract details schema, fetched from sandbox. For each one: generate values the schema accepts, run them through `useHeadlessForm` and `parseFormValues`, and check that sandbox accepts the payload. Headless, no UI. It runs on a schedule rather than on PRs: running every country is too slow for each PR, and one country's schema changing in sandbox shouldn't block unrelated work. A green run means sandbox schemas work with the SDK; sandbox can differ from production. + +Platform's FE already has smoke tests: Playwright, one test per country, run on merge requests against a real backend in CI. Ours would differ in running headless and on a schedule, but three things are worth copying: + +- Values are generated from the schema, not written per country: fill the fields, validate, and repeat until the schema accepts them. +- Each country is its own test, so one broken schema doesn't hide the others. +- On failure, the generator is re-run on the saved schema to tell an SDK regression apart from a backend schema regression. + +One thing to avoid: about 41 of the ~91 countries in Platform's FE smoke list are commented out, many with "flaky for some reason that is unknown". Skipped countries here should each have a reason and a ticket, so the list shrinks instead of growing. + +#### Where a bug's test goes + +- Reproducible with a schema alone → a unit situation. +- Needs steps, navigation or API calls → an integration test. +- Needs the real backend → E2E. +- Only some countries break and you don't know which → smoke finds it. + +Failures move down the layers. When E2E or smoke finds a schema bug, the fix comes with a situation that reproduces it on a minimal schema. That keeps the slow layers finding new problems instead of guarding old ones. + +### 7.2 Vitest Globals Vitest globals are **enabled** in `vitest.config.ts`: @@ -1132,7 +1168,7 @@ describe('MyComponent', () => { **Why:** Globals reduce boilerplate. This is a project convention - follow it. -### 7.2 MSW for API Mocking +### 7.3 MSW for API Mocking All API calls are mocked using MSW (Mock Service Worker): @@ -1184,7 +1220,7 @@ export const mockEmployment = { }; ``` -### 7.3 Strict Equality Assertions +### 7.4 Strict Equality Assertions Always use **exact object matching**, never `expect.objectContaining()`: @@ -1204,7 +1240,7 @@ expect(mockApiCall).toHaveBeenCalledWith({ **Why:** `objectContaining` lets tests pass even if we're passing extra unexpected fields. Strict equality catches bugs. -### 7.4 QueryClient Management +### 7.5 QueryClient Management React Query tests must wrap components in a provider: diff --git a/CLAUDE.md b/CLAUDE.md index 7f27dff0d..fab38da48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ Tailwind v4 (`@tailwindcss/postcss`) + CSS variables. Public component classes a - **Strict equality in mocks** — use `toHaveBeenCalledWith({ exact: 'shape' })`, not `expect.objectContaining()`. - **MSW for API mocking** in tests — never `vi.mock('@/src/client')`. Override endpoints with `server.use(http.get(...))` inside `beforeEach`; defaults live in [src/tests/handlers.ts](src/tests/handlers.ts) and are reset after each test by [src/tests/server.ts](src/tests/server.ts). This holds for headless-hook tests (`renderHook` + `useFlowName()`) exactly as much as for rendered-flow tests — [OnboardingFlowGermanyDynamicSteps.test.tsx](src/flows/Onboarding/tests/OnboardingFlowGermanyDynamicSteps.test.tsx) and [useOnboardingJsfV1ContractDetails.test.tsx](src/flows/Onboarding/tests/useOnboardingJsfV1ContractDetails.test.tsx) are the references. Mocking the generated client bypasses the real query/serialization path and silently drops any endpoint the flow starts calling (the server is set to `onUnhandledRequest: 'error'`, so MSW surfaces those instead). Reserve `vi.fn()` for consumer callbacks (`onSubmit`, `onSuccess`, `render`) and for the rare test that asserts on request _arguments_ rather than behaviour. - **Schema fixtures already include the response envelope** — a `contractDetailsSchemaV1` is `{ data: }`, so a handler is just `HttpResponse.json(fixture)`. Add new ones under `tests/fixtures/
/` and re-export from that folder's `index.ts`. +- **Form behaviour bugs get a situation, not an integration test** — when the bug is in how a JSON Schema form behaves (conditional visibility, computed or forced values, money conversion, `initialValues`, `jsfModify`), reproduce it first as a failing entry in [jsfEngineSituations.ts](src/common/tests/jsfEngineSituations.ts), then fix it. Each situation runs against every jsf engine and `useHeadlessForm` strategy in [jsfEngineContract.test.tsx](src/common/tests/jsfEngineContract.test.tsx). Use a minimal inline schema unless the bug depends on a specific country's fixture, and name the situation after the behaviour, not the country or ticket. Integration tests are for flow wiring (steps, API calls, submit payloads). How to write one: [docs/USE_HEADLESS_FORM_ROLLOUT.md](docs/USE_HEADLESS_FORM_ROLLOUT.md#adding-a-situation). - **Tests must wrap React Query consumers** in a `QueryClientProvider` and `queryClient.clear()` between tests. Use the shared `queryClient` + `TestProviders` from [src/tests/testHelpers.tsx](src/tests/testHelpers.tsx) rather than constructing a local `QueryClient`. - **`no-console: warn`** in oxlint — only `console.warn` / `console.error` permitted. - **No `any`** — `typescript/no-explicit-any: error`. Use the `$TSFixMe` type from `src/types/remoteFlows.ts` for genuine workarounds. diff --git a/docs/USE_HEADLESS_FORM_ROLLOUT.md b/docs/USE_HEADLESS_FORM_ROLLOUT.md new file mode 100644 index 000000000..56c19f373 --- /dev/null +++ b/docs/USE_HEADLESS_FORM_ROLLOUT.md @@ -0,0 +1,101 @@ +# 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`, with `transformMoneyFields: false`. Conditionals are resolved by `handleValidation` with `isPartialValidation: true`. The first build is seeded with `initialValues` (saved values in API units), so visibility is right without a mounted step. When `jsfModify` changes, the form is rebuilt from the last validated values; a new schema starts again from `initialValues`. + +## Adding a situation + +When a form misbehaves because of the schema or the hook (a field doesn't appear, a computed value is wrong, money is converted twice, `initialValues` or `jsfModify` are lost), reproduce it as a situation before fixing it. A situation runs against every engine and strategy in [`jsfEngineContract.test.tsx`](../src/common/tests/jsfEngineContract.test.tsx), so one entry covers what would otherwise take an integration test per flow and country. + +Add an entry to `SITUATIONS` in [`jsfEngineSituations.ts`](../src/common/tests/jsfEngineSituations.ts): + +- `situation`: the behaviour, e.g. "fieldset hidden by a radio keeps its children's values". Not the country, the flow or the ticket. +- `schema`: the smallest schema that shows the bug, written inline. Use a country fixture only when the bug depends on that country's schema. Leave out `x-rmt-meta`; the test adds one per engine. +- `fill`: what the user does, with `userEvent` and `chooseInGroup`. +- `assert`: what the user sees and what `parseFormValues` returns. + +Run it before the fix and check that it fails. A situation that passes either way doesn't protect anything. + +Behaviour that only exists in one strategy (rebuilding when `initialValues` or `jsfModify` change, falling back when the schema changes) goes in the `buildOnce lifecycle` block of the test instead. + +Keep integration tests for flow wiring: steps, API calls and submit payloads. + +## Phases + +### Phase 1: move every call site onto the hook, same behaviour + +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. + +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): + +- [ ] 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 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 | +| 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`. diff --git a/src/common/tests/jsfEngineContract.test.tsx b/src/common/tests/jsfEngineContract.test.tsx new file mode 100644 index 000000000..dcb873b03 --- /dev/null +++ b/src/common/tests/jsfEngineContract.test.tsx @@ -0,0 +1,230 @@ +import { useState } from 'react'; +import { FieldValues } from 'react-hook-form'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + chooseInGroup, + Schema, + signingBonusSchema, + SITUATIONS, +} from '@/src/common/tests/jsfEngineSituations'; +import { + HeadlessFormStrategy, + useHeadlessForm, +} 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 { JSFModify } from '@/src/flows/types'; +import { queryClient, TestProviders } from '@/src/tests/testHelpers'; + +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 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(), + }, +]; + +function Harness({ + schema, + strategy, + options, + initialValues, + onCapture = () => {}, +}: { + schema: Schema; + strategy: HeadlessFormStrategy; + options: { jsfModify?: JSFModify } | undefined; + initialValues?: FieldValues; + onCapture?: (submitted: FieldValues) => void; +}) { + const [values, setValues] = useState({}); + const inlineOptions = options && structuredClone(options); + const headless = useHeadlessForm( + strategy === 'rebuild' + ? { schema, options: inlineOptions, strategy, values } + : { + schema, + options: inlineOptions, + strategy, + initialValues: initialValues && structuredClone(initialValues), + }, + ); + 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 }) => { + 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 assert(onCapture.mock.lastCall?.[0]); + assertApplied?.(); + }); + }, + ); + }); +}); + +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(signingBonusSchema); + 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(signingBonusSchema) }); + + await waitFor(() => + expect(screen.queryByLabelText('Signing bonus amount')).toBeNull(), + ); + }); +}); diff --git a/src/common/tests/jsfEngineSituations.ts b/src/common/tests/jsfEngineSituations.ts new file mode 100644 index 000000000..399ced2b1 --- /dev/null +++ b/src/common/tests/jsfEngineSituations.ts @@ -0,0 +1,167 @@ +import { FieldValues } from 'react-hook-form'; +import { screen, within } from '@testing-library/react'; +import { UserEvent } from '@testing-library/user-event'; + +export type Schema = Record; + +export type Situation = { + situation: string; + schema: Schema; + fill: (user: UserEvent) => Promise; + assert: (submitted: FieldValues) => Promise; +}; + +const money = (title: string) => ({ + title, + type: ['integer', 'null'], + 'x-jsf-presentation': { inputType: 'money', currency: 'EUR' }, +}); + +const yesNo = (title: string) => ({ + title, + type: 'string', + oneOf: [ + { const: 'yes', title: 'Yes' }, + { const: 'no', title: 'No' }, + ], + 'x-jsf-presentation': { inputType: 'radio', direction: 'row' }, +}); + +const monthlySalaryInCents = { + '-': [ + { '/': [{ var: 'annual_gross_salary' }, 12] }, + { '%': [{ '/': [{ var: 'annual_gross_salary' }, 12] }, 1] }, + ], +}; + +export const signingBonusSchema: Schema = { + type: 'object', + additionalProperties: false, + properties: { + annual_gross_salary: money('Annual gross salary'), + has_signing_bonus: yesNo('Offer a signing bonus?'), + signing_bonus_amount: money('Signing bonus amount'), + }, + required: ['annual_gross_salary', 'has_signing_bonus'], + allOf: [ + { + if: { + properties: { has_signing_bonus: { const: 'yes' } }, + required: ['has_signing_bonus'], + }, + then: { required: ['signing_bonus_amount'] }, + else: { properties: { signing_bonus_amount: false } }, + }, + ], + 'x-jsf-order': [ + 'annual_gross_salary', + 'has_signing_bonus', + 'signing_bonus_amount', + ], +}; + +const monthlyAllowanceSchema: Schema = { + type: 'object', + additionalProperties: false, + properties: { + annual_gross_salary: money('Annual gross salary'), + pays_monthly_allowance: yesNo('Pay a monthly allowance?'), + monthly_allowance: money('Monthly allowance'), + }, + required: ['annual_gross_salary', 'pays_monthly_allowance'], + allOf: [ + { + if: { + properties: { + annual_gross_salary: { minimum: 1 }, + pays_monthly_allowance: { const: 'yes' }, + }, + required: ['annual_gross_salary', 'pays_monthly_allowance'], + }, + then: { + properties: { + monthly_allowance: { + 'x-jsf-logic-computedAttrs': { + const: 'monthly_allowance_in_cents', + default: 'monthly_allowance_in_cents', + 'x-jsf-presentation': { + statement: { + description: + 'You need to pay an additional {{monthly_allowance_value}} EUR monthly.', + severity: 'info', + }, + }, + }, + }, + }, + required: ['monthly_allowance'], + }, + else: { properties: { monthly_allowance: false } }, + }, + ], + 'x-jsf-order': [ + 'annual_gross_salary', + 'pays_monthly_allowance', + 'monthly_allowance', + ], + 'x-jsf-logic': { + computedValues: { + monthly_allowance_in_cents: { + rule: { if: [{ var: 'annual_gross_salary' }, monthlySalaryInCents, 0] }, + }, + monthly_allowance_value: { + rule: { + if: [ + { var: 'annual_gross_salary' }, + { '/': [monthlySalaryInCents, 100] }, + 0, + ], + }, + }, + }, + }, +}; + +export const chooseInGroup = async ( + user: UserEvent, + group: RegExp, + option: string, +) => { + const radioGroup = await screen.findByRole('radiogroup', { name: group }); + await user.click(within(radioGroup).getByRole('radio', { name: option })); +}; + +export const SITUATIONS: Situation[] = [ + { + situation: 'forced money value computed from another money field', + schema: monthlyAllowanceSchema, + fill: async (user) => { + await user.type( + await screen.findByLabelText('Annual gross salary'), + '1234.56', + ); + await chooseInGroup(user, /Pay a monthly allowance/i, 'Yes'); + }, + assert: async (submitted) => { + expect( + await screen.findByText(/additional 102.88 EUR monthly/), + ).toBeInTheDocument(); + expect(submitted.monthly_allowance).toBe(10288); + }, + }, + { + situation: 'radio reveals a conditional money field', + schema: signingBonusSchema, + fill: async (user) => { + await chooseInGroup(user, /Offer a signing bonus/i, 'Yes'); + await user.type( + await screen.findByLabelText('Signing bonus amount'), + '1000.50', + ); + }, + assert: async (submitted) => { + expect(screen.getByLabelText('Signing bonus amount')).toBeVisible(); + expect(submitted.signing_bonus_amount).toBe(100050); + }, + }, +]; diff --git a/src/common/useHeadlessForm.ts b/src/common/useHeadlessForm.ts new file mode 100644 index 000000000..c06709418 --- /dev/null +++ b/src/common/useHeadlessForm.ts @@ -0,0 +1,106 @@ +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'; +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 Schema = Record; + +type UseHeadlessFormArgs = { + schema: Schema | undefined; + options?: { jsfModify?: JSFModify }; +} & ( + | { strategy: 'buildOnce'; initialValues?: FieldValues } + | { strategy: 'rebuild'; values: FieldValues } +); + +export type HeadlessForm = { + form: JSONSchemaFormResultWithFieldsets | null; + handleValidation: (values: FieldValues) => Promise; + onValuesChange: (values: FieldValues) => Promise; + parseFormValues: (values: FieldValues) => Promise; +}; + +const useDeepStable = (value: T): T => { + const [stable, setStable] = useState(value); + if (!equal(stable, value)) { + setStable(value); + } + return stable; +}; + +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; + 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 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: + // 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, + }); + 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, schema, isBuildOnce], + ); + + const onValuesChange = useCallback( + async (nextValues: FieldValues) => { + if (isBuildOnce) await handleValidation(nextValues); + }, + [handleValidation, isBuildOnce], + ); + + const parseFormValues = useCallback( + async (nextValues: FieldValues) => + form ? parseJSFToValidate(nextValues, form.fields) : {}, + [form], + ); + + return { form, handleValidation, onValuesChange, parseFormValues }; +} diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index 99ea7fc77..5b282201f 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 { HeadlessForm, useHeadlessForm } from '@/src/common/useHeadlessForm'; import { countriesOptions } from '@/src/common/api/countries'; import { useMemo } from 'react'; @@ -252,17 +253,16 @@ export const useJSONSchemaForm = ({ }); }; -export const useContractDetailsSchema = ({ +export const useLegacyContractDetailsSchema = ({ 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 } => { @@ -272,11 +272,79 @@ export const useContractDetailsSchema = ({ json_schema_version: jsonSchemaVersion, } : {}; - const { data: response, ...reactQueryResult } = useQuery({ + const { data: schema, isLoading } = useQuery({ + queryKey: [ + 'onboarding-legacy-contract-details-schema', + countryCode, + jsonSchemaVersion, + query, + ], + 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, + initialValues, + options, + query = {}, + jsonSchemaVersion, +}: { + countryCode: string; + initialValues?: FieldValues; + options?: FlowOptions & { queryOptions?: { enabled?: boolean } }; + query?: Record; + 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-contract-details-schema', countryCode, jsonSchemaVersion, + query, ], retry: false, queryFn: async () => { @@ -304,18 +372,86 @@ export const useContractDetailsSchema = ({ return response; }, enabled: options?.queryOptions?.enabled, + select: ({ data }) => data?.data, + }); + + const { form, handleValidation, parseFormValues } = useHeadlessForm({ + schema, + initialValues, + options: { jsfModify: options?.jsfModify }, + strategy: 'buildOnce', + }); + + 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 contractDetailsFormFrance: JSONSchemaFormResultWithFieldsets | null = - useMemo(() => { - const schemaData = response?.data.data; - if (!schemaData) return null; - return createHeadlessForm(schemaData, {}, options); - }, [options, response?.data]); + const { form, handleValidation, onValuesChange, parseFormValues } = + useHeadlessForm({ + schema, + initialValues, + options: { jsfModify: options?.jsfModify }, + strategy: 'buildOnce', + }); return { - data: contractDetailsFormFrance, - isLoading: reactQueryResult.isLoading, + data: form, + isLoading, + handleValidation, + onValuesChange, + parseFormValues, }; }; diff --git a/src/flows/Onboarding/hooks.tsx b/src/flows/Onboarding/hooks.tsx index fba199db6..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,7 @@ import { useEmploymentOnboardingReservesStatus, useEngagementAgreementDetailsSchema, useGetPreOnboardingRequirements, - useJSONSchemaForm, + useLegacyContractDetailsSchema, useUpdateBenefitsOffers, useUpdateEmployment, useUpdateEmploymentEngagementAgreementDetails, @@ -167,39 +168,33 @@ const getLoadingStates = ({ const useJsfV1ContractDetails = ({ countryCode, + initialValues, query, enabled, jsonSchemaVersion, jsfModify, }: { countryCode: string; + initialValues: 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, + initialValues, + query, + options: { queryOptions: { enabled }, jsfModify }, + jsonSchemaVersion, + }); - return { - form, - isLoading: isLoadingContractDetails, - }; + return { form, isLoading, handleValidation, parseFormValues }; }; export const useOnboarding = ({ @@ -222,7 +217,6 @@ export const useOnboarding = ({ }, }); - const [, setFieldsCount] = useState(0); const [internalEmploymentId, setInternalEmploymentId] = useState< string | undefined >(employmentId); @@ -513,49 +507,20 @@ export const useOnboarding = ({ unknown >; - const useJSONSchema = ({ - form, - options: jsonSchemaOptions = {}, - query = {}, - jsonSchemaVersion, - }: { - form: JSONSchemaFormType; - options?: { - jsfModify?: JSFModify; - queryOptions?: { enabled?: boolean }; - }; - 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, - fieldValues: mergedFormValues, - query, - options: { - ...jsonSchemaOptions, - queryOptions: { - enabled: jsonSchemaOptions.queryOptions?.enabled ?? true, - }, - }, - jsonSchemaVersion, - }); - }; + 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 isBasicInformationDetailsEnabled = Boolean( internalCountryCode && @@ -576,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: { @@ -712,8 +689,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, }, @@ -732,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, @@ -746,8 +732,11 @@ export const useOnboarding = ({ const { form: contractDetailsFormV1, isLoading: isLoadingContractDetailsFormV1, + handleValidation: handleContractDetailsV1Validation, + parseFormValues: parseContractDetailsV1Values, } = useJsfV1ContractDetails({ countryCode: internalCountryCode as string, + initialValues: contractDetailsSavedValues, query: { employment_id: internalEmploymentId as string, }, @@ -857,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; @@ -875,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 = { @@ -904,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 = { @@ -1088,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 ( @@ -1121,9 +1092,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') { @@ -1273,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 ( @@ -1311,20 +1275,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; @@ -1334,17 +1285,21 @@ export const useOnboarding = ({ selectCountryForm, benefitOffersSchema, basicInformationForm, + handleBasicInformationValidation, engagementAgreementDetailsSchema, contractDetailsForm, contractDetailsFormV1, + handleContractDetailsV1Validation, isJsfV1ContractDetailsEnabled, - setFieldsCount, ], ); const checkFieldUpdates = useCallback( async (values: FieldValues) => { setFieldValues(values); + if (stepState.currentStep.name === 'basic_information') { + await onBasicInformationValuesChange(values); + } if ( isJsfV1ContractDetailsEnabled && stepState.currentStep.name === 'contract_details' @@ -1357,6 +1312,7 @@ export const useOnboarding = ({ isJsfV1ContractDetailsEnabled, stepState, handleValidation, + onBasicInformationValuesChange, ], ); 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' }, + }, }, }, },