Skip to content

Reactivity Rule Catalogue (S0-4)

The top-20 reactive form patterns, catalogued from Filament v4 (visibleWhen/requiredWhen/disabledWhen/live/afterStateUpdated/dependentOptions) and Laravel Nova (dependsOn, readonly, showWhen), classified against the Adonia JsonLogic subset (the reactivity contract).

Classification buckets:

Bucket Meaning
jsonlogic Compiles to the declarative JsonLogic subset; evaluated client-side (cosmetic) and mirrored server-side (§11.2).
capability-url Needs server data → a capability URL under urls (§9.2), called with current form state.
registry-transform Declarative sets entry with a named fn:* transform registered on both sides (server for fill recompute, client for live preview).
server-closure Not expressible declaratively in v1 → server closure; validation closures run at submit (always server-side anyway), visibility closures force refetchSchema: true.

Spike evidence: every row below is executable in spikes/jsonlogic-reactivity/src/catalogue.check.ts (23 fixtures, 39 cases, 0 failures); the wire JSON lives in docs/reactivity/fixtures/.

The 20 patterns

# Pattern Example (Filament/Nova) Bucket Adonia compile
1 show-when-equals show publishedAt when status = published jsonlogic visibleWhen('status','published'){ "===": [{"var":"status"}, "published"] }
2 show-when-not-equals show cancelReason when status ≠ active jsonlogic when('status').notEquals('active'){ "!==": … }
3 show-when-in show shipping fields when type ∈ {physical, digital} jsonlogic when('type').in([…]){ "in": [{"var":"type"}, […]] }
4 show-when-not-in hide internal notes from customer/guest roles jsonlogic when('role').notIn([…]){ "!": [{ "in": … }] }
5 show-if-checkbox “Other” checkbox reveals a text field jsonlogic when('other').truthy(){ "var": "other" } (bare var; consumers Boolean it)
6 multi-condition-and show coupon when type = discount ∧ amount > 100 jsonlogic when.all(…, …){ "and": […] }
7 multi-condition-or show archive warning when status ∈ {archived, deleted} via OR jsonlogic when.any(…, …){ "or": […] }
8 negated-composite show public note when ¬(internal ∨ draft) jsonlogic when.not(when.any(…)){ "!": [{ "or": … }] }
9 numeric-threshold show bulk discount when quantity ≥ 10 (boundary pinned) jsonlogic when('quantity').gte(10){ ">=": … }
10 required-when-equals companyName required when accountType = business jsonlogic requiredWhen(...) → same grammar; mirrored server-side (§11.2)
11 required-unless vatId required unless country = US jsonlogic when.not(when('country').equals('US'))
12 disabled-when / readonly-when-editing slug locked once published; slug readonly on edit jsonlogic (+ static) dynamic: disabledWhen(...) (dynamic rule); static: readonlyOn('edit') at compile time, no rule
13 sets/slugify derivation title.live()slug slugified jsonlogic (sets) sets: [{ "target":"slug", "transform":"slugify" }]; server recomputes (R6)
14 sets case-normalization SKU uppercased as typed jsonlogic (sets) transform: "uppercase" / "lowercase"
15 copy-on-checkbox “billing same as shipping” copies fields while checked jsonlogic (sets) sets: [{ "target":"billingStreet", "transform":"copy", "source":"shippingStreet", "if": {"var":"sameAsShipping"} }]
16 dependent select options refetch country → states capability-url refetch: { "url":"urls.options", "withState":["country"] } + GET field.options (§10.1)
17 searchable relation options belongsTo author, search-as-you-type capability-url urls.options on the node; server exists validation (§11.1)
18 conditional default on change changing ticket type re-defaults priority registry-transform sets: [{ "target":"priority", "transform":"fn:priorityForType", "source":"type" }]
19 computed sum across repeater rows invoice total = Σ line-item prices registry-transform fn:sumPrices receives the whole items array; JsonLogic arithmetic/map/reduce stay out of v1
20 cross-field date validation endDate ≥ startDate server-closure rules(v => …) closure; enforced at submit, always server-side; client pre-validation optional/cosmetic

Pattern details and edge semantics

  • 1–9 (visibility conditions) — all compile through the when() DSL; none require server round-trips. Bare-var truthiness (#5) pins JS truthiness: false|null|0|""|NaN falsy; empty arrays/objects are truthy (PHP semantics do NOT apply).
  • 9 (numeric threshold) — relational semantics are null-safe: a null operand (absent/empty state) makes the comparison false, never coerced to 0 (kills the null <= 10 footgun). Two numbers compare numerically; two ISO-8601 strings order lexicographically (dates work). Mixed number/string comparisons are a compiler dev-warning (runtime coerces and warns). F.number SHOULD still dehydrate empty state to null rather than "".
  • 10–11 (requiredWhen) — the §11.2 mirror: the server re-evaluates the rule against the submitted state and enforces requiredness even when the client UI was bypassed. Fixtures 10-*/11-* pin requires exactly.
  • 12 (disabled)disabledWhen true ⇒ never required, never filled; a tampered submission is shape-validated but its value dropped (R5). Static readonlyOn('edit') is a compile-time projection concern, no rule on the wire.
  • 13–15 (sets)sets derivations are always recomputed server-side from submitted source values (R6); the client preview is cosmetic, so a tampered slug in the payload is overwritten by the derivation (fixture 13-*). source defaults to the live field itself; if guards the derivation (both are the reactivity contract to §9.3).
  • 16–17 (capability URLs) — no JsonLogic at all; state-dependence is explicit (withState) because closures are not introspectable. dependentOptions(fn, { dependsOn: ['country'] }) (state dependency list) → withState.
  • 18–19 (registry transforms)fn:<name> resolves from registry.transforms on both sides; unregistered names fail closed (derivation skipped + dev warning), never eval. Aggregation transforms receive plain dotted-path sources (no items.*.price wildcards — the whole array is passed, the transform aggregates).
  • 20 (cross-field validation) — validation is always server-enforced, so a closure here costs nothing; only client-live cross-field validation would need the subset, deferred.

Considered and deferred (not in the 20)

Pattern Why deferred
Condition on repeater row count (items.length > 3) No length operator in the subset; v2 candidate (count op) or fn: rule names. Workaround: server closure + refetchSchema.
Regex matching inside conditions No match/test op in v1; static mask/regex validation covers the common case.
Record-dependent visibility on edit (e.g. “hide if record locked”) Per-record ⇒ §9.4 cache bypass subtree + refetchSchema; deliberately not a form-state rule.
Arithmetic in conditions (total > creditLimit) Needs a derived value → fn: transform on a sets target, then a plain > condition on that target.
Wizard/step visibility Not a separate mechanism: visibleWhen attaches to any schema node (layout included), same grammar.

Repeater-item scoping (interlocks with protocol v1)

var paths are absolute against the root form state. Inside a repeater item subtree, ./-prefixed paths ({"var":"./label"}) are item-relative against the row sub-state. No relative-first fallback. The v1 fixture corpus is flat; per-row mirroring fixtures are a v1.1 extension (see mirroring-test-design §Matrix, row R-7).

Navigation

Type to search…

↑↓ navigate↵ selectEsc close