Conditional Fields

Spec for conditional fields — showing or hiding an element based on the value of another field. This is the deferred conditions non-goal from Form Config v1, now well-enabled by the htmx work: endpoint A renders forms at request time, value repopulation preserves what the user typed across a re-render, and the inline validation re-render already proves the server-re-renders-the-form loop (target: 0.4.x).

The model — server evaluates, htmx re-renders

The relay renders server-side (no client JS), so conditions are evaluated on the server and the affected form is re-rendered over htmx:

  1. A field that some condition depends on (a controller) is rendered with htmx attributes so that changing it re-fetches the form.
  2. htmx hx-gets the form (endpoint A) with the current values; the server evaluates every condition, hides the fields whose condition isn’t met, repopulates the rest, and returns the re-rendered form.
  3. htmx swaps it back. Repopulation means nothing already entered is lost.

The same evaluation runs at submit time (see Submit semantics).

Use case — reveal on a correct entry

This must be supported (and gets its own how-to): a text field that, when the correct value is entered, hides itself and reveals an html element.

forms:
  gate:
    fields:
      - name: code
        type: text
        label: Access code
        show_when: { field: code, not_equals: "open-sesame" }   # visible until correct
      - type: html
        content: '<div class="unlocked">Welcome — here is the content…</div>'
        show_when: { field: code, equals: "open-sesame" }        # revealed once correct
    outputs: [ ... ]

Flow: empty code → the input shows, the html is hidden. The user types open-sesame; change fires an hx-get; the server evaluates — code’s condition is now false so the input collapses to a hidden input carrying the value, and the html’s condition is true so it renders. The reveal is stable because the value persists (see the visibility rule), and it survives submit.

This is a lightweight, server-side reveal gate, not authentication. The correct value lives only in the S3 config and is compared server-side (never sent to the browser), but the endpoint is still brute-forceable — pair it with the captcha / rate-limit / min-fill controls if it guards anything sensitive, and never treat it as real access control. The how-to will say this plainly.

Decisions to confirm

A. Condition config surface

A structured show_when clause on any element — not a free-text expression (the project has no general expression engine by design; a structured clause is safe to parse and evaluate):

show_when: { field: account_type, equals: business }
# grouped:
show_when:
  all:                                    # every condition holds (any = at least one)
    - { field: account_type, equals: business }
    - { field: employees, one_of: [medium, large] }

Operators (recommended core set): equals, not_equals, one_of, not_one_of, filled (non-empty value), empty. Comparisons are against the referenced field’s submitted value(s); one_of/equals on a multi-value controller test membership. Only show_when in v1 — “hide when” is expressed with the negation operators, so no separate hide_when key. Confirm the operator set + show_when-only.

B. What “hidden” means

Forced by the reveal use case:

  • A hidden data field (has a name/value) collapses to a value-preserving hidden input<input type="hidden" name="…" value="<current value>">. It’s not shown for editing and not validated (below), but its value survives the re-render and the submit, so a field can hide itself based on its own value and stay stable. (Once collapsed to a hidden input it no longer fires change, so a self-reveal is final — acceptable for the gate pattern.)
  • A hidden html/non-data element (no value) is omitted entirely.

“Omit everything” was the original idea; it breaks a self-referential reveal (value gone → condition flips back → a required gate even 422s). Collapsing data fields to hidden inputs fixes both. Confirm this collapse/omit split.

C. Re-render mechanism

Whole-form re-render (recommended v1): each controller is rendered with hx-get="{form endpoint}" hx-trigger="change" hx-target="closest form" hx-swap="outerHTML" hx-include="closest form", so any change re-fetches and re-renders the whole form (with repopulation). The renderer derives the controller set automatically from the conditions and decorates only those fields. Endpoint A (GET /api/v1/form/{form}) is extended to read current values from the query string, repopulate, and evaluate. The alternative — targeted re-render of a dependent region by id — is more surgical but needs per-region rendering + a target-id model; deferred. Confirm whole-form for v1.

