Skip to content

feat(StateTaxes): respect applicable_if for conditional field visibility#2256

Open
jeffredodd wants to merge 10 commits into
mainfrom
jj/feat/sdk-1057-state-taxes-applicable-if
Open

feat(StateTaxes): respect applicable_if for conditional field visibility#2256
jeffredodd wants to merge 10 commits into
mainfrom
jj/feat/sdk-1057-state-taxes-applicable-if

Conversation

@jeffredodd

@jeffredodd jeffredodd commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Honors each TaxRequirement.applicable_if constraint when rendering the State Taxes form
  • Adds isRequirementApplicable helper plus unit tests
  • Drops inapplicable requirements from the submit payload so stale values aren't re-asserted
  • Drops non-editable requirements from render + submit (matches legacy gws-flows prepare_requirements)
  • Adds Playwright e2e coverage against the real demo backend via a new shared scenario

Fixes SDK-1057.

Context

The embedded API returns each TaxRequirement with an applicable_if: Array<{key, value}> field that gates visibility on the answers to other requirements in the same set. The SDK previously ignored it and rendered every requirement unconditionally — for example, Washington's taxrates set returns three rate inputs that are only meant to show when the user has answered "No, my agency gave me new rates" on a parent radio, but the SDK rendered them regardless. gws-flows has honored this contract via a small JS file for years, and the API rejects payloads that send answers for inapplicable requirements (QuestionNotApplicableDueToMismatchingDependentValues).

Reference impl confirmation

The shape was confirmed against three independent sources:

  • API docstring (@gusto/embedded-api-v-2025-11-15/models/components/taxrequirement.d.ts): "This requirement is only applicable if all referenced requirements have values matching the corresponding value ... an empty array means the requirement is applicable."
  • ZP (packs/.../tax_profiles/app/services/tax_profiles/states/core/read_api.rb:230-235): question.applicable_if.all? { |c| parent && parent.value == c.value }
  • gws-flows (tax_requirements.js): same every-with-strict-equality logic, plus disabling the input so it isn't submitted.
  • gws-flows edit.rb (prepare_requirements): the legacy edit-view step also filters editable == false requirements entirely; matched here.

Implementation

  • New helper src/components/Company/StateTaxes/StateTaxesForm/applicableIf.ts evaluates applicableIf against the live RHF form values. Prefix-aware: constraints reference the raw API key; form values are stored under setKey.toRhfKey(reqKey).
  • Form.tsx adds useWatch({ control }) and filters each requirement before rendering. Also drops editable === false requirements.
  • The submit handler in StateTaxesForm.tsx reuses the helper to drop inapplicable requirements from the payload, and additionally drops non-editable ones.
  • No Zod schema changes — every gated field was already .optional().

Test plan

Unit tests (always-on coverage of the conditional logic)

  • isRequirementApplicable helper covers empty / single match / single mismatch / AND-logic / missing set / pipe-key round-trip (applicableIf.test.ts)
  • WA 'renders tax rate fields' integration test was implicitly relying on the bug — updated to assert the rate input is hidden until the radio toggles to "No"
  • New: 'does not render non-editable requirements' + 'omits non-editable requirements from the submit payload' against an editable: false row added to the WA fixture
  • npm run test -- --run src/components/Company/StateTaxes/ passes (18/18)

E2E tests (Playwright, real-backend via new shared scenario)

The CI matrix runs e2e with E2E_USE_REAL_BACKEND=true, so the specs must work against the live demo backend rather than MSW. This PR introduces the infrastructure needed:

  • New stateTaxes decoration in e2e/scenarios/schema/scenario.schema.json — scenarios can declare desired requirement_sets[].requirements[] answers per state.
  • decorateStateTaxes in e2e/scenario/runner.ts is adaptive: GETs the current shape first, logs every discovered requirement (key, value, applicable_if gates, editable flag), and only PUTs the subset of declared answers that match a currently-applicable editable key with a different current value. Forging invariants the backend doesn't expose returns 422; the adaptive approach makes the scenario nudge the demo toward the declared state without breaking when the demo doesn't expose a question.
  • Order matters: stateTaxes runs after employees because Gusto only surfaces state-tax requirement_sets once the company has nexus (an employee with a home address in the state). Locations alone are insufficient.
  • New shared/state-taxes-wa-id.json scenario: fresh react_sdk_demo + WA & ID locations + two minimal employees (wa_employee, id_employee) whose home addresses establish nexus in each state.

