Three behaviours turn the form from a list of inputs into something that reacts:
the slug writes itself, Published at only exists for published posts, and a
subtitle appears for the one post kind that uses it. All three are declared, all
three are enforced twice.
live() and sets
F.text('title').label('Title').required().maxLength(200).live().sets('slug', 'slugify')examples/blog-admin/app/adonia/resources/post_resource.ts
live(debounceMs = 300) marks the field as one whose changes re-run the
reactive graph as you type. sets(target, transform) declares a derivation:
each keystroke in title rewrites slug. The built-in transforms are
slugify, lowercase, uppercase and copy; fn:<name> reaches a transform
you registered, and an unresolvable name fails compilation rather than
silently doing nothing.
On the wire the rule rides beside props, never inside it:
{
"type": "text-input",
"key": "title",
"props": { "label": "Title", "required": true, "maxLength": 200 },
"reactive": {
"live": { "debounceMs": 300 },
"sets": [{ "target": "slug", "transform": "slugify" }]
}
}The server re-runs every derivation on submit and overwrites whatever the
client sent for the target. Post title=Reactivity, Chapter Eight! with
slug=client-tampered-slug and the row stores reactivity-chapter-eight: the
client’s copy of the rule is a convenience, not the authority.
Re-derivation happens after validation, so sets is not a defaulting
mechanism — a required target still has to arrive. And because it runs on every
submit, editing a title would move a published URL. The fix is the fill set,
not a condition:
F.text('slug').label('Slug').required().maxLength(220).unique().readonlyOn('edit')readonlyOn('edit') renders and validates the field but leaves it out of the
fill set for that mode: the derivation still runs, and the column does not
move.
visibleWhen / requiredWhen
F.datetime('publishedAt')
.label('Published at')
.visibleWhen('status', 'published')
.requiredWhen('status', 'published')with F.select('status')…live() driving it. The matcher compiles to JsonLogic:
{ "visibleWhen": { "===": [{ "var": "status" }, "published"] } }A bare value becomes ===, an array becomes in, and a when(…) expression
passes through — when.all, when.any, when.not, gte, contains,
isTruthy and the rest compose without a server round trip. A closure matcher
is allowed but leaves the declarative path: it forces a descriptor refetch.
The mirror is what matters. While status is not published:
- the client hides the field, and
- the server prunes the key before validation, omits it from the validator, and omits it from the fill set.
So a client that submits publishedAt anyway changes nothing:
curl -X POST /admin/posts -d 'status=draft&publishedAt=2020-01-01T00:00:00.000Z&…'stores published_at = NULL. Flip status to published and the same key
becomes required — enforced by the compiled validator, not by the widget:
{ "publishedAt": "The publishedAt field must be defined" }subtitle in the tutorial app is the same rule with a deliberately silly
trigger, so you can watch it happen: type special into Title and the field
appears; save with anything else in Title and a submitted subtitle is discarded.
The rest of the catalogue
The twenty patterns Adonia supports — and the exact JsonLogic each compiles to — are in the reactivity catalogue, with a JSON fixture per pattern that the server and client test suites both run. The four mechanisms are worth knowing by name:
| Mechanism | Example | Cost |
|---|---|---|
| JsonLogic conditions | visibleWhen, requiredWhen, disabledWhen |
none — evaluated in the browser |
sets derivations |
slugify, uppercase, copy | none |
| Capability URLs | dependent options, searchable relations | one JSON request |
| Server closures | rules(v => …) cross-field checks |
submit-time only |
Prefer them in that order. Everything above the line is mirrored automatically; everything below it is server-side by definition.
Next: theming and tests.