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 typed Option, null, then the empty value itself. A key is required exactly when none is admitted, so an optional key, Schema.NullOr, and an array need no flag from you. A plain Schema.String admits ""; add Schema.isMinLength(1) to require it.
  • A key typed Option (Schema.OptionFromNullOr(Schema.String)) is edited as what it holds: a Text control, checked by the inner schema, submitting Option.some(value), and filled from an Option.
  • 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.
  • ValidatedAll checks 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:

QueryTrue 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:

ts
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. Valid on 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 needs RemoteClient makes the placement need it. Add such a check with the Form.checks step, not in Form.make's options: Effect's R is not an inference site a mapped type can win, so Form.make reads it as never and refuses the check ("RemoteClient is not assignable to never"). The step reads the requirement off the functions it is given.
    ts
    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.
  • The ask is the form's Command PostForm.Check (named PostForm.check), a Command definition like any other, so a Scene or Story resolves it with Command.resolve(PostForm.Check, PostForm.Message.Checked({ key, draft, error })).
  • A submit waits. Submitted while a check runs, the form sets submitPending and sends the value when the last check passes, or nothing if one fails. An edit in between cancels the wait. A filled form (fill validates nothing) is checked on submit the same way.
  • context.values holds the other keys that currently decode, which is how an edit form lets a post keep its own slug.
  • context.subject is 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. Send Message.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 survives fill and Reset: 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:

ts
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:

ts
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,
  },
})
MessageSaid whenDefault
required(field)an empty draft the schema does not admitRequired
unparsed(field)text its control cannot read, such as a Number that is not onethe 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'smessage
form(message)the input as a whole fails: a rule that spans keysmessage

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:

ts
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.