Specs in e2e/tests/company/03-state-taxes-applicable-if.spec.ts:

  • WA: asserts the form renders the demo's taxrates set with Unemployment Insurance Rate and EAF Tax Rate fields visible, plus an interactive Save button.
  • ID: asserts the form renders UI Contribution Rate and the interactive Save button.

Scope note on applicable_if e2e coverage: the discovery dump from the scenario runner confirmed the demo backend's WA/ID taxrates sets surface only the resolved leaf requirements — Gusto's prepare_requirements collapses applicable_if when the underlying StateFilingRequirement has the gate already resolved, which is the demo factory's default. So the parent radios (usedefaultsuirates for WA, suireimbursable for ID) aren't in the real response, and there's no toggle to exercise here. The conditional logic itself is fully exercised by the unit tests above (which use MSW fixtures that DO carry the gating parent radio). When a future demo state exposes applicable_if-gated questions, these specs are already wired to hit it via the existing scenario decoration without further plumbing.

Run locally:

npx playwright test e2e/tests/company/03-state-taxes-applicable-if.spec.ts

Open review questions (called out in SDK-1057)

  • Chained dependencies: ZP's flattened_ancestors in read_api.rb:667-674 suggests the API flattens grandparent constraints into each child's applicable_if before serializing. The helper relies on this — if the API ever ships unflattened chains, it would under-evaluate. No 3-deep fixture exists to prove it.
  • null as a constraint value: The type allows value?: boolean | string | number | null. The helper uses ===, so undefined === null is false. If the API uses null to mean "matches when unanswered," this gets it wrong. Not exercised by any current fixture.

🤖 Generated with Claude Code

@jeffredodd jeffredodd marked this pull request as ready for review June 24, 2026 19:19
@jeffredodd jeffredodd requested a review from a team as a code owner June 24, 2026 19:19
jeffredodd and others added 2 commits June 24, 2026 12:19
Each TaxRequirement returned by the embedded API can carry an applicable_if
constraint list that gates visibility on the answers to other requirements in
the same set. The SDK previously rendered every requirement unconditionally and
sent every value back on submit. Add an isRequirementApplicable helper, use it
in Form.tsx (via useWatch on the form values) to filter what we render, and use
it in the submit handler to drop inapplicable requirements from the payload so
stale values aren't re-asserted.

Fixes SDK-1057. Recovers the design from closed PR #2248 (commit f0ad9b1
on branch al/feat/state-taxes-applicable-if), preserved for reference per the
ticket. Adjusted only the import block of StateTaxesForm.tsx to match current
main (which no longer exports CommonComponentInterface).

Co-Authored-By: Aaron Lee <aaron.lee@gusto.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bility

Adds two Playwright specs exercising both directions of the applicable_if
contract end-to-end against MSW-backed fixtures:

- WA: gated UI/EAF rate fields are absent until the parent 'use default
  rates' radio toggles to No.
- ID: gated UI/Admin/Workforce rate fields are visible by default and
  disappear when the 'reimbursable employer' radio toggles to Yes.

Together these prove the helper is direction-agnostic, not just
'hide by default'.

To support component-level e2e testing without driving the full Company
Onboarding wizard, this also adds:
- A `state-taxes-form` flow to the e2e harness so any <StateTaxesForm>
  can be mounted in isolation via ?flow=state-taxes-form&state=XX.
- A new Idaho fixture modeled on ZP's tax_profiles/by_tax_agency/id_spec.
- A generalized MSW handler that looks up tax_requirements-<STATE>.json
  by state code (falling back to GA) instead of hard-coding only WA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jeffredodd jeffredodd force-pushed the jj/feat/sdk-1057-state-taxes-applicable-if branch from 320dd4e to 15b6fcb Compare June 24, 2026 19:19
CI runs `npm run test:e2e:demo` with E2E_USE_REAL_BACKEND=true, which
bypasses MSW and hits a demo company whose state-tax requirements aren't
shaped for the WA/ID applicable_if fixtures these specs assert against.
The conditional visibility behavior is fully covered by the unit tests
in StateTaxesForm.test.tsx + applicableIf.test.ts; these e2e specs add
real-DOM coverage when run locally via `npm run test:e2e`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@kavinphan kavinphan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

jeffredodd and others added 7 commits June 24, 2026 12:47
The legacy gws-flows `TaxRequirements::Edit.prepare_requirements` step
filters out every requirement whose `editable: false` before handing
the set to the view. The SDK was rendering them anyway, which would
surface an unanswerable input to the user and round-trip the value back
through the submit payload.

