Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 

README.md

Examples

Two front-ends, one schema, no per-field code: the same form drawn by React and by Vue, both driven entirely by a dyfields schema. Both are TypeScript, and take their types from the library's own declarations — a schema key that does not exist is a compile error rather than an undefined on screen.

cd examples/react && npm install && npm run dev
cd examples/vue   && npm install && npm run dev

Each opens a page with the form on the left and, on the right, the payload that a submit would send — recomputed on every keystroke. Submit once and the demo keeps the document as if a server had stored it and reopens the form on what a GET would hand back; from then on the panel shows the patch an update would send instead. npm run typecheck checks the types without building; npm run build does both.

What they demonstrate

Where to look
Drawing a form from a schema Field.tsx / DyField.vue — one component, thirteen types, any depth
Which fields exist right now visible() decides; the components never re-implement visible_when
What is wrong, all of it at once validate().errors, keyed by path, shown under the control that caused it
Where a secret goes doc.secrets['companions.c_3b71.id_number'], never inside values
What gets submitted validate().values and .secrets, not the raw inputs
What is safe to log redact()
What an update sends diff(loaded, current) — only what the user actually changed
What the server answers apply(stored, patch), on a document the form was never given
A value the form is not allowed to see mask + redact(); the control says it is held, not empty

Both examples pick between two schemas at the top of the page: the event registration form (every group mode, a file upload, a decimal, a transient password confirmation, a companion list whose rows read a field at the root of the form) and the data pipeline form (two levels of object_list, secrets at both levels, a map, a json escape hatch, and fields that appear only for the sink type you picked). Both schemas are the fixtures the Go and JavaScript test suites already run against, imported from testdata/ rather than copied, so what the demo draws cannot drift away from what the checkers are tested on.

Layout

examples/
  shared/
    schemas.ts       the two demo schemas
    form.ts          path arithmetic, field-to-control mapping, input coercion, the shared
                     types, and store() -- the eight-line stand-in for a server
    dom.ts           event-target narrowing, in one place
    styles.css       one stylesheet, so the only difference between the two is the code
  react/src/
    App.tsx          groups, the three group modes, the document and the ways to change it
    Field.tsx        one field; renders itself again for the rows of an object_list
    Payload.tsx      the right-hand panel
    form-context.ts  what every field needs, and the shape of it
  vue/src/
    DyForm.vue       the document, provided to the tree
    DyGroup.vue      one group
    DyField.vue      one field; renders itself again for the rows of an object_list
    DyPayload.vue
    form.ts          the same contract, as a typed InjectionKey

The two apps share the same model on purpose — the document is replaced rather than mutated in both, and the three operations (setValue, setSecret, removeEntry) have the same signatures — so the React and Vue versions can be read side by side. The one place they differ is the reactive wrapper: React's context carries plain values, Vue's injection carries a Ref and two ComputedRefs, and both say so in their types, so a component that reads doc.values where it should read doc.value.values never reaches the browser.

Four ideas worth taking away

The form asks; it never decides. Every rule in these examples comes out of validate() and visible(). There is no per-field if in the components, and no rule is written twice — which is the only way the browser and the server can agree about what is valid.

The payload is the checker's output, not the inputs' contents. validate() returns a document with defaults filled in, invisible fields removed and transient fields dropped. Submitting that, rather than the raw form state, is what stops the form and the server from disagreeing about what was sent. It also means a document loaded from a server should be run through validate() once before it goes into component state — which is exactly what emptyDoc() in shared/form.ts does for a new form.

A row's identity is its $id, not its position. Rows carry a client-generated $id, secrets are keyed by it, and deleting a row deletes its secrets by prefix. That is why removing the middle row of a list cannot shift the next row's password onto it.

A client cannot check what it was never shown. After the first submit the form holds neither the secrets nor the masked values, so it is in no position to say whether the record is complete — validate() on what it holds would report fields missing that are not missing at all. In an edit the demo therefore shows apply(stored, patch): the server's answer, on the merged document. The one exception is the transient fields, which are dropped from the payload by design and so never reach the server at all; those the form checks itself. The library draws that line rather than the demo: apply() reports nothing about a transient field, because a server cannot check what it was never sent — so mergeErrors() in shared/form.ts only has to add the form's own transient errors back, and a real backend gets the other half for free.

Three things a real form would add

  • Errors before the user has typed anything. The demo shows every problem immediately, because that is what makes the checker visible. A real form usually waits until a field has been touched, or until submit, and keeps the full list for the summary panel.
  • A real server. The demo's "server" is store() in shared/form.ts: it keeps the document, hands back the redacted version, and pockets the masked values. Behind a real one the same three calls sit on the other side of the wire — redact() on the way out, apply() on the way in — and nothing in the components changes.
  • impactOf() before a destructive save. impactOf(current, patch) lists the values a patch is about to throw away, which is what a "this will clear 3 fields, continue?" dialog is made of. The demo applies the patch without asking.