Entity list
A table of records with an Add button, and the dialog that creates or edits one through its form.
On this page
Code
import { Badge } from "@bosunkit/components/badge";
import type { Suggestion } from "@bosunkit/components/autocomplete";
import type { Column } from "@bosunkit/components/data-table";
import { EntityList, type EntityListEditing, type EntityRow } from "@bosunkit/components/entity-list";
import { writtenMoney } from "@bosunkit/components/money-input";
import type {
FieldOption,
FieldState,
FormSchema,
FormValues,
SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useEffect, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A passage's expenses that load, sort and filter, open in a form with a conditional field and a port search, " +
"save after a request, refuse an expense over the approval limit and refuse to delete a reconciled one.";
const CATEGORY_LABELS: Readonly<Record<string, string>> = {
fuel: "Fuel",
mooring: "Mooring",
repair: "Repair",
provisions: "Provisions",
};
const CATEGORY_BADGES: Readonly<Record<string, "amber" | "blue" | "red" | "green">> = {
fuel: "amber",
mooring: "blue",
repair: "red",
provisions: "green",
};
function categoryCell(row: Record<string, unknown>): ReactElement {
const category: string = typeof row.category === "string" ? row.category : "";
return (
<Badge variant={Object.hasOwn(CATEGORY_BADGES, category) ? CATEGORY_BADGES[category] : "dim"}>
{Object.hasOwn(CATEGORY_LABELS, category) ? CATEGORY_LABELS[category] : category}
</Badge>
);
}
/** The amount in its own currency, written the English way: "€312.60". */
function amountCell(row: Record<string, unknown>): string {
const amount: unknown = row.amount;
const currency: unknown = row.currency;
return typeof amount === "number" && typeof currency === "string" ? writtenMoney(amount, currency, "en") : "";
}
const COLUMNS: readonly Column[] = [
{ key: "description", label: "Description", sortable: true, filter: "text" },
{
key: "category",
label: "Category",
sortable: true,
width: "130px",
render: categoryCell,
filter: "option",
filterOptions: Object.entries(CATEGORY_LABELS).map(
([value, label]: [string, string]): { value: string; label: string } => ({ value, label }),
),
},
{ key: "port", label: "Port", sortable: true },
{ key: "amount", label: "Amount", sortable: true, sortDescendingFirst: true, width: "110px", render: amountCell },
{ key: "incurred_at", label: "Date", sortable: true, sortDescendingFirst: true, width: "120px" },
];
const SCHEMA: FormSchema = {
entity: "expense",
package: "ops",
groups: [
{ id: "expense", label: "expense", weight: 10 },
{ id: "receipt", label: "receipt", weight: 20 },
],
fields: [
{
name: "description",
label: "Description",
type: "string",
priority: "primary",
required: true,
max_length: 120,
weight: 10,
group: "expense",
placeholder: "What was paid for",
},
{
name: "category",
label: "Category",
type: "enum",
priority: "primary",
required: true,
enum: "expense_categories",
one_of: ["fuel", "mooring", "repair", "provisions"],
weight: 20,
group: "expense",
width: "half",
},
{
name: "amount",
label: "Amount",
type: "float",
priority: "primary",
required: true,
min: 0,
component: "money",
currency_field: "currency",
decimal_places: 2,
weight: 30,
group: "expense",
width: "half",
description: "Over €2,000 needs the skipper's approval.",
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "primary",
enum: "currencies",
required: true,
default: "EUR",
weight: 35,
group: "expense",
width: "half",
},
{
name: "port",
label: "Port",
type: "autocomplete",
priority: "primary",
autocomplete: "ports",
min_chars: 2,
weight: 40,
group: "expense",
width: "half",
placeholder: "Type two letters",
},
{
name: "incurred_at",
label: "Date",
type: "date",
priority: "primary",
weight: 50,
group: "expense",
width: "half",
},
{
name: "has_receipt",
label: "Receipt kept",
type: "bool",
priority: "primary",
weight: 10,
group: "receipt",
},
{
name: "receipt_ref",
label: "Receipt number",
type: "string",
priority: "primary",
weight: 20,
group: "receipt",
visible_if: "data.has_receipt == true",
required_if: "data.has_receipt == true",
help: "As printed on the receipt, so the reconciliation can find it.",
},
{ name: "vessel_id", label: "Vessel", type: "string", priority: "secondary", weight: 90, group: "receipt" },
],
};
const CATEGORY_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
expense_categories: Object.entries(CATEGORY_LABELS).map(([value, label]: [string, string]): FieldOption => ({
value,
label,
})),
currencies: [
{ value: "EUR", label: "Euro" },
{ value: "GBP", label: "Pound sterling" },
{ value: "TRY", label: "Turkish lira" },
],
};
const PORTS: readonly string[] = [
"Palma de Mallorca",
"Puerto de Sóller",
"Cartagena",
"Almerimar",
"Gibraltar",
"Lagos",
"Cascais",
"Baiona",
"A Coruña",
];
const EXPENSES: readonly EntityRow[] = [
{
id: "e1",
description: "Diesel, 182 L at the fuel dock",
category: "fuel",
port: "Palma de Mallorca",
amount: 312.6,
currency: "EUR",
incurred_at: "2026-09-28",
has_receipt: true,
receipt_ref: "STP-0928-114",
},
{
id: "e2",
description: "Two nights alongside, pontoon F",
category: "mooring",
port: "Cartagena",
amount: 96,
currency: "EUR",
incurred_at: "2026-09-30",
},
{
id: "e3",
description: "Raw-water impeller and gasket",
category: "repair",
port: "Cartagena",
amount: 48.9,
currency: "EUR",
incurred_at: "2026-10-01",
},
{
id: "e4",
description: "Provisions for the Gibraltar leg",
category: "provisions",
port: "Almerimar",
amount: 184.35,
currency: "EUR",
incurred_at: "2026-10-03",
},
{
id: "e5",
description: "Marina berth with shore power",
category: "mooring",
port: "Gibraltar",
amount: 71.5,
currency: "EUR",
incurred_at: "2026-10-05",
},
];
/** Expenses the reconciliation has closed: their delete is refused. */
const RECONCILED: ReadonlySet<string> = new Set<string>(["e1"]);
/** The most a crew member may enter without the skipper's approval. */
const APPROVAL_LIMIT: number = 2000;
const FIXED: FormValues = { vessel_id: "althea" };
function wait(milliseconds: number): Promise<void> {
return new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, milliseconds);
});
}
/** Stands in for bosund's evaluation of the receipt number's conditions. */
function evaluate(values: FormValues): Promise<Readonly<Record<string, FieldState>>> {
const kept: boolean = values.has_receipt === true;
return Promise.resolve({ receipt_ref: { visible: kept, required: kept, editable: true, readonly: false } });
}
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 }),
),
);
export default function Showcase(): ReactElement {
const [loading, setLoading]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
const [rows, setRows]: [readonly EntityRow[], Dispatch<SetStateAction<readonly EntityRow[]>>] =
useState<readonly EntityRow[]>(EXPENSES);
const [editing, setEditing]: [EntityListEditing, Dispatch<SetStateAction<EntityListEditing>>] =
useState<EntityListEditing>(null);
const [nextId, setNextId]: [number, Dispatch<SetStateAction<number>>] = useState<number>(EXPENSES.length + 1);
// Stands in for the first request for the records.
useEffect((): (() => void) => {
const timer: ReturnType<typeof setTimeout> = setTimeout((): void => {
setLoading(false);
}, 1200);
return (): void => {
clearTimeout(timer);
};
}, []);
async function save(values: FormValues): Promise<void> {
await wait(600);
if (typeof values.amount === "number" && values.amount > APPROVAL_LIMIT) {
throw new Error(
"Couldn't save the expense: amounts over €2,000 need the skipper's approval. Nothing was saved; ask Maya " +
"to enter it.",
);
}
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.map((row: EntityRow): EntityRow => (row.id === id ? { ...values, id } : row)),
);
} else {
setRows((current: readonly EntityRow[]): readonly EntityRow[] => [
...current,
{ ...values, id: `e${String(nextId)}` },
]);
setNextId((current: number): number => current + 1);
}
setEditing(null);
}
async function remove(): Promise<void> {
await wait(500);
if (editing?.mode !== "edit") {
return;
}
const id: unknown = editing.row.id;
if (typeof id === "string" && RECONCILED.has(id)) {
throw new Error("reconciled");
}
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.filter((row: EntityRow): boolean => row.id !== id),
);
setEditing(null);
}
return (
<div style={{ width: "100%" }}>
<EntityList
label="Expense"
columns={COLUMNS}
titleField="description"
rows={rows}
loading={loading}
error={null}
editing={editing}
saveAction={save}
deleteAction={remove}
onOpenCreate={(): void => {
setEditing({ mode: "create" });
}}
onOpenEdit={(row: EntityRow): void => {
setEditing({ mode: "edit", row });
}}
onClose={(): void => {
setEditing(null);
}}
schema={SCHEMA}
enumOptions={CATEGORY_OPTIONS}
evaluate={evaluate}
suggestFor={suggestFor}
fixed={FIXED}
/>
</div>
);
}
An entity list shows one kind of record: a count, an Add button and a data table of the
records. A press on Add or on a row opens a dialog with the record's form, empty for a
new record and filled in for an existing one. The caller owns the records, the requests and which dialog is open: it
passes rows, loading, error and editing, and the list calls onOpenCreate, onOpenEdit and onClose. A
rejected save shows its message above the form's buttons. Delete asks once, naming the record, before it deletes it, and
a failed delete shows a badge beside the Delete button. error with no records and no open dialog is the whole view; with
records, or a record open, the list keeps them and says why above them, so a reload that fails after a save never closes
the record being edited. The list asks for its schema and option lists again when the crew's language changes, since
a caller may put their text in that language as they arrive; the schema shown stays until the new one comes, and an
open record keeps what was typed.
The schema can describe the whole list. With no columns, the list shows the fields that carry a column, in its order,
with each value as the crew read it: a choice by its label, a number with its prefix and unit, a date and time in UTC.
A sortable field sorts from its heading, a filter field filters its column, and a searchable one, or a schema
with a search URL, puts a search field above the table. The schema's label, title_field and default_sort name the
record, title its dialog and sort the list as it opens, and its actions leave out Add, the row press or Delete for a
record the crew may not create, edit or delete. A field's column_width sets its column's width and wrap lets its text
run onto more lines; a column_hidden field's column starts hidden, and a schema with column_picker lets the crew
choose the columns.
Each example keeps its own records in memory. Add, Save and Delete update that example's table; reloading the page resets it. Loading has a Finish loading button, and Load failed has a Try again button that shows the records.
The opening example demonstrates two deliberate rejections: an amount above €2,000 needs approval, and the first expense is reconciled and cannot be deleted. Lower the amount to retry a save, or choose another row to try Delete. A rejected save keeps the form open with the entered values.
From an entity to its list and forms
One entity definition in bosund gives the whole list: the record's Go struct with its form: tags and its
RegisterConfig describe the fields, the list's columns, its search and sort, the actions the crew may take and the
record's collection and item, and GET /forms/{entity} serves them. useEntityList(entity, { request, fixed }) turns
that schema into the list's props: it loads the records from the collection, its placeholders ({vessel_id}) filled from
fixed and the other fixed values sent as the list's query; it creates a record with a POST to the collection, updates
one with a PUT to its item and deletes one with a DELETE, then loads the records again; and it answers the form's
choices, conditions and suggestions from bosund's form endpoints. SchemaEntityList draws it, with a placeholder while
the schema loads, so a page is one line:
<SchemaEntityList entity="crew_member" request={bosund} fixed={{ vessel_id }} />The kit makes no request of its own: request is the page's transport, which adds the address, the session and the
headers, and rejects with offline: true when nothing answered, so the list says bosund cannot be reached.
A record can change while its form is open: the crew say "speak Dutch" to bosun, another browser saves, a watch relief
writes the duty. So a PUT names, under as_read, the value each field it changes had when the form read it, and bosund
checks the save against it, field by field, with no lock. A field that moved elsewhere comes back as a 409 whose
conflicts list it with its value now; the transport puts them on its error, and the field says it was not saved and
what it is now; it is never shown as saved. A save that goes through is answered with the record as bosund now stores
it, and the next field's save builds on that.
A delete can be undone. Given onUndoable, the list deletes without asking: the record leaves at once, the request
waits while Undo is offered, and Undo before then sends nothing (after it, Undo creates the record again); a delete
waiting when the list closes is sent then. useUndoToast() turns the notice into a toast
under a ToastProvider: Undo with the seconds left to press it, and the request sent when the toast leaves without
Undo, never earlier. Without onUndoable, Delete asks once before it deletes.
The table's rows take a checkbox when the schema allows delete or edit. Ticked rows show how many are selected, with
Delete, which removes them together behind one Undo, and Change…, a form of the schema's choice fields that sets the
same value on each of them with a PUT and can be undone the same way; a field left empty keeps each record's own
value. Each PUT names the value its record held, as a form's save does, so a record that changed elsewhere since the
list loaded is refused: the dialog names the records it could not change and why, and Undo covers only the ones that
changed.
A form keeps what the crew have not saved. Closing a form that holds changes, by Cancel, Escape or outside the modal, asks "Close without saving?" with Keep editing and Discard. A new record's form keeps its draft per entity and fixed values, so a form closed half-filled opens again as it was left, and offers Save and add another, which keeps the modal open for the next record. Duplicate, in a record's modal, opens a new record filled from it without its id.
A new record starts from the defaults the schema gives its fields (bosund's default tag): the current time in UTC,
a literal value, or the value saved with the last record. For a field whose default is skipper, the list asks bosund
for the fixed vessel's crew and takes its skipper; for position.lat and position.lon, the page passes the vessel's
position from its event stream as position. A source the list does not have leaves its fields empty.
A money field's column shows the amount with its currency ("€182.40"), and a rating's column its stars, named by the
value. The page passes the crew's own currency as moneySources, and the list asks bosund for the day's ECB rates
(GET /forms/rates) when the schema has a money field, so the forms show an amount in another currency in the crew's
own; while bosund has no rates, the amount shows alone.
A record's modal says who changed it last and when, from the schema's history (bosund's ops audit trail): "Changed
by Maya Lindqvist, 14:02 UTC" for a change made the same UTC day, with its date written out for an earlier one. Show
previous values lists each field that change set, its value before and after. The list asks for the history when the
modal opens and again after a save.
The list takes keys from the page while no modal is open and the crew are not typing in a field: N opens a new record,
/ moves to the search, and Enter on a focused row opens it; in a modal, Ctrl+S (Command+S) saves. The Add button and
the search name their keys in aria-keyshortcuts. shortcuts={false} turns the page's keys off, for the second list
on a page that holds two.
The list follows changes made elsewhere through subscribe(entity, onChange), which the page feeds from bosund's event
stream: a saved record that belongs to the page's fixed values joins the list or takes its old place, a deleted one
leaves it, and a change the report does not describe loads the records again. The records sit in a store the list reads
through useSyncExternalStore, so a report, a reload and a delete each change the list once.
Examples
Loading
Code
import { Button } from "@bosunkit/components/button";
import { EntityList, type EntityListEditing, type EntityRow } from "@bosunkit/components/entity-list";
import type { Column } from "@bosunkit/components/data-table";
import type { FieldOption, FieldState, FormSchema, FormValues, SchemaFormProps } from "@bosunkit/components/schema-form";
import { useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";
const COLUMNS: readonly Column[] = [
{ key: "description", label: "Description", sortable: true },
{ key: "category", label: "Category", sortable: true },
{ key: "amount", label: "Amount", align: "end" },
{ key: "currency", label: "Currency" },
{ key: "incurred_at", label: "Date", sortable: true },
];
const SCHEMA: FormSchema = {
entity: "expense",
package: "ops",
groups: [{ id: "expense", label: "Expense", weight: 10 }],
fields: [
{
name: "description",
label: "Description",
type: "string",
priority: "primary",
required: true,
weight: 10,
group: "expense",
},
{
name: "category",
label: "Category",
type: "enum",
priority: "primary",
enum: "expense_categories",
one_of: ["fuel", "mooring", "repair"],
weight: 20,
group: "expense",
width: "half",
},
{
name: "amount",
label: "Amount",
type: "float",
priority: "primary",
component: "money",
currency_field: "currency",
decimal_places: 2,
weight: 30,
group: "expense",
width: "half",
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "primary",
enum: "currencies",
required: true,
default: "EUR",
weight: 35,
group: "expense",
width: "half",
},
{ name: "incurred_at", label: "Date", type: "date", priority: "primary", weight: 40, group: "expense" },
{ name: "vessel_id", label: "Vessel", type: "string", priority: "secondary", weight: 50, group: "expense" },
],
};
const CATEGORIES: Readonly<Record<string, readonly FieldOption[]>> = {
expense_categories: [
{ value: "fuel", label: "Fuel" },
{ value: "mooring", label: "Mooring" },
{ value: "repair", label: "Repair" },
],
currencies: [
{ value: "EUR", label: "Euro" },
{ value: "GBP", label: "Pound sterling" },
{ value: "TRY", label: "Turkish lira" },
],
};
const EXPENSES: readonly EntityRow[] = [
{ id: "e1", description: "Diesel, Valletta", category: "fuel", amount: 182.4, currency: "EUR", incurred_at: "2026-08-14" },
{ id: "e2", description: "Marina berth", category: "mooring", amount: 65, currency: "EUR", incurred_at: "2026-08-15" },
];
const FIXED: FormValues = { vessel_id: "v-1" };
function noEvaluation(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export const description: string =
"Finish the simulated request to show the records, then add or edit an expense.";
export default function Loading(): ReactElement {
const [rows, setRows]: [readonly EntityRow[], Dispatch<SetStateAction<readonly EntityRow[]>>] =
useState<readonly EntityRow[]>(EXPENSES);
const [editing, setEditing]: [EntityListEditing, Dispatch<SetStateAction<EntityListEditing>>] =
useState<EntityListEditing>(null);
const nextId: RefObject<number> = useRef<number>(1);
const [loading, setLoading]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
function save(values: FormValues): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.map((row: EntityRow): EntityRow => (row.id === id ? { ...row, ...values, id } : row)),
);
} else {
const id: string = `created-${String(nextId.current++)}`;
setRows((current: readonly EntityRow[]): readonly EntityRow[] => [...current, { ...values, id }]);
}
setEditing(null);
return Promise.resolve();
}
function remove(): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.filter((row: EntityRow): boolean => row.id !== id),
);
setEditing(null);
}
return Promise.resolve();
}
return (
<div style={{ width: "100%" }}>
<EntityList
label="Expense"
columns={COLUMNS}
titleField="description"
rows={rows}
loading={loading}
error={null}
editing={editing}
saveAction={save}
deleteAction={remove}
onOpenCreate={(): void => {
setEditing({ mode: "create" });
}}
onOpenEdit={(row: EntityRow): void => {
setEditing({ mode: "edit", row });
}}
onClose={(): void => {
setEditing(null);
}}
schema={SCHEMA}
enumOptions={CATEGORIES}
evaluate={noEvaluation}
suggestFor={noSuggestions}
fixed={FIXED}
/>
{loading && (
<div style={{ marginTop: "var(--space-md)" }}>
<Button
onClick={(): void => {
setLoading(false);
}}
>
Finish loading
</Button>
</div>
)}
</div>
);
}
Load failed
Code
import { Button } from "@bosunkit/components/button";
import { EntityList, type EntityListEditing, type EntityRow } from "@bosunkit/components/entity-list";
import type { Column } from "@bosunkit/components/data-table";
import type { FieldOption, FieldState, FormSchema, FormValues, SchemaFormProps } from "@bosunkit/components/schema-form";
import { useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";
const COLUMNS: readonly Column[] = [
{ key: "description", label: "Description", sortable: true },
{ key: "category", label: "Category", sortable: true },
{ key: "amount", label: "Amount", align: "end" },
{ key: "currency", label: "Currency" },
{ key: "incurred_at", label: "Date", sortable: true },
];
const SCHEMA: FormSchema = {
entity: "expense",
package: "ops",
groups: [{ id: "expense", label: "Expense", weight: 10 }],
fields: [
{
name: "description",
label: "Description",
type: "string",
priority: "primary",
required: true,
weight: 10,
group: "expense",
},
{
name: "category",
label: "Category",
type: "enum",
priority: "primary",
enum: "expense_categories",
one_of: ["fuel", "mooring", "repair"],
weight: 20,
group: "expense",
width: "half",
},
{
name: "amount",
label: "Amount",
type: "float",
priority: "primary",
component: "money",
currency_field: "currency",
decimal_places: 2,
weight: 30,
group: "expense",
width: "half",
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "primary",
enum: "currencies",
required: true,
default: "EUR",
weight: 35,
group: "expense",
width: "half",
},
{ name: "incurred_at", label: "Date", type: "date", priority: "primary", weight: 40, group: "expense" },
{ name: "vessel_id", label: "Vessel", type: "string", priority: "secondary", weight: 50, group: "expense" },
],
};
const CATEGORIES: Readonly<Record<string, readonly FieldOption[]>> = {
expense_categories: [
{ value: "fuel", label: "Fuel" },
{ value: "mooring", label: "Mooring" },
{ value: "repair", label: "Repair" },
],
currencies: [
{ value: "EUR", label: "Euro" },
{ value: "GBP", label: "Pound sterling" },
{ value: "TRY", label: "Turkish lira" },
],
};
const EXPENSES: readonly EntityRow[] = [
{ id: "e1", description: "Diesel, Valletta", category: "fuel", amount: 182.4, currency: "EUR", incurred_at: "2026-08-14" },
{ id: "e2", description: "Marina berth", category: "mooring", amount: 65, currency: "EUR", incurred_at: "2026-08-15" },
];
const FIXED: FormValues = { vessel_id: "v-1" };
function noEvaluation(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export const description: string =
"A simulated load failure with a working retry. Try again to show the records.";
export default function LoadFailed(): ReactElement {
const [rows, setRows]: [readonly EntityRow[], Dispatch<SetStateAction<readonly EntityRow[]>>] =
useState<readonly EntityRow[]>(EXPENSES);
const [editing, setEditing]: [EntityListEditing, Dispatch<SetStateAction<EntityListEditing>>] =
useState<EntityListEditing>(null);
const nextId: RefObject<number> = useRef<number>(1);
const [error, setError]: [string | null, Dispatch<SetStateAction<string | null>>] =
useState<string | null>("Couldn't load the list. Try again.");
function save(values: FormValues): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.map((row: EntityRow): EntityRow => (row.id === id ? { ...row, ...values, id } : row)),
);
} else {
const id: string = `created-${String(nextId.current++)}`;
setRows((current: readonly EntityRow[]): readonly EntityRow[] => [...current, { ...values, id }]);
}
setEditing(null);
return Promise.resolve();
}
function remove(): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.filter((row: EntityRow): boolean => row.id !== id),
);
setEditing(null);
}
return Promise.resolve();
}
return (
<div style={{ width: "100%" }}>
<EntityList
label="Expense"
columns={COLUMNS}
titleField="description"
rows={rows}
loading={false}
error={error}
editing={editing}
saveAction={save}
deleteAction={remove}
onOpenCreate={(): void => {
setEditing({ mode: "create" });
}}
onOpenEdit={(row: EntityRow): void => {
setEditing({ mode: "edit", row });
}}
onClose={(): void => {
setEditing(null);
}}
schema={SCHEMA}
enumOptions={CATEGORIES}
evaluate={noEvaluation}
suggestFor={noSuggestions}
fixed={FIXED}
/>
{error !== null && (
<div style={{ marginTop: "var(--space-md)" }}>
<Button
onClick={(): void => {
setError(null);
}}
>
Try again
</Button>
</div>
)}
</div>
);
}
Empty
Code
import { EntityList, type EntityListEditing, type EntityRow } from "@bosunkit/components/entity-list";
import type { Column } from "@bosunkit/components/data-table";
import type { FieldOption, FieldState, FormSchema, FormValues, SchemaFormProps } from "@bosunkit/components/schema-form";
import { useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";
const COLUMNS: readonly Column[] = [
{ key: "description", label: "Description", sortable: true },
{ key: "category", label: "Category", sortable: true },
{ key: "amount", label: "Amount", align: "end" },
{ key: "currency", label: "Currency" },
{ key: "incurred_at", label: "Date", sortable: true },
];
const SCHEMA: FormSchema = {
entity: "expense",
package: "ops",
groups: [{ id: "expense", label: "Expense", weight: 10 }],
fields: [
{
name: "description",
label: "Description",
type: "string",
priority: "primary",
required: true,
weight: 10,
group: "expense",
},
{
name: "category",
label: "Category",
type: "enum",
priority: "primary",
enum: "expense_categories",
one_of: ["fuel", "mooring", "repair"],
weight: 20,
group: "expense",
width: "half",
},
{
name: "amount",
label: "Amount",
type: "float",
priority: "primary",
component: "money",
currency_field: "currency",
decimal_places: 2,
weight: 30,
group: "expense",
width: "half",
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "primary",
enum: "currencies",
required: true,
default: "EUR",
weight: 35,
group: "expense",
width: "half",
},
{ name: "incurred_at", label: "Date", type: "date", priority: "primary", weight: 40, group: "expense" },
{ name: "vessel_id", label: "Vessel", type: "string", priority: "secondary", weight: 50, group: "expense" },
],
};
const CATEGORIES: Readonly<Record<string, readonly FieldOption[]>> = {
expense_categories: [
{ value: "fuel", label: "Fuel" },
{ value: "mooring", label: "Mooring" },
{ value: "repair", label: "Repair" },
],
currencies: [
{ value: "EUR", label: "Euro" },
{ value: "GBP", label: "Pound sterling" },
{ value: "TRY", label: "Turkish lira" },
],
};
const FIXED: FormValues = { vessel_id: "v-1" };
function noEvaluation(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export const description: string =
"Add the first expense to an empty list. Deleting it returns the list to its empty state.";
export default function Empty(): ReactElement {
const [rows, setRows]: [readonly EntityRow[], Dispatch<SetStateAction<readonly EntityRow[]>>] =
useState<readonly EntityRow[]>([]);
const [editing, setEditing]: [EntityListEditing, Dispatch<SetStateAction<EntityListEditing>>] =
useState<EntityListEditing>(null);
const nextId: RefObject<number> = useRef<number>(1);
function save(values: FormValues): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.map((row: EntityRow): EntityRow => (row.id === id ? { ...row, ...values, id } : row)),
);
} else {
const id: string = `created-${String(nextId.current++)}`;
setRows((current: readonly EntityRow[]): readonly EntityRow[] => [...current, { ...values, id }]);
}
setEditing(null);
return Promise.resolve();
}
function remove(): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.filter((row: EntityRow): boolean => row.id !== id),
);
setEditing(null);
}
return Promise.resolve();
}
return (
<div style={{ width: "100%" }}>
<EntityList
label="Expense"
columns={COLUMNS}
titleField="description"
rows={rows}
loading={false}
error={null}
editing={editing}
saveAction={save}
deleteAction={remove}
onOpenCreate={(): void => {
setEditing({ mode: "create" });
}}
onOpenEdit={(row: EntityRow): void => {
setEditing({ mode: "edit", row });
}}
onClose={(): void => {
setEditing(null);
}}
schema={SCHEMA}
enumOptions={CATEGORIES}
evaluate={noEvaluation}
suggestFor={noSuggestions}
fixed={FIXED}
/>
</div>
);
}
Rows
Code
import { EntityList, type EntityListEditing, type EntityRow } from "@bosunkit/components/entity-list";
import type { Column } from "@bosunkit/components/data-table";
import type { FieldOption, FieldState, FormSchema, FormValues, SchemaFormProps } from "@bosunkit/components/schema-form";
import { useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";
const COLUMNS: readonly Column[] = [
{ key: "description", label: "Description", sortable: true },
{ key: "category", label: "Category", sortable: true },
{ key: "amount", label: "Amount", align: "end" },
{ key: "currency", label: "Currency" },
{ key: "incurred_at", label: "Date", sortable: true },
];
const SCHEMA: FormSchema = {
entity: "expense",
package: "ops",
groups: [{ id: "expense", label: "Expense", weight: 10 }],
fields: [
{
name: "description",
label: "Description",
type: "string",
priority: "primary",
required: true,
weight: 10,
group: "expense",
},
{
name: "category",
label: "Category",
type: "enum",
priority: "primary",
enum: "expense_categories",
one_of: ["fuel", "mooring", "repair"],
weight: 20,
group: "expense",
width: "half",
},
{
name: "amount",
label: "Amount",
type: "float",
priority: "primary",
component: "money",
currency_field: "currency",
decimal_places: 2,
weight: 30,
group: "expense",
width: "half",
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "primary",
enum: "currencies",
required: true,
default: "EUR",
weight: 35,
group: "expense",
width: "half",
},
{ name: "incurred_at", label: "Date", type: "date", priority: "primary", weight: 40, group: "expense" },
{ name: "vessel_id", label: "Vessel", type: "string", priority: "secondary", weight: 50, group: "expense" },
],
};
const CATEGORIES: Readonly<Record<string, readonly FieldOption[]>> = {
expense_categories: [
{ value: "fuel", label: "Fuel" },
{ value: "mooring", label: "Mooring" },
{ value: "repair", label: "Repair" },
],
currencies: [
{ value: "EUR", label: "Euro" },
{ value: "GBP", label: "Pound sterling" },
{ value: "TRY", label: "Turkish lira" },
],
};
const EXPENSES: readonly EntityRow[] = [
{ id: "e1", description: "Diesel, Valletta", category: "fuel", amount: 182.4, currency: "EUR", incurred_at: "2026-08-14" },
{ id: "e2", description: "Marina berth", category: "mooring", amount: 65, currency: "EUR", incurred_at: "2026-08-15" },
];
const FIXED: FormValues = { vessel_id: "v-1" };
function noEvaluation(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export const description: string =
"Sort the expenses, add one, or press a row to edit or delete it. Changes stay in this example.";
export default function Rows(): ReactElement {
const [rows, setRows]: [readonly EntityRow[], Dispatch<SetStateAction<readonly EntityRow[]>>] =
useState<readonly EntityRow[]>(EXPENSES);
const [editing, setEditing]: [EntityListEditing, Dispatch<SetStateAction<EntityListEditing>>] =
useState<EntityListEditing>(null);
const nextId: RefObject<number> = useRef<number>(1);
function save(values: FormValues): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.map((row: EntityRow): EntityRow => (row.id === id ? { ...row, ...values, id } : row)),
);
} else {
const id: string = `created-${String(nextId.current++)}`;
setRows((current: readonly EntityRow[]): readonly EntityRow[] => [...current, { ...values, id }]);
}
setEditing(null);
return Promise.resolve();
}
function remove(): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.filter((row: EntityRow): boolean => row.id !== id),
);
setEditing(null);
}
return Promise.resolve();
}
return (
<div style={{ width: "100%" }}>
<EntityList
label="Expense"
columns={COLUMNS}
titleField="description"
rows={rows}
loading={false}
error={null}
editing={editing}
saveAction={save}
deleteAction={remove}
onOpenCreate={(): void => {
setEditing({ mode: "create" });
}}
onOpenEdit={(row: EntityRow): void => {
setEditing({ mode: "edit", row });
}}
onClose={(): void => {
setEditing(null);
}}
schema={SCHEMA}
enumOptions={CATEGORIES}
evaluate={noEvaluation}
suggestFor={noSuggestions}
fixed={FIXED}
/>
</div>
);
}
Editing a row
Code
import { EntityList, type EntityListEditing, type EntityRow } from "@bosunkit/components/entity-list";
import type { Column } from "@bosunkit/components/data-table";
import type { FieldOption, FieldState, FormSchema, FormValues, SchemaFormProps } from "@bosunkit/components/schema-form";
import { useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";
const COLUMNS: readonly Column[] = [
{ key: "description", label: "Description", sortable: true },
{ key: "category", label: "Category", sortable: true },
{ key: "amount", label: "Amount", align: "end" },
{ key: "currency", label: "Currency" },
{ key: "incurred_at", label: "Date", sortable: true },
];
const SCHEMA: FormSchema = {
entity: "expense",
package: "ops",
groups: [{ id: "expense", label: "Expense", weight: 10 }],
fields: [
{
name: "description",
label: "Description",
type: "string",
priority: "primary",
required: true,
weight: 10,
group: "expense",
},
{
name: "category",
label: "Category",
type: "enum",
priority: "primary",
enum: "expense_categories",
one_of: ["fuel", "mooring", "repair"],
weight: 20,
group: "expense",
width: "half",
},
{
name: "amount",
label: "Amount",
type: "float",
priority: "primary",
component: "money",
currency_field: "currency",
decimal_places: 2,
weight: 30,
group: "expense",
width: "half",
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "primary",
enum: "currencies",
required: true,
default: "EUR",
weight: 35,
group: "expense",
width: "half",
},
{ name: "incurred_at", label: "Date", type: "date", priority: "primary", weight: 40, group: "expense" },
{ name: "vessel_id", label: "Vessel", type: "string", priority: "secondary", weight: 50, group: "expense" },
],
};
const CATEGORIES: Readonly<Record<string, readonly FieldOption[]>> = {
expense_categories: [
{ value: "fuel", label: "Fuel" },
{ value: "mooring", label: "Mooring" },
{ value: "repair", label: "Repair" },
],
currencies: [
{ value: "EUR", label: "Euro" },
{ value: "GBP", label: "Pound sterling" },
{ value: "TRY", label: "Turkish lira" },
],
};
const EXPENSES: readonly EntityRow[] = [
{ id: "e1", description: "Diesel, Valletta", category: "fuel", amount: 182.4, currency: "EUR", incurred_at: "2026-08-14" },
{ id: "e2", description: "Marina berth", category: "mooring", amount: 65, currency: "EUR", incurred_at: "2026-08-15" },
];
const FIXED: FormValues = { vessel_id: "v-1" };
function noEvaluation(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export const description: string =
"Edit an expense, save it back to the table, or delete it. Cancel keeps the saved record.";
export default function EditingARow(): ReactElement {
const [rows, setRows]: [readonly EntityRow[], Dispatch<SetStateAction<readonly EntityRow[]>>] =
useState<readonly EntityRow[]>(EXPENSES);
const [editing, setEditing]: [EntityListEditing, Dispatch<SetStateAction<EntityListEditing>>] =
useState<EntityListEditing>(null);
const nextId: RefObject<number> = useRef<number>(1);
function save(values: FormValues): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.map((row: EntityRow): EntityRow => (row.id === id ? { ...row, ...values, id } : row)),
);
} else {
const id: string = `created-${String(nextId.current++)}`;
setRows((current: readonly EntityRow[]): readonly EntityRow[] => [...current, { ...values, id }]);
}
setEditing(null);
return Promise.resolve();
}
function remove(): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.filter((row: EntityRow): boolean => row.id !== id),
);
setEditing(null);
}
return Promise.resolve();
}
return (
<div style={{ width: "100%" }}>
<EntityList
label="Expense"
columns={COLUMNS}
titleField="description"
rows={rows}
loading={false}
error={null}
editing={editing}
saveAction={save}
deleteAction={remove}
onOpenCreate={(): void => {
setEditing({ mode: "create" });
}}
onOpenEdit={(row: EntityRow): void => {
setEditing({ mode: "edit", row });
}}
onClose={(): void => {
setEditing(null);
}}
schema={SCHEMA}
enumOptions={CATEGORIES}
evaluate={noEvaluation}
suggestFor={noSuggestions}
fixed={FIXED}
/>
</div>
);
}
From the schema alone
Code
import { EntityList, type EntityListEditing, type EntityRow } from "@bosunkit/components/entity-list";
import type { FieldOption, FieldState, FormSchema, FormValues, SchemaFormProps } from "@bosunkit/components/schema-form";
import { useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";
// bosund's expense schema with its list metadata: which fields are columns, how they sort, filter and search, the
// field that names a record, and the sort the list opens with.
const SCHEMA: FormSchema = {
entity: "expense",
package: "ops",
label: "Expense",
label_plural: "Expenses",
title_field: "description",
default_sort: "-incurred_at",
actions: ["list", "create", "edit", "delete"],
groups: [{ id: "expense", label: "Expense", weight: 10 }],
fields: [
{
name: "description",
label: "Description",
type: "string",
priority: "primary",
required: true,
column: 1,
searchable: true,
weight: 10,
group: "expense",
},
{
name: "category",
label: "Category",
type: "enum",
priority: "primary",
enum: "expense_categories",
one_of: ["fuel", "mooring", "repair"],
column: 2,
filter: "option",
weight: 20,
group: "expense",
width: "half",
},
{
name: "amount",
label: "Amount",
type: "float",
priority: "primary",
component: "money",
currency_field: "currency",
decimal_places: 2,
column: 3,
sortable: true,
weight: 30,
group: "expense",
width: "half",
},
{
name: "currency",
label: "Currency",
type: "enum",
priority: "primary",
enum: "currencies",
required: true,
default: "EUR",
weight: 35,
group: "expense",
width: "half",
},
{
name: "incurred_at",
label: "Date",
type: "date",
priority: "primary",
column: 4,
sortable: true,
weight: 40,
group: "expense",
},
{ name: "vessel_id", label: "Vessel", type: "string", priority: "secondary", weight: 50, group: "expense" },
],
};
const CATEGORIES: Readonly<Record<string, readonly FieldOption[]>> = {
expense_categories: [
{ value: "fuel", label: "Fuel" },
{ value: "mooring", label: "Mooring" },
{ value: "repair", label: "Repair" },
],
currencies: [
{ value: "EUR", label: "Euro" },
{ value: "GBP", label: "Pound sterling" },
{ value: "TRY", label: "Turkish lira" },
],
};
const EXPENSES: readonly EntityRow[] = [
{ id: "e1", description: "Diesel, Valletta", category: "fuel", amount: 182.4, currency: "EUR", incurred_at: "2026-08-14" },
{ id: "e2", description: "Marina berth, Valletta", category: "mooring", amount: 65, currency: "EUR", incurred_at: "2026-08-15" },
{ id: "e3", description: "Impeller and gasket", category: "repair", amount: 48.9, currency: "EUR", incurred_at: "2026-08-21" },
{ id: "e4", description: "Diesel, Siracusa", category: "fuel", amount: 151.2, currency: "EUR", incurred_at: "2026-08-27" },
{ id: "e5", description: "Anchorage fee, Ortigia", category: "mooring", amount: 22, currency: "EUR", incurred_at: "2026-08-28" },
{ id: "e6", description: "Rigging check", category: "repair", amount: 120, currency: "EUR", incurred_at: "2026-09-03" },
];
const FIXED: FormValues = { vessel_id: "v-1" };
function noEvaluation(): Promise<Readonly<Record<string, FieldState>>> {
return Promise.resolve({});
}
const noSuggestions: SchemaFormProps["suggestFor"] =
(): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
Promise.resolve([]);
export const description: string =
"An expense list drawn from its schema alone: its columns, the Category filter, the search over descriptions and " +
"the newest first come from the fields' metadata, and a delete asks before it deletes. Changes stay in this example.";
export default function FromSchema(): ReactElement {
const [rows, setRows]: [readonly EntityRow[], Dispatch<SetStateAction<readonly EntityRow[]>>] =
useState<readonly EntityRow[]>(EXPENSES);
const [editing, setEditing]: [EntityListEditing, Dispatch<SetStateAction<EntityListEditing>>] =
useState<EntityListEditing>(null);
const nextId: RefObject<number> = useRef<number>(1);
function save(values: FormValues): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.map((row: EntityRow): EntityRow => (row.id === id ? { ...row, ...values, id } : row)),
);
} else {
const id: string = `created-${String(nextId.current++)}`;
setRows((current: readonly EntityRow[]): readonly EntityRow[] => [...current, { ...values, id }]);
}
setEditing(null);
return Promise.resolve();
}
function remove(): Promise<void> {
if (editing?.mode === "edit") {
const id: unknown = editing.row.id;
setRows((current: readonly EntityRow[]): readonly EntityRow[] =>
current.filter((row: EntityRow): boolean => row.id !== id),
);
setEditing(null);
}
return Promise.resolve();
}
return (
<div style={{ width: "100%" }}>
<EntityList
rows={rows}
loading={false}
error={null}
editing={editing}
saveAction={save}
deleteAction={remove}
onOpenCreate={(): void => {
setEditing({ mode: "create" });
}}
onOpenEdit={(row: EntityRow): void => {
setEditing({ mode: "edit", row });
}}
onClose={(): void => {
setEditing(null);
}}
schema={SCHEMA}
enumOptions={CATEGORIES}
evaluate={noEvaluation}
suggestFor={noSuggestions}
fixed={FIXED}
/>
</div>
);
}
Wired to bosund
Code
import { Button } from "@bosunkit/components/button";
import {
SchemaEntityList,
useUndoToast,
type UndoNotice,
type EntityChange,
type EntityRequest,
type EntityRequestInit,
type EntitySubscribe,
} from "@bosunkit/components/entity-list";
import { ToastProvider } from "@bosunkit/components/toast";
import type { FormSchema } from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A crew list from the entity's name alone: the list, its columns, search and sort, and the forms that create, edit " +
"and delete a crew member come from bosund's schema, through an in-memory bosund that logs each request below. A " +
"delete or a change to the ticked rows can be undone from its toast, a record says who changed it last and what it " +
"held before, and a crew member another device adds appears as bosund reports it.";
// bosund's crew_member schema, abridged, with the list metadata reference/forms serves for it.
const CREW_MEMBER: FormSchema = {
entity: "crew_member",
package: "ops",
label: "Crew member",
label_plural: "Crew members",
title_field: "name",
default_sort: "name",
actions: ["list", "create", "edit", "delete"],
collection: "/ops/crew",
item: "/ops/crew/{id}",
history: "/ops/audit?entity_type=crew_member&entity_id={id}",
groups: [
{ id: "identity", label: "Identity", weight: 10 },
{ id: "contact", label: "Contact", weight: 40 },
],
fields: [
{
name: "name",
label: "Name",
type: "string",
required: true,
max_length: 100,
autofill: "name",
column: 1,
sortable: true,
searchable: true,
weight: 10,
group: "identity",
},
{
name: "role",
label: "Role",
type: "enum",
enum: "crew_roles",
required: true,
column: 2,
filter: "option",
weight: 20,
group: "identity",
},
{
name: "phone",
label: "Phone",
type: "string",
keyboard: "tel",
autofill: "tel",
column: 3,
weight: 40,
group: "contact",
},
{
name: "email",
label: "Email",
type: "string",
keyboard: "email",
autofill: "email",
column: 4,
searchable: true,
weight: 50,
group: "contact",
},
],
};
const ROLES: readonly { value: string; label: string }[] = [
{ value: "skipper", label: "Skipper" },
{ value: "crew", label: "Crew" },
{ value: "guest", label: "Guest" },
];
type Record_ = Readonly<Record<string, unknown>>;
/**
* An in-memory bosund for one vessel's crew: the routes `useEntityList` asks, answering as bosund does, each request
* logged. A real page passes its own transport instead.
*/
export function inMemoryBosund(log: (line: string) => void): EntityRequest {
let crew: Record_[] = [
{ id: "c1", vessel_id: "v1", name: "Maya Lindqvist", role: "skipper", phone: "+46 70 123 45 67" },
{ id: "c2", vessel_id: "v1", name: "Jonas Berg", role: "crew", email: "jonas@berg.example" },
];
let next: number = 3;
// bosund's ops audit trail for the crew, newest first: who changed a record, when, and its JSON before and after.
let audit: Record_[] = [
{
created_at: "2026-10-12T09:30:00Z",
operation: "update",
entity_id: "c2",
actor: "skipper:Maya Lindqvist",
data_before: JSON.stringify({ ...crew[1], email: "jonas@old.example" }),
data_after: JSON.stringify(crew[1]),
},
];
// Records a change as bosund does after a save, made by the person this example's requests come from.
const record: (operation: string, id: string, before: Record_ | undefined, after: Record_ | undefined) => void = (
operation: string,
id: string,
before: Record_ | undefined,
after: Record_ | undefined,
): void => {
audit = [
{
created_at: new Date().toISOString(),
operation,
entity_id: id,
actor: "skipper:Maya Lindqvist",
data_before: before === undefined ? "" : JSON.stringify(before),
data_after: after === undefined ? "" : JSON.stringify(after),
},
...audit,
];
};
return (path: string, init: EntityRequestInit): Promise<unknown> => {
log(`${init.method} ${path}`);
const [route = "", query = ""]: string[] = path.split("?");
const parameters: URLSearchParams = new URLSearchParams(query);
if (init.method === "GET" && route === "/forms/crew_member") {
return Promise.resolve(CREW_MEMBER);
}
if (init.method === "GET" && route === "/forms/meta/crew_roles") {
return Promise.resolve(ROLES);
}
if (init.method === "GET" && route === "/ops/audit") {
return Promise.resolve(audit.filter((entry: Record_): boolean => entry["entity_id"] === parameters.get("entity_id")));
}
if (init.method === "POST" && route === "/forms/crew_member/eval") {
return Promise.resolve({});
}
if (init.method === "GET" && route === "/ops/crew") {
const vessel: string | null = parameters.get("vessel_id");
return Promise.resolve(crew.filter((member: Record_): boolean => vessel === null || member["vessel_id"] === vessel));
}
if (init.method === "POST" && route === "/ops/crew") {
const body: Record_ = init.body as Record_;
const created: Record_ = { ...body, id: typeof body["id"] === "string" ? body["id"] : `c${String(next++)}` };
crew = [...crew, created];
record("create", String(created["id"]), undefined, created);
return Promise.resolve(created);
}
const id: string | undefined = /^\/ops\/crew\/([^/]+)$/u.exec(route)?.[1];
if (id !== undefined && init.method === "PUT") {
const before: Record_ | undefined = crew.find((member: Record_): boolean => member["id"] === id);
// The values as the form read them are bosund's check, not part of the record.
const changes: Record_ = Object.fromEntries(
Object.entries(init.body as Record_).filter(([name]: [string, unknown]): boolean => name !== "as_read"),
);
crew = crew.map((member: Record_): Record_ => (member["id"] === id ? { ...member, ...changes } : member));
const after: Record_ | undefined = crew.find((member: Record_): boolean => member["id"] === id);
record("update", id, before, after);
// bosund answers a save with the record as it now stores it.
return Promise.resolve(after);
}
if (id !== undefined && init.method === "DELETE") {
crew = crew.filter((member: Record_): boolean => member["id"] !== id);
return Promise.resolve(undefined);
}
return Promise.reject(new Error(`404 ${path}`));
};
}
/** Records a crewmate's phone adds while the example is open, one press at a time. */
const ARRIVALS: readonly Record_[] = [
{ id: "c90", vessel_id: "v1", name: "Grace Hopper", role: "crew", email: "grace@hopper.example" },
{ id: "c91", vessel_id: "v1", name: "Ada Lovelace", role: "guest" },
];
/** The list's crew, with deletes undone from a toast and records another device adds shown as they arrive. */
function Crew({
request,
subscribe,
}: {
readonly request: EntityRequest;
readonly subscribe: EntitySubscribe;
}): ReactElement {
const onUndoable: (notice: UndoNotice) => void = useUndoToast();
return (
<SchemaEntityList
entity="crew_member"
request={request}
fixed={{ vessel_id: "v1" }}
phoneCountry="SE"
onUndoable={onUndoable}
subscribe={subscribe}
/>
);
}
export default function WiredToBosund(): ReactElement {
const [requests, setRequests]: [readonly string[], Dispatch<SetStateAction<readonly string[]>>] = useState<
readonly string[]
>([]);
// One bosund for the example's life, which logs into the list under it.
const [request]: [EntityRequest, Dispatch<SetStateAction<EntityRequest>>] = useState<EntityRequest>(
(): EntityRequest =>
inMemoryBosund((line: string): void => {
setRequests((previous: readonly string[]): readonly string[] => [...previous.slice(-5), line]);
}),
);
// bosund's change reports, as its event stream would deliver them: the list listens, the button reports.
const [listeners]: [Set<(change: EntityChange) => void>, Dispatch<SetStateAction<Set<(change: EntityChange) => void>>>] =
useState<Set<(change: EntityChange) => void>>((): Set<(change: EntityChange) => void> => new Set());
const [arrived, setArrived]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
const subscribe: EntitySubscribe = (_entity: string, onChange: (change: EntityChange) => void): (() => void) => {
listeners.add(onChange);
return (): void => {
listeners.delete(onChange);
};
};
const next: Record_ | undefined = ARRIVALS.at(arrived);
return (
<ToastProvider viewportLabel="Notifications" dismissLabel="Dismiss" moreActionsLabel="More actions">
<div style={{ display: "grid", gap: "var(--space-md)", width: "100%" }}>
<Crew request={request} subscribe={subscribe} />
<div>
<Button
variant="ghost"
size="sm"
disabled={next === undefined}
onClick={(): void => {
if (next === undefined) {
return;
}
// Another device saved a crew member: bosund stores it and reports it on its stream.
void request("/ops/crew", { method: "POST", body: next }).then((): void => {
for (const listener of listeners) {
listener({ kind: "saved", record: next });
}
});
setArrived((previous: number): number => previous + 1);
}}
>
{next === undefined ? "No one else is joining" : `A crewmate's phone adds ${String(next["name"])}`}
</Button>
</div>
<ol
aria-label="Requests to bosund"
style={{
margin: 0,
paddingInlineStart: "var(--space-lg)",
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "var(--text-dim)",
}}
>
{requests.map(
(line: string, index: number): ReactElement => (
<li key={`${String(index)}-${line}`}>{line}</li>
),
)}
</ol>
</div>
</ToastProvider>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | undefined | – | The record's name, singular, as display text: "Add {label}"; the schema's label when unset. |
| columns | readonly Column[] | undefined | – | The table's columns. Unset, the schema's: the fields it marks as columns, sorted, filtered and searched as it says, each value shown as the crew read it (a choice by its label, a number with its unit, a time in UTC). |
| titleField | string | undefined | – | The field whose value titles the edit modal; the schema's title_field, else "name", when unset. |
| rowsrequired | readonly EntityRow[] | – | The records. |
| loadingrequired | boolean | – | Whether the records are still loading. |
| errorrequired | string | null | – | Why the records could not load, as display text; null when they did. |
| editingrequired | EntityListEditing | – | The open modal. |
| saveActionrequired | (values: FormValues, details?: SubmitDetails) => Promise<void> | – | Saves the form's values: creates a record, or updates the one being edited. A rejection's message shows. With
{ another: true } the crew pressed "Save and add another", and the caller keeps the create modal open. |
| addAnother | boolean | undefined | – | Offers "Save and add another" while a record is created, as SchemaForm takes it. |
| draftKey | string | undefined | – | Keeps what the crew type into a new record under this key until it is saved, as SchemaForm takes it. |
| defaultSources | DefaultSources | undefined | – | What a new record's default tokens are filled from (position, skipper, time), as SchemaForm takes it. |
| lastValuesKey | string | undefined | – | Where the values of fields whose default is last are kept, as SchemaForm takes it. |
| moneySources | MoneySources | undefined | – | The crew's own currency and the day's rates, for the forms' money fields, as SchemaForm takes them. |
| onDuplicate | ((row: EntityRow) => void) | undefined | – | Opens a new record filled from the one being edited, without its id: a Duplicate button in the edit modal, shown when it is set and the schema allows create. |
| deleteRowsAction | ((rows: readonly EntityRow[]) => Promise<void>) | undefined | – | Deletes the selected records. With it, and the schema allowing delete, the table's rows take a checkbox and the
selection offers Delete, asking first while confirmDelete is on. A rejection shows that they could not be
deleted. |
| updateRowsAction | ((rows: readonly EntityRow[], changes: FormValues) => Promise<void>) | undefined | – | Sets the same values on the selected records. With it, and the schema allowing edit, the selection offers Change…: a form of the schema's choice fields, where a field left empty keeps each record's value. A rejection's message shows in that form. |
| deleteActionrequired | () => Promise<void> | – | Deletes the record being edited. A rejection shows that it could not be deleted. |
| confirmDelete | boolean | undefined | true | Whether Delete asks once, naming the record, before it deletes (the default). Off for a delete the crew can undo,
such as useEntityList's with onUndoable. |
| onOpenCreaterequired | () => void | – | Called when the crew press Add. |
| onOpenEditrequired | (row: EntityRow) => void | – | Called with the row the crew press. |
| onCloserequired | () => void | – | Called when the crew close the modal. |
| schemarequired | FormSchema | – | The record's form. |
| enumOptionsrequired | Readonly<Record<string, readonly FieldOption[]>> | – | The form's enum options, by source. |
| evaluaterequired | (values: FormValues) => Promise<Readonly<Record<string, FieldState>>> | – | The form's CEL evaluation, as SchemaForm takes it. |
| suggestForrequired | SchemaFormProps["suggestFor"] | – | The form's autocomplete suggestions, by source. |
| labelFor | SchemaFormProps["labelFor"] | – | The name an id field's source has for a held id, as SchemaForm takes it. |
| fixed | Readonly<FormValues> | undefined | – | Values the page sets on every record, such as the vessel's id. |
| phoneCountry | string | undefined | – | The country a phone field starts in, as SchemaForm takes it. |
| layout | SchemaFormProps["layout"] | – | How the form arranges its groups, over the schema's layout, as SchemaForm takes it. |
| shortcuts | boolean | undefined | true | Whether the page's keys reach the list (the default): N opens a new record and / moves to the search, while no modal is open and the crew are not typing in a field. A page with two lists turns them off on one. |
| history | RecordHistory | null | undefined | – | The open record's last change, shown in its modal with a way to see the values it held before; none when unset. |
| detail | ((row: EntityRow) => ReactNode) | undefined | – | What the page shows about the open record in its modal, above the form: the records that belong to it (a crew member's documents), which the list's own schema does not hold. None while a record is created. |
SchemaEntityList
| Prop | Type | Default | Description |
|---|---|---|---|
| phoneCountry | string | undefined | – | The country a phone field starts in, as SchemaForm takes it. |
| layout | SchemaFormProps["layout"] | – | How the form arranges its groups, over the schema's layout, as SchemaForm takes it. |
| entityrequired | string | – | The entity bosund serves the form of, as GET /forms/{entity} names it: crew_member. |
| requestrequired | EntityRequest | – | The caller's transport to bosund. |
| fixed | Readonly<FormValues> | undefined | – | Values the page sets on every record, such as the vessel's id. They fill the placeholders of the schema's
collection and item ({vessel_id}), filter the list as query parameters where no placeholder takes them,
and join every save. |
| editing | EntityListEditing | undefined | – | The open dialog, when the caller holds it, in the page's address for instance; held here when unset. |
| onEditingChange | ((editing: EntityListEditing) => void) | undefined | – | Called with the dialog to open, or null to close it, when the caller holds editing. |
| subscribe | EntitySubscribe | undefined | – | Subscribes to the entity's changes as bosund reports them (an SSE event or a WebSocket message the caller's transport receives), so the list follows records saved or deleted elsewhere; returns the function that unsubscribes. Without it the list loads its records again only after its own saves and deletes. |
| onUndoable | ((notice: UndoNotice) => void) | undefined | – | Called after an action the crew can take back (a delete, a change to several records), with what to tell them
and how to undo it; useUndoToast shows it as a toast with Undo. With it, a delete needs no confirmation: the
records leave the list at once, the request waits out the notice's timeout (or until the toast closes, when it
holds the delete), and Undo before then sends nothing; after it, Undo creates the records again. Without it, the
list asks before it deletes. |
| position | { readonly lat: number; readonly lon: number } | null | undefined | – | The vessel's position in decimal degrees, for the fields whose default is position.lat or position.lon; the
page passes what its event stream last reported, or null while it is unknown. |
| moneySources | MoneySources | undefined | – | The crew's own currency, for the money fields' amount in it; with no rates, the list asks bosund for the day's
ECB rates (GET /forms/rates) when the schema has a money field. |
Related
- Empty state: what an empty list says.
- Error messages: what a list that could not load says.