Skip to content

refactor(form): add useHeadlessForm and move contract details onto it - #1433

Open
gabrielseco wants to merge 16 commits into
mainfrom
refactor/use-headless-form
Open

gabrielseco wants to merge 16 commits into
mainfrom
refactor/use-headless-form

Conversation

@gabrielseco

@gabrielseco gabrielseco commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds one shared, tested way for the embeddable flows to build and validate their forms, and moves the Onboarding contract details step onto it, for both schema engines, with no change in behaviour.

Why

  • Each flow builds and validates its forms with its own hand-written copy of the same logic. Each copy can break on its own, as the money-in-cents bug fixed in fix(form): restore money conversion in createHeadlessForm when options are passed  #1429 did in one of them, and no shared test notices.
  • Testing every schema situation in every flow doesn't scale. With one shared piece, schema situations are tested once and each flow only needs one smoke test proving it uses that piece.
  • The shared piece offers both behaviours flows use today: rebuild the form on every change (old), or build it once and resolve conditional fields through validation (new). Flows can move over first without any behaviour change, then switch to the new behaviour one at a time.
  • Contract details already uses both behaviours (older countries rebuild, jsf v1 countries build once), so moving it first proves both strategies against real flows.

Stacked on #1429. Merge that first; this PR then retargets to main.

What changed

Toggle details
  • src/common/useHeadlessForm.ts (internal, not exported): returns { form, handleValidation, onValuesChange, parseFormValues }.
    • rebuild: createHeadlessForm(schema, values, options), rebuilt when values change. Validation uses isPartialValidation: false.
    • buildOnce: built once per schema/options, transformMoneyFields: false, validation with isPartialValidation: true. The comment explaining why invisible values are kept moved here from hooks.tsx. It validates once after each build (with the latest values) so visibility is right without a mounted step, and so a changed jsfModify doesn't reset it. A pending validation for a replaced form is cancelled before it touches the old fields.
    • options are compared by value (fast-deep-equal, as useJSONSchemaForm already does). Without this, an options object created during render made buildOnce rebuild, validate and re-render forever: 132 renders in 500 ms, "Too many re-renders" in the test.
  • Legacy contract details (useLegacyContractDetailsSchema in src/flows/Onboarding/api.ts, rebuild): only building moves to the hook. Validation and submit parsing for this step still go through the hand-written branches in hooks.tsx, which come next in the rollout. useJSONSchema is unchanged for the other steps. mergedFormValues moved out of that wrapper so both can use it (same calculation).
  • jsf v1 contract details (useContractDetailsSchema, buildOnce): building, validation and submit parsing all go through the hook. The fieldsCount state that forced a re-render after each validation is gone, since the hook re-renders itself. The replay uses the step's form values, not the server employment data, so money is never converted from cents twice on this path. The unused transformMoneyFields option on this internal hook is removed.
  • Contract details query keys (both steps): the schema request sends employment_id, but the keys only held country and schema version, so one employment's schema was served to every other employment of the same country in the session. The keys now include the request query. contractDetailsSchemaEmploymentId.test.tsx checks both hooks refetch for a new employment, and fails with the old keys.
  • Tests
    • src/common/tests/jsfEngineContract.test.tsx renders the real useJSONSchemaForm + JSONSchemaFormFields, so ForcedValueField's setValue loop runs, with real clicks and typing. Grid: situations × engines (jsf v0, jsf v1, no meta) × strategies. It starts with a forced money value computed from another money field (the PRT allowance) and a radio that reveals a conditional money field (PRT signing bonus). Deliberately breaking each piece made the expected tests fail: cents conversion in rebuild (3), ForcedValueField's setValue (6), buildOnce value changes (6), and the signing-bonus click (6).
    • src/common/tests/useHeadlessForm.test.tsx: a changed jsfModify rebuilds the buildOnce form, replays the latest values, and leaves the replaced form untouched. Options recreated with the same content on every render keep the same form within a few renders.
    • Smoke tests per step: fix(form): restore money conversion in createHeadlessForm when options are passed  #1429's PRT extended-hours flow test fails when the hook's rebuild stops converting to cents. France's flow tests and useOnboardingJsfV1ContractDetails.test.tsx fail when the hook's buildOnce validation is broken.
    • useOnboardingJsonSchemaVersion.test.tsx: the mocked schema gains x-jsf-presentation, like every real schema (all 20 root fixture schemas have it). The hook builds during render, not inside a React Query select, so the money conversion's lookup no longer turns a malformed schema into a silent query error.

Known behaviour on the legacy (rebuild) step, unchanged by this PR and shared with #1429: on the first render of a prefilled step, server values already in cents are converted again, so a probe computed the PRT allowance as 8125770 before it settled on 81257 once the step mounted. Users never see it. Anything that reads the form before the step mounts could, and that isn't verified. It goes away once that step switches to buildOnce.

No public API change: nothing new is exported from src/index.tsx or internals. useContractDetailsSchema and useJsfV1ContractDetails are internal.

Screenshots

N/A

Related Resources

Testing

  • npx vitest run src/common/tests/jsfEngineContract.test.tsx src/common/tests/useHeadlessForm.test.tsx

  • npx vitest run src/flows/Onboarding/tests/OnboardingFlow.test.tsx -t "extended work hours allowance" (legacy step)

  • npx vitest run src/flows/Onboarding/tests/OnboardingFlowFrance.test.tsx src/flows/Onboarding/tests/useOnboardingJsfV1ContractDetails.test.tsx (jsf v1 step)

  • Tested against the example/ app in a browser

  • Feature flag: N/A

