Form / foldkit-form
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 input admits for it, tried in order: leaving the key out (Schema.optionalKey,Schema.optional),Option.none()for a key typedOption,null, then the empty value itself. A key isrequiredexactly when none is admitted, so an optional key,Schema.NullOr, and an array need no flag from you. A plainSchema.Stringadmits""; addSchema.isMinLength(1)to require it. - A key typed
Option(Schema.OptionFromNullOr(Schema.String)) is edited as what it holds: aTextcontrol, checked by the inner schema, submittingOption.some(value), and filled from anOption. - 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. ValidatedAllchecks every key a submit would, rows too, and submits nothing: for a page that sends several forms at once. A rule that spans keys is said only by a submit.- A validation that changes nothing, such as a blur on an empty optional key, returns the Model it was given, so nothing redraws.
Two questions a view asks before a submit:
| Query | True when |
|---|---|
form.isValid(model) | a submit now would hand over the value at once: every key and row valid, no check running or still to run |
form.canSubmit(model) | nothing is invalid; a check still running does not stop it, since the submit waits for the answer |
form.value(model) | the decoded input as Some when every key and row is valid as it stands, None otherwise: all or nothing, unlike partial |
A page that submits its forms itself reads isValid and sends form.value;
a Save button reads canSubmit.
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.tsconst 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. - The ask is the form's Command
PostForm.Check(namedPostForm.check), a Command definition like any other, so a Scene or Story resolves it withCommand.resolve(PostForm.Check, PostForm.Message.Checked({ key, draft, error })). - 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.