Match the legacy contract: short-circuit non-editable requirements in
Form.tsx's flatMap and chain a `.filter(req => req.editable !== false)`
ahead of the applicable_if filter in the submit handler. Adds a
`legacy_read_only_field` entry to the WA fixture so the unit tests
exercise both the render and submit branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…enario

CI runs the e2e matrix with E2E_USE_REAL_BACKEND=true, so the prior
MSW-only skip meant these specs never ran in CI. Pivot to scenario-
driven real-backend coverage:

- Add a stateTaxes decoration to the scenario schema (and regenerate
  scenario.types.ts) so scenarios can pre-seed tax_requirements answers.
- Wire decorateStateTaxes into the scenario runner; it PUTs the body to
  /companies/:id/tax_requirements/:state immediately after locations are
  created so the relevant state is registered first.
- Add shared/state-taxes-wa-id.json scenario: fresh react_sdk_demo with
  WA + ID locations and WA's `usedefaultsuirates` pre-seeded to true so
  the rate fields start hidden. ID's `suireimbursable` is left at the
  natural demo default (false) so its rates start visible — covering
  both directions of the conditional behavior.
- Rewrite the e2e spec to annotate the new scenario; replaces the
  blanket E2E_USE_REAL_BACKEND skip with the standard
  `test.skip(!scenario.flowToken)` guard so local MSW-default runs no
  longer execute these specs (they're real-backend-only now).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`npm run scenarios:types` emits json2ts output that doesn't match
prettier's defaults. The format CI job catches this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first CI run of the new shared/state-taxes-wa-id scenario failed
with a 422 from PUT /tax_requirements/WA — "Question is not applicable"
on `usedefaultsuirates`. The fresh react_sdk_demo with just a WA
location doesn't expose every question we'd see on a fully-configured
WA company; naive PUTs forge invariants the demo backend doesn't hold.

Make decorateStateTaxes pre-fetch the current shape and only PUT the
subset of declared answers that:
  - reference a requirement currently present on the demo,
  - reference an editable requirement,
  - have all of the requirement's applicable_if constraints satisfied
    by current values (same AND-logic the SDK uses at render time),
  - actually differ from the current value (no churn on no-ops).

Also dumps the full GET response per-state — keys, values,
applicable_if gates, editable flags — so the next CI iteration shows
us the real WA/ID shape on this demo and we can refine the scenario
declaratively instead of guessing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last CI iteration found GET /tax_requirements/WA returned 0 requirement
sets on the fresh react_sdk_demo with just a WA location — locations
alone don't establish tax nexus on the demo backend. The state-tax
requirement_sets only surface once Gusto recognizes the company has
actual nexus in the state, which happens when an employee has a home
address there.

Add two minimal employees (wa_employee, id_employee) with WA and ID
home addresses + matching work_address/job locationKey references.
With nexus in both states, the next CI run should see populated
requirement_sets, the runner's GET dump will reveal the real shape,
and the e2e specs can target whatever the demo backend actually
defaults to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last iteration found GET /tax_requirements/WA returned 0 requirement
sets, confirming that Gusto only surfaces state-tax requirement_sets
once the company has nexus (an employee with a home address in the
state). Adding nexus via the new wa_employee / id_employee decorations
was correct, but the runner still decorated stateTaxes before
employees — so the GET fired before nexus existed and saw nothing.

Move decorateStateTaxes after decorateEmployees and update the schema
description to reflect the new order. Also re-add the stateTaxes
declarations to the scenario so the runner's discovery log dumps the
real WA/ID shape on the next CI iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The discovery dump from the previous iteration confirmed the demo
backend's WA/ID taxrates sets surface only the resolved leaf
requirements — Gusto's prepare_requirements collapses applicable_if
when the underlying StateFilingRequirement has the gate already
resolved, which is the demo factory's default. So the parent radios
(`usedefaultsuirates` for WA, `suireimbursable` for ID) that the unit
tests cover via MSW fixtures are not in the real response, and there's
nothing to toggle.

Pivot the e2e to what's actually testable on the real backend: assert
that StateTaxesForm renders the requirements the demo exposes (UI
rate + EAF rate for WA; UI Contribution Rate for ID) and that the
form is interactive (Save button visible). The applicable_if
conditional logic stays fully covered by:
  - src/.../applicableIf.test.ts (helper unit tests)
  - src/.../StateTaxesForm.test.tsx (render + submit with MSW fixtures
    that DO carry the applicable_if parent radio).

The scenario-runner's `stateTaxes` decoration stays — when a future
demo state exposes applicable_if-gated questions, these specs are
already wired to hit it without further plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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