🤖 Generated with Claude Code


Note

Medium Risk
Touches onboarding contract-details form build/validation and money-in-cents paths for two schema engines; behaviour is intended to be unchanged but the surface area is large and money-sensitive.

Overview
Introduces shared useHeadlessForm with rebuild (rebuild on value changes, money at build time) and buildOnce (single build, conditionals via partial validation). Onboarding contract details is the first consumer: legacy path uses useLegacyContractDetailsSchema + rebuild (build only; validation still in hooks.tsx); jsf v1 useContractDetailsSchema uses buildOnce and routes validation/submit parsing through the hook (drops the fieldsCount re-render hack).

createHeadlessForm now defaults transformMoneyFields to true when an options object is passed without that flag (fixes invoice schedule row conditionals); invoice schedule API drops the redundant explicit flag. Contract-details React Query keys include the request query so schemas refetch per employment_id.

Adds rollout doc docs/USE_HEADLESS_FORM_ROLLOUT.md, contract tests (jsfEngineContract, useHeadlessForm, employment-id refetch), and onboarding smoke tests for PRT allowance cents on submit.

Reviewed by Cursor Bugbot for commit 5575dbc. Bugbot is set up for automated code reviews on this repo. Configure here.

cammellos and others added 11 commits September 29, 2026 18:32
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tails 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
createHeadlessForm now defaults transformMoneyFields per key (#1426), so
the explicit override in useJSONSchemaForm is redundant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gies

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 <noreply@anthropic.com>
…ssForm

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 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📦 Bundle Size Report

Metric Current Previous Change Status
Total (gzip) 223.19 kB 222.52 kB +666 B (+0.3%) 🔴
Total (raw) 621.35 kB 620.18 kB +1.17 kB (+0.2%) 🔴
CSS (gzip) 21.94 kB 21.94 kB 0 B (0%) 🟢
CSS (raw) 114.43 kB 114.43 kB 0 B (0%) 🟢

Size Limits

  • ✅ Total gzipped: 223.19 kB / 350 kB (63.8%)
  • ✅ Total raw: 621.35 kB / 850 kB (73.1%)
  • ✅ CSS gzipped: 21.94 kB / 25 kB (87.8%)

Largest Files (Top 5)

  1. index.esm-wIkXrqU6.js - 11.4 kB (0 B (0%))
  2. styles.css - 10.97 kB (0 B (0%))
  3. index.css - 10.97 kB (0 B (0%))
  4. internals-BsaPpwoq.js - 6.14 kB (new)
  5. hooks-BWCLuuRe.js - 5.8 kB (new)
View All Files (283 total)
File Size (gzip) Change
index.esm-wIkXrqU6.js 11.4 kB 0 B (0%)
styles.css 10.97 kB 0 B (0%)
index.css 10.97 kB 0 B (0%)
internals-BsaPpwoq.js 6.14 kB new
hooks-BWCLuuRe.js 5.8 kB new
sdk.gen-hikpofx9.js 5.48 kB 0 B (0%)
index.js 5.46 kB +1 B (+0.0%)
flows/Onboarding/hooks.js 4.34 kB -85 B (-1.9%)
utils-qfe-oQBs.js 4.07 kB 0 B (0%)
FieldSetField-Bds5UIuj.js 4.03 kB 0 B (0%)

✅ Bundle size check passed

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Deploy preview for remote-flows ready!

Project:remote-flows
Status: ✅  Deploy successful!
Preview URL:https://remote-flows-j052nrrsx-remotecom.vercel.app
Latest Commit:5575dbc

Deployed with vercel-action

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Deploy preview for adp-cost-calculator ready!

Project:adp-cost-calculator
Status: ✅  Deploy successful!
Preview URL:https://adp-cost-calculator-i0pmx822y-remotecom.vercel.app
Latest Commit:5575dbc

Deployed with vercel-action

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📊 Coverage Report

✅ Coverage increased! 🎉

Metric Current Previous Change Status
Lines 86.45% 86.35% +0.10% 🟢
Statements 86.03% 85.91% +0.12% 🟢
Functions 85.05% 84.92% +0.13% 🟢
Branches 77.96% 77.80% +0.15% 🟢

Detailed Breakdown

Lines Coverage
  • Covered: 4886 / 5652
  • Coverage: 86.45%
  • Change: +0.10% (36 lines)
Statements Coverage
  • Covered: 4977 / 5785
  • Coverage: 86.03%
  • Change: +0.12% (43 statements)
Functions Coverage
  • Covered: 1303 / 1532
  • Coverage: 85.05%
  • Change: +0.13% (13 functions)
Branches Coverage
  • Covered: 3031 / 3888
  • Coverage: 77.96%
  • Change: +0.15% (27 branches)

✅ Coverage check passed

gabrielseco and others added 2 commits September 30, 2026 10:42
… 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 <noreply@anthropic.com>
…ssForm

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 <noreply@anthropic.com>
@gabrielseco gabrielseco changed the title refactor(form): add useHeadlessForm and move legacy contract details onto it refactor(form): add useHeadlessForm and move contract details onto it Sep 30, 2026
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 <noreply@anthropic.com>
@gabrielseco
gabrielseco changed the base branch from refactor/drop-get-forced-value to main September 30, 2026 09:00
gabrielseco and others added 2 commits September 30, 2026 11:14
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants