Skip to content

Latest commit

 

History

History
524 lines (396 loc) · 17.9 KB

File metadata and controls

524 lines (396 loc) · 17.9 KB

Using dyfields in a form

The README is the reference: every call, every argument, every deliberate difference from the Go side. This is the other half — one form, from the first render to the second save, in the order a UI actually meets the library.

The schema throughout is testdata/registration_schema.json, the event registration fixture both test suites run against, so every output below is real rather than illustrative. It has one of everything: a toggleable group, a collapsible one, fields that appear only under a condition, a repeating section with a secret in each row, a read-only amount, a masked phone number and a transient password confirmation.

Two working front ends are in examples/: the same form drawn by React and by Vue, sharing examples/shared/form.ts. Where this document says "the renderer", that is what it means.


The shape of the whole thing

start-up      load(schemaJSON)            once, at module scope
every render  compiled.visible(doc)       what to draw
every change  compiled.validate(doc)      what is wrong, and the payload
first save    POST validate().values      the checker's output, not the inputs
reopen        GET  → a redacted document  masks and secrets are not in it
every change  compiled.diff(loaded, cur)  only what the user touched
later saves   PUT  the patch              the server runs apply()

The library holds no state. Every call takes the document and returns something new, so a form keeps exactly one piece of state — the document — and derives everything else from it on the way to the DOM.


1. Start-up: compile once

import { load } from '@brobridge/dyfields'

const schema = await (await fetch('/api/forms/registration')).json()
export const compiled = load(schema)

load() throws if the schema itself does not make sense — an unresolvable reference, a visibility cycle, a mask on a field that can never be masked. That is a bug in the schema rather than in the user's input, so it belongs in the path that can still refuse to start. In a browser that means it fails on the first render instead of quietly skipping a rule for the rest of the session.

The compiled schema is immutable and holds no per-document state. Keep one per schema at module scope; never build it inside a component.

If you would rather show the problems than throw:

import { validateSchema } from '@brobridge/dyfields'
const problems = validateSchema(schema)   // [] when it is fine

2. The empty form: what to draw

const doc = { values: {}, secrets: {} }
const { groups, fields } = compiled.visible(doc)

groups and fields are plain booleans keyed by path:

groups   // { applicant: true, attendance: true, accommodation: true,
         //   companions_group: true, payment: true, account: true }

// the five fields that start out hidden
Object.entries(fields).filter(([, on]) => !on).map(([k]) => k)
// [ 'student_id', 'room_type', 'nights', 'special_request', 'tax_id' ]

student_id and tax_id are hidden because their visible_when does not hold yet. room_type, nights and special_request are hidden because they sit in a toggleable group whose toggle defaults to false.

The renderer's job stops at reading those booleans:

for (const g of schema.groups) {
  // The toggle of a toggleable group is drawn even when the group is closed --
  // it is what opens it. It is not in g.fields: the schema declares it once,
  // under `toggle`, and the library adds it to the document as a boolean.
  if (g.toggle) drawToggle(g.toggle)
  if (!groups[g.key]) continue
  for (const f of g.fields) {
    if (!fields[f.name]) continue
    drawField(f)
  }
}

There is no per-field if anywhere in the two examples, and that is the point: the browser and the server decide visibility from the same declaration, so they cannot disagree about which fields the document is even supposed to have.

Group modes are a rendering instruction, not a rule:

mode What the renderer does
always draw the section
collapsible draw it with a disclosure header; the fields still exist when collapsed
toggleable draw the toggle field as a checkbox; its value decides groups[key]

A toggle is an ordinary boolean field in the document — need_accommodation here — so it is set with the same setValue as everything else.


3. On every change: one call, every problem

const { values, secrets, errors } = compiled.validate(doc)

On the empty document:

errors.map((e) => `${e.path}: ${e.code}: ${e.reason}`)
// [ 'applicant: contact_required: provide at least one of email or phone',
//   'arrive_at: required: Arrival is required',
//   'full_name: required: Full name is required',
//   'leave_at: required: Departure is required',
//   'leave_at: leave_before_arrive: departure must be later than arrival',
//   'password: required: Password is required',
//   'ticket_type: required: Ticket is required' ]

Every problem, in one pass — a checker that stops at the first one makes the user fix and resubmit as many times as they made mistakes. Group-level rules report against the group key (applicant), field-level ones against the field path, so both have somewhere to go on screen:

const byField = new Map()
for (const e of errors) {
  if (!byField.has(e.path)) byField.set(e.path, [])
  byField.get(e.path).push(e)
}
// byField.get('full_name')?.[0].reason  ->  under the control
// byField.get('applicant')              ->  under the section heading

Note the second leave_at error. Nothing has been typed yet, and the form is already claiming departure is before arrival. That is honest — the checker answers about the document it was given — but it is not what a user should see on an untouched form. Every real form gates display, never the check:

const shown = errors.filter((e) => touched.has(e.path) || submitted)

The demo deliberately shows everything from the first keystroke, because that is what makes the checker visible.

