xoong
Menu · Forms flow
DocsArchitectureForms flow

Forms flow

A form is a typed draft of a record, the baseline it came from, and one small piece of state per field path. The rules come from the request schema of the operation; the DOM only shows the model.

This page is the inside view. For using forms, see Forms.

From the schema to the controls

The pieces, in the order the data meets them:

From the request schema to the form controls At the top left, the request schema in the OpenAPI document becomes the bodySchema node tree in operations.json when xoong sync runs. At the top right is the record, the item of the enclosing xo-view. Both feed the form model, created by createForm: the bodySchema is its schema and a copy of the record is its baseline. The model holds the baseline, the draft and the state of every path. Below it is the DOM binder, attach(form). Effects carry values and errors from the model to the binder, and set(path, raw) carries changes back. At the bottom is the screen the person sees: a window with the form controls (input, select, textarea, XoControl), which the binder writes to and whose input, change and focusout events it hears. To the right of the screen, the code you write, in a window named people.html, which makes the form. The code window shows people.html: <xo-view get="getPerson" p-id="1"> <xo-form op="updatePerson" submit="Save"></xo-form> </xo-view>. Steps: 1. xoong sync wrote the request schema of the operation into operations.json, as the bodySchema node tree. 2. createForm builds the form model from the bodySchema, and keeps a copy of the record as its baseline. 3. The model's values and errors reach the DOM binder through effects. 4. The binder writes them into the form controls: the screen shows the record's values. 5. You change the e-mail field. The control fires input (and later change and focusout), and the binder hears it through delegated events. 6. The binder tells the model: set(path, raw). The model is the only place that holds the value. 7. The model validates the new value: the e-mail is not valid. Its effects carry the new state to the binder. 8. The binder writes the error note: the field shows "Enter a valid email address." and is marked invalid. When the animation plays, a dot follows the road in order while the code that causes the step is lit. The schema becomes the bodySchema and, with the record, makes the model; the model's values go out through the binder to the controls on the screen. Then a change a person makes comes back as set(path, raw), and the new value and error go out again. Request schemarequestBody in OpenAPI bodySchemanode tree in operations.json Recorditem of the enclosing xo-view Form modelcreateForm({ schema, value, rules })baseline, draft, state per path DOM binderattach(form): delegated listeners, effect per control is drawn as xoong sync baseline = copy effects set(path, raw) value, error note input, change, focusout

The model is the single source of truth. The DOM is a view of it: values go out through effects, changes come back through delegated events. In motion, a dot follows the road in order while the code that causes the step is lit. The schema becomes the bodySchema and, with the record, makes the model; the model's values go out through the binder to the controls on the screen. Then a change a person makes comes back as set(path, raw), and the new value and error go out again. Use the slider, or press a number, to look at one step.

  1. bodySchema. xoong sync converts the JSON request body of an operation into a compact node tree and stores it in operations.json. A node has a type t (string, integer, number, boolean, object, array, union, any) and the rules that OpenAPI states: format, enum, length, range, pattern, nullable, readOnly, default, title, description. allOf is merged; oneOf: [X, null] is a nullable X; a oneOf of objects becomes a union when a discriminator or a property with one fixed value per member tells them apart. Otherwise the node is any, which is accepted as it is and not drawn by <xo-form>, and xoong doctor warns (XO161). A multipart body has no bodySchema: xo-submit sends FormData.
  2. The record. An edit form (the operation has a path parameter, or xo-body names a record) starts from the record in its scope, read from the store. A create form starts from the schema's defaults.
  3. The model copies the record twice: the baseline (what was loaded) and the draft (what the person edits).
  4. The binder connects the model to the real <form>.
{
  "t": "object",
  "required": ["title"],
  "props": {
    "title": { "t": "object", "props": { "en": { "t": "string", "minLength": 1 }, "vi": { "t": "string" } } },
    "publishedAt": { "t": "string", "format": "date-time", "nullable": true },
    "blocks": { "t": "array", "items": { "t": "union", "key": "type", "map": {
      "text":  { "t": "object", "props": { "type": { "t": "string", "enum": ["text"] }, "body": { "t": "string" } } },
      "image": { "t": "object", "props": { "type": { "t": "string", "enum": ["image"] }, "src": { "t": "string", "format": "uri" } } }
    } } }
  }
}

