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.
| Kind | Draft | Chosen when |
|---|---|---|
Text, Multiline | string | the schema is a string (Multiline only when asked for) |
Hidden | string | only when asked for: a key the form carries and does not show, such as the id being edited |
Number | string | the schema is a number |
Toggle | boolean | the schema is a boolean |
Select | string | the schema is a union of string or number literals; carries options, and gives the chosen option itself, so 3 is 3 |
RelationOne | string (an id, "" for none) | the key is Relation.input of a one |
RelationMany | string[] | the key is Relation.input of a many |
The resolver goes from the most to the least explicit source:
inputsgiven toForm.make, for this form only.Input.of(control)metadata on the Entity member, for that member everywhere.- The relation the key writes.
- The shape of the key's schema.
If none of them says, Form.make throws and names the key. It does not guess.
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:
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'draftis'text','flag'(a boolean) or'list'(ids).parsereads text as something else before the key's schema sees it, asNumberdoes;undefinedmeans it does not read, andunparsedis what to say. Text that is only spaces is nothing entered, not a failure to parse.shown: falsemakes a kind that is carried and not drawn, asHiddenis.- 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.
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 ownWhat the form does with it:
- The Bundle's Messages travel as the form's
ControlMessage, andcolor.sendbuilds 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 makesauthoredChangedtrue, 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
ControlMessages. The form's Bundle gains the control's Subscriptions and Resources, keyed under the key (ColorPicker@fields.color/…), andinitstarts the control's init Commands. fill,partial,settled, submit andResetall go throughvalue,fillandsettled.Resetreturns the Bundle's initial Model.- Nothing entered is
valuegivingundefinedornull, which a required key refuses as it would an empty text box. - Types follow the control.
model.fields.color.valueis the picker's Model,color.sendtakes only the picker's Messages, andfieldrefuses 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:
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:
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.