Submit semantics

Conditions are evaluated again at submit, against the submitted values, before validation. A field whose condition is not met is not validated (a hidden required field does not 422). Its value is preserved — a data field submits its value through the collapsed hidden input, so the condition stays stable and the value is still available to mapping/relay.

This deliberately differs from the honeypot/token fields, which are stripped: conditional-hidden values are kept so a reveal can persist and be relayed. (Trade-off: a field the user filled and then hid still relays its value; an author who wants it dropped simply doesn’t map it. Dropping hidden values is a possible future knob, not v1.)

Concretely, in the handler: after the existing honeypot / min-fill / captcha steps, evaluate each element’s show_when against the submission; mark the unmet ones hidden; ValidateFields skips hidden fields; their values are left in the submission untouched.

Evaluation model

  • One pass, against submitted values. Each condition reads the referenced field’s submitted value(s) directly. Because a hidden data field still submits its value (collapsed hidden input), controllers that hide themselves remain evaluable. Full multi-level cascade to a fixpoint is out of scope for v1 (documented); keep condition chains shallow.
  • Reference validation at config load. show_when.field must name a declared input field in the same form (referencing an html element — which has no value — or an undeclared name is a parse error). This needs a field-name lookup: wire and guard form.FieldsMap (today dead code; it must skip nameless html elements so they can’t collide on the empty-string key).

Interactions

  • Min-fill token: each re-render mints a fresh token (endpoint A already does), so toggling a condition resets the min-fill clock — acceptable (the user is actively interacting). Documented.
  • Repopulation: re-render relies on RenderOptions.Values; a newly-shown field starts from its default (or empty).
  • Wrapper / prefix / suffix: an omitted html element emits none of its markup; a collapsed data field emits only the bare hidden input (no wrapper / label / prefix / suffix). A visible controller keeps its wrapper; the hx-* attributes go on the control.
  • Multi-value controllers (checkboxes, multi-select): one_of / equals test membership across all submitted values.

Security / trust

Conditions are structured config (no code/expressions), evaluated over submitted values with a fixed operator set — no new injection surface. The hx-* attributes are fixed strings + the form’s own endpoint path (not user input). The reveal gate keeps its secret server-side but is not authentication (see the use case).

Testability & phasing (red/green/verify)

Mostly unit-testable; the live re-render is deploy-verified.

  • Phase 1 — condition model + evaluator (r/g): parse show_when (operators + all/any) onto the field; a pure evaluator Visible(field, values) bool; config-load validation (referenced field exists and is an input; operator/shape valid); wire + guard FieldsMap.
  • Phase 2 — submit-side visibility (r/g): the handler evaluates conditions and marks unmet fields hidden; ValidateFields skips them; hidden required no longer 422s; a shown one still validates; values are preserved (not stripped). Both htmx and non-htmx paths.
  • Phase 3 — render visibility + collapse + htmx triggers (r/g): the renderer collapses hidden data fields to value-preserving hidden inputs, omits hidden html elements, and decorates controllers with the hx-* attributes (only controllers; byte-identical when a form has no conditions — goldens intact); endpoint A reads query values → repopulate + evaluate. Handler-level tested; the load→change→re-render loop is deploy-verified.
  • Phase 4 — docs + schema: show_when in the field-model reference + JSON schema $def; a “reveal on a correct entry” how-to (the config above + the htmx wiring + the not-real-auth caveat); CHANGELOG.md.

Non-goals (v1)

  • Conditions that change anything other than visibility (swapping options, toggling required independently, computed values).
  • Multi-level cascade evaluation to a fixpoint (one pass only).
  • Dropping (vs preserving) a hidden field’s submitted value.
  • Targeted region re-render (whole-form only).
  • Free-text / expression-language conditions; client-side (JS) evaluation.