The form model

createForm({ schema, value, rules }) has no DOM and no framework: it is pure signals, so it is tested in plain Node.

import { createForm } from '@xoong/forms';

// No DOM needed: the model runs in plain Node, which is how it is tested.
const form = createForm({ schema: op.bodySchema, value: record });
form.set('title.en', 'Hello');       // a control's raw value, coerced to the schema's type
form.field('title.vi').error;        // reactive: read it inside an effect or a template
form.dirty;                          // normalise(draft) differs from normalise(baseline)
await form.submit((body) => client.mutate('updateArticle', { id }, body));
<xo-view get="getArticle" p-id=":id">
  <form xo-submit="updateArticle">
    <label>Slug <input name="slug"></label>
    <label>Title <input name="title.en"></label>
    <small xo-if="$form.errors['title.en']">{$form.errors['title.en']}</small>
    <p xo-if="$form.dirty">Unsaved changes</p>
    <button>Save</button>
  </form>
</xo-view>
const form = document.querySelector('form');   // after its xo-view has started
form.rules = {
  slug: async (value, { record }) => ((await isTaken(value, record.id)) ? 'Already used' : ''),
  '*': (record) => (record.start > record.end ? { end: 'Must be after the start' } : {}),
};
form.serialize = (draft, body) => body;        // the escape hatch for the payload

The DOM binder

Every <form xo-submit> whose operation has a bodySchema gets a binder as soon as its view starts, once @xoong/forms is imported (importing it registers the binder with xo-submit). It is attached at once, not at the first click, so fields are filled before anyone touches the form.

The binder announces itself with events that bubble from the form: xo-form-ready (form.model exists from then), xo-dirty ({ dirty }: the router listens to this, see Router), xo-array-change and xo-reveal. The view around the form fires xo-invalid. In markup, $form is the model: $form.dirty, $form.valid, $form.submitting, $form.errors["title.vi"], $form.touched[...], $form.filled[...]. A tab can show an error dot from the model, even for fields that are not on the visible tab.

The submit pipeline

The submit pipeline Submitting runs these steps in order. 1. touchAll makes every error visible. 2. validate runs the schema rules, the custom rules and the async rules, and waits for them. If anything is invalid, nothing is sent: a cancelable xo-reveal fires, the first invalid field gets focus, and then xo-invalid fires. If valid, 3. normalize(draft) reduces the draft to the request schema, following emptyAs and pruneEmpty. 4. client.mutate sends it. On success, the saved record becomes the baseline for an edit form, and a create form resets, unless it has xo-keep. On failure, the error fields land on their paths and the detail is shown as a summary. touchAll()every error becomes visible validate()schema rules, custom rules,async rules (awaited) Invalid: nothing sentxo-reveal, then focus;then xo-invalid fires normalize(draft)only request-schema fields; emptyAs client.mutate(op, params, body)optimistic, send, normalise, invalidate Successedit: saved record = new baselinecreate: the form resets Failureerror.fields land on theirpaths; detail as a summary valid invalid ok error

Nothing leaves the page until the model says the draft is valid.

The generated form

<xo-form op="updateUser"> is only a markup generator. It writes the <form xo-submit> you would have written from the operation's bodySchema, and from then on it is an ordinary form with the same binder. There is no second code path and no second validation.

<!-- you write -->
<xo-form op="updateUser" submit="Save"></xo-form>

<!-- the template writes (then the binder takes over, as for any xo-submit form) -->
<form xo-submit="updateUser" class="xo-form">
  <label class="xo-field"><span>Name</span><input name="name"></label>
  <label class="xo-field"><span>Email</span><input name="email"></label>
  <button class="xo-submit">Save</button>
</form>
{
  "fields": {
    "Article.slug": { "hint": "Used in the address", "order": 1 },
    "Article.body": { "widget": "xo-richtext", "label": "Body", "order": 2 },
    "Article.internalNote": { "hidden": true }
  }
}

Where to look in the code