refactor(form): add useHeadlessForm and move contract details onto it - #1433
Open
gabrielseco wants to merge 16 commits into
Open
gabrielseco wants to merge 16 commits into
gabrielseco wants to merge 16 commits into
Conversation
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>
Contributor
📦 Bundle Size Report
Size Limits
Largest Files (Top 5)
View All Files (283 total)
✅ Bundle size check passed |
Contributor
|
Deploy preview for remote-flows ready!
Deployed with vercel-action |
Contributor
|
Deploy preview for adp-cost-calculator ready!
Deployed with vercel-action |
Contributor
📊 Coverage Report✅ Coverage increased! 🎉
Detailed BreakdownLines Coverage
Statements Coverage
Functions Coverage
Branches Coverage
✅ Coverage check passed |
… 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>
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
changed the base branch from
refactor/drop-get-forced-value
to
main
September 30, 2026 09:00
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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 usesisPartialValidation: false.buildOnce: built once perschema/options,transformMoneyFields: false, validation withisPartialValidation: true. The comment explaining why invisible values are kept moved here fromhooks.tsx. It validates once after each build (with the latest values) so visibility is right without a mounted step, and so a changedjsfModifydoesn't reset it. A pending validation for a replaced form is cancelled before it touches the old fields.optionsare compared by value (fast-deep-equal, asuseJSONSchemaFormalready does). Without this, anoptionsobject created during render madebuildOncerebuild, validate and re-render forever: 132 renders in 500 ms, "Too many re-renders" in the test.useLegacyContractDetailsSchemainsrc/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 inhooks.tsx, which come next in the rollout.useJSONSchemais unchanged for the other steps.mergedFormValuesmoved out of that wrapper so both can use it (same calculation).useContractDetailsSchema,buildOnce): building, validation and submit parsing all go through the hook. ThefieldsCountstate 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 unusedtransformMoneyFieldsoption on this internal hook is removed.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.tsxchecks both hooks refetch for a new employment, and fails with the old keys.src/common/tests/jsfEngineContract.test.tsxrenders the realuseJSONSchemaForm+JSONSchemaFormFields, soForcedValueField'ssetValueloop 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 inrebuild(3),ForcedValueField'ssetValue(6),buildOncevalue changes (6), and the signing-bonus click (6).src/common/tests/useHeadlessForm.test.tsx: a changedjsfModifyrebuilds thebuildOnceform, 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.rebuildstops converting to cents. France's flow tests anduseOnboardingJsfV1ContractDetails.test.tsxfail when the hook'sbuildOncevalidation is broken.useOnboardingJsonSchemaVersion.test.tsx: the mocked schema gainsx-jsf-presentation, like every real schema (all 20 root fixture schemas have it). The hook builds during render, not inside a React Queryselect, 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 as8125770before it settled on81257once 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 tobuildOnce.No public API change: nothing new is exported from
src/index.tsxorinternals.useContractDetailsSchemaanduseJsfV1ContractDetailsare internal.Screenshots
N/A
Related Resources
Testing
npx vitest run src/common/tests/jsfEngineContract.test.tsx src/common/tests/useHeadlessForm.test.tsxnpx 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 browserFeature 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
useHeadlessFormwithrebuild(rebuild on value changes, money at build time) andbuildOnce(single build, conditionals via partial validation). Onboarding contract details is the first consumer: legacy path usesuseLegacyContractDetailsSchema+rebuild(build only; validation still inhooks.tsx); jsf v1useContractDetailsSchemausesbuildOnceand routes validation/submit parsing through the hook (drops thefieldsCountre-render hack).createHeadlessFormnow defaultstransformMoneyFieldsto 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 peremployment_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.