Schema form
A form drawn from the schema bosund serves for a record, with its groups, priorities, inputs, conditions and constraints.
On this page
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
SchemaForm,
type FieldOption,
type FieldState,
type SchemaField,
type FormSchema,
type FormValues,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The primary vessel's details as bosund serves them: eight groups of secondary and advanced fields, an MMSI that " +
"names its station, the mast height and keel type that leave when the propulsion is motor, read-only timestamps, " +
"help under its field, and each field saved on its own as it changes, the first save refused once.";
// SCHEMA and ENUM_OPTIONS are bosund's own: `GET /forms/vessel`, and `GET /forms/meta/<source>` for each enum source
// it names. A throwaway Go program printed them, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults and the
// fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor("vessel") and forms.GetMeta as JSON.
// bosund registers each option with its value as its label; ui/web looks `form.option.<source>.<value>` up first.
const SCHEMA: FormSchema = {
entity: "vessel",
package: "fleet",
save_mode: "auto",
groups: [
{ id: "identity", label: "identity", weight: 10 },
{ id: "radio", label: "radio", weight: 70 },
{ id: "hull", label: "hull", weight: 110 },
{ id: "dimensions", label: "dimensions", weight: 130 },
{ id: "ground_tackle", label: "ground tackle", weight: 190 },
{ id: "engine", label: "engine", weight: 250 },
{ id: "capacities", label: "capacities", weight: 280 },
{ id: "notes", label: "notes", weight: 320 },
],
fields: [
{
name: "name",
label: "Name",
type: "string",
priority: "primary",
required: true,
min: 1,
max: 200,
min_length: 1,
max_length: 200,
weight: 10,
group: "identity",
autofocus: true,
},
{
name: "make",
label: "Make",
type: "autocomplete",
priority: "secondary",
max: 100,
autocomplete: "vessel_makes",
weight: 20,
group: "identity",
min_chars: 2,
},
{
name: "model",
label: "Model",
type: "autocomplete",
priority: "secondary",
max: 100,
autocomplete: "vessel_models",
weight: 30,
group: "identity",
min_chars: 2,
},
{
name: "hull",
label: "Hull",
type: "enum",
priority: "secondary",
one_of: ["monohull", "catamaran", "trimaran"],
enum: "hull_types",
weight: 35,
group: "identity",
},
{
name: "propulsion",
label: "Propulsion",
type: "enum",
priority: "secondary",
one_of: ["sail", "motor", "motorsailer"],
enum: "propulsion_types",
weight: 36,
group: "identity",
},
{ name: "is_primary", label: "Primary Vessel", type: "bool", priority: "secondary", weight: 50, group: "identity" },
{
name: "home_port",
label: "Home Port",
type: "autocomplete",
priority: "secondary",
max: 200,
autocomplete: "ports",
weight: 60,
group: "identity",
min_chars: 2,
},
{
name: "mmsi",
label: "MMSI",
help: "9-digit Maritime Mobile Service Identity",
type: "string",
priority: "secondary",
min_length: 9,
max_length: 9,
pattern: "^\\d{9}$",
weight: 70,
group: "radio",
placeholder: "123456789",
keyboard: "numeric",
},
{
name: "call_sign",
label: "Call Sign",
type: "string",
priority: "secondary",
max: 10,
max_length: 10,
weight: 80,
group: "radio",
placeholder: "ZL1234",
},
{
name: "flag",
label: "Flag",
help: "Country of registration",
type: "string",
priority: "advanced",
max: 50,
max_length: 50,
weight: 90,
group: "identity",
},
{
name: "hin",
label: "HIN",
help: "Hull Identification Number",
type: "string",
priority: "advanced",
max: 50,
max_length: 50,
weight: 100,
group: "identity",
},
{
name: "imo_number",
label: "IMO Number",
help: "IMO ship identification number",
type: "string",
priority: "advanced",
max: 20,
max_length: 20,
weight: 101,
group: "identity",
},
{
name: "classification_society",
label: "Classification Society",
type: "string",
priority: "advanced",
max: 100,
max_length: 100,
weight: 102,
group: "identity",
},
{
name: "registered_owner",
label: "Registered Owner",
type: "string",
priority: "advanced",
max: 200,
max_length: 200,
weight: 103,
group: "identity",
},
{
name: "manager",
label: "Manager",
type: "string",
priority: "advanced",
max: 200,
max_length: 200,
weight: 104,
group: "identity",
},
{
name: "year_built",
label: "Year Built",
type: "int",
priority: "advanced",
min: 1800,
max: 2100,
weight: 105,
group: "identity",
keyboard: "numeric",
},
{
name: "hull_material",
label: "Hull Material",
type: "enum",
priority: "advanced",
max: 50,
enum: "hull_materials",
weight: 110,
group: "hull",
},
{
name: "hull_color",
label: "Hull Color",
type: "string",
priority: "advanced",
max: 50,
max_length: 50,
weight: 120,
group: "hull",
},
{
name: "loa_m",
label: "LOA",
type: "float",
priority: "secondary",
min: 0,
max: 500,
weight: 130,
group: "dimensions",
unit: "m",
keyboard: "decimal",
decimal_places: 2,
},
{
name: "registered_length_m",
label: "Registered Length",
type: "float",
priority: "advanced",
min: 0,
max: 500,
weight: 135,
group: "dimensions",
unit: "m",
keyboard: "decimal",
decimal_places: 2,
},
{
name: "beam_m",
label: "Beam",
type: "float",
priority: "secondary",
min: 0,
max: 100,
weight: 140,
group: "dimensions",
unit: "m",
keyboard: "decimal",
decimal_places: 2,
},
{
name: "draft_m",
label: "Draft",
help: "Critical for grounding avoidance",
type: "float",
priority: "secondary",
critical: true,
min: 0,
max: 50,
weight: 150,
group: "dimensions",
unit: "m",
keyboard: "decimal",
decimal_places: 2,
},
{
name: "mast_height_m",
label: "Mast Height",
help: "Critical for bridge clearance",
type: "float",
priority: "secondary",
critical: true,
visible_if: "data.propulsion != 'motor'",
min: 0,
max: 100,
weight: 160,
group: "dimensions",
unit: "m",
keyboard: "decimal",
decimal_places: 1,
},
{
name: "displacement_kg",
label: "Displacement",
type: "float",
priority: "advanced",
min: 0,
weight: 170,
group: "dimensions",
unit: "kg",
keyboard: "numeric",
},
{
name: "gross_tonnage",
label: "Gross Tonnage",
type: "float",
priority: "advanced",
min: 0,
weight: 175,
group: "dimensions",
keyboard: "numeric",
},
{
name: "depth_transducer_offset_m",
label: "Depth Transducer Offset",
help: "Depth below the waterline where the sounder transducer is mounted; lets bosun report true depth below the surface",
type: "float",
priority: "advanced",
min: 0,
max: 5,
weight: 175,
group: "dimensions",
unit: "m",
keyboard: "decimal",
decimal_places: 2,
},
{
name: "keel_type",
label: "Keel Type",
type: "enum",
priority: "advanced",
visible_if: "data.propulsion != 'motor'",
max: 50,
enum: "keel_types",
weight: 180,
group: "dimensions",
},
{
name: "anchor_type",
label: "Anchor Type",
type: "enum",
priority: "advanced",
max: 50,
enum: "anchor_types",
weight: 190,
group: "ground_tackle",
},
{
name: "chain_length_m",
label: "Chain Length",
help: "For scope calculation",
type: "float",
priority: "advanced",
min: 0,
max: 500,
weight: 200,
group: "ground_tackle",
unit: "m",
keyboard: "decimal",
decimal_places: 0,
},
{
name: "chain_factor",
label: "Chain Factor",
help: "Typical scope ratio (e.g. 5:1)",
type: "float",
priority: "advanced",
min: 1,
max: 20,
weight: 210,
group: "ground_tackle",
placeholder: "5",
keyboard: "decimal",
decimal_places: 1,
},
{
name: "engine_type",
label: "Engine Type",
type: "enum",
priority: "advanced",
one_of: ["diesel", "petrol", "electric", "hybrid"],
enum: "engine_types",
weight: 250,
group: "engine",
},
{
name: "fuel_capacity_l",
label: "Fuel Capacity",
help: "For range planning",
type: "float",
priority: "secondary",
min: 0,
weight: 280,
group: "capacities",
unit: "L",
keyboard: "numeric",
},
{
name: "water_capacity_l",
label: "Water Capacity",
type: "float",
priority: "secondary",
min: 0,
weight: 290,
group: "capacities",
unit: "L",
keyboard: "numeric",
},
{
name: "battery_capacity_ah",
label: "Battery Capacity",
type: "float",
priority: "advanced",
min: 0,
weight: 300,
group: "capacities",
unit: "Ah",
keyboard: "numeric",
},
{
name: "nominal_voltage",
label: "Nominal Voltage",
help: "DC house bank voltage",
type: "enum",
value_type: "float",
priority: "advanced",
one_of: ["6", "12", "24", "32", "36", "42", "48"],
enum: "nominal_voltages",
weight: 305,
group: "capacities",
unit: "V",
},
{
name: "notes",
label: "Notes",
type: "text",
component: "textarea",
priority: "advanced",
weight: 320,
group: "notes",
rows: 3,
},
{ name: "created_at", label: "Created At", type: "datetime", readonly: true, weight: 900, group: "notes" },
{ name: "updated_at", label: "Updated At", type: "datetime", readonly: true, weight: 910, group: "notes" },
],
collection: "/fleet/vessels",
item: "/fleet/vessels/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
anchor_types: [
{ value: "cqr", label: "cqr" },
{ value: "delta", label: "delta" },
{ value: "rocna", label: "rocna" },
{ value: "mantus", label: "mantus" },
{ value: "bruce", label: "bruce" },
{ value: "danforth", label: "danforth" },
{ value: "fortress", label: "fortress" },
{ value: "spade", label: "spade" },
],
engine_types: [
{ value: "diesel", label: "diesel" },
{ value: "petrol", label: "petrol" },
{ value: "electric", label: "electric" },
{ value: "hybrid", label: "hybrid" },
],
hull_materials: [
{ value: "grp", label: "grp" },
{ value: "steel", label: "steel" },
{ value: "aluminum", label: "aluminum" },
{ value: "wood", label: "wood" },
{ value: "ferro", label: "ferro" },
{ value: "carbon", label: "carbon" },
],
hull_types: [
{ value: "monohull", label: "monohull" },
{ value: "catamaran", label: "catamaran" },
{ value: "trimaran", label: "trimaran" },
],
keel_types: [
{ value: "fin", label: "fin" },
{ value: "full", label: "full" },
{ value: "lifting", label: "lifting" },
{ value: "centerboard", label: "centerboard" },
{ value: "bulb", label: "bulb" },
{ value: "wing", label: "wing" },
],
nominal_voltages: [
{ value: "6", label: "6" },
{ value: "12", label: "12" },
{ value: "24", label: "24" },
{ value: "32", label: "32" },
{ value: "36", label: "36" },
{ value: "42", label: "42" },
{ value: "48", label: "48" },
],
propulsion_types: [
{ value: "sail", label: "sail" },
{ value: "motor", label: "motor" },
{ value: "motorsailer", label: "motorsailer" },
],
};
/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
...SCHEMA,
fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};
/** The vessel as `GET /fleet/vessels/{id}` answers: a Hanse 460 with a single diesel. */
const ALTHEA: FormValues = {
id: "7c1e4d2a-5b8f-4e61-9a3d-2f6b8c0e1a47",
name: "Althea",
make: "Hanse",
model: "460",
hull: "monohull",
propulsion: "sail",
home_port: "Palma de Mallorca",
mmsi: "235012345",
call_sign: "MXYZ5",
flag: "United Kingdom",
year_built: 2018,
hull_material: "grp",
hull_color: "White",
loa_m: 14.4,
beam_m: 4.4,
draft_m: 2.25,
mast_height_m: 21.5,
displacement_kg: 12800,
depth_transducer_offset_m: 0.45,
keel_type: "bulb",
anchor_type: "rocna",
chain_length_m: 70,
chain_factor: 5,
engine_type: "diesel",
fuel_capacity_l: 220,
water_capacity_l: 450,
battery_capacity_ah: 400,
nominal_voltage: 12,
notes: "Bow thruster breaker under the forward berth.",
created_at: "2026-03-14T09:12:00Z",
updated_at: "2026-10-09T17:45:00Z",
};
/**
* Stands in for `POST /forms/vessel/eval`. The schema's two conditions are `data.propulsion != 'motor'`, on
* mast_height_m and keel_type. With no propulsion the expression has no key to read and fails, and bosund shows a
* field whose visible_if fails (reference/forms/eval.go), so both stay until the propulsion is motor.
*/
function evaluate(values: FormValues): Promise<Readonly<Record<string, FieldState>>> {
const visible: boolean = values.propulsion !== "motor";
const state: FieldState = { visible, required: false, editable: true, readonly: false };
return Promise.resolve({ mast_height_m: state, keel_type: state });
}
/** What the vessel's autocomplete sources answer: names from the fleet's records and the place catalog. */
const SUGGESTIONS: Readonly<Record<string, readonly string[]>> = {
vessel_makes: ["Hanse", "Hallberg-Rassy", "Highfield"],
vessel_models: ["460", "HR 44", "CL 340"],
ports: ["Palma de Mallorca", "Port d'Andratx", "Puerto Portals", "Port de Sóller", "Ciutadella", "Mahón"],
};
const suggestFor: SchemaFormProps["suggestFor"] =
(source: string): ReturnType<SchemaFormProps["suggestFor"]> =>
(query: string): Promise<readonly Suggestion[]> =>
Promise.resolve(
(SUGGESTIONS[source] ?? [])
.filter((name: string): boolean => name.toLowerCase().includes(query.toLowerCase()))
.map((name: string): Suggestion => ({ value: name, label: name })),
);
function ignore(): void {
// The example has nowhere to go back to.
}
export default function Showcase(): ReactElement {
const [attempts, setAttempts]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
// The vessel schema saves each field on its own (save_mode auto): the first save is refused, the rest land.
async function save(): Promise<void> {
await new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, 700);
});
setAttempts((current: number): number => current + 1);
if (attempts === 0) {
throw new Error(
"Couldn't save this field because Bosun didn't answer. Your change is kept here; leave the field to save it again.",
);
}
}
return (
<SchemaForm
id="vessel-details"
schema={SHOWN}
enumOptions={ENUM_OPTIONS}
evaluate={evaluate}
suggestFor={suggestFor}
initial={ALTHEA}
fixed={{ is_primary: true }}
submitAction={save}
onCancel={ignore}
/>
);
}
A schema form draws a record's form from the schema bosund serves for it. bosund writes the schema from the
form:"…" and validate:"…" tags on the record's Go struct (reference/forms), so a field's label, type, group,
limits and conditions are written once, beside the field, and every form for that record follows them.
The form takes five things from bosund through its caller: the schema (GET /forms/{entity}), the options of each
enum source it names (GET /forms/meta/{source}), the suggestions of each autocomplete source
(GET /forms/autocomplete/{source}?q=), the name of a record whose id a field holds
(GET /forms/autocomplete/{source}?value=), and the state of the fields that carry a CEL condition
(POST /forms/{entity}/eval). The kit component asks for none of them: enumOptions, suggestFor, labelFor and
evaluate are props, and the caller makes the requests. A save checks the schema's constraints in the browser first; bosund's
own save of the record decides.
How a schema field is drawn
| The schema says | The form draws |
|---|---|
string | A text input; keyboard=tel or url makes it a tel or url input that keeps the text as typed |
string with keyboard=email | The email input, which offers the domain after the @ |
string with the e164 pattern | The phone input, which saves E.164 |
string with the mmsi pattern | The MMSI input, which names the station and saves the digits |
string with component=vhf_channel | The VHF channel input, which writes the frequencies, refuses a channel with no voice, questions 16 unless purpose is monitored, and saves the designator |
string with one_of and no enum source | A select of the allowed values; more than ten, a combo box that narrows as the crew type |
text | A text area of rows lines that the crew drag taller |
int, float | A number input with min, max, a step of decimal_places and its unit inside the field |
float named lat, with a lon in its group | One position input for both, in degrees and minutes, with the vessel's position from defaultSources.position, saving decimal degrees in each |
int, float with component=hour_meter | The hour meter input, reading decimal hours or hours and minutes, saving the tenth |
int with component=scale and a scale | The scale picker of that scale's levels, saving the level's code |
enum with component=scale, scale=douglas | The scale picker of the Douglas sea states, saving the state's word (calm_glassy … phenomenal) |
int, float with unit m, L or kts | The quantity input, typed in feet, gallons or km/h through its unit button and saved in the schema's unit, to decimal_places, rounded the way rounding says |
int, float with unit ° from 0 to 360 | The bearing input, in the north reference names (°T when unset), saying the point from or to when sense is set, saving whole degrees |
bool | A switch |
date | The date picker on the UTC calendar, saving the ISO date |
time | The time picker, its unit printed as the reference |
datetime | The date and time input, read in UTC with UTC beside the time, saving the instant |
enum | A select of the schema's options, else the source's, else one_of, with "—" to clear it unless required; more than ten, a combo box that narrows as the crew type; value_type saves a number |
autocomplete | An autocomplete that asks suggestFor after min_chars characters |
autocomplete with autocomplete_ids | A combobox that shows a record's name and saves its id, naming a held id through labelFor |
array with enum, one_of or options | A checkbox group of the choices, saving the ones ticked |
array | A text area with one item per line, or a JSON array when item_type is json |
json | A text area of JSON, parsed on save |
component | The kit input the field asks for: slider a slider with step, marks and a soft_max limit; checkbox a checkbox; segmented segments for two to five choices; cards radio cards with each choice's description; chips a list of words added with Enter, up to max_items; password a password input; yaml the YAML editor; date_of_birth the date of birth input, also for autofill=bday; combobox a list searched by typing; money a money input of the amount and the field currency_field names, drawn as one; rating a rating out of max; signature a signature input whose SVG the field holds |
min_date, max_date | The calendar's earliest and latest day: an ISO date, or the day the form opens |
max_rows | The most lines a text area grows to |
copyable, with readonly | The value as text with a copy button inside the box, copying the value as stored |
default | A new record's starting value, counted as unchanged: a literal of the field's type; now, the UTC date, time or date and time; position.lat and position.lon, from defaultSources.position; skipper, from defaultSources.skipper, its id in a crew_ids field and its name in any other; last, the value saved with the last new record, kept under lastValuesKey. A source that is unknown leaves the field empty |
autofill | What the browser may fill the field with: name, email, tel, bday and the like |
A group's description | A line under the group's heading, at the top of its tab, and on its wizard step |
unit, prefix | Text after or before the input |
width quarter, third, half, full | That share of the group's twelve columns; unset, the width its value needs (below) |
priority secondary, advanced | "More fields", open; "Advanced fields", closed; one "More fields" when a group has only one kind; a group with no primary field draws its secondary ones at once |
critical | A bar at the field's edge |
readonly, readonly_if, editable_if | A field the crew read and cannot change |
required, required_if | An asterisk, a save refused while it is empty, and the field drawn at once whatever its priority |
visible_if | The field leaves while the condition is false |
help, description | Both under the field, always shown |
autofocus | The focus when the form opens, whatever the input |
save_mode auto, on the form or field | While a record is edited, the field saves on its own as it changes, with no Save button |
How a field looks
Every field draws outlined: its name on the box's top edge, resting in the middle
of an empty box, its description and help under it, its error in their place, a prefix such as Ch and a unit such as m
inside the box, read after the value; a field with a prefix keeps its name on the edge. An amount of money is a money
field (component: "money" with its currency_field), drawn as the money input with its currency, not a prefix. A
phone number's extension is a text field of the same group with autofill: "tel-extension", drawn inside the phone
input after the number and saved in its own field. A switch has no box and stands with its name beside it.
A field is checked as the crew leave it, once they have typed something: a wrong format or a value too long shows its
message there, not at Save, and a field showing a message is checked again as they type, so the message changes as
they fix it and leaves when it is fixed. An empty field they move past says nothing until Save. The kit's own inputs
(email, phone, MMSI, the dates, the password) check themselves and keep their own messages. A text field with a
max_length counts its characters in its hint line ("5 / 7") once the crew type, and near the limit for a long one.
It never cuts what is typed or pasted: past the limit the count goes on ("9 / 7") and the field says how long it may
be. A tab or wizard step whose required fields are all filled, with nothing to fix, is marked done.
A new record keeps its draft: given draftKey, what the crew type is kept in the browser until it is saved, and a form
closed half-filled opens again as they left it, saying so, with Start over to empty it. A wizard's draft keeps the step
too: it opens again on the step the crew left it on, with every step they had reached open to them. A record being
edited keeps no draft. onDirtyChange says when the values start or stop differing from those the form opened with, so
the dialog around it can ask before it discards them. With addAnother, a new record's form offers Save and add
another, which saves through submitAction with { another: true } and empties the form for the next record, the focus
on its first field.
How the fields are laid out
Each group's fields sit in rows of twelve columns, in their weight order. A field takes the share its width names, or
else what its value needs:
| The field | Its share of the row |
|---|---|
| A switch, a number with its unit, a time, text up to 10 characters | A quarter |
| A date, a date and time, an MMSI, a list of short choices, text up to 24 characters | A third |
| A name or other text up to 200 characters, an email address, a phone number, a looked-up record, a list of longer choices | Half |
| Long text, a list, JSON, text longer than 200 characters, a list of sentence-long choices | The row |
A row of several fields that ends short gives its spare columns to its last text or list field, so the row ends square; numbers, dates and switches keep their width, and so does a field alone on its row. The form measures its own width, not the screen's: narrower than 36em, as in a dialog or a side panel, two quarter fields share a row and anything wider takes the row. A group's label starts with a capital, since bosund writes it from the group's id in lower case.
How the groups are arranged
The schema's layout, or the form's layout prop over it, sets how the groups are arranged:
layout | The form draws |
|---|---|
auto | Tabs for a form of four or more groups and sixteen or more fields, which would otherwise run several screens; sections for the rest |
sections | Each group under its heading, one after another |
tabs | A tab for each group; on a form 48em wide or more with five or more groups, the tabs stand in a column beside the fields, as panes |
wizard | A step for each group while a record is created; a record being edited opens in tabs, so the crew go straight to the field they came to change |
Fields no listed group holds are drawn above the tabs. A save the form refuses opens the tab that holds the first field to fix, and each tab with fields to fix says how many beside its name.
Ctrl+S, or Command+S on a Mac, submits the form from any of its fields as Enter does: it saves, or on a wizard step
before the last, moves to the next step. The Save button names the keys in aria-keyshortcuts.
When to use it
Use a schema form for a record bosund describes with form tags, on a screen whose job is to create or edit that record: a crew member, a guest, a contact, a place, the vessel, its equipment, documents and sails, a maintenance task, an expense, a log entry. The entity list opens one in its dialog.
Build the form by hand when the screen is about something other than the record's fields:
- a value set from a chart or an instrument, such as a passage plan's waypoints, which belong on the chart;
- one setting saved on its own as the crew change it, which is a setting control;
- a title or single value renamed in place, which is an inline edit;
- a step of onboarding or of a conversation, where the order and the words matter more than the record;
- an action that acts on the vessel at once, such as raising an anchor alarm, which is a button with a confirmation.
Examples
Every field type
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
SchemaForm,
type FieldOption,
type FieldState,
type FormSchema,
type FormValues,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"One field of each of the twelve types reference/forms knows, filled in, with the values a save sends shown below.";
/**
* No bosund schema uses all twelve types, and none uses date, time or json yet, so this one is assembled: each
* field copies the field named in its comment, and the three unused types are written as their struct tags would
* give them (`form:"type=date"`, `form:"type=time"`, and a map field, which reference/forms types as json).
*/
const SCHEMA: FormSchema = {
entity: "field_types",
groups: [{ id: "types", label: "field types", weight: 10 }],
fields: [
// vessel.name
{
name: "name",
label: "Name (string)",
type: "string",
required: true,
max_length: 200,
weight: 10,
group: "types",
},
// vessel.notes
{ name: "notes", label: "Notes (text)", type: "text", component: "textarea", rows: 3, weight: 20, group: "types" },
// vessel.year_built
{
name: "year_built",
label: "Year built (int)",
type: "int",
min: 1800,
max: 2100,
keyboard: "numeric",
weight: 30,
group: "types",
width: "half",
},
// vessel.draft_m
{
name: "draft_m",
label: "Draft (float)",
type: "float",
min: 0,
max: 50,
unit: "m",
decimal_places: 2,
keyboard: "decimal",
weight: 40,
group: "types",
width: "half",
},
// crew_member.has_own_lifejacket
{ name: "has_own_lifejacket", label: "Own life jacket (bool)", type: "bool", weight: 50, group: "types" },
// A `time.Time` tagged type=date: the day a haul-out is booked.
{ name: "haul_out", label: "Haul-out (date)", type: "date", weight: 60, group: "types", width: "half" },
// A string tagged type=time: high water at the home port.
{ name: "high_water", label: "High water (time)", type: "time", weight: 70, group: "types", width: "half" },
// expense.occurred_at
{ name: "occurred_at", label: "Occurred at (datetime)", type: "datetime", weight: 80, group: "types" },
// vessel.hull
{
name: "hull",
label: "Hull (enum)",
type: "enum",
enum: "hull_types",
one_of: ["monohull", "catamaran", "trimaran"],
weight: 90,
group: "types",
width: "half",
},
// vessel.home_port
{
name: "home_port",
label: "Home port (autocomplete)",
type: "autocomplete",
autocomplete: "ports",
min_chars: 2,
weight: 100,
group: "types",
width: "half",
},
// crew_member.certifications
{
name: "certifications",
label: "Certifications (array)",
type: "array",
item_type: "string",
help: "Yachtmaster STCW etc",
weight: 110,
group: "types",
},
// A map field: the anchor watch's alarm limits.
{ name: "alarm_limits", label: "Alarm limits (json)", type: "json", weight: 120, group: "types" },
],
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
hull_types: [
{ value: "monohull", label: "monohull" },
{ value: "catamaran", label: "catamaran" },
{ value: "trimaran", label: "trimaran" },
],
};
const INITIAL: FormValues = {
name: "Althea",
notes: "Bow thruster breaker under the forward berth.",
year_built: 2018,
draft_m: 2.25,
has_own_lifejacket: true,
haul_out: "2026-11-02",
high_water: "14:52",
occurred_at: "2026-10-09T17:45:00Z",
hull: "monohull",
home_port: "Palma de Mallorca",
certifications: ["Yachtmaster Offshore", "STCW Basic Safety"],
alarm_limits: { drag_m: 15, gust_kn: 30 },
};
const PORTS: readonly string[] = ["Palma de Mallorca", "Port d'Andratx", "Puerto Portals", "Port de Sóller"];
const suggestFor: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> =>
(query: string): Promise<readonly Suggestion[]> =>
Promise.resolve(
PORTS.filter((port: string): boolean => port.toLowerCase().includes(query.toLowerCase())).map(
(port: string): Suggestion => ({ value: port, label: port }),
),
);
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
export default function FieldTypes(): ReactElement {
const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
null,
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
<SchemaForm
id="field-types"
schema={SCHEMA}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={suggestFor}
initial={INITIAL}
submitAction={(values: FormValues): void => {
setSent(values);
}}
/>
{sent !== null && (
<pre
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text-dim)",
margin: 0,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
{JSON.stringify(sent, null, 2)}
</pre>
)}
</div>
);
}
Groups, priorities and widths
Groups come in their weight order, and fields within a group in theirs. Fields with no group, or a group the schema does not list, come first.
Code
import {
SchemaForm,
type FieldOption,
type FieldState,
type SchemaField,
type FormSchema,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";
export const description: string =
"A new place: eleven groups in weight order, the identity fields always shown, the depths and weather under an " +
"open More fields, the swinging radius under the anchorage's closed Advanced fields, the marina and cost fields " +
"folded, and latitude and longitude, the two depths, and country and region in half-width pairs.";
// bosund's `GET /forms/place`, and the options of the enum sources it names.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
entity: "place",
package: "ops",
save_mode: "auto",
groups: [
{ id: "identity", label: "identity", weight: 10 },
{ id: "location", label: "location", weight: 30 },
{ id: "depths", label: "depths", weight: 70 },
{ id: "anchor", label: "anchor", weight: 90 },
{ id: "weather", label: "weather", weight: 120 },
{ id: "contact", label: "contact", weight: 150 },
{ id: "marina", label: "marina", weight: 190 },
{ id: "cost", label: "cost", weight: 230 },
{ id: "notes", label: "notes", weight: 260 },
{ id: "personal", label: "personal", weight: 270 },
{ id: "safety", label: "safety", weight: 320 },
],
fields: [
{
name: "name",
label: "Name",
type: "string",
priority: "primary",
required: true,
min: 1,
max: 200,
min_length: 1,
max_length: 200,
weight: 10,
group: "identity",
autofocus: true,
},
{
name: "type",
label: "Type",
type: "enum",
priority: "primary",
required: true,
one_of: ["anchorage", "marina", "port", "mooring", "fuel"],
enum: "place_types",
weight: 20,
group: "identity",
},
{
name: "is_port_of_entry",
label: "Port of Entry",
help: "Customs and immigration can clear the vessel in here.",
type: "bool",
priority: "primary",
weight: 25,
group: "identity",
},
{
name: "country",
label: "Country",
type: "string",
priority: "advanced",
max: 100,
max_length: 100,
weight: 30,
group: "location",
width: "half",
},
{
name: "region",
label: "Region",
type: "string",
priority: "advanced",
max: 100,
max_length: 100,
weight: 40,
group: "location",
width: "half",
},
{
name: "lat",
label: "Lat",
type: "float",
priority: "primary",
required: true,
min: -90,
max: 90,
weight: 50,
group: "identity",
width: "half",
keyboard: "decimal",
decimal_places: 6,
},
{
name: "lon",
label: "Lon",
type: "float",
priority: "primary",
required: true,
min: -180,
max: 180,
weight: 60,
group: "identity",
width: "half",
keyboard: "decimal",
decimal_places: 6,
},
{
name: "depth_min_m",
label: "Min Depth",
help: "Grounding risk!",
type: "float",
priority: "secondary",
critical: true,
weight: 70,
group: "depths",
unit: "m",
width: "half",
keyboard: "decimal",
decimal_places: 1,
},
{
name: "depth_max_m",
label: "Max Depth",
type: "float",
priority: "secondary",
weight: 80,
group: "depths",
unit: "m",
width: "half",
keyboard: "decimal",
decimal_places: 1,
},
{
name: "holding_quality",
label: "Holding",
type: "enum",
priority: "secondary",
enum: "holding_quality",
weight: 90,
group: "anchor",
},
{
name: "bottom_type",
label: "Bottom",
type: "enum",
priority: "secondary",
enum: "bottom_types",
weight: 100,
group: "anchor",
},
{
name: "swinging_radius_m",
label: "Swinging Radius",
type: "float",
priority: "advanced",
weight: 110,
group: "anchor",
unit: "m",
keyboard: "decimal",
decimal_places: 0,
},
{
name: "protected_from",
label: "Protected From",
type: "array",
item_type: "string",
priority: "secondary",
weight: 120,
group: "weather",
},
{
name: "exposed_to",
label: "Exposed To",
type: "array",
item_type: "string",
priority: "secondary",
critical: true,
weight: 130,
group: "weather",
},
{
name: "max_wind_kts",
label: "Max Wind",
help: "Safety limit",
type: "int",
priority: "secondary",
critical: true,
weight: 140,
group: "weather",
unit: "kts",
keyboard: "numeric",
},
{
name: "vhf_channel",
label: "VHF Channel",
type: "string",
priority: "secondary",
weight: 150,
group: "contact",
placeholder: "16",
},
{
name: "phone",
label: "Phone",
type: "string",
priority: "advanced",
weight: 160,
group: "contact",
keyboard: "tel",
},
{
name: "email",
label: "Email",
type: "string",
priority: "advanced",
weight: 170,
group: "contact",
keyboard: "email",
},
{
name: "website",
label: "Website",
type: "string",
priority: "advanced",
weight: 180,
group: "contact",
keyboard: "url",
},
{
name: "berth_count",
label: "Berths",
type: "int",
priority: "advanced",
weight: 190,
group: "marina",
keyboard: "numeric",
},
{
name: "max_loa_m",
label: "Max LOA",
type: "float",
priority: "advanced",
weight: 200,
group: "marina",
unit: "m",
keyboard: "decimal",
decimal_places: 1,
},
{
name: "max_draft_m",
label: "Max Draft",
type: "float",
priority: "advanced",
weight: 210,
group: "marina",
unit: "m",
keyboard: "decimal",
decimal_places: 1,
},
{
name: "facilities",
label: "Facilities",
type: "array",
item_type: "string",
priority: "advanced",
weight: 220,
group: "marina",
},
{
name: "daily_rate",
label: "Daily Rate",
type: "float",
priority: "advanced",
weight: 230,
group: "cost",
keyboard: "decimal",
decimal_places: 2,
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "advanced",
enum: "currencies",
weight: 240,
group: "cost",
width: "third",
},
{ name: "rate_notes", label: "Rate Notes", type: "string", priority: "advanced", weight: 250, group: "cost" },
{
name: "approach_notes",
label: "Approach Notes",
type: "text",
component: "textarea",
priority: "advanced",
weight: 260,
group: "notes",
rows: 3,
},
{
name: "rating",
label: "Rating",
type: "int",
priority: "advanced",
min: 1,
max: 5,
weight: 270,
group: "personal",
keyboard: "numeric",
},
{ name: "last_visited", label: "Last Visited", type: "datetime", readonly: true, weight: 280, group: "personal" },
{ name: "visit_count", label: "Visit Count", type: "int", readonly: true, weight: 290, group: "personal" },
{
name: "personal_notes",
label: "Personal Notes",
type: "text",
component: "textarea",
priority: "advanced",
weight: 300,
group: "personal",
rows: 3,
},
{ name: "recommended", label: "Recommended", type: "bool", priority: "advanced", weight: 310, group: "personal" },
{
name: "hazards",
label: "Hazards",
type: "text",
component: "textarea",
priority: "secondary",
critical: true,
weight: 320,
group: "safety",
rows: 2,
},
{
name: "tags",
label: "Tags",
type: "array",
item_type: "string",
priority: "advanced",
weight: 330,
group: "personal",
},
{ name: "created_at", label: "Created At", type: "datetime", readonly: true, weight: 900, group: "personal" },
{ name: "updated_at", label: "Updated At", type: "datetime", readonly: true, weight: 910, group: "personal" },
],
collection: "/ops/places",
item: "/ops/places/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
bottom_types: [
{ value: "sand", label: "sand" },
{ value: "mud", label: "mud" },
{ value: "rock", label: "rock" },
{ value: "weed", label: "weed" },
{ value: "coral", label: "coral" },
{ value: "gravel", label: "gravel" },
{ value: "clay", label: "clay" },
],
currencies: [
{ value: "EUR", label: "EUR" },
{ value: "USD", label: "USD" },
{ value: "GBP", label: "GBP" },
{ value: "AUD", label: "AUD" },
{ value: "CAD", label: "CAD" },
{ value: "NZD", label: "NZD" },
{ value: "JPY", label: "JPY" },
{ value: "CHF", label: "CHF" },
{ value: "NOK", label: "NOK" },
{ value: "SEK", label: "SEK" },
{ value: "DKK", label: "DKK" },
],
holding_quality: [
{ value: "excellent", label: "excellent" },
{ value: "good", label: "good" },
{ value: "fair", label: "fair" },
{ value: "poor", label: "poor" },
],
place_types: [
{ value: "anchorage", label: "anchorage" },
{ value: "marina", label: "marina" },
{ value: "mooring", label: "mooring" },
{ value: "fuel_dock", label: "fuel_dock" },
{ value: "town_quay", label: "town_quay" },
],
};
/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
...SCHEMA,
fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
function ignore(): void {
// The example saves nothing.
}
export default function GroupsAndPriorities(): ReactElement {
return (
<SchemaForm
id="new-place"
schema={SHOWN}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={noSuggestions}
submitAction={ignore}
submitLabel="Add place"
/>
);
}
Conditions
A field's visible_if, required_if, editable_if and readonly_if are CEL expressions over data, the form's
values, the actor's role, which bosund takes from the session, and the onboarding phase. A condition that fails to evaluate leaves the
field shown and editable, and its required and read-only state as the schema sets them.
Code
import {
SchemaForm,
type FieldOption,
type FieldState,
type FormSchema,
type FormValues,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";
export const description: string =
"An anchoring record whose fields follow its CEL conditions: Chain out shows only for chain, the rope notes are " +
"required for rope and editable once a rode is chosen, the swing radius is read-only until then, and the test " +
"rpm shows and is required once the holding is tested.";
/**
* The vessel's two `visible_if` conditions are the only ones in bosund's schemas, so this schema is written to show
* all four kinds. bosund evaluates them with cel-go over `data`, the form's values (reference/forms/eval.go).
*/
const SCHEMA: FormSchema = {
entity: "anchoring",
groups: [{ id: "tackle", label: "ground tackle", weight: 10 }],
fields: [
{
name: "rode",
label: "Rode",
type: "enum",
enum: "rode_kinds",
required: true,
weight: 10,
group: "tackle",
width: "half",
},
{
name: "chain_out",
label: "Chain out",
type: "float",
unit: "m",
decimal_places: 0,
max: 80,
weight: 20,
group: "tackle",
width: "half",
visible_if: 'data.rode == "chain"',
},
{
name: "rode_notes",
label: "Rope and chain lengths",
type: "text",
rows: 2,
weight: 30,
group: "tackle",
placeholder: "For example: 10 m of chain, then 40 m of 16 mm nylon",
required_if: 'data.rode == "rope"',
editable_if: "has(data.rode)",
},
{
name: "swing_radius",
label: "Swing radius",
type: "int",
unit: "m",
min: 10,
weight: 40,
group: "tackle",
width: "half",
readonly_if: "!has(data.rode)",
help: "Chain out plus the vessel's length; choose the rode first.",
},
{ name: "holding_tested", label: "Holding tested", type: "bool", weight: 50, group: "tackle", width: "half" },
{
name: "test_rpm",
label: "Tested at",
type: "int",
unit: "rpm",
min: 800,
max: 3600,
weight: 60,
group: "tackle",
width: "half",
visible_if: "data.holding_tested == true",
required_if: "data.holding_tested == true",
},
{ name: "recorded_by", label: "Recorded by", type: "string", readonly: true, weight: 70, group: "tackle" },
],
};
const RODE_KINDS: Readonly<Record<string, readonly FieldOption[]>> = {
rode_kinds: [
{ value: "chain", label: "All chain" },
{ value: "rope", label: "Chain and rope" },
],
};
/**
* Stands in for `POST /forms/anchoring/eval`, which answers every field. A value the crew cleared is left out of the
* request, so `has(data.rode)` is false for it; a comparison against a missing key fails, and bosund then shows the
* field, keeps it editable, and leaves required and read-only at the schema's own (reference/forms/api/handlers.go).
*/
function evaluate(values: FormValues): Promise<Readonly<Record<string, FieldState>>> {
const rode: unknown = values.rode;
const rodeSet: boolean = rode !== undefined;
const tested: boolean | undefined = values.holding_tested === undefined ? undefined : values.holding_tested === true;
return Promise.resolve({
// visible_if: data.rode == "chain"; fails, and shows the field, while no rode is set.
chain_out: { visible: !rodeSet || rode === "chain", required: false, editable: true, readonly: false },
// required_if: data.rode == "rope"; editable_if: has(data.rode).
rode_notes: { visible: true, required: rode === "rope", editable: rodeSet, readonly: false },
// readonly_if: !has(data.rode); a read-only field is not editable.
swing_radius: { visible: true, required: false, editable: rodeSet, readonly: !rodeSet },
// visible_if and required_if: data.holding_tested == true; fails, and shows the field, while unset.
test_rpm: { visible: tested !== false, required: tested === true, editable: true, readonly: false },
});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
function ignore(): void {
// The example saves nothing.
}
export default function Conditions(): ReactElement {
return (
<SchemaForm
id="conditions"
schema={SCHEMA}
enumOptions={RODE_KINDS}
evaluate={evaluate}
suggestFor={noSuggestions}
initial={{ holding_tested: false, recorded_by: "Maya Lindqvist" }}
submitAction={ignore}
/>
);
}
Constraint validation
reference/forms has no CEL validation rules: the constraints come from validate tags, and CEL only decides a
field's state. A refused save names each problem under its field, opens a folded section that holds one, and moves
the focus to the first.
Code
import { SchemaForm, type FieldState, type FormSchema, type SchemaFormProps } from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";
export const description: string =
"Values that break the schema's constraints: a press on Save names each problem under its field, opens the " +
"folded section that holds one, and sends nothing.";
/**
* Each field copies the constraints bosund's schemas give the field named in its comment; go-playground `validate`
* tags set them (reference/forms/parse.go: `max=10` on a string sets max_length, `min`/`max` on a number its bounds,
* `imo` the IMO pattern, `max=5` on a slice max_items).
*/
const SCHEMA: FormSchema = {
entity: "vessel",
groups: [{ id: "identity", label: "identity", weight: 10 }],
fields: [
// vessel.name: required, 1 to 200 characters.
{
name: "name",
label: "Name",
type: "string",
required: true,
min_length: 1,
max_length: 200,
weight: 10,
group: "identity",
},
// vessel.call_sign: at most 10 characters.
{
name: "call_sign",
label: "Call Sign",
type: "string",
max_length: 10,
placeholder: "ZL1234",
weight: 20,
group: "identity",
width: "half",
},
// `validate:"imo"`: IMO and seven digits.
{
name: "imo_number",
label: "IMO Number",
type: "string",
pattern: "^IMO\\d{7}$",
weight: 30,
group: "identity",
width: "half",
},
// vessel.year_built: 1800 to 2100, in the advanced fields.
{
name: "year_built",
label: "Year Built",
type: "int",
min: 1800,
max: 2100,
keyboard: "numeric",
priority: "advanced",
weight: 40,
group: "identity",
width: "half",
},
// vessel.chain_factor: 1 to 20.
{
name: "chain_factor",
label: "Chain Factor",
type: "float",
min: 1,
max: 20,
decimal_places: 1,
keyboard: "decimal",
priority: "advanced",
weight: 50,
group: "identity",
width: "half",
},
// sail.reefs: at most five reef points, as numbers.
{
name: "reefs",
label: "Reefs",
type: "array",
item_type: "float",
max_items: 5,
placeholder: "One reef depth per line",
priority: "advanced",
weight: 60,
group: "identity",
},
],
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
function ignore(): void {
// Nothing reaches it: every save is refused.
}
export default function Validation(): ReactElement {
return (
<SchemaForm
id="validation"
schema={SCHEMA}
enumOptions={{}}
evaluate={noConditions}
suggestFor={noSuggestions}
initial={{
call_sign: "MXYZ5-ALTHEA",
imo_number: "9074729",
year_built: 1790,
chain_factor: 0.5,
reefs: [1.2, 2.4, 3.6, 4.8, 6, 7.2],
}}
submitAction={ignore}
/>
);
}
Required field missing
Code
import {
SchemaForm,
type FieldOption,
type FieldState,
type FormSchema,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";
export const description: string =
"An empty form: a press on Save shows the message under the required Name field and saves nothing.";
const SCHEMA: FormSchema = {
entity: "crew_member",
package: "ops",
groups: [
{ id: "identity", label: "identity", weight: 10 },
{ id: "anchoring", label: "anchoring", weight: 20 },
{ id: "emergency", label: "emergency", weight: 70 },
],
fields: [
{
name: "name",
label: "Name",
type: "string",
priority: "primary",
required: true,
min_length: 1,
max_length: 200,
weight: 10,
group: "identity",
placeholder: "Full name",
width: "half",
},
{
name: "role",
label: "Role",
type: "enum",
priority: "primary",
required: true,
one_of: ["skipper", "mate", "crew", "guest"],
enum: "crew_roles",
weight: 20,
group: "identity",
width: "half",
},
{
name: "home_port",
label: "Home port",
type: "autocomplete",
priority: "primary",
autocomplete: "places",
min_chars: 2,
weight: 25,
group: "identity",
placeholder: "Port",
},
{
name: "nickname",
label: "Nickname",
type: "string",
priority: "advanced",
max_length: 100,
weight: 30,
group: "identity",
placeholder: "Call sign",
},
{
name: "date_of_birth",
label: "Date of Birth",
help: "For legal and medical purposes",
type: "date",
priority: "advanced",
weight: 35,
group: "identity",
},
{
name: "kind",
label: "Kind",
help: "Chain or rope",
type: "string",
priority: "primary",
weight: 10,
group: "anchoring",
},
{
name: "chain_length",
label: "Chain length",
type: "float",
priority: "primary",
weight: 20,
group: "anchoring",
unit: "m",
decimal_places: 1,
visible_if: 'data.kind == "chain"',
},
{
name: "rode_notes",
label: "Rode notes",
type: "text",
priority: "primary",
weight: 30,
group: "anchoring",
required_if: 'data.kind == "rope"',
editable_if: 'data.kind != ""',
},
{
name: "emergency_contact_name",
label: "Emergency Contact",
help: "Who to call in emergency",
type: "string",
priority: "primary",
critical: true,
weight: 70,
group: "emergency",
},
{ name: "vessel_id", label: "Vessel", type: "string", priority: "primary", weight: 80, group: "emergency" },
],
};
const ROLES: readonly FieldOption[] = [
{ value: "skipper", label: "Skipper" },
{ value: "crew", label: "Crew" },
];
const FIELD_STATES: Readonly<Record<string, FieldState>> = {
kind: { visible: true, required: false, editable: true, readonly: false },
chain_length: { visible: false, required: false, editable: true, readonly: false },
rode_notes: { visible: true, required: true, editable: false, readonly: false },
};
function ignore(): void {
// The example saves nothing.
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export default function RequiredFieldMissing(): ReactElement {
return (
<SchemaForm
schema={SCHEMA}
enumOptions={{ crew_roles: ROLES }}
evaluate={(): Promise<Readonly<Record<string, FieldState>>> => Promise.resolve(FIELD_STATES)}
suggestFor={noSuggestions}
fixed={{ vessel_id: "v-1" }}
submitAction={ignore}
onCancel={ignore}
/>
);
}
Enum sources
Code
import {
SchemaForm,
type FieldOption,
type FieldState,
type FormSchema,
type FormValues,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Where a choice field's options come from: its enum source, the schema's own options, or the allowed values when " +
"the source is unknown; a numeric choice saves a number.";
/**
* Four fields from bosund's schemas, in one group and all primary so they show together, and one written for the
* schema's own `options`, which no bosund schema sets yet.
*/
const SCHEMA: FormSchema = {
entity: "choices",
groups: [{ id: "choices", label: "choices", weight: 10 }],
fields: [
// crew_member.role: the crew_roles source lists two of the four values validation allows.
{
name: "role",
label: "Role",
type: "enum",
required: true,
one_of: ["skipper", "mate", "crew", "guest"],
enum: "crew_roles",
weight: 10,
group: "choices",
width: "half",
},
// crew_member.language: the languages source names each language in itself.
{
name: "language",
label: "Language",
type: "enum",
one_of: ["en", "de", "fr", "es", "it", "nl"],
enum: "languages",
weight: 20,
group: "choices",
width: "half",
},
// crew_change.action: its bare `enum` tag names a source "true" that bosund does not register, so the allowed
// values become the options.
{
name: "action",
label: "Action",
type: "enum",
required: true,
one_of: ["embark", "disembark"],
enum: "true",
weight: 30,
group: "choices",
width: "half",
},
// vessel.nominal_voltage: a float field shown as a choice, so value_type says to save a number.
{
name: "nominal_voltage",
label: "Nominal Voltage",
help: "DC house bank voltage",
type: "enum",
value_type: "float",
one_of: ["6", "12", "24", "32", "36", "42", "48"],
enum: "nominal_voltages",
unit: "V",
weight: 40,
group: "choices",
width: "half",
},
// The schema's own options, which win over any source.
{
name: "rode",
label: "Rode",
type: "enum",
options: [
{ value: "chain", label: "All chain" },
{ value: "rope", label: "Chain and rope" },
],
weight: 50,
group: "choices",
},
],
};
/** What `GET /forms/meta/<source>` answers for the sources the fields name. */
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
crew_roles: [
{ value: "skipper", label: "skipper" },
{ value: "crew", label: "crew" },
],
languages: [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" },
{ value: "fr", label: "Français" },
{ value: "es", label: "Español" },
{ value: "it", label: "Italiano" },
{ value: "nl", label: "Nederlands" },
],
nominal_voltages: ["6", "12", "24", "32", "36", "42", "48"].map((volts: string): FieldOption => ({
value: volts,
label: volts,
})),
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export default function EnumSources(): ReactElement {
const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
null,
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
<SchemaForm
id="enum-sources"
schema={SCHEMA}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={noSuggestions}
initial={{ role: "skipper", language: "nl", action: "embark", nominal_voltage: 24, rode: "chain" }}
submitAction={(values: FormValues): void => {
setSent(values);
}}
/>
{sent !== null && (
<pre
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text-dim)",
margin: 0,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
{JSON.stringify(sent, null, 2)}
</pre>
)}
</div>
);
}
Lists and JSON
Code
import {
SchemaForm,
type FieldState,
type FormSchema,
type FormValues,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Lists and JSON: allergies one per line, reef depths as numbers, inspection findings as a JSON array, and alarm " +
"limits as a JSON object; a save sends each as its JSON type.";
/**
* Three array fields from bosund's schemas, in one group and all primary, and a JSON object field, which no bosund
* schema has yet: a map or struct field is typed json (reference/forms/parse.go:237-238).
*/
const SCHEMA: FormSchema = {
entity: "lists",
groups: [{ id: "lists", label: "lists", weight: 10 }],
fields: [
// crew_member.allergies: strings.
{
name: "allergies",
label: "Allergies",
help: "Life-threatening reactions",
type: "array",
item_type: "string",
critical: true,
weight: 10,
group: "lists",
},
// sail.reefs: numbers, at most five.
{ name: "reefs", label: "Reefs", type: "array", item_type: "float", max_items: 5, weight: 20, group: "lists" },
// inspection.findings: objects, at most a hundred.
{
name: "findings",
label: "Findings",
type: "array",
item_type: "json",
max_items: 100,
weight: 30,
group: "lists",
},
// A map field: the anchor watch's alarm limits.
{ name: "alarm_limits", label: "Alarm limits", type: "json", weight: 40, group: "lists" },
],
};
const INITIAL: FormValues = {
allergies: ["Penicillin", "Peanuts"],
reefs: [1.2, 2.4, 3.6],
findings: [{ code: "R-12", description: "Replace the forestay toggle pin", severity: "major", category: "rigging" }],
alarm_limits: { drag_m: 15, gust_kn: 30 },
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export default function ArraysAndJson(): ReactElement {
const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
null,
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
<SchemaForm
id="arrays-and-json"
schema={SCHEMA}
enumOptions={{}}
evaluate={noConditions}
suggestFor={noSuggestions}
initial={INITIAL}
submitAction={(values: FormValues): void => {
setSent(values);
}}
/>
{sent !== null && (
<pre
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text-dim)",
margin: 0,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
{JSON.stringify(sent, null, 2)}
</pre>
)}
</div>
);
}
Units and prefixes
Code
import {
SchemaForm,
type FieldOption,
type FieldState,
type FormSchema,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";
export const description: string =
"Units after the input, a prefix before it, and the decimal places that set the step: meters, kilograms, knots, " +
"amp-hours and volts from bosund's schemas, and Ch before a marina's VHF channel; an amount of money is a money " +
"field instead, with its currency.";
/** Fields from bosund's schemas in one group, all primary; the daily rate's prefix is written in, as none sets one. */
const SCHEMA: FormSchema = {
entity: "units",
groups: [{ id: "units", label: "units", weight: 10 }],
fields: [
// vessel.draft_m: metres to two decimal places.
{
name: "draft_m",
label: "Draft",
type: "float",
critical: true,
min: 0,
max: 50,
unit: "m",
keyboard: "decimal",
decimal_places: 2,
weight: 10,
group: "units",
width: "half",
},
// vessel.displacement_kg
{
name: "displacement_kg",
label: "Displacement",
type: "float",
min: 0,
unit: "kg",
keyboard: "numeric",
weight: 20,
group: "units",
width: "half",
},
// place.max_wind_kts: whole knots.
{
name: "max_wind_kts",
label: "Max Wind",
type: "int",
unit: "kts",
keyboard: "numeric",
weight: 30,
group: "units",
width: "third",
},
// vessel.battery_capacity_ah
{
name: "battery_capacity_ah",
label: "Battery Capacity",
type: "float",
min: 0,
unit: "Ah",
keyboard: "numeric",
weight: 40,
group: "units",
width: "third",
},
// vessel.nominal_voltage: a choice with a unit.
{
name: "nominal_voltage",
label: "Nominal Voltage",
type: "enum",
value_type: "float",
enum: "nominal_voltages",
unit: "V",
weight: 50,
group: "units",
width: "third",
},
// contact.vhf_channel, as a number with `prefix=Ch` added.
{
name: "vhf_channel",
label: "VHF Channel",
type: "int",
prefix: "Ch",
keyboard: "numeric",
min: 1,
max: 88,
weight: 60,
group: "units",
width: "half",
},
],
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
nominal_voltages: ["6", "12", "24", "32", "36", "42", "48"].map((volts: string): FieldOption => ({
value: volts,
label: volts,
})),
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
function ignore(): void {
// The example saves nothing.
}
export default function UnitsAndPrefixes(): ReactElement {
return (
<SchemaForm
id="units"
schema={SCHEMA}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={noSuggestions}
initial={{
draft_m: 2.25,
displacement_kg: 12800,
max_wind_kts: 25,
battery_capacity_ah: 400,
nominal_voltage: 12,
vhf_channel: 9,
}}
submitAction={ignore}
/>
);
}
Critical fields
Code
import {
SchemaForm,
type FieldOption,
type FieldState,
type FormSchema,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";
export const description: string =
"A crew member's emergency and medical groups: the fields bosund marks critical (the emergency contact, blood " +
"type, conditions, medications and allergies, the medical notes) carry a bar at their edge.";
// The emergency and medical groups of bosund's `GET /forms/crew_member`, and the options of blood_types.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
entity: "crew_member",
package: "ops",
save_mode: "auto",
groups: [
{ id: "emergency", label: "emergency", weight: 70 },
{ id: "medical", label: "medical", weight: 100 },
],
fields: [
{
name: "emergency_contact_name",
label: "Emergency Contact",
help: "Who to call in emergency",
type: "string",
priority: "secondary",
critical: true,
weight: 70,
group: "emergency",
},
{
name: "emergency_contact_phone",
label: "Emergency Phone",
type: "string",
priority: "secondary",
critical: true,
weight: 80,
group: "emergency",
keyboard: "tel",
},
{
name: "emergency_contact_relationship",
label: "Emergency Contact Relationship",
type: "string",
priority: "advanced",
weight: 90,
group: "emergency",
placeholder: "Partner, parent, etc.",
},
{
name: "blood_type",
label: "Blood Type",
help: "Critical for transfusions",
type: "enum",
priority: "secondary",
critical: true,
enum: "blood_types",
weight: 100,
group: "medical",
},
{
name: "medical_conditions",
label: "Medical Conditions",
help: "Ongoing conditions",
type: "array",
item_type: "string",
priority: "secondary",
critical: true,
weight: 110,
group: "medical",
},
{
name: "medications",
label: "Medications",
help: "Current medications",
type: "array",
item_type: "string",
priority: "secondary",
critical: true,
weight: 120,
group: "medical",
},
{
name: "allergies",
label: "Allergies",
help: "Life-threatening reactions",
type: "array",
item_type: "string",
priority: "secondary",
critical: true,
weight: 130,
group: "medical",
},
{
name: "dietary_requirements",
label: "Dietary Requirements",
help: "For provisioning",
type: "array",
item_type: "string",
priority: "advanced",
weight: 140,
group: "medical",
},
{
name: "medical_notes",
label: "Medical Notes",
type: "text",
component: "textarea",
priority: "advanced",
critical: true,
weight: 150,
group: "medical",
rows: 3,
},
],
collection: "/ops/crew",
item: "/ops/crew/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
blood_types: [
{ value: "A+", label: "A+" },
{ value: "A-", label: "A-" },
{ value: "B+", label: "B+" },
{ value: "B-", label: "B-" },
{ value: "AB+", label: "AB+" },
{ value: "AB-", label: "AB-" },
{ value: "O+", label: "O+" },
{ value: "O-", label: "O-" },
],
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
function ignore(): void {
// The example saves nothing.
}
export default function CriticalFields(): ReactElement {
return (
<SchemaForm
id="critical"
schema={SCHEMA}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={noSuggestions}
initial={{
emergency_contact_name: "Jonas Lindqvist",
emergency_contact_phone: "+46 70 123 45 67",
emergency_contact_relationship: "Brother",
blood_type: "O-",
allergies: ["Penicillin"],
medications: ["Salbutamol inhaler, in the port cabin's locker"],
}}
submitAction={ignore}
/>
);
}
Fixed fields
Values the page sets, such as the vessel a crew member belongs to, go in fixed: their fields are not drawn and
every save carries them.
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
SchemaForm,
type FieldOption,
type FieldState,
type SchemaField,
type FormSchema,
type FormValues,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Editing the first vessel: the page fixes is_primary, so the Primary Vessel switch is not drawn, and each field's " +
"save carries true, shown below.";
// The identity fields of bosund's `GET /forms/vessel`, and the options of the enum sources they name.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
entity: "vessel",
package: "fleet",
save_mode: "auto",
groups: [{ id: "identity", label: "identity", weight: 10 }],
fields: [
{
name: "name",
label: "Name",
type: "string",
priority: "primary",
required: true,
min: 1,
max: 200,
min_length: 1,
max_length: 200,
weight: 10,
group: "identity",
autofocus: true,
},
{
name: "make",
label: "Make",
type: "autocomplete",
priority: "secondary",
max: 100,
autocomplete: "vessel_makes",
weight: 20,
group: "identity",
min_chars: 2,
},
{
name: "model",
label: "Model",
type: "autocomplete",
priority: "secondary",
max: 100,
autocomplete: "vessel_models",
weight: 30,
group: "identity",
min_chars: 2,
},
{
name: "hull",
label: "Hull",
type: "enum",
priority: "secondary",
one_of: ["monohull", "catamaran", "trimaran"],
enum: "hull_types",
weight: 35,
group: "identity",
},
{
name: "propulsion",
label: "Propulsion",
type: "enum",
priority: "secondary",
one_of: ["sail", "motor", "motorsailer"],
enum: "propulsion_types",
weight: 36,
group: "identity",
},
{ name: "is_primary", label: "Primary Vessel", type: "bool", priority: "secondary", weight: 50, group: "identity" },
{
name: "home_port",
label: "Home Port",
type: "autocomplete",
priority: "secondary",
max: 200,
autocomplete: "ports",
weight: 60,
group: "identity",
min_chars: 2,
},
],
collection: "/fleet/vessels",
item: "/fleet/vessels/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
hull_types: [
{ value: "monohull", label: "monohull" },
{ value: "catamaran", label: "catamaran" },
{ value: "trimaran", label: "trimaran" },
],
propulsion_types: [
{ value: "sail", label: "sail" },
{ value: "motor", label: "motor" },
{ value: "motorsailer", label: "motorsailer" },
],
};
/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
...SCHEMA,
fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};
const PORTS: readonly string[] = ["Palma de Mallorca", "Port d'Andratx", "Puerto Portals", "Port de Sóller"];
const suggestFor: SchemaFormProps["suggestFor"] =
(source: string): ReturnType<SchemaFormProps["suggestFor"]> =>
(query: string): Promise<readonly Suggestion[]> =>
Promise.resolve(
(source === "ports" ? PORTS : [])
.filter((port: string): boolean => port.toLowerCase().includes(query.toLowerCase()))
.map((port: string): Suggestion => ({ value: port, label: port })),
);
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
export default function FixedFields(): ReactElement {
const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
null,
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
<SchemaForm
id="first-vessel"
schema={SHOWN}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={suggestFor}
initial={{ name: "Althea", make: "Hanse", model: "460", hull: "monohull", propulsion: "sail" }}
fixed={{ is_primary: true }}
submitAction={(values: FormValues): void => {
setSent(values);
}}
/>
{sent !== null && (
<pre
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text-dim)",
margin: 0,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
{JSON.stringify(sent, null, 2)}
</pre>
)}
</div>
);
}
Saving each field as it changes
A schema whose save_mode is auto saves each field on its own while a record is edited: typed text once the typing
pauses for 800 ms or the field loses the focus, and a switch, a list or a date as soon as it is chosen. Each save
sends submitAction only that field's value with the fixed values, so the caller updates that field and no other. The
field shows Saving… at the end of its label until the save resolves, then Saved; a refused save shows its reason
under the field, keeps the typed value and leaves the focus where it is, and leaving the field tries again. A
field's saves go one at a time, so the save that lands last always carries the newest value. A field's own
save_mode decides for that field, and the Save button stays while any field drawn waits for it. A record being
created has nothing to save into yet, so its form saves on Save.
Code
import {
SchemaForm,
type FieldOption,
type FieldState,
type FormSchema,
type FormValues,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A crew member's card, which bosund marks `save_mode: auto`: each field saves on its own, a typed one once typing " +
"pauses and a switch or a list at once, with Saving… and Saved at the end of its label. Type Jonas Lindqvist as " +
"the name to see a refused save.";
// Fields of bosund's `GET /forms/crew_member` (save_mode auto, ops/forms.go), and the options of crew_roles.
const SCHEMA: FormSchema = {
entity: "crew_member",
package: "ops",
save_mode: "auto",
groups: [
{ id: "identity", label: "identity", weight: 10 },
{ id: "preferences", label: "preferences", weight: 195 },
],
fields: [
{
name: "name",
label: "Name",
type: "string",
priority: "primary",
required: true,
min_length: 1,
max_length: 200,
weight: 10,
group: "identity",
placeholder: "Full name",
},
{
name: "role",
label: "Role",
type: "enum",
priority: "primary",
required: true,
one_of: ["skipper", "mate", "crew", "guest"],
enum: "crew_roles",
weight: 20,
group: "identity",
},
{
name: "clock_12",
label: "12-hour clock",
help: "Speak times as five p m instead of seventeen hundred",
type: "bool",
priority: "primary",
weight: 36,
group: "identity",
},
{
name: "watch_state",
label: "Watch Duty",
type: "string",
priority: "primary",
one_of: ["unknown", "active", "passive", "off", "sleeping", "incapacitated", "overboard"],
weight: 195,
group: "preferences",
},
{
name: "notes",
label: "Notes",
type: "text",
component: "textarea",
priority: "primary",
weight: 220,
group: "preferences",
rows: 3,
},
],
collection: "/ops/crew",
item: "/ops/crew/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
crew_roles: [
{ value: "skipper", label: "skipper" },
{ value: "crew", label: "crew" },
],
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
function ignore(): void {
// The example has nowhere to go back to.
}
export default function AutoSave(): ReactElement {
const [sent, setSent]: [FormValues[], Dispatch<SetStateAction<FormValues[]>>] = useState<FormValues[]>([]);
// bosund takes 600 ms to answer, and refuses a name another crew member already has.
async function save(values: FormValues): Promise<void> {
setSent((previous: FormValues[]): FormValues[] => [...previous, values].slice(-3));
await new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, 600);
});
if (values["name"] === "Jonas Lindqvist") {
throw new Error("Couldn't save the name: Jonas Lindqvist is already in the crew. Use another name.");
}
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
<SchemaForm
id="auto-save"
schema={SCHEMA}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={noSuggestions}
initial={{ name: "Maya Lindqvist", role: "crew", clock_12: false, watch_state: "off" }}
fixed={{ vessel_id: "7c1e4d2a-5b8f-4e61-9a3d-2f6b8c0e1a47" }}
submitAction={save}
onCancel={ignore}
/>
{sent.length > 0 && (
<pre
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text-dim)",
margin: 0,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
{sent.map((values: FormValues): string => JSON.stringify(values)).join("\n")}
</pre>
)}
</div>
);
}
Fields that hold a record's id
A field whose autocomplete source lists records by id (bosund's *_ids sources, which mark the field
autocomplete_ids) holds the chosen record's id and shows its name. When the form opens it asks labelFor for the
name of each id it holds; choosing a suggestion shows that suggestion's name. Typed text only searches: leaving the
field without choosing puts the name back, and leaving it empty clears the id.
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
SchemaForm,
type FieldState,
type FormSchema,
type FormValues,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A maintenance task's assignment, whose fields hold the crew member's and the contractor's ids: each shows the " +
"name, from `labelFor` when the form opens and from the chosen suggestion after, and Save sends the ids.";
// The assignment group of bosund's `GET /forms/maintenance_entry`: `autocomplete=crew_ids` and `contact_ids`, which
// bosund marks `autocomplete_ids`. suggestFor and labelFor stand in for `GET /forms/autocomplete/{source}?q=` and
// `?value=`.
const SCHEMA: FormSchema = {
entity: "maintenance_entry",
package: "fleet",
save_mode: "explicit",
groups: [{ id: "assignment", label: "assignment", weight: 40 }],
fields: [
{
name: "assignee_crew_id",
label: "Crew Member",
help: "Crew member doing the work",
type: "autocomplete",
priority: "primary",
autocomplete: "crew_ids",
autocomplete_ids: true,
min_chars: 1,
weight: 42,
group: "assignment",
},
{
name: "assignee_contact_id",
label: "Contractor",
help: "Yard or contractor doing the work. The skipper signs off their work",
type: "autocomplete",
priority: "primary",
autocomplete: "contact_ids",
autocomplete_ids: true,
min_chars: 1,
weight: 44,
group: "assignment",
},
],
};
const RECORDS: Readonly<Record<string, readonly Suggestion[]>> = {
crew_ids: [
{ value: "3f6c1a2e-5b8f-4e61-9a3d-2f6b8c0e1a01", label: "Maya Lindqvist" },
{ value: "3f6c1a2e-5b8f-4e61-9a3d-2f6b8c0e1a02", label: "Jonas Lindqvist" },
{ value: "3f6c1a2e-5b8f-4e61-9a3d-2f6b8c0e1a03", label: "Ines Moreau" },
],
contact_ids: [
{ value: "8a2d4c6e-1f3b-4a5c-9e7d-0b1c2d3e4f01", label: "Marina Yard Horta" },
{ value: "8a2d4c6e-1f3b-4a5c-9e7d-0b1c2d3e4f02", label: "Lewmar Services" },
],
};
const suggestFor: SchemaFormProps["suggestFor"] =
(source: string): ReturnType<SchemaFormProps["suggestFor"]> =>
(query: string): Promise<readonly Suggestion[]> =>
Promise.resolve(
(RECORDS[source] ?? []).filter((record: Suggestion): boolean =>
record.label.toLowerCase().includes(query.toLowerCase()),
),
);
const labelFor: NonNullable<SchemaFormProps["labelFor"]> =
(source: string): ReturnType<NonNullable<SchemaFormProps["labelFor"]>> =>
(value: string): Promise<Suggestion | null> =>
Promise.resolve(
(RECORDS[source] ?? []).find((record: Suggestion): boolean => record.value === value) ?? null,
);
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
export default function IdAutocomplete(): ReactElement {
const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
null,
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
<SchemaForm
id="id-autocomplete"
schema={SCHEMA}
enumOptions={{}}
evaluate={noConditions}
suggestFor={suggestFor}
labelFor={labelFor}
initial={{ assignee_crew_id: "3f6c1a2e-5b8f-4e61-9a3d-2f6b8c0e1a01" }}
submitAction={(values: FormValues): void => {
setSent(values);
}}
/>
{sent !== null && (
<pre
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text-dim)",
margin: 0,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
{JSON.stringify(sent, null, 2)}
</pre>
)}
</div>
);
}
Maritime inputs
Code
import {
SchemaForm,
type FieldState,
type FormSchema,
type FormValues,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The kit's inputs a field's schema calls for: an email that offers the domain, a free-text phone, an E.164 phone " +
"with its country, an MMSI that names its station, and a date on the calendar.";
/** Five fields from bosund's schemas, each with the validator or tag that calls for its input. */
const SCHEMA: FormSchema = {
entity: "inputs",
groups: [{ id: "inputs", label: "inputs", weight: 10 }],
fields: [
// crew_member.email: `validate:"email"` gives keyboard=email and the address pattern: EmailInput.
{
name: "email",
label: "Email",
type: "string",
pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
keyboard: "email",
weight: 10,
group: "inputs",
},
// crew_member.emergency_contact_phone: keyboard=tel with no pattern is free text, kept as typed: a `tel` input.
{
name: "emergency_contact_phone",
label: "Emergency Phone",
type: "string",
keyboard: "tel",
weight: 20,
group: "inputs",
width: "half",
},
// crew_member.phone: `validate:"omitempty,e164"` gives keyboard=tel and the E.164 pattern: PhoneInput, which
// saves E.164.
{
name: "phone",
label: "Phone",
type: "string",
pattern: "^\\+[1-9]\\d{1,14}$",
keyboard: "tel",
weight: 30,
group: "inputs",
width: "half",
},
// vessel.mmsi: `validate:"mmsi"` gives the nine-digit pattern: MmsiInput.
{
name: "mmsi",
label: "MMSI",
help: "9-digit Maritime Mobile Service Identity",
type: "string",
min_length: 9,
max_length: 9,
pattern: "^\\d{9}$",
placeholder: "123456789",
keyboard: "numeric",
weight: 40,
group: "inputs",
width: "half",
},
// crew_member.date_of_birth: `form:"type=date"`: DatePicker.
{ name: "date_of_birth", label: "Date of Birth", type: "date", weight: 50, group: "inputs", width: "half" },
],
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export default function MaritimeInputs(): ReactElement {
const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
null,
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
<SchemaForm
id="maritime-inputs"
schema={SCHEMA}
enumOptions={{}}
evaluate={noConditions}
suggestFor={noSuggestions}
phoneCountry="GB"
initial={{
email: "maya@lindqvist.example",
emergency_contact_phone: "+46 70 765 43 21 (evenings)",
phone: "+46701234567",
mmsi: "235012345",
date_of_birth: "1990-03-12T00:00:00Z",
}}
submitAction={(values: FormValues): void => {
setSent(values);
}}
/>
{sent !== null && (
<pre
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text-dim)",
margin: 0,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
{JSON.stringify(sent, null, 2)}
</pre>
)}
</div>
);
}
Autofocus
Code
import { Button } from "@bosunkit/components/button";
import {
SchemaForm,
type FieldOption,
type FieldState,
type FormSchema,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A form that opens from a button: the expense's category, which the schema marks autofocus, takes the focus, " +
"though it is a list and not a text input.";
// The first fields of bosund's `GET /forms/expense`, and the options of the enum sources they name.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
entity: "expense",
package: "ops",
save_mode: "explicit",
groups: [
{ id: "identity", label: "identity", weight: 10 },
{ id: "cost", label: "cost", weight: 50 },
],
fields: [
{
name: "category",
label: "Category",
type: "enum",
priority: "primary",
required: true,
one_of: [
"fuel",
"mooring",
"provisioning",
"maintenance",
"repairs",
"equipment",
"insurance",
"customs",
"fees",
"crew",
"entertainment",
"communication",
"medical",
"other",
],
enum: "expense_categories",
weight: 10,
group: "identity",
autofocus: true,
},
{
name: "occurred_at",
label: "Date",
type: "date",
priority: "primary",
required: true,
weight: 40,
group: "identity",
},
{
name: "amount",
label: "Amount",
type: "float",
priority: "primary",
min: 0,
weight: 50,
group: "cost",
keyboard: "decimal",
decimal_places: 2,
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "primary",
required: true,
min_length: 3,
max_length: 3,
enum: "currencies",
weight: 60,
group: "cost",
width: "third",
},
{
name: "description",
label: "Description",
type: "string",
priority: "secondary",
max: 500,
max_length: 500,
weight: 30,
group: "identity",
},
],
collection: "/ops/expenses",
item: "/ops/expenses/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
currencies: [
{ value: "EUR", label: "EUR" },
{ value: "USD", label: "USD" },
{ value: "GBP", label: "GBP" },
{ value: "AUD", label: "AUD" },
{ value: "CAD", label: "CAD" },
{ value: "NZD", label: "NZD" },
{ value: "JPY", label: "JPY" },
{ value: "CHF", label: "CHF" },
{ value: "NOK", label: "NOK" },
{ value: "SEK", label: "SEK" },
{ value: "DKK", label: "DKK" },
],
expense_categories: [
{ value: "fuel", label: "fuel" },
{ value: "mooring", label: "mooring" },
{ value: "provisioning", label: "provisioning" },
{ value: "maintenance", label: "maintenance" },
{ value: "insurance", label: "insurance" },
{ value: "customs", label: "customs" },
{ value: "crew", label: "crew" },
{ value: "charter", label: "charter" },
],
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export default function Autofocus(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
if (!open) {
return (
<Button
variant="primary"
onClick={(): void => {
setOpen(true);
}}
>
Add expense
</Button>
);
}
return (
<SchemaForm
id="autofocus"
schema={SCHEMA}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={noSuggestions}
submitAction={(): void => {
setOpen(false);
}}
onCancel={(): void => {
setOpen(false);
}}
submitLabel="Add expense"
/>
);
}
A crew member
Code
import {
SchemaForm,
type FieldOption,
type FieldState,
type SchemaField,
type FormSchema,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";
export const description: string =
"A crew member as bosund describes one: eight groups, the email and phone with their own inputs, the critical " +
"emergency and medical fields, read-only watch and service dates, and the vessel's id fixed by the page.";
// bosund's `GET /forms/crew_member`, and the options of the enum sources it names. clothing_sizes and
// lifejacket_sizes are not registered in bosund, so the jacket, glove and life jacket sizes have no options.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
entity: "crew_member",
package: "ops",
save_mode: "auto",
groups: [
{ id: "identity", label: "identity", weight: 10 },
{ id: "contact", label: "contact", weight: 40 },
{ id: "emergency", label: "emergency", weight: 70 },
{ id: "medical", label: "medical", weight: 100 },
{ id: "experience", label: "experience", weight: 160 },
{ id: "preferences", label: "preferences", weight: 195 },
{ id: "gear", label: "gear", weight: 230 },
{ id: "meta", label: "meta", weight: 900 },
],
fields: [
{
name: "name",
label: "Name",
type: "string",
priority: "primary",
required: true,
min: 1,
max: 200,
min_length: 1,
max_length: 200,
weight: 10,
group: "identity",
placeholder: "Full name",
autofocus: true,
},
{
name: "role",
label: "Role",
type: "enum",
priority: "primary",
required: true,
one_of: ["skipper", "mate", "crew", "guest"],
enum: "crew_roles",
weight: 20,
group: "identity",
},
{
name: "nickname",
label: "Nickname",
type: "string",
priority: "advanced",
max: 100,
max_length: 100,
weight: 30,
group: "identity",
placeholder: "Call sign",
},
{
name: "date_of_birth",
label: "Date of Birth",
help: "For legal and medical purposes",
type: "date",
priority: "secondary",
weight: 35,
group: "identity",
},
{
name: "language",
label: "Language",
type: "enum",
priority: "secondary",
one_of: ["en", "de", "fr", "es", "it", "nl"],
enum: "languages",
weight: 35,
group: "identity",
},
{
name: "clock_12",
label: "12-hour clock",
help: "Speak times as five p m instead of seventeen hundred",
type: "bool",
priority: "secondary",
weight: 36,
group: "identity",
},
{
name: "email",
label: "Email",
type: "string",
priority: "secondary",
pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
weight: 40,
group: "contact",
keyboard: "email",
},
{
name: "phone",
label: "Phone",
type: "string",
priority: "secondary",
pattern: "^\\+[1-9]\\d{1,14}$",
weight: 50,
group: "contact",
keyboard: "tel",
},
{
name: "nationality",
label: "Nationality",
help: "For border crossings",
type: "string",
priority: "advanced",
max: 100,
max_length: 100,
weight: 60,
group: "contact",
},
{
name: "emergency_contact_name",
label: "Emergency Contact",
help: "Who to call in emergency",
type: "string",
priority: "secondary",
critical: true,
weight: 70,
group: "emergency",
},
{
name: "emergency_contact_phone",
label: "Emergency Phone",
type: "string",
priority: "secondary",
critical: true,
weight: 80,
group: "emergency",
keyboard: "tel",
},
{
name: "emergency_contact_relationship",
label: "Emergency Contact Relationship",
type: "string",
priority: "advanced",
weight: 90,
group: "emergency",
placeholder: "Partner, parent, etc.",
},
{
name: "blood_type",
label: "Blood Type",
help: "Critical for transfusions",
type: "enum",
priority: "secondary",
critical: true,
enum: "blood_types",
weight: 100,
group: "medical",
},
{
name: "medical_conditions",
label: "Medical Conditions",
help: "Ongoing conditions",
type: "array",
item_type: "string",
priority: "secondary",
critical: true,
weight: 110,
group: "medical",
},
{
name: "medications",
label: "Medications",
help: "Current medications",
type: "array",
item_type: "string",
priority: "secondary",
critical: true,
weight: 120,
group: "medical",
},
{
name: "allergies",
label: "Allergies",
help: "Life-threatening reactions",
type: "array",
item_type: "string",
priority: "secondary",
critical: true,
weight: 130,
group: "medical",
},
{
name: "dietary_requirements",
label: "Dietary Requirements",
help: "For provisioning",
type: "array",
item_type: "string",
priority: "advanced",
weight: 140,
group: "medical",
},
{
name: "medical_notes",
label: "Medical Notes",
type: "text",
component: "textarea",
priority: "advanced",
critical: true,
weight: 150,
group: "medical",
rows: 3,
},
{
name: "certifications",
label: "Certifications",
help: "Yachtmaster STCW etc",
type: "array",
item_type: "string",
priority: "secondary",
weight: 160,
group: "experience",
},
{
name: "experience_level",
label: "Experience Level",
type: "enum",
priority: "advanced",
enum: "experience_levels",
weight: 170,
group: "experience",
},
{
name: "experience_notes",
label: "Experience Notes",
type: "text",
component: "textarea",
priority: "advanced",
weight: 180,
group: "experience",
rows: 3,
},
{
name: "sea_miles",
label: "Sea Miles",
help: "Logged sea miles",
type: "int",
priority: "advanced",
weight: 190,
group: "experience",
keyboard: "numeric",
},
{
name: "watch_state",
label: "Watch Duty",
type: "string",
priority: "secondary",
one_of: ["unknown", "active", "passive", "off", "sleeping", "incapacitated", "overboard"],
weight: 195,
group: "preferences",
},
{ name: "watch_since", label: "Duty Since", type: "datetime", readonly: true, weight: 196, group: "preferences" },
{
name: "watch_preference",
label: "Watch Preference",
type: "string",
priority: "advanced",
weight: 200,
group: "preferences",
},
{
name: "bunk_assignment",
label: "Bunk Assignment",
type: "string",
priority: "advanced",
weight: 210,
group: "preferences",
},
{
name: "notes",
label: "Notes",
type: "text",
component: "textarea",
priority: "advanced",
weight: 220,
group: "preferences",
rows: 4,
},
{
name: "height",
label: "Height",
type: "string",
priority: "advanced",
weight: 230,
group: "gear",
placeholder: "5'10\" or 178cm",
},
{
name: "weight",
label: "Weight",
type: "string",
priority: "advanced",
weight: 240,
group: "gear",
placeholder: "175 lbs or 80kg",
},
{
name: "shoe_size",
label: "Shoe Size",
type: "string",
priority: "advanced",
weight: 250,
group: "gear",
placeholder: "EU 43 / US 10",
},
{
name: "shoe_notes",
label: "Shoe Notes",
type: "string",
priority: "advanced",
weight: 251,
group: "gear",
placeholder: "Wide, orthotics, etc.",
},
{
name: "jacket_size",
label: "Jacket Size",
type: "enum",
priority: "advanced",
enum: "clothing_sizes",
weight: 260,
group: "gear",
},
{
name: "trouser_size",
label: "Trouser Size",
type: "string",
priority: "advanced",
weight: 270,
group: "gear",
placeholder: "32x32 / M",
},
{
name: "glove_size",
label: "Glove Size",
type: "enum",
priority: "advanced",
enum: "clothing_sizes",
weight: 275,
group: "gear",
},
{
name: "vision_needs",
label: "Vision/Glasses",
type: "string",
priority: "advanced",
weight: 277,
group: "gear",
placeholder: "Prescription, night vision issues",
},
{
name: "has_own_lifejacket",
label: "Has Own PFD",
help: "Crew brings their own life jacket",
type: "bool",
priority: "secondary",
weight: 278,
group: "gear",
},
{
name: "lifejacket_size",
label: "Lifejacket Size",
help: "For fitted PFD assignment",
type: "enum",
priority: "advanced",
enum: "lifejacket_sizes",
weight: 280,
group: "gear",
},
{
name: "lifejacket_last_serviced",
label: "PFD Last Serviced",
help: "When lifejacket was last serviced",
type: "date",
priority: "advanced",
weight: 285,
group: "gear",
},
{
name: "lifejacket_service_due",
label: "PFD Service Due",
help: "Auto-calculated: last serviced + 12 months",
type: "date",
priority: "advanced",
critical: true,
readonly: true,
weight: 290,
group: "gear",
},
{ name: "created_at", label: "Created At", type: "datetime", readonly: true, weight: 900, group: "meta" },
{ name: "updated_at", label: "Updated At", type: "datetime", readonly: true, weight: 910, group: "meta" },
],
collection: "/ops/crew",
item: "/ops/crew/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
blood_types: [
{ value: "A+", label: "A+" },
{ value: "A-", label: "A-" },
{ value: "B+", label: "B+" },
{ value: "B-", label: "B-" },
{ value: "AB+", label: "AB+" },
{ value: "AB-", label: "AB-" },
{ value: "O+", label: "O+" },
{ value: "O-", label: "O-" },
],
crew_roles: [
{ value: "skipper", label: "skipper" },
{ value: "crew", label: "crew" },
],
experience_levels: [
{ value: "novice", label: "novice" },
{ value: "competent", label: "competent" },
{ value: "proficient", label: "proficient" },
{ value: "expert", label: "expert" },
],
languages: [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" },
{ value: "fr", label: "Français" },
{ value: "es", label: "Español" },
{ value: "it", label: "Italiano" },
{ value: "nl", label: "Nederlands" },
],
};
/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
...SCHEMA,
fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
function ignore(): void {
// The example saves nothing.
}
export default function CrewMember(): ReactElement {
return (
<SchemaForm
id="crew-member"
schema={SHOWN}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={noSuggestions}
initial={{
name: "Maya Lindqvist",
role: "crew",
language: "en",
email: "maya@lindqvist.example",
phone: "+46701234567",
emergency_contact_name: "Jonas Lindqvist",
emergency_contact_phone: "+46 70 765 43 21",
blood_type: "O-",
allergies: ["Penicillin"],
certifications: ["Yachtmaster Offshore", "STCW Basic Safety"],
watch_state: "off",
watch_since: "2026-10-11T04:00:00Z",
has_own_lifejacket: true,
lifejacket_last_serviced: "2026-03-01T00:00:00Z",
lifejacket_service_due: "2027-03-01T00:00:00Z",
created_at: "2026-04-02T10:15:00Z",
updated_at: "2026-10-11T04:00:00Z",
}}
fixed={{ vessel_id: "7c1e4d2a-5b8f-4e61-9a3d-2f6b8c0e1a47" }}
submitAction={ignore}
onCancel={ignore}
/>
);
}
A wizard for a new record
Identity
Code
import { SchemaForm, type FieldState, type FormSchema, type FormValues } from "@bosunkit/components/schema-form";
import type { Suggestion } from "@bosunkit/components/autocomplete";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A new crew member walked through a step for each group: the name and role the first step needs before the " +
"next, Back keeping what was typed, and Save on the last step sending every field.";
// Three groups of bosund's crew_member schema, with the form's layout set to a wizard.
const SCHEMA: FormSchema = {
entity: "crew_member",
layout: "wizard",
groups: [
{ id: "identity", label: "identity", weight: 10 },
{ id: "contact", label: "contact", weight: 40 },
{ id: "emergency", label: "emergency", weight: 70 },
],
fields: [
{ name: "name", label: "Name", type: "string", required: true, max_length: 100, weight: 10, group: "identity" },
{
name: "role",
label: "Role",
type: "enum",
one_of: ["skipper", "crew", "guest"],
required: true,
weight: 20,
group: "identity",
},
{ name: "date_of_birth", label: "Date of Birth", type: "date", weight: 30, group: "identity" },
{ name: "email", label: "Email", type: "string", keyboard: "email", weight: 40, group: "contact" },
{ name: "phone", label: "Phone", type: "string", pattern: "^\\+[1-9]\\d{1,14}$", weight: 50, group: "contact" },
{
name: "emergency_contact_name",
label: "Emergency Contact",
type: "string",
description: "Who to call in an emergency",
critical: true,
weight: 70,
group: "emergency",
},
{
name: "emergency_contact_phone",
label: "Emergency Phone",
type: "string",
keyboard: "tel",
critical: true,
weight: 80,
group: "emergency",
},
],
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
function noSuggestions(): (query: string, signal: AbortSignal) => Promise<readonly Suggestion[]> {
return (): Promise<readonly Suggestion[]> => Promise.resolve([]);
}
export default function NewCrewMember(): ReactElement {
const [saved, setSaved]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] =
useState<FormValues | null>(null);
return (
<div style={{ display: "grid", gap: "var(--space-md)", width: "100%" }}>
<SchemaForm
id="new-crew-member"
schema={SCHEMA}
enumOptions={{}}
evaluate={noConditions}
suggestFor={noSuggestions}
submitAction={(values: FormValues): void => {
setSaved(values);
}}
phoneCountry="SE"
/>
{saved === null ? null : (
<p role="status" style={{ margin: 0 }}>
Saved {String(saved.name)}.
</p>
)}
</div>
);
}
Inputs a field asks for
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import { SchemaForm, type FieldState, type FormSchema } from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";
export const description: string =
"A departure check whose fields ask for the kit's inputs by their component: the fuel on a slider with a preferred " +
"maximum, the rig as segments, the passage as cards that say what each means, spares as chips, the flags flown as " +
"checkboxes, a date that cannot be in the past, and the guest network's password.";
const SCHEMA: FormSchema = {
entity: "departure_check",
groups: [
{
id: "departure",
label: "departure",
weight: 10,
description: "What the skipper checks before casting off.",
},
{ id: "aboard", label: "aboard", weight: 20 },
],
fields: [
{
name: "departs_on",
label: "Departs on",
type: "date",
required: true,
min_date: "today",
weight: 10,
group: "departure",
},
{
name: "passage",
label: "Passage",
type: "enum",
component: "cards",
options: [
{ value: "coastal", label: "Coastal", description: "Within a day's sail of shelter." },
{ value: "offshore", label: "Offshore", description: "More than a day's sail from shelter." },
{ value: "ocean", label: "Ocean", description: "Weeks out of reach of help.", disabled: true },
],
required: true,
weight: 20,
group: "departure",
},
{
name: "fuel_pct",
label: "Fuel",
type: "int",
component: "slider",
min: 0,
max: 100,
step: 5,
soft_max: 90,
unit: "%",
marks: [{ value: 0, label: "Empty" }, { value: 50 }, { value: 100, label: "Full" }],
help: "Leave room for expansion in the heat.",
weight: 30,
group: "departure",
},
{
name: "rig",
label: "Rig",
type: "enum",
component: "segmented",
one_of: ["sloop", "cutter", "ketch"],
weight: 40,
group: "departure",
},
{
name: "spares",
label: "Spares aboard",
type: "array",
item_type: "string",
component: "chips",
max_items: 8,
placeholder: "Type a spare and press Enter",
weight: 10,
group: "aboard",
},
{
name: "flags",
label: "Flags flown",
type: "array",
item_type: "string",
one_of: ["Ensign", "Courtesy", "Q", "Club burgee"],
weight: 20,
group: "aboard",
},
{
name: "briefed",
label: "Crew briefed on the safety equipment",
type: "bool",
component: "checkbox",
required: true,
weight: 30,
group: "aboard",
},
{
name: "guest_wifi",
label: "Guest network password",
type: "string",
component: "password",
weight: 40,
group: "aboard",
},
],
};
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
function noSuggestions(): (query: string, signal: AbortSignal) => Promise<readonly Suggestion[]> {
return (): Promise<readonly Suggestion[]> => Promise.resolve([]);
}
function ignore(): void {
// The example keeps nothing.
}
export default function ComponentInputs(): ReactElement {
return (
<SchemaForm
id="departure-check"
schema={SCHEMA}
enumOptions={{}}
evaluate={noConditions}
suggestFor={noSuggestions}
initial={{ fuel_pct: 85, rig: "sloop", spares: ["Impeller", "Fan belt"], flags: ["Ensign", "Courtesy"] }}
submitAction={ignore}
/>
);
}
A maintenance task
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
SchemaForm,
type FieldOption,
type FieldState,
type SchemaField,
type FormSchema,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";
export const description: string =
"A maintenance task as bosund describes one: title, category, status, service item and action first, the " +
"assignment, timing and cost under More fields, and the sign-off fields that only bosund writes, read-only.";
// bosund's `GET /forms/maintenance_entry`, and the options of the enum sources it names.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
entity: "maintenance_entry",
package: "fleet",
save_mode: "explicit",
groups: [
{ id: "identity", label: "identity", weight: 10 },
{ id: "assignment", label: "assignment", weight: 42 },
{ id: "details", label: "details", weight: 50 },
{ id: "timing", label: "timing", weight: 60 },
{ id: "work", label: "work", weight: 120 },
{ id: "cost", label: "cost", weight: 140 },
{ id: "vendor", label: "vendor", weight: 160 },
{ id: "payment", label: "payment", weight: 180 },
{ id: "location", label: "location", weight: 200 },
{ id: "meta", label: "meta", weight: 210 },
],
fields: [
{
name: "title",
label: "Title",
type: "string",
priority: "primary",
required: true,
min: 1,
max: 200,
min_length: 1,
max_length: 200,
weight: 10,
group: "identity",
autofocus: true,
},
{
name: "category",
label: "Category",
type: "enum",
priority: "primary",
required: true,
one_of: ["engine", "hull", "rigging", "electrical", "plumbing", "safety", "sails", "deck", "interior", "other"],
enum: "maintenance_categories",
weight: 20,
group: "identity",
},
{
name: "system",
label: "System",
help: "Specific system affected",
type: "string",
priority: "secondary",
max: 100,
max_length: 100,
weight: 30,
group: "identity",
},
{
name: "status",
label: "Status",
type: "enum",
priority: "primary",
required: true,
one_of: ["pending", "scheduled", "in_progress", "deferred", "awaiting_signoff", "completed", "canceled"],
enum: "maintenance_statuses",
weight: 40,
group: "identity",
},
{
name: "assignee_crew_id",
label: "Crew Member",
help: "Crew member doing the work",
type: "autocomplete",
priority: "secondary",
autocomplete: "crew_ids",
weight: 42,
group: "assignment",
min_chars: 1,
},
{
name: "assignee_contact_id",
label: "Contractor",
help: "Yard or contractor doing the work. The skipper signs off their work",
type: "autocomplete",
priority: "secondary",
autocomplete: "contact_ids",
weight: 44,
group: "assignment",
min_chars: 1,
},
{
name: "signed_off_by_crew_id",
label: "Signed Off By",
type: "string",
readonly: true,
weight: 46,
group: "assignment",
},
{
name: "signed_off_at",
label: "Signed Off At",
type: "datetime",
readonly: true,
weight: 48,
group: "assignment",
},
{
name: "description",
label: "Description",
type: "text",
component: "textarea",
priority: "secondary",
max: 5000,
weight: 50,
group: "details",
rows: 3,
},
{
name: "performed_at",
label: "Performed At",
type: "datetime",
priority: "secondary",
weight: 60,
group: "timing",
},
{
name: "engine_hours_at_service",
label: "Engine Hours",
help: "Engine hours when service performed",
type: "float",
priority: "advanced",
min: 0,
weight: 70,
group: "timing",
keyboard: "decimal",
decimal_places: 1,
},
{ name: "due_on", label: "Due On", type: "date", priority: "secondary", weight: 80, group: "timing" },
{
name: "due_hours",
label: "Due At Engine Hours",
help: "Engine hour reading at which the work is due",
type: "float",
priority: "advanced",
min: 0,
weight: 90,
group: "timing",
keyboard: "decimal",
decimal_places: 1,
},
{
name: "service_interval_hours",
label: "Interval (hrs)",
type: "float",
priority: "advanced",
min: 0,
weight: 100,
group: "timing",
keyboard: "numeric",
},
{
name: "service_interval_days",
label: "Interval (days)",
type: "int",
priority: "advanced",
min: 1,
weight: 110,
group: "timing",
keyboard: "numeric",
},
{
name: "service_item",
label: "Schedule Item",
help: "Which manual service this was; the reminder counts from it. Bosun fills it in when online — offline pick it here",
type: "autocomplete",
priority: "primary",
max: 100,
autocomplete: "service_items",
weight: 112,
group: "timing",
min_chars: 1,
},
{
name: "service_action",
label: "Schedule Action",
type: "enum",
priority: "primary",
one_of: ["replace", "check", "drain", "adjust"],
enum: "service_actions",
weight: 114,
group: "timing",
},
{
name: "engine_instance",
label: "Engine",
help: "Engine instance (0 port or single",
type: "int",
priority: "secondary",
min: 0,
weight: 116,
group: "timing",
keyboard: "numeric",
},
{
name: "parts_used",
label: "Parts Used",
type: "array",
item_type: "string",
priority: "advanced",
max_items: 50,
weight: 120,
group: "work",
},
{
name: "labor_hours",
label: "Labor Hours",
type: "float",
priority: "advanced",
min: 0,
weight: 130,
group: "work",
keyboard: "decimal",
decimal_places: 1,
},
{
name: "cost",
label: "Cost",
type: "float",
priority: "secondary",
min: 0,
weight: 140,
group: "cost",
keyboard: "decimal",
decimal_places: 2,
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "advanced",
max: 3,
enum: "currencies",
weight: 150,
group: "cost",
width: "third",
},
{
name: "vendor",
label: "Vendor",
type: "autocomplete",
priority: "advanced",
max: 200,
autocomplete: "contacts",
weight: 160,
group: "vendor",
min_chars: 2,
},
{
name: "vendor_contact",
label: "Vendor Contact",
type: "string",
priority: "advanced",
max: 200,
max_length: 200,
weight: 170,
group: "vendor",
},
{
name: "invoice_number",
label: "Invoice Number",
type: "string",
priority: "advanced",
max: 100,
max_length: 100,
weight: 180,
group: "payment",
},
{
name: "payment_status",
label: "Payment Status",
type: "enum",
priority: "advanced",
one_of: ["pending", "paid", "partial"],
enum: "payment_statuses",
weight: 190,
group: "payment",
},
{
name: "location",
label: "Location",
help: "Where work was done",
type: "autocomplete",
priority: "advanced",
max: 200,
autocomplete: "places",
weight: 200,
group: "location",
min_chars: 2,
},
{
name: "notes",
label: "Notes",
type: "text",
component: "textarea",
priority: "advanced",
max: 5000,
weight: 210,
group: "meta",
rows: 3,
},
{
name: "tags",
label: "Tags",
type: "array",
item_type: "string",
priority: "advanced",
max_items: 20,
weight: 220,
group: "meta",
},
{ name: "created_at", label: "Created At", type: "datetime", readonly: true, weight: 900, group: "meta" },
],
collection: "/fleet/vessels/{vessel_id}/maintenance",
item: "/fleet/maintenance/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
currencies: [
{ value: "EUR", label: "EUR" },
{ value: "USD", label: "USD" },
{ value: "GBP", label: "GBP" },
{ value: "AUD", label: "AUD" },
{ value: "CAD", label: "CAD" },
{ value: "NZD", label: "NZD" },
{ value: "JPY", label: "JPY" },
{ value: "CHF", label: "CHF" },
{ value: "NOK", label: "NOK" },
{ value: "SEK", label: "SEK" },
{ value: "DKK", label: "DKK" },
],
maintenance_categories: [
{ value: "engine", label: "engine" },
{ value: "hull", label: "hull" },
{ value: "rigging", label: "rigging" },
{ value: "electrical", label: "electrical" },
{ value: "plumbing", label: "plumbing" },
{ value: "safety", label: "safety" },
{ value: "sails", label: "sails" },
{ value: "deck", label: "deck" },
{ value: "interior", label: "interior" },
{ value: "other", label: "other" },
],
maintenance_statuses: [
{ value: "pending", label: "pending" },
{ value: "scheduled", label: "scheduled" },
{ value: "in_progress", label: "in_progress" },
{ value: "deferred", label: "deferred" },
{ value: "awaiting_signoff", label: "awaiting_signoff" },
{ value: "completed", label: "completed" },
{ value: "canceled", label: "canceled" },
],
payment_statuses: [
{ value: "pending", label: "pending" },
{ value: "paid", label: "paid" },
{ value: "partial", label: "partial" },
],
service_actions: [
{ value: "replace", label: "replace" },
{ value: "check", label: "check" },
{ value: "drain", label: "drain" },
{ value: "adjust", label: "adjust" },
],
};
/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
...SCHEMA,
fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};
/** What the autocomplete sources answer: the crew, contacts and places on record, and the engine's service items. */
const SOURCES: Readonly<Record<string, readonly Suggestion[]>> = {
crew: [
{ value: "Maya Lindqvist", label: "Maya Lindqvist" },
{ value: "Tom Whitaker", label: "Tom Whitaker" },
],
crew_ids: [
{ value: "c0a8012e-41f7-4b8e-9d3c-5e2a7b6f1d90", label: "Maya Lindqvist" },
{ value: "5d2f9b31-8a4e-4c7d-b1e6-0f3a9c8d2e75", label: "Tom Whitaker" },
],
contacts: [
{ value: "Varadero Palma", label: "Varadero Palma" },
{ value: "Náutica Reig", label: "Náutica Reig" },
],
contact_ids: [{ value: "9e4b7a12-3c6d-4f8e-a2b5-7d1c0e9f3a68", label: "Varadero Palma" }],
places: [
{ value: "Palma de Mallorca", label: "Palma de Mallorca" },
{ value: "Port d'Andratx", label: "Port d'Andratx" },
],
service_items: [
{ value: "Engine oil & filter", label: "Engine oil & filter" },
{ value: "Raw water impeller", label: "Raw water impeller" },
{ value: "Fuel filter", label: "Fuel filter" },
],
};
const suggestFor: SchemaFormProps["suggestFor"] =
(source: string): ReturnType<SchemaFormProps["suggestFor"]> =>
(query: string): Promise<readonly Suggestion[]> =>
Promise.resolve(
(SOURCES[source] ?? []).filter((suggestion: Suggestion): boolean =>
suggestion.label.toLowerCase().includes(query.toLowerCase()),
),
);
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
function ignore(): void {
// The example saves nothing.
}
export default function MaintenanceTask(): ReactElement {
return (
<SchemaForm
id="maintenance-task"
schema={SHOWN}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={suggestFor}
initial={{
title: "Replace the raw water impeller",
category: "engine",
status: "scheduled",
service_item: "Raw water impeller",
service_action: "replace",
system: "Yanmar 4JH80 cooling",
engine_instance: 0,
due_on: "2026-11-02T00:00:00Z",
description: "Impeller vanes cracked at the last check; spare in the engine room locker.",
cost: 64.5,
currency: "EUR",
created_at: "2026-10-08T08:30:00Z",
}}
submitAction={ignore}
onCancel={ignore}
submitLabel="Save task"
/>
);
}
An expense
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
SchemaForm,
type FieldOption,
type FieldState,
type SchemaField,
type FormSchema,
type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";
export const description: string =
"A new expense as bosund describes one: category, time, amount and currency first, the place and vendor under " +
"More fields, the receipt, payment and fuel details folded, and the converted amount bosund works out, read-only.";
// bosund's `GET /forms/expense`, and the options of the enum sources it names.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
entity: "expense",
package: "ops",
save_mode: "explicit",
groups: [
{ id: "identity", label: "identity", weight: 10 },
{ id: "cost", label: "cost", weight: 50 },
{ id: "location", label: "location", weight: 100 },
{ id: "vendor", label: "vendor", weight: 110 },
{ id: "receipt", label: "receipt", weight: 130 },
{ id: "details", label: "details", weight: 160 },
{ id: "meta", label: "meta", weight: 180 },
],
fields: [
{
name: "category",
label: "Category",
type: "enum",
priority: "primary",
required: true,
one_of: [
"fuel",
"mooring",
"provisioning",
"maintenance",
"repairs",
"equipment",
"insurance",
"customs",
"fees",
"crew",
"entertainment",
"communication",
"medical",
"other",
],
enum: "expense_categories",
weight: 10,
group: "identity",
autofocus: true,
},
{
name: "subcategory",
label: "Subcategory",
type: "string",
priority: "secondary",
max: 100,
max_length: 100,
weight: 20,
group: "identity",
},
{
name: "description",
label: "Description",
type: "string",
priority: "secondary",
max: 500,
max_length: 500,
weight: 30,
group: "identity",
},
{
name: "occurred_at",
label: "Date",
type: "date",
priority: "primary",
required: true,
weight: 40,
group: "identity",
},
{
name: "amount",
label: "Amount",
type: "float",
priority: "primary",
min: 0,
weight: 50,
group: "cost",
keyboard: "decimal",
decimal_places: 2,
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "primary",
required: true,
min_length: 3,
max_length: 3,
enum: "currencies",
weight: 60,
group: "cost",
width: "third",
},
{ name: "amount_base", label: "Base Amount", type: "float", min: 0, readonly: true, weight: 70, group: "cost" },
{
name: "currency_base",
label: "Base Currency",
type: "string",
max: 3,
max_length: 3,
readonly: true,
weight: 80,
group: "cost",
},
{ name: "exchange_rate", label: "Exchange Rate", type: "float", min: 0, readonly: true, weight: 90, group: "cost" },
{
name: "location",
label: "Location",
type: "autocomplete",
priority: "secondary",
max: 200,
autocomplete: "places",
weight: 100,
group: "location",
min_chars: 2,
},
{
name: "vendor",
label: "Vendor",
type: "autocomplete",
priority: "secondary",
max: 200,
autocomplete: "contacts",
weight: 110,
group: "vendor",
min_chars: 2,
},
{
name: "vendor_contact",
label: "Vendor Contact",
type: "string",
priority: "advanced",
max: 200,
max_length: 200,
weight: 120,
group: "vendor",
},
{
name: "receipt_number",
label: "Receipt Number",
type: "string",
priority: "advanced",
max: 100,
max_length: 100,
weight: 130,
group: "receipt",
},
{
name: "payment_method",
label: "Payment Method",
type: "enum",
priority: "advanced",
one_of: ["cash", "card", "transfer", "boat_account"],
enum: "payment_methods",
weight: 140,
group: "receipt",
},
{
name: "paid_by",
label: "Paid By",
type: "autocomplete",
priority: "advanced",
max: 200,
autocomplete: "crew",
weight: 150,
group: "receipt",
min_chars: 1,
},
{
name: "fuel_liters",
label: "Fuel (L)",
type: "float",
priority: "advanced",
min: 0,
weight: 160,
group: "details",
unit: "L",
keyboard: "decimal",
decimal_places: 1,
},
{
name: "fuel_type",
label: "Fuel Type",
type: "enum",
priority: "advanced",
one_of: ["diesel", "petrol", "lpg"],
enum: "fuel_types",
weight: 170,
group: "details",
},
{
name: "notes",
label: "Notes",
type: "text",
component: "textarea",
priority: "advanced",
max: 2000,
weight: 180,
group: "meta",
rows: 2,
},
{
name: "tags",
label: "Tags",
type: "array",
item_type: "string",
priority: "advanced",
max_items: 20,
weight: 190,
group: "meta",
},
{ name: "created_at", label: "Created At", type: "datetime", readonly: true, weight: 900, group: "meta" },
],
collection: "/ops/expenses",
item: "/ops/expenses/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
currencies: [
{ value: "EUR", label: "EUR" },
{ value: "USD", label: "USD" },
{ value: "GBP", label: "GBP" },
{ value: "AUD", label: "AUD" },
{ value: "CAD", label: "CAD" },
{ value: "NZD", label: "NZD" },
{ value: "JPY", label: "JPY" },
{ value: "CHF", label: "CHF" },
{ value: "NOK", label: "NOK" },
{ value: "SEK", label: "SEK" },
{ value: "DKK", label: "DKK" },
],
expense_categories: [
{ value: "fuel", label: "fuel" },
{ value: "mooring", label: "mooring" },
{ value: "provisioning", label: "provisioning" },
{ value: "maintenance", label: "maintenance" },
{ value: "insurance", label: "insurance" },
{ value: "customs", label: "customs" },
{ value: "crew", label: "crew" },
{ value: "charter", label: "charter" },
],
fuel_types: [
{ value: "diesel", label: "diesel" },
{ value: "petrol", label: "petrol" },
{ value: "lpg", label: "lpg" },
],
payment_methods: [
{ value: "cash", label: "cash" },
{ value: "card", label: "card" },
{ value: "transfer", label: "transfer" },
{ value: "account", label: "account" },
],
};
/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
...SCHEMA,
fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};
/** What the autocomplete sources answer: the crew, contacts and places on record, and the engine's service items. */
const SOURCES: Readonly<Record<string, readonly Suggestion[]>> = {
crew: [
{ value: "Maya Lindqvist", label: "Maya Lindqvist" },
{ value: "Tom Whitaker", label: "Tom Whitaker" },
],
crew_ids: [
{ value: "c0a8012e-41f7-4b8e-9d3c-5e2a7b6f1d90", label: "Maya Lindqvist" },
{ value: "5d2f9b31-8a4e-4c7d-b1e6-0f3a9c8d2e75", label: "Tom Whitaker" },
],
contacts: [
{ value: "Varadero Palma", label: "Varadero Palma" },
{ value: "Náutica Reig", label: "Náutica Reig" },
],
contact_ids: [{ value: "9e4b7a12-3c6d-4f8e-a2b5-7d1c0e9f3a68", label: "Varadero Palma" }],
places: [
{ value: "Palma de Mallorca", label: "Palma de Mallorca" },
{ value: "Port d'Andratx", label: "Port d'Andratx" },
],
service_items: [
{ value: "Engine oil & filter", label: "Engine oil & filter" },
{ value: "Raw water impeller", label: "Raw water impeller" },
{ value: "Fuel filter", label: "Fuel filter" },
],
};
const suggestFor: SchemaFormProps["suggestFor"] =
(source: string): ReturnType<SchemaFormProps["suggestFor"]> =>
(query: string): Promise<readonly Suggestion[]> =>
Promise.resolve(
(SOURCES[source] ?? []).filter((suggestion: Suggestion): boolean =>
suggestion.label.toLowerCase().includes(query.toLowerCase()),
),
);
function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
function ignore(): void {
// The example saves nothing.
}
export default function Expense(): ReactElement {
return (
<SchemaForm
id="expense"
schema={SHOWN}
enumOptions={ENUM_OPTIONS}
evaluate={noConditions}
suggestFor={suggestFor}
initial={{ category: "fuel", occurred_at: "2026-10-10T16:20:00Z", currency: "EUR", fuel_type: "diesel" }}
submitAction={ignore}
onCancel={ignore}
submitLabel="Add expense"
/>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| schemarequired | FormSchema | – | The form to render. |
| enumOptionsrequired | Readonly<Record<string, readonly FieldOption[]>> | – | Each enum source's options, keyed by the source a field names in enum. |
| evaluaterequired | (values: FormValues) => Promise<Readonly<Record<string, FieldState>>> | – | The state of the fields with CEL conditions for these values, keyed by field name. Asked 150 ms after the values stop changing, only when a field has a condition. Reject when bosund could not evaluate: the fields keep their last state, all visible at first, and bosund validates the save. |
| suggestForrequired | (source: string) => (query: string, signal: AbortSignal) => Promise<readonly Suggestion[]> | – | The suggestions an autocomplete field asks for, by its source. |
| labelFor | ((source: string) => (value: string, signal: AbortSignal) => Promise<Suggestion | null>) | undefined | – | The suggestion an id field's source has for a held id (bosund's GET /forms/autocomplete/{source}?value=), so
the field shows the record's name; null when no record has it. Asked once per held id when the form opens. |
| initial | Readonly<FormValues> | undefined | – | The values to edit; omitted when creating. |
| defaults | Readonly<FormValues> | undefined | – | The values a new record starts from, such as a duplicated record's without its id: the form creates, as without
initial, and these count as unchanged. Such a form keeps no draft and restores none; unused with initial. |
| fixed | Readonly<FormValues> | undefined | – | Values the page sets, such as the vessel's id: their fields are hidden and the values join every submission. |
| submitActionrequired | (values: FormValues, details?: SubmitDetails) => Promise<void> | void | – | Called with the valid values on Save; a rejection's message shows above the buttons. An auto-saved field (see
save_mode) calls it on its own with only its own value and the fixed values, so the caller updates only the
fields it is given; a rejection's message shows under that field. |
| addAnother | boolean | undefined | false | Shows "Save and add another" beside Save while a record is created: it saves through submitAction with
{ another: true }, then empties the form for the next record, keeping the fixed values. The caller keeps its
dialog open for that save. |
| draftKey | string | undefined | – | Keeps what the crew type into a new record in the browser under this key until it is saved, so a form closed half-filled opens again as they left it, with a line saying so and a way to start over. Records being edited keep no draft. |
| onDirtyChange | ((dirty: boolean) => void) | undefined | – | Called when the form's values start or stop differing from the ones it opened with. |
| defaultSources | DefaultSources | undefined | – | What the schema's default tokens are filled from on a new record: the position, the skipper, the time. |
| moneySources | MoneySources | undefined | – | The crew's own currency and the day's rates, for a money field's amount in another currency. |
| lastValuesKey | string | undefined | – | Keeps the values saved with a new record for the fields whose default is last in the browser under this key,
and starts the next new record from them. |
| onCancel | (() => void) | undefined | – | Shows a Cancel button that calls it; Done when every field saves on its own. |
| submitLabel | string | undefined | – | The submit button's text; "Save" when unset. |
| id | string | undefined | – | The form's id, which also starts its inputs' ids (<id>-<name>), so two forms on one page keep their fields apart.
Unset, the inputs' ids are field-<name>. |
| phoneCountry | string | undefined | – | The country an E.164 phone field starts in, ISO 3166-1 alpha-2: the vessel's flag or home port, which the caller knows. A number already held shows in its own country. The phone input's own default (GB) when unset. |
| layout | "auto" | FormLayout | undefined | – | How the groups are arranged, over the schema's layout; see FormSchema.layout. |
Related
- Entity list: a table of records whose dialog holds this form.
- Email input, phone input, MMSI input and date picker: the inputs a field's schema calls for.
- Error messages: how a field's problem is worded.