diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 5b1fe1fda..a95ea1da1 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -230,8 +230,8 @@ 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`, 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 **Check:** Are validation errors user-friendly? Do they display with proper field labels? diff --git a/.cursor/rules/json-schema-form-usage.mdc b/.cursor/rules/json-schema-form-usage.mdc index 916ff75a9..fd696f5ed 100644 --- a/.cursor/rules/json-schema-form-usage.mdc +++ b/.cursor/rules/json-schema-form-usage.mdc @@ -1,57 +1,59 @@ --- 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, + initialValues: savedValues, + 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. +`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:** `useBasicInformationSchema` and `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 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 7048f1de2..071992833 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": ["**/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,19 @@ "rules": { "typescript/no-deprecated": "off" } + }, + { + "files": [ + "scripts/**", + "src/common/createHeadlessForm.tsx", + "src/common/useHeadlessForm.ts", + "src/**/tests/**", + "src/**/__tests__/**", + "src/**/*.test.{ts,tsx}" + ], + "rules": { + "no-restricted-imports": "off" + } } ] } diff --git a/CLAUDE.md b/CLAUDE.md index fab38da48..1980d356c 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 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 diff --git a/docs/USE_HEADLESS_FORM_ROLLOUT.md b/docs/USE_HEADLESS_FORM_ROLLOUT.md index 56c19f373..a6cfbfcf8 100644 --- a/docs/USE_HEADLESS_FORM_ROLLOUT.md +++ b/docs/USE_HEADLESS_FORM_ROLLOUT.md @@ -38,7 +38,9 @@ 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. ### Phase 2: switch `rebuild` steps to `buildOnce`, one step per PR @@ -55,7 +57,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` +- [ ] Remove the last `no-restricted-imports -- TODO` disable comment ## Call sites @@ -68,10 +71,10 @@ 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 | | +| Contract documents `useGetContractDocumentSignatureSchema` | todo | | | ContractorOnboarding `useGetEligibilityQuestionnaire` | todo | | | ContractorOnboarding `useGetContractOriginSchema` | todo | | | Invoice schedules `useGetCreateInvoiceScheduleSchema` | todo | Money-sensitive | 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", 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/contract-documents/api.ts b/src/common/contract-documents/api.ts index e5bf72b67..4081972f9 100644 --- a/src/common/contract-documents/api.ts +++ b/src/common/contract-documents/api.ts @@ -8,6 +8,7 @@ import { } from '@/src/client'; import { Client } from '@/src/client/client'; import { signatureSchema } from '@/src/common/contract-documents/json-schemas/signature'; +// 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 { useClient } from '@/src/context'; import { JSFModify } from '@/src/flows/types'; 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..661790317 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -146,8 +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 - // 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 166c97563..ffcf068aa 100644 --- a/src/flows/ContractorOnboarding/api.ts +++ b/src/flows/ContractorOnboarding/api.ts @@ -36,6 +36,7 @@ import { JSFModify, } from '@/src/flows/types'; 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 5b282201f..dc6b9dcf1 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'; @@ -457,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, @@ -485,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, + }; }; /** @@ -740,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 ? { @@ -754,7 +771,7 @@ export const useEngagementAgreementDetailsSchema = ( } : {}; - return useQuery({ + const { data: schema, isLoading } = useQuery({ queryKey: [ 'engagement-agreement-details', countryCode, @@ -781,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'], + }, + }); + }); }); 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 16a5060cf..227e83f8a 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';