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:
- A field that some condition depends on (a controller) is rendered with htmx attributes so that changing it re-fetches the form.
- 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. - 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 fireschange, 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.fieldmust name a declared input field in the same form (referencing anhtmlelement — which has no value — or an undeclared name is a parse error). This needs a field-name lookup: wire and guardform.FieldsMap(today dead code; it must skip namelesshtmlelements 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 itsdefault(or empty). - Wrapper / prefix / suffix: an omitted
htmlelement 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; thehx-*attributes go on the control. - Multi-value controllers (
checkboxes, multi-select):one_of/equalstest 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 evaluatorVisible(field, values) bool; config-load validation (referenced field exists and is an input; operator/shape valid); wire + guardFieldsMap. - Phase 2 — submit-side visibility (r/g): the handler evaluates conditions
and marks unmet fields hidden;
ValidateFieldsskips them; hiddenrequiredno 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
htmlelements, and decorates controllers with thehx-*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_whenin 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, togglingrequiredindependently, 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.