Entity views API
The entity-view kit (packages/shell/src/shell/ui/entity-views.tsx) turns one field config into 9 interchangeable views plus a create/edit form. Views are data-agnostic and callback-driven: rows are plain objects with a string id, views never touch a backend, and styling rides the shell's CSS variables so every design themes them automatically.
FieldDef
export type FieldType = "text" | "longtext" | "number" | "boolean" | "select" | "tags" | "date" | "image" | "position";
export interface FieldDef {
/** row property name */
key: string;
label: string;
type: FieldType;
/** select: allowed options (order = board column order) */
options?: { value: string; label: string; tone?: "ok" | "warn" | "danger" | "accent" }[];
/** show in table/list/grid summaries (default true; longtext/position default false) */
summary?: boolean;
/** field is required in the detail form */
required?: boolean;
}
How each FieldType renders read-only (FieldValue): empty/null renders an em dash. boolean = check/cross glyph; select = a badge with the option label, colored by its tone; tags = one badge per tag; date = toLocaleDateString(); number = toLocaleString(); image = 28x28 rounded thumbnail; position = muted (x, y) pair; text/longtext = plain string. Option tones map to CSS variables: ok → --lab-ok, warn → --lab-warn, danger → --lab-danger, accent → --lab-accent.
EntityConfig
export interface EntityConfig<Row extends EntityRow = EntityRow> {
/** singular + plural labels, e.g. ["Record", "Records"] */
labels: [string, string];
fields: FieldDef[];
/** which field is the display title (type text, required) */
titleKey: string;
/** select field used for board columns + stats breakdown (optional) */
statusKey?: string;
/** date field used by timeline/calendar (optional) */
dateKey?: string;
/** image field used by gallery (optional) */
imageKey?: string;
/** position field ({x,y} 0-100 %) used by canvas (optional) */
positionKey?: string;
/** number field aggregated in stats (optional) */
amountKey?: string;
/** optional custom card renderer used by grid/gallery/canvas */
renderCard?: (row: Row) => ReactNode;
}
Note: renderCard is currently consumed by the grid view (the gallery and canvas render their own fixed card shape).
Rows and callbacks
export interface EntityRow {
id: string;
[key: string]: unknown;
}
export interface EntityCallbacks<Row extends EntityRow = EntityRow> {
/** open detail / edit */
onOpen?: (row: Row) => void;
/** patch one field (board drag, canvas drop, table inline toggle) */
onPatch?: (id: string, patch: Record<string, unknown>) => void;
onDelete?: (id: string) => void;
}
availableViews
ENTITY_VIEWS is the ordered tuple: table, list, grid, board, gallery, timeline, calendar, stats, canvas. availableViews(cfg) filters it by role keys — board needs statusKey; timeline and calendar need dateKey; gallery needs imageKey; canvas needs positionKey; table, list, grid, and stats are always available. Omit a role key and its views disappear.
The 9 views
- EntityTable — columns are titleKey + summary fields. Click a header to sort (asc, click again for desc); sorting is a nullish-aware string/number compare. The title column header is position: sticky at left 0. boolean cells become live checkboxes when onPatch is provided (click stops propagation). Row click fires onOpen.
- EntityList — one compact row per record: title plus the first 3 summary fields.
- EntityGrid — auto-fill card grid (minmax 220px); renders cfg.renderCard(row) if given, otherwise title + label/value pairs for summary fields.
- EntityBoard — kanban over the statusKey select field; one column per option in options order, header shows tone-colored label + count. Cards use native HTML5 drag: dragstart puts row.id in dataTransfer, dropping on a column calls onPatch(id, { [statusKey]: column.value }). Returns null if the status field has no options.
- EntityGallery — image-led cards (minmax 180px, 4/3 aspect); shows the imageKey image or a placeholder glyph, title below.
- EntityTimeline — rows sorted newest-first by dateKey, grouped per day (toLocaleDateString medium; rows without a date group under "No date").
- EntityCalendar — Monday-first month grid with prev/next month paging (internal state, starts at the current month). Each day cell shows up to 3 accent-colored entries; overflow collapses to a "+N" counter. Entry click fires onOpen.
- EntityStats — aggregate cards: total row count; if amountKey is set, also Total and Average (rounded) of that field. If statusKey has options, a breakdown card renders count + percentage bar per option.
- EntityCanvas — free 2D board over positionKey. Cards are absolutely positioned at left/top percentages; position units are 0-100 (percent of the canvas). Dropping a dragged card computes x clamped to 0-95 and y clamped to 0-92 and calls onPatch(id, { [positionKey]: { x, y } }). Rows without a position default to { x: 5, y: 5 }.
EntityDetail (create + edit form)
export function EntityDetail<Row extends EntityRow>({ cfg, row, onSave, onDelete, onClose }: {
cfg: EntityConfig<Row>;
/** undefined = create mode */
row?: Row;
onSave: (values: Record<string, unknown>) => void;
onDelete?: (id: string) => void;
onClose: () => void;
})
- position and image fields are excluded from the form (set them via canvas drag / your own upload flow).
- Type coercion on submit: number inputs become Number(v) (empty → omitted); a tags value typed as a string is comma-split, trimmed, and empties filtered.
- Required handling: if a required field is empty on submit, the submit silently aborts (plus native required on text-ish inputs). Empty/undefined values are omitted from the saved object entirely.
- Defaults in create mode: boolean → false, tags → [], select → first option, everything else → "".
- Delete button appears only in edit mode when onDelete is passed; it calls onDelete(row.id) then onClose().
EntityViews + ViewSwitcher
export function EntityViews<Row extends EntityRow>({ cfg, rows, view, ...cb }:
{ cfg: EntityConfig<Row>; rows: Row[]; view: EntityView } & EntityCallbacks<Row>)
export function ViewSwitcher({ views, view, onChange }:
{ views: EntityView[]; view: EntityView; onChange: (v: EntityView) => void })
EntityViews is the one-liner dispatcher: config + rows + a view name renders the matching view and forwards the callbacks (stats takes no callbacks). ViewSwitcher renders one icon button per view, highlighting the active one — pair it with availableViews(cfg) and a useState for the current view.