validate() also returns values and secrets, and those are the interesting half — see §6.


4. A field appearing is a consequence, not an event

Pick the student ticket and choose a company invoice:

doc.values.ticket_type = 'student'
doc.values.invoice_type = 'company'

const { fields } = compiled.visible(doc)
fields.student_id   // true
fields.tax_id       // true

Nothing was subscribed and nothing fired. visible() is a pure function of the document, so a form that recomputes it on every render gets conditional fields for free. In React that is a useMemo on doc; in Vue a computed.

The rule that hid the field also governs it: student_id is required_when the same condition, so it becomes required in the same instant it appears, without the renderer knowing either fact.

The reverse matters more. When a field goes invisible its value is removed, by validate() and by the server's apply() alike. Switch the invoice back to personal and tax_id is gone from the payload — which is why the panel shows the checker's output rather than the form's state.


5. Repeating sections: object_list

{ "name": "companions", "type": "object_list", "item_label": "{name}",
  "fields": [ { "name": "name", ... }, { "name": "id_number", "type": "secret" }, ... ] }

Three things a renderer has to get right.

Rows carry their own identity. A row is a $id the client mints, not a position:

compiled.requiresItemID('companions')   // true

true means something in the subtree is a secret, and secret keys are built from $id, so a row without one has nowhere to put its password. Mint it when the row is added — [A-Za-z0-9_-]{1,64}:

const $id = 'c_' + Math.random().toString(36).slice(2, 6)
doc.values.companions.push({ $id })

Secrets live outside the tree, flat, keyed by that $id:

doc.secrets['companions.c_3b71.id_number'] = 'A234567890'

Deleting a row therefore means deleting its secrets by prefix, which is what removeEntry() in examples/shared/form.ts does. It is also the reason identity is the $id: remove the middle row of a list keyed by position and every password below it shifts onto the wrong person.

Errors point at both. path uses the index, because that is the row on screen; itemPath uses the $id, because that is what the secret key is built from:

{ path: 'companions[1].guardian', field: 'guardian', group: 'companions_group',
  itemPath: 'companions.c_3b71', code: 'required',
  reason: 'Guardian is required' }

Conditions inside a row resolve inside that row — guardian is visible when this row's is_child is true. To reach out to the form as a whole, the schema says $.:

{ "name": "room_share", "type": "boolean",
  "visible_when": { "field": "$.need_accommodation", "is_true": true } }

So every row's "share a room" checkbox appears the moment the accommodation toggle goes on, and none of that reaches the component.


6. Submitting: the payload is the checker's output

const { values, secrets, errors } = compiled.validate(doc)
if (errors.length > 0) return

await fetch('/api/registrations', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ values, secrets }),
})

Post values and secrets, never doc. They are not the same object:

  • defaults are filled in (invoice_type: 'personal', need_accommodation: false),
  • values of hidden fields are removed,
  • transient fields are dropped after they are checked.
const out = compiled.validate(fullPayload)
'password_confirm' in out.secrets   // false — it was compared, then dropped

Submitting the form's own state instead would send the confirmation to the server, along with whatever a since-hidden field still happened to be holding. The same applies to a document that arrives from elsewhere: run it through validate() once before it becomes component state, which is what emptyDoc() in examples/shared/form.ts does for a new form.


7. Reopening: what a GET hands back is not what you sent

The server stores values and secrets, and answers with redact():

const pub = compiled.redact(stored)
{
  "values": {
    "full_name": "Mei-Ling Chen",
    "phone": "*********5678",                  // mask: { keep_tail: 4 }
    "special_request": "*********************************",  // mask: "all"
    "companions": [ { "$id": "c_8f2a", "name": "Xiao-Ming Wang", ... } ],
    ...
  },
  "secretsSet": [
    "companions.c_3b71.id_number",
    "companions.c_8f2a.id_number",
    "password"
  ]
}

Two things the form has to render honestly.

secretsSet is a list of paths, not values. It is the difference between a password box that says "already configured" and one that looks empty. Leave the input blank, and submit '' for it — the patch rules read '' as "untouched".

A masked value is not the value. '*********5678' is what the field holds now, and the user must not be invited to treat it as editable text. The examples read the mask rules off the schema rather than sniffing for asterisks, and show the control as held:

const masked = new Set()
compiled.walk((cf) => { if (cf.f.mask) masked.add(cf.path) })   // schema paths
// -> render "held by the server as *********5678", with an "edit" affordance
//    that clears the control before the user types

And a caution that belongs on this page rather than in a footnote: running validate() on a reopened document reports things that are not wrong.

compiled.validate(reopened).errors.map((e) => `${e.path}: ${e.code}`)
// [ 'companions[0].id_number: required',
//   'companions[1]: duplicate',
//   'companions[1].id_number: required',
//   'password_confirm: required',
//   'password_confirm: password_mismatch' ]

Nothing is missing on the server. The form simply was not shown the ID numbers, so it cannot see that they are present and distinct. A client cannot check what it was never shown — so in an edit, the count on screen comes from the server's answer to the patch (§9), not from validate().


