Form / foldkit-form

Controls

A control is data: a kind (a name), the draft the Model holds while it is edited, and whatever data its kind needs, with nothing about how it is drawn. There is one primitive, and every kind is a value of it, the ones below included.

KindDraftChosen when
Text, Multilinestringthe schema is a string (Multiline only when asked for)
Hiddenstringonly when asked for: a key the form carries and does not show, such as the id being edited
Numberstringthe schema is a number
Togglebooleanthe schema is a boolean
Selectstringthe schema is a union of string or number literals; carries options, and gives the chosen option itself, so 3 is 3
RelationOnestring (an id, "" for none)the key is Relation.input of a one
RelationManystring[]the key is Relation.input of a many

The resolver goes from the most to the least explicit source:

  1. inputs given to Form.make, for this form only.
  2. Input.of(control) metadata on the Entity member, for that member everywhere.
  3. The relation the key writes.
  4. The shape of the key's schema.

If none of them says, Form.make throws and names the key. It does not guess.

ts
import { Form, Input } from 'foldkit-form'

const Cms = Post.pipe(Entity.annotateMembers({ title: Input.of(Input.multiline()) }))

Form.make('Rename', Entity.input(Cms, RenameInput), { inputs: { id: Input.text() } })

A key that holds an id without being a relation asks for a picker with Input.relationOne(Author) or Input.relationMany(Tag), as the page Builder's Block props do.

Your own kind of control

The kinds above are made with Input.kind, and so is a date picker, a rich text editor, or a price:

ts
const Cents = Input.kind<{ readonly currency: string }>('Cents', {
  draft: 'text', // what the Model holds while it is edited
  // The value the schema is given, when that is not the text itself.
  parse: draft => (/^\d+(\.\d{1,2})?$/.test(draft) ? Math.round(Number(draft) * 100) : undefined),
  unparsed: 'Enter an amount',
})

Form.make('Price', input, { inputs: { cents: Cents.of({ currency: 'USD' }) } })

Cents.is(control) && control.data.currency // 'USD'
  • draft is 'text', 'flag' (a boolean) or 'list' (ids).
  • parse reads text as something else before the key's schema sees it, as Number does; undefined means it does not read, and unparsed is what to say. Text that is only spaces is nothing entered, not a failure to parse.
  • shown: false makes a kind that is carried and not drawn, as Hidden is.
  • Nothing else in the form treats a shipped kind differently. A view draws your kind once it has a renderer for it; see foldkit-mixins-form.

A control with a Model of its own

Some controls are more than a draft. A color picker has a popover that is open or closed and a palette it looked up; a page builder has a selection and an undo history; a rich-text editor has a document and a cursor. Input.bundle makes such a control out of an ordinary Bundle: the key's draft is the Bundle's Model, and the form carries everything else the Bundle has.

ts
const ColorInput = Input.bundle('ColorPicker', {
  bundle: ColorPicker, // Model { open, hex, recent }, its own Messages, a Command, a Subscription
  value: model => model.hex, // what the key validates and submits
  fill: (model, hex) => ({ ...model, hex }), // a value the form was given, written into the Model
  settled: model => ({ ...model, open: false }), // a stored form shown again: nothing in flight
})

const PostForm = Form.make('PostForm', input, { inputs: { color: ColorInput } })
const color = PostForm.control('color')

color.field(model) // { _tag, value: { open, hex, recent } }: validation state, the Model as value
color.send(ColorMessage.Opened()) // the form's Message carrying one of the picker's own

What the form does with it:

  • The Bundle's Messages travel as the form's Control Message, and color.send builds one. One the Bundle does not take is dropped.
  • An edit is a change of value. A Message that changes value(model) is validated against the key's schema, clears the last submit's failures, and makes authoredChanged true, so a CMS autosaves it. A Message that changes only the rest of the Model, such as opening the popover, is none of those.
  • Its Commands, Subscriptions and Resources are the form's. Commands come back as Control Messages. The form's Bundle gains the control's Subscriptions and Resources, keyed under the key (ColorPicker@fields.color/…), and init starts the control's init Commands.
  • fill, partial, settled, submit and Reset all go through value, fill and settled. Reset returns the Bundle's initial Model.
  • Nothing entered is value giving undefined or null, which a required key refuses as it would an empty text box.
  • Types follow the control. model.fields.color.value is the picker's Model, color.send takes only the picker's Messages, and field refuses the key, whose draft is not text.

The Bundle may not have an OutMessage, since a key has no parent to hand one to, and its Commands may need no services. A key given a control like this takes no check; say what is valid in its schema. A nested form whose control has Subscriptions or Resources is refused, because a row cannot run them yet.

A key that follows another

A slug is its title until the author decides otherwise. Input.following keeps the key's own control and writes its draft from another key:

ts
const PostForm = Form.make('PostForm', input, {
  inputs: { slug: Input.following('title', slugify) },
})

PostForm.isFollowing(model, 'slug') // false once the author has written it
PostForm.partial(model) // what decodes as it stands, by key: for saving unfinished work
PostForm.settled(model) // a stored Model, shown again with no check or submit in flight
PostForm.Message.Refused({ key: 'slug', error: 'That address is taken' }) // a server's word, on its key
  • While the author has not written the key, each edit of the key it follows rewrites it, through the function given, and it is validated and checked as if typed. With nothing to follow yet it shows no failure; a submit still does.
  • Once they write it, it is theirs, and the key it followed moves on without it. Emptying it hands it back, which is how a view offers "regenerate".
  • A form filled with a value for it does not follow. An address that is already published must not move because its title was edited. Filled with nothing for it, it follows.
  • A key may follow a key that follows. A key that follows itself, through any chain, or that follows something that is not another text key of the form, is refused when the form is made.

A picker that searches

A relation usually has too many targets to list. Input.search() keeps the key's picker and gives it a search:

ts
const EditPost = Form.make('EditPost', input, { inputs: { authorId: Input.search() } })

EditPost.Message.Searched({ key: 'authorId', text: 'ad' })
EditPost.search(model, 'authorId') // 'ad'

The form holds what was typed and does nothing else with it: it changes no draft and validates nothing. The application reads it as the input of the query that lists the choices, so finding an author is an ordinary query. A fill or a reset starts the search over. Input.search() on a key that is not a relation throws.

Labels

A label is the schema's own title annotation, and the description its description, first on the input key's schema and then on the Entity field's. Those annotations already reach JSON Schema, so an agent tool and a form read the same words. A relation has no schema to annotate, so it takes Form.label('Author', 'Who wrote it') as Entity metadata. With neither, the label is the key.