foldkit-form
v0.2.0
Published
Forms for Foldkit: a Bundle over core field validation, built from an operation's input and the Entity it writes.
Maintainers
Readme
foldkit-form
A form as a Foldkit Submodel: built from the input an operation accepts, holding
what the user is typing, and handing the parent a decoded value when it is
valid. It adds no state system. Field state is Foldkit core's
fieldValidation, and the form is a foldkit-bundle
Bundle the parent places like any other.
Status: headless. It owns the Model, Messages, validation, and a description of each control, and draws nothing.
foldkit-mixins-formdraws it through Mixins slots; or draw fromcontrolsyourself.
What it owns
| Fact | Owner |
| --- | --- |
| What may be submitted, and when it is valid | the input Schema.Struct |
| What each input key means (a field, a relation's id) | Entity.input |
| The draft in each control, and whether it has been checked | foldkit-form, in the parent's Model |
| What happens to a submitted value | the parent: a Remote mutation, a Sync operation, a plain update |
| Whether a submit is in flight, and its failure | whoever performs it; not the form |
| The options of a relation picker | the application; the form only names the target Entity |
Mental model
Schema.Struct (the operation's input)
+
Entity.input (what each key means)
|
v
Form.make ──► controls: key · control · label · required (draw these)
|
v
Bundle
Model: one fieldValidation Field per key, holding the draft
Changed / Blurred ──► validate the draft against the key's schema
Submitted ──► every key valid, input decoded ──► out Message { value }A draft is what the control holds, which is not the value it submits. A
number being typed is text ("4." is a fine thing to have typed), an unchosen
relation is "". The draft becomes a value only when it is checked.
Install
pnpm add effect foldkit foldkit-bundle foldkit-entity foldkit-formExample
import { Schema } from 'effect'
import { Bundle } from 'foldkit-bundle'
import { Entity } from 'foldkit-entity'
import { Form } from 'foldkit-form'
const Post = Entity.define(
'Post',
Schema.Struct({
id: Schema.String,
title: Schema.String.check(Schema.isMinLength(1)).annotate({ title: 'Title' }),
}),
)
// The operation's input, declared once. Give the same value to the operation.
const RenameInput = Schema.Struct({ id: Schema.String, title: Post.fields.title.schema })
const Rename = Form.make('Rename', Entity.input(Post, RenameInput))
// Place it: a Model field and a Message variant of the page.
const Page = Bundle.compose({ saved: Schema.Array(RenameInput) }).pipe(
Bundle.withChild('rename', Rename.bundle, {
// `submitted.value` is a decoded RenameInput. What it means is the page's.
onOut: submitted => model => ({
model: { ...model, saved: [...model.saved, submitted.value] },
}),
}),
)
const RenameForm = Page.children.renameForm.maketakes the name and anEntity.input. It resolves a control per key and throws if it cannot. A view must also supply a renderer for each control kind, including custom kinds.Rename.bundleis an ordinary Bundle.onOutis required, so a submit is never dropped by omission.Rename.Messagebuilds the form's Messages for your view to dispatch:Changed({ key, value }),Blurred({ key }),Submitted(),Reset().Rename.controlslists the keys in the input's order, each with itscontrol,label,description,required, and the Entitymember.Rename.field(model.rename, key)reads one key's state asField<Draft>, for a view that walkscontrols;model.rename.fields.titleis the same value typed to its key.
Run one edit through the parent
Continuing the example above, an assembly initializes the slice and routes its wrapped Messages. This reducer-level example needs no renderer:
const { placements } = Page
const update = placements.update(model => ({ model }))
let model = placements.initial({ saved: [] }).model
model = update(model, Page.Message.GotRenameMessage({
message: Rename.Message.Changed({ key: 'title', value: 'Hello' }),
})).model
model = update(model, Page.Message.GotRenameMessage({
message: Rename.Message.Submitted(),
})).model
// model.saved contains the decoded input, with title "Hello" and id "".Here a plain Schema.String allows the empty id. For editing a real row, fill
the form with that row's id first; add an input-schema constraint if an empty
id is invalid for your operation. onOut stores the value locally in this
example. It does not save to a server. Forms with asynchronous checks also need
the runtime to execute their returned Commands.
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 literals; carries options |
| 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() } })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 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.
Validation
Each key is checked against the input's own schema for that key, not the Entity's: the operation decides what is valid, and may be stricter.
- An edit (
Changed) checks the new draft at once. - Leaving a control (
Blurred) checks the draft as it stands, which is how a required key left empty comes to say so. - Empty means
""or[]. What an empty draft submits is whatever the schema admits for it, tried in order: leaving the key out,null, then the empty value itself. A key isrequiredexactly when none is admitted, soSchema.optional,Schema.NullOr, and an array need no flag from you. A plainSchema.Stringadmits""; addSchema.isMinLength(1)to require it. - A number that does not parse reads
Enter a number; one that parses is checked by the schema. Only spaces is nothing entered, not zero. - Submit checks every key, so every failure shows. When all pass, the whole
input is decoded; a rule that spans keys fails there and lands in
model.errors, since it belongs to no one control. The next edit clears them.
Field state is foldkit/fieldValidation's Field: NotValidated, Validating
(a check is running), Valid, Invalid with its errors. Read it with that module's match, isInvalid,
and the rest.
Checks: rules only something else can answer
Whether a slug is taken is not in the schema. A check is an Effect the form is given; it answers with what is wrong, or nothing:
const PostForm = Form.make('PostForm', Entity.input(Post, PostInput), {
checks: {
// The decoded value, and whatever else in the form decodes right now.
slug: (slug, { values }) =>
isSlugTaken(slug, values.id).pipe(
Effect.map(taken => (taken ? `"${slug}" is taken` : undefined)),
),
},
debounce: '300 millis',
})A check runs after the key's own schema passes, never instead of it, so it receives a decoded value: a number, not the text that was typed.
While it runs the key reads
Validating, which is Foldkit core's own state for this.Validon a checked key means the check passed.The form does not know what answers. The check's requirements (
R) become the Bundle's, so a check that needsRemoteClientmakes the placement need it. Add such a check with theForm.checksstep, not inForm.make's options: Effect'sRis not an inference site a mapped type can win, soForm.makereads it asneverand refuses the check ("RemoteClientis not assignable tonever"). The step reads the requirement off the functions it is given.const PostForm = Form.make('PostForm', Entity.input(Post, PostInput), { debounce: 0, }).pipe(Form.checks({ slug: isAddressFree }))Each edit asks again after
debounce, and an answer for a draft the key no longer holds is dropped.A submit waits. Submitted while a check runs, the form sets
submitPendingand sends the value when the last check passes, or nothing if one fails. An edit in between cancels the wait. A filled form (fillvalidates nothing) is checked on submit the same way.context.valuesholds the other keys that currently decode, which is how an edit form lets a post keep its own slug.context.subjectis what the form is editing, for what its values do not say. A post's input carries a title and an address, not the row's id, so "is this address taken?" cannot tell the post's own address from someone else's. SendMessage.About({ subject: { id } })once when the form opens on a row, and the check has it;form.subject(model)reads it back. A form that creates something is about nothing, which is the{}it starts with. It changes no draft, answers no submit, and survivesfillandReset: which row the form is about does not change because its contents did.
Messages
Say a rule in its own words on the rule, where Effect Schema already takes them:
Schema.String.check(Schema.isMinLength(3, { message: 'Give it at least 3 letters' }))The form has three things to say for itself, and messages words them, or
rewrites what Schema says by default, which is also how a form is translated:
const Rename = Form.make('Rename', Entity.input(Post, RenameInput), {
messages: {
required: field => `${field.label} is missing`,
unparsed: field => `${field.label} must be a number`,
invalid: (field, message) => `${field.label}: ${message}`,
form: message => message,
},
})| Message | Said when | Default |
| --- | --- | --- |
| required(field) | an empty draft the schema does not admit | Required |
| unparsed(field) | text its control cannot read, such as a Number that is not one | the kind's own words (Enter a number) |
| invalid(field, message) | the key's schema rejects the draft; message is the check's own, or Schema's | message |
| form(message) | the input as a whole fails: a rule that spans keys | message |
field is the key, its label, and its control.
Words as text, in one place
Each of these may be text with blanks instead of a function: '{label} is required'
({label}, {key}, and {message} where there is one). Text can be kept in a
translation catalogue, and it can go where a function cannot: Foldkit admits no
function nested in a placed view's inputs. The drawn packages' words are text
too, and the three shapes share no key, so an application writes its words once:
import type { FormMessages } from 'foldkit-form'
import type { ViewWords } from 'foldkit-mixins-crud'
import type { FormViewWords } from 'foldkit-mixins-form'
export const words = {
required: '{label} is required', // foldkit-form
submit: 'Save', // foldkit-mixins-form
add: 'Another {label}',
yes: 'Live', // foldkit-mixins-crud
empty: 'No posts yet.',
} satisfies FormMessages & FormViewWords & ViewWords
Form.make('EditPost', input, { messages: words })
placed.view(model, h, { options, words })
PostTable({ page, words }, h)fillWords(template, values) is the blank-filling they all use.
Pipe steps
Every option is also a pipe step that gives a new form, made from the same input with that option added to:
const AuthorForm = Form.make('Author', NewAuthor) // as a library might hand it over
const Finished = AuthorForm.pipe(
Form.inputs({ bio: Input.multiline() }),
Form.checks({ name: name => isNameTaken(name) }),
Form.messages({ required: field => `${field.label} fehlt` }),
)Form.inputs, Form.checks, Form.messages, Form.nested and Form.debounce.
Each adds to what the form already has, so a form can be made in one place and
finished in another. A step's keys are checked against the form it is piped
into, and Form.checks adds what its checks need to what the form needs. The
first form is left as it was.
Nested input
A key mapped with Relation.nested
holds the relation's target, not an id. A nested form is a form: the key
holds rows, each a Model of the form built from the nested input.
const NewComment = Entity.input(Blog.Comment, Schema.Struct({ body: Schema.String }))
const CreatePost = Entity.input(
Blog.Post,
Schema.Struct({ title: Schema.String, comments: Schema.Array(NewComment.schema) }),
{ comments: Relation.nested(Blog.Post.relations.comments, NewComment) },
)
// The form that edits a comment alone is the form a post's form nests.
const CommentForm = Form.make('Comment', NewComment, { inputs: { body: Input.multiline() } })
const PostForm = Form.make('PostForm', CreatePost, { nested: { comments: CommentForm } })
PostForm.row('comments', 'r0').Changed({ key: 'body', value: 'First' }) // a Message of PostForm
PostForm.nested.comments // CommentForm
PostForm.Message.RowAdded({ key: 'comments' })
PostForm.Message.RowRemoved({ key: 'comments', row: 'r0' })
PostForm.rows(PostForm.initial, 'comments') // [{ id, model }], each a Model of the nested form| The key is | Rows |
| --- | --- |
| a one whose schema must be there | exactly one, from the start; it cannot be removed |
| a one that admits null or undefined | none or one; none submits that nothing |
| a many | any number, starting with none |
- The nested form is one you make and pass (
nested: { comments: CommentForm }), from the same inputRelation.nestedwas given. The form that edits a comment alone is the form a post nests, with its controls, checks and words. A nested key given none gets a plain form of its input, with this form'smessagesanddebounce. A form of another input throws. - A row is addressed with types.
PostForm.row('comments', id)has the nested form's own constructors (Changed,Blurred,Searched,RowAdded, ...), each givingPostForm's Message, andsend(message)for one already made, which is how a row inside a row is reached.PostForm.nested.commentsis the form itself. Underneath it isMessage.Nested({ key, row, message }). A Message for a row that is gone, or that is not one of the nested form, is dropped. A row's id is never reused. - A nested key's control is of kind
Nested, withdata{ cardinality, optional, form }(narrow it withInput.Nested.is(control));formis the nested form, with its owncontrols,field, androws. Nested keys are inmodel.rows, notmodel.fields. - A submit validates every row too and shows every failure; it waits for a check
running in a row as it does for its own. A rule on the list itself
(
Schema.isMaxLength(5)) is a failure of the whole, inerrors. fillfills rows from values, and nesting goes as deep as the inputs do.
Editing existing values
fill shows values as drafts, for an edit form. Keys you do not pass keep their
draft.
const load = RenameForm.helpers.fill({ id: 'p1', title: 'Hello' }) // an Update.Step of the pageWhat to load, and how a loaded value becomes input values, both follow from the
form's input, which the form keeps as Rename.input:
const Current = Entity.selectFor(Rename.input) // a Selection of `id` and `title`
declare const loaded: typeof Current.schema.Type
const prefill = RenameForm.helpers.fill(Entity.valuesFor(Rename.input, loaded))A relation is loaded as a ref and read back as the id the form holds. See
foldkit-entity.
foldkit-crud does this, the save, and its status for you.
Did an edit change anything?
authoredChanged(before, after) answers whether a completed transition changed
what the author wrote:
const next = RenameForm.bundle.update(model, message, undefined)
if (RenameForm.authoredChanged(model, next.model)) scheduleAutosave()It compares the two Models, not the Message: validation state, search text, the
edited subject, and row bookkeeping are not authored content, so a blur, a
refused edit, or a repeated value reports false while a changed draft, an
added or removed row, or a changed row reports true. Nested rows recurse
through their own forms, and a control whose output is written back into the
Model composes for the same reason — which is what lets a consumer such as
foldkit-cms autosave without knowing the form's Message
tags.
Limits
- Headless: no view here (see
foldkit-mixins-form), and no relation picker data.RelationOneandRelationManycarry the target Entity; listing its options is a query the application makes. - One
ChangedMessage carries any draft, so a view can dispatch a draft of the wrong kind for a key. The form ignores it rather than storing it. - A key whose value is a struct is editable only as a nested input of a relation's target; a free-standing struct has no control. Rows keep the order they were added in; there is no reordering.
