xoong
Menu · Forms
DocsGuidesForms

Forms

A form is a <form xo-submit> inside an xo-view. The request schema of the operation gives each field its type, its limits and its errors, and the record fills the fields.

Generated from the schema

<xo-form op="createPerson"> (inside an <xo-view>) draws every field of the request body: text, e-mail, a select for the enum, and the rich widgets chosen by the schema (x-xoong-widget in OpenAPI, or a hint). Submit it empty to see the validation messages, then add a person with an address that is already used (ada@example.org) to see a server error. The list refreshes by itself after a successful save.

Newest people

  • {name}
    {email}
<div class="split">
  <xo-view><xo-form op="createPerson" submit="Add person"></xo-form></xo-view>
  <xo-view get="listPeople" p-sort="-id" p-limit="5">
    <h3>Newest people</h3>
    <ul class="people"><li xo-each="items"><strong>{name}</strong><br><small>{email}</small></li></ul>
  </xo-view>
</div>
"PersonInput": {
  "type": "object",
  "required": ["name", "email"],
  "properties": {
    "name": { "type": "string", "minLength": 2, "maxLength": 40 },
    "email": { "type": "string", "format": "email" },
    "role": { "type": "string", "enum": ["admin", "editor", "viewer"] },
    "topics": { "type": "array", "items": { "type": "string", "enum": ["web", "data", "design", "ops"] }, "x-xoong-widget": "xo-combobox" },
    "keywords": { "type": "array", "items": { "type": "string" }, "x-xoong-widget": "xo-tags" },
    "bio": { "type": "string", "x-xoong-widget": "xo-richtext" }
  }
}
{
  "fields": {
    "PersonInput.name": { "label": "Full name", "placeholder": "Ada Lovelace", "order": 1 },
    "PersonInput.email": { "label": "E-mail", "placeholder": "ada@example.org", "order": 2 },
    "PersonInput.role": { "order": 3 },
    "PersonInput.topics": { "order": 4 },
    "PersonInput.keywords": { "placeholder": "Type a keyword and press Enter", "order": 5 },
    "PersonInput.bio": { "order": 6 }
  }
}

Written by hand

For a layout of your own, write the form. name is a path (title.en, items[0].x). The record fills the fields, errors appear after blur or submit, and $form tells the markup what is going on: dirty, valid, submitting, errors['email']. Put sections in <xo-tabs>: when a field on another tab is invalid, that tab opens before the focus moves there.

{$form.errors['name']} {$form.errors['email']}

Unsaved changes Saving…

<xo-view get="getPerson" p-id="1">
  <form xo-submit="updatePerson" xo-notice="Saved">
    <xo-tabs label="Person">
      <section xo-tab="General">
        <label class="field">Name <input name="name" autocomplete="off"></label>
        <span class="error" xo-if="$form.errors['name']">{$form.errors['name']}</span>
        <label class="field">E-mail <input name="email" type="email"></label>
        <span class="error" xo-if="$form.errors['email']">{$form.errors['email']}</span>
        <label class="field">Role
          <select name="role"><option>admin</option><option>editor</option><option>viewer</option></select>
        </label>
      </section>
      <section xo-tab="Topics">
        <xo-combobox name="topics" label="Topics" multiple>
          <option value="web">Web</option><option value="data">Data</option>
          <option value="design">Design</option><option value="ops">Operations</option>
        </xo-combobox>
        <xo-tags name="keywords" label="Keywords" placeholder="Add a keyword"></xo-tags>
      </section>
      <section xo-tab="Bio">
        <xo-richtext name="bio" label="Bio" toolbar="compact"></xo-richtext>
      </section>
    </xo-tabs>
    <p class="toolbar">
      <button>Save</button>
      <span class="hint" xo-if="$form.dirty">Unsaved changes</span>
      <span class="hint" xo-if="$form.submitting" aria-busy="true">Saving…</span>
    </p>
  </form>
</xo-view>

Clear the name or type a single letter, go to another tab and press Save: the first invalid field is revealed, and nothing is sent until the form is valid. The leave guard of <xo-router> uses the same dirty state.

Rows you can add, move and remove

An array of values or objects is edited with xo-each over the draft. xo-add appends a row, xo-remove removes one, xo-move moves it (the focus follows the row), and xo-drag on a handle lets a pointer drag it onto another row of the same list.

Keywords
⠿

Unsaved changes

<xo-view get="getPerson" p-id="2">
  <form xo-submit="updatePerson" xo-notice="Saved">
    <fieldset>
      <legend>Keywords</legend>
      <div class="row" xo-each="keywords">
        <span class="handle" xo-drag="keywords[{$index}]" title="Drag to reorder">⠿</span>
        <input name="keywords[{$index}]" aria-label="Keyword">
        <button type="button" xo-move="keywords[{$index}]:up" aria-label="Move up">↑</button>
        <button type="button" xo-move="keywords[{$index}]:down" aria-label="Move down">↓</button>
        <button type="button" xo-remove="keywords[{$index}]" aria-label="Remove">✕</button>
      </div>
      <button type="button" xo-add="keywords">Add keyword</button>
    </fieldset>
    <p class="toolbar"><button>Save</button> <span class="hint" xo-if="$form.dirty">Unsaved changes</span></p>
  </form>
</xo-view>

Your own rules

Schema rules always apply. Add rules the schema cannot express by path, with the loaded record at hand; a rule returns an empty string when the value is fine, or the message. Rules may be asynchronous, for example to ask the server whether a slug is free.

const form = document.querySelector('form');

form.rules = {
  email: (value) => (value.endsWith('@example.org') ? '' : 'Use a company address'),
  '*': (record) => (record.role === 'admin' && !record.topics.length ? { topics: 'Admins need a topic' } : {}),
};
form.serialize = (draft, body) => ({ ...body, name: body.name.trim() }); // the last word on the request body

On an invalid submit the form fires a cancelable xo-reveal event with the path, so tabs and collapsed sections can open before the focus moves, then xo-invalid.