Replaces the always-text-input cell editor with a per-property
widget factory keyed off the row's JSON Schema (form.yaml). The
table view now picks the right editor for each cell automatically:
strings get text inputs, enums get dropdowns, integers get number
inputs with min/max, dates get date pickers, booleans get
checkboxes, multi-select arrays get a multi-select. Cells whose
schema is a complex type (nested object, generic array, oneOf /
anyOf / allOf) can't be inline-edited and punt to the row's
form-mode editor on Enter / double-click.
Schema discovery:
context.js walkServer fetches <currentdir>/form.yaml as a
companion to <currentdir>/table.yaml — same file the form-mode
renderer already loads, just from the table view's perspective.
Best-effort: a directory with table.yaml but no form.yaml still
renders as a sortable/filterable table; cells just fall back to
plain text inputs without per-property hints. The schema is
exposed as ctx.rowSchema and consumed by the editor's
propertySchemaFor() helper, which walks dot-separated field
names through schema.properties to locate each column's
property schema.
Editor factory (editor.js):
- propertySchemaFor(col) — schema lookup keyed by col.field.
- isComplexSchema(s) — true for nested object, generic array,
oneOf/anyOf/allOf. Multi-select-friendly arrays
(string-enum + uniqueItems) are NOT complex; they get an
inline multi-select widget.
- makeWidget(propSchema, col, initialValue) — dispatches to one
of the widget builders below based on schema type / format /
enum + column-spec hints (col.format / col.enum) for tables
without a form.yaml.
Widget builders, each returning {element, getValue, focus}:
- widgetText — plain <input type=text>, default fallback.
- widgetTextarea — for string with maxLength > 200 (long
narrative fields).
- widgetTyped(type) — typed inputs the browser can help validate;
used for date / date-time / email.
- widgetNumber — <input type=number> with min/max/step
derived from schema.minimum/maximum/
multipleOf. Integer schemas force step=1.
getValue returns Number, not string, so
the draft buffer holds the right type for
JSON serialization later.
- widgetCheckbox — <input type=checkbox>; getValue returns
bool. initial value coerces from "true"/
true string-or-bool.
- widgetSelect — <select> with empty placeholder + one
option per enum choice; getValue returns
the chosen string or null.
- widgetMultiSelect — <select multiple> with size = min(6, N);
getValue returns the array of selected
values (preserves order in the option list).
Complex-type cells:
isComplexSchema(propSchema) → enterEdit calls navigateToRowForm,
which routes to row.url (already the <id>.yaml.html re-edit URL
the row tracker holds). Phase 5 may swap this for an inline
side-panel mount of form-mode in the same bundle, but the
current navigate-out path delivers the same eventual UX without
needing the side-panel scaffolding.
Type-aware draft equality:
The pre-Phase-2 commit treated every value as a string and
compared via String() equality, which would mark any number-
column edit dirty even when the user re-typed the same number.
The new sameValue() helper handles bool/object via JSON-string
equality and falls back to loose string compare so 42 == "42"
isn't a false dirty. Drafts hold typed values (number, bool,
array) instead of all strings, so when Phase 3 wires the row PUT
the body shape matches the JSON Schema the server validates
against without an additional coercion pass.
Tests (tests/tables.spec.js — 7 new specs, total 22 in the
table view, all 27 in the file):
- enum column edits via select dropdown — verifies the empty
placeholder + 3 enum options render and the chosen value
displays back in the cell.
- integer column gives a number input with min/max — verifies
the type/min/max/step attributes derive from the schema, AND
the draft buffer holds typeof === 'number'.
- boolean column gives a checkbox — verifies type=checkbox and
the draft holds true after Space-toggle. (Toggle via Space,
not Playwright's .check() helper, to dodge the click+blur
race a focused-checkbox-inside-grid-cell hits.)
- format:date column gives a date input — verifies type=date
and the existing value pre-populates as YYYY-MM-DD.
- multi-select enum-array column gives a multi-select.
- complex (object) column navigates to the row form on edit —
verifies no inline editor mounts AND the navigate seam
receives the row's URL.
- no rowSchema → falls back to plain text editor — verifies the
best-effort behavior for directories with only table.yaml.
Bundle size: 124 KB → 127 KB (+3 KB for the factory + widget
builders).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| css | ||
| js | ||
| sample | ||
| build.sh | ||
| README.md | ||
| template.html | ||
ZDDC Tables
Render a directory of YAML files as a sortable, filterable table — read-only, with click-row → edit-in-form integration. Backed by zddc-server's form handler so the table view and the form editor are two sides of the same data.
Anchor use case. A Master Deliverables List (MDL) under Archive/<Party>/MDL/, where each .yaml file is one expected deliverable. Multiple parties keep their own MDLs side by side; the table aggregates within a single party's directory.
How it works
- Storage is file-per-row YAML — one
*.yamlfile in a directory per table row. Concurrent edits don't collide on a shared blob, every row has independent git history, and per-row ACL inherits from the cascading.zddcchain. - Discovery is
.zddc-declarative. Drop atables:entry in the directory's.zddcto register the table; no file-presence auto-mount, no phantom tables from rogue YAML drops. - Rendering is server-side:
zddc-serverreads every*.yamlunder the rows directory, normalizes them into a JSON list, and inlines the list into the page on render. The browser does sorting, filtering, and click-row navigation locally — no further server round-trips for those. - Editing is delegated to the existing form tool. Each row's click target is the form's re-edit URL (
<dir>/<name>/<basename>.yaml.html), whichzddc-serveralready serves via the form handler. The table itself never writes.
Setup (for an MDL at Archive/Acme/MDL/)
Archive/Acme/
├── .zddc # declares: tables: { MDL: ./MDL.table.yaml }
├── MDL.table.yaml # column spec + rows path + row schema reference
├── MDL.form.yaml # JSON Schema for one row (used by both the table and the form editor)
└── MDL/
├── D-001.yaml # one row
├── D-002.yaml # one row
└── ...
Visit Archive/Acme/MDL.table.html and the table renders. Visit Archive/Acme/MDL.form.html to add a new row (the form handler creates a YAML in MDL/).
.zddc declaration
tables:
MDL: ./MDL.table.yaml
The map key (MDL) becomes the URL stem and must match both the rows directory name and the form spec name. v1 enforces this with a load-time spec-validation error.
Table spec (MDL.table.yaml)
title: Master Deliverables List
description: Optional description shown above the table.
rowSchema: ./MDL.form.yaml # path to the row's JSON Schema (form-spec format)
rows: ./MDL # directory of *.yaml row files (non-recursive in v1)
columns:
- field: id # top-level key OR JSON Pointer (e.g. /nested/path)
title: ID
width: 7em
sort: asc # default sort key (overridden by defaults.sort below)
- field: title
title: Deliverable
- field: dueDate
title: Due
format: date # date | datetime | number | bool
- field: status
title: Status
enum: [pending, submitted, accepted, rejected] # constrains values + enables enum filter
defaults:
sort:
- { field: dueDate, dir: asc }
filter:
status: [pending, submitted] # initial filter state; clear with the toolbar button
Columns are explicit — the renderer does not auto-derive from the row schema. Pick the subset you want to display.
ACL behavior
- The page-level read check uses the cascade at the spec directory; a caller without
rgets a 403. - Per-row "edit" affordance is recomputed against the row's own parent dir. If the user has
wthere, the row is clickable; otherwise it's plain text. Hard enforcement remains on the form-handler side (the form's POST will refuse a write the cascade denies). Issued/Receivedarchive folders are server-enforced WORM. The decider stripsw/d/afrom non-admin grants under those subtrees, so an MDL placed insideIssued/shows every row as read-only with no special-casing in the table tool.
v1 limits
- Read-only grid; click-row opens the form editor. Inline cell editing is a v2 candidate (would PUT each edit through the new file API in
zddc/internal/handler/fileapi.go). - One directory of
*.yamlper table; cross-directory aggregation (Archive/*/MDL/*.yamlas one combined view) is not yet supported. - No virtualization — large tables (>1000 rows) will be slow.
- No multi-row bulk operations, no add-row UI inside the table (use the form editor at
<name>.form.html). .zddc tables:declarations are direct-lookup only; no upward cascade. Each directory hosting a table needs its own declaration.
Build & develop
sh tables/build.sh # build (writes tables/dist/tables.html)
sh tables/build.sh --release alpha # cut alpha
sh ./build # full lockstep build (all tools + zddc-server)
(cd zddc && go test ./internal/handler/... ./internal/zddc/...)
npx playwright test --project=tables
Authoritative architecture and build docs are in ../AGENTS.md and ../ARCHITECTURE.md.