8. Editing: send only what changed

const loaded = { values: structuredClone(pub.values), secrets: {} }
// ... the user changes the company name ...
const patch = compiled.diff(loaded, current)
{ "values": { "company": "Brobridge Inc." } }

That is the whole request. Every other field is absent, which the server reads as "unchanged" — including phone, which the form only ever held as '*********5678'.

This is load-bearing, not an optimisation. Nothing marks a masked value as masked; it is a string like any other. A form that PUTs the whole document back does this:

compiled.apply(stored, { values: { phone: loaded.values.phone } }).values.phone
// '*********5678'   — the mask is now the stored value, and no rule can undo it

There is no second protection, deliberately: apply() cannot reject what it cannot recognise. Sending a patch is the protection.

Rows behave the same way. Edit one companion, delete another, add a third:

{
  "values": {
    "companions": [
      { "meal": "normal", "$id": "c_8f2a" },
      { "$id": "c_9c04", "name": "Ah-Bao Lin", "meal": "normal", "is_child": false },
      { "$id": "c_3b71", "$deleted": true }
    ]
  },
  "secrets": { "companions.c_9c04.id_number": "B123456789" }
}

An untouched row contributes nothing, an edited one contributes only its changed fields plus its $id, a deleted one contributes $deleted, and the new row carries its secret. After apply() the server holds two companions and c_3b71's ID number is gone with its row.

diff() never clears a secret on its own. A form never reads a secret back, so "I do not have it" and "delete it" cannot be told apart from the values alone, and only one of the two is recoverable if the guess is wrong. Clearing a secret stays something the caller says outright — { secrets: { password: null } }.


9. Saving: the server's answer is the one on screen

apply() belongs to whoever holds the stored document — the Go server in most deployments, this library in a Node one. The browser calls it only to show what the server would answer, which is what the examples do; the authority is still the process that persists the result.

It merges the patch into the document the form was never given, then checks the result:

const next = compiled.apply(stored, patch)
if (next.errors.length > 0) return respond(422, { errors: next.errors })
persist(next)          // the checker's output again, not anything you assembled

Two things only exist here.

readonly is enforced only in apply(), because rejecting a change needs a baseline to compare against:

compiled.apply(stored, { values: { amount: '1.00' } }).errors
// [ { path: 'amount', code: 'readonly',
//     reason: 'Amount due is read-only and cannot be changed' } ]

transient fields are the form's, and only the form's. apply() reports nothing about one, or about a rule that reads one:

compiled.apply(stored, { values: { company: 'X' } }).errors   // []

Without that, password_confirm — required whenever a password is set, and required to equal it — would fail on every unrelated update, because the stored document structurally cannot contain a confirmation. A server cannot check what it was never sent, the mirror image of §7. compile() works out which rules those are once, so no backend has to remember.

So an edit screen shows the server's errors plus its own transient ones:

const shown = [...server, ...form.filter((e) => transient.has(e.field))]

which is the whole body of mergeErrors() in examples/shared/form.ts. Note that the server's half needs no filtering — apply() already reports nothing about a transient field. All this adds is the half only the form can supply.


10. Before a destructive save

Turning the accommodation toggle off produces a one-key patch:

{ "values": { "need_accommodation": false } }

and takes five values with it, because those fields stop existing:

compiled.impactOf(stored, patch)
// [ 'companions.c_3b71.room_share', 'companions.c_8f2a.room_share',
//   'nights', 'room_type', 'special_request' ]

impactOf() answers "what would this throw away" before it happens, which is what a "this will clear 5 fields, continue?" dialog is made of. It is the one call with no equivalent anywhere else in the library.


Wiring it into a component

The two examples keep one piece of state and derive the rest. React:

const [doc, setDoc] = useState(() => emptyDoc(compiled))

const result = useMemo(() => compiled.validate(doc), [compiled, doc])
const vis    = useMemo(() => compiled.visible(doc), [compiled, doc])
const patch  = useMemo(
  () => (saved ? compiled.diff(saved.loaded, { values: result.values, secrets: result.secrets }) : null),
  [compiled, saved, result],
)

function setValue(path, v) {
  setDoc((d) => ({ ...d, values: v === undefined ? delIn(d.values, path) : setIn(d.values, path, v) }))
}

Vue, the same contract:

const doc = ref(emptyDoc(compiled))
const vis = computed(() => compiled.visible(doc.value))
const result = computed(() => compiled.validate(doc.value))

Replace the document rather than mutating it — the derived values are cheap, and a mutation that skips them puts the screen and the checker out of step. The three operations both apps expose are setValue, setSecret and removeEntry; everything else on screen is a function of doc.


What this library is not

The server remains the authority. Everything here exists to make the form honest while the user is still typing: the same schema, the same rules, the same codes, so the answer does not change when they press submit. It does not replace the check that happens afterwards, and readonly is the standing proof — one rule the browser structurally cannot enforce.