diff --git a/AGENTS.md b/AGENTS.md index 381d77e..bd37ccc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug. ## Architecture -Eight independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files declared in `.zddc tables:` as a sortable table whose rows click through to the form editor (see "Form-data system" and "Tables system" below). +Eight independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `.table.yaml` next to a sibling `/` rows-dir (see "Form-data system" and "Tables system" below). ``` tool/ @@ -333,13 +333,13 @@ A schema-driven form renderer used to collect structured data into YAML files in **Form spec**: `.form.yaml` — top-level envelope is `{title, description, schema, ui, mode}`. `schema` is JSON Schema 2020-12 (subset; see "Validator subset" below). `ui` is RJSF-style (`ui:widget`, `ui:order`, `ui:autofocus`, `ui:placeholder`, `ui:help`, `ui:readonly`, `ui:options.{addable,removable}`). LLMs author this dialect well. -**URL conventions** (form posts back to its own URL; server strips `.html`): -- `GET //.form.html` — render empty form -- `POST //.form.html` — create new submission → 201 + Location capability URL -- `GET ///.yaml.html` — render form pre-filled from `.yaml` -- `POST ///.yaml.html` — overwrite that submission → 200 +**URL conventions** (form posts back to its own URL; server strips `.html`). The spec lives **inside** the rows-dir alongside the row YAMLs, so the whole form (spec + every submission) is a single self-contained directory: +- `GET //form.html` — render empty form +- `POST //form.html` — create new submission → 201 + Location capability URL pointing at the new `/.yaml` +- `GET //.yaml.html` — render form pre-filled from `.yaml` +- `POST //.yaml.html` — overwrite that submission → 200 -**Storage**: spec at `/.form.yaml`, submissions at `//-.yaml`. Submissions folder is created lazily; ACL applies via the existing `.zddc` cascade. +**Storage**: spec at `/form.yaml`, submissions at `/-.yaml` (siblings of the spec). Copying `` elsewhere copies the spec plus every submission together. ACL applies via the existing `.zddc` cascade. **Round-trip**: v0 is form-as-truth — submission YAML is regenerated from form state on every save; comments in submissions are not preserved. File-as-truth mode (lossless YAML round-trip via the eemeli/yaml Document API) is a v1 feature, needed for hand-edited files like `.zddc` itself. @@ -347,7 +347,36 @@ A schema-driven form renderer used to collect structured data into YAML files in **Renderer subset** (`form/js/`): types listed above, enum (select / `ui:widget: radio`), `format: date|email`, textarea, nested objects, arrays of primitives, arrays of objects with add/remove rows. `ui:show-when` and reorder are v1. -**Adding a new form**: drop a `.form.yaml` into any path users can write to (per `.zddc` ACL). No code change required. Visit `/.form.html`. +**Adding a new form**: create a directory `/` and drop `form.yaml` into it (per `.zddc` ACL). No code change required. Visit `/form.html`. + +## Tables system (`tables/` + zddc-server table handler) + +Read/aggregate counterpart to the form system. Renders a directory of YAML row files as a sortable, filterable table; each row clicks through to its `.yaml.html` form editor. The tables tool (`tables/`) is the renderer; the server-side recognizer is `zddc/internal/handler/tablehandler.go RecognizeTableRequest`. + +**Discovery is presence-based**, the same convention as forms: a `/table.yaml` on disk auto-mounts at `/table.html`. The directory is the table. + +**Storage** (self-contained directory): + +``` +/ + table.yaml ← spec + form.yaml ← row-edit form (paired with table.yaml) + .yaml ... ← rows +``` + +`table.yaml` and `form.yaml` are excluded from the rows list. Each row is also a form submission — the same files the form system reads — so the table view and the per-row form editor are two views of one folder of YAMLs. Copying `/` elsewhere copies the entire table (spec + form + every row) — that's the whole point of the in-dir layout. + +**One table per directory** by construction (the spec is the singleton `table.yaml`). No `.zddc` reference needed; presence-based discovery is the entire rule. To make a directory a table, drop a `table.yaml` in it — that's it. + +**Subfolders inside a table dir are allowed and silently ignored as rows.** The rows iterator filters non-`.yaml` entries, so directories don't show up in the table view. Legitimate subfolder use cases: +- **Nested sub-tables** — `/sub-list/table.yaml` is its own self-contained table at `/sub-list/table.html`. Composition, not violation. +- **Per-row attachments** — `/.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path. +- **Drafts / staging** — `/.drafts/.yaml` (dot-prefix → hidden from listings as well as from the table). +- **Future per-row history** — `/.history//.yaml` if/when version sidecars are added. + +**Default-MDL fallback at `archive//mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive//mdl/`, presence-based discovery is the rule. + +**Adding a new table**: create a directory `/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `/table.html`. ## Implementation-vs-dependency policy diff --git a/build b/build index bd462be..d7cafa1 100755 --- a/build +++ b/build @@ -199,17 +199,20 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html" cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html" echo "Populated $EMBED_DIR/ for //go:embed" +fi - # The form renderer lives next to its handler (no cascade needed — it's a - # fixed renderer, not a per-folder-override tool). - cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/internal/handler/form.html" - echo "Populated zddc/internal/handler/form.html for //go:embed" +# The unified tables renderer ships both table-mode and form-mode in +# one HTML — see tables/template.html and tables/js/mode.js. The Go +# server embeds a single tables.html (//go:embed in tablehandler.go); +# both ServeTable and ServeForm output these same bytes with their +# respective inline-context blob. Form-mode-only standalone use is +# served by form/dist/form.html (download-only, not embedded). Refresh +# on every build (including plain dev `./build`) so iteration on +# form/tables JS shows up in the binary without needing a beta cut. +cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html" +echo "Populated zddc/internal/handler/tables.html for //go:embed" - # Same pattern for the tables renderer — embedded directly into the - # handler package (read-only directory-of-YAML view; not subject to - # per-folder version overrides). - cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html" - echo "Populated zddc/internal/handler/tables.html for //go:embed" +if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then # Assemble the embedded versions manifest from the per-tool .label sidecars # written by shared/build-lib.sh's compute_build_label. The Go side reads diff --git a/form/js/main.js b/form/js/main.js index 0b67bfb..809d12f 100644 --- a/form/js/main.js +++ b/form/js/main.js @@ -2,10 +2,22 @@ 'use strict'; function boot() { + // When this bundle is hosted by the unified tables.html, the + // mode dispatcher decides which app paints. Skip when mode is + // not "form" — table-mode requests are handled by tablesApp. + // (Standalone form/dist/form.html has no zddcMode global; treat + // undefined as form-mode for back-compat.) + if (window.zddcMode && window.zddcMode !== 'form') { + return; + } app.context = app.modules.context.load(); if (app.context.title) { - const t = document.getElementById('form-title'); + // Standalone form.html has #form-title in its header; unified + // tables.html bundle has #table-title (shared across modes). + // Whichever exists, write to it. + const t = document.getElementById('form-title') || + document.getElementById('table-title'); if (t) { t.textContent = app.context.title; } diff --git a/tables/build.sh b/tables/build.sh index 6abd533..c6ce994 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -20,14 +20,21 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ "css/table.css" \ + "../form/css/form.css" \ > "$css_temp" +# Single bundle hosts both apps. mode.js runs first to set +# window.zddcMode based on the URL, then each app's main.js bails +# early when its mode isn't selected. Form modules live under +# window.formApp; table modules under window.tablesApp; no namespace +# collisions. concat_files \ "../shared/vendor/js-yaml.min.js" \ "../shared/zddc.js" \ "../shared/zddc-source.js" \ "../shared/theme.js" \ "../shared/help.js" \ + "js/mode.js" \ "js/app.js" \ "js/context.js" \ "js/util.js" \ @@ -35,6 +42,17 @@ concat_files \ "js/sort.js" \ "js/render.js" \ "js/main.js" \ + "../form/js/app.js" \ + "../form/js/context.js" \ + "../form/js/util.js" \ + "../form/js/widgets.js" \ + "../form/js/object.js" \ + "../form/js/array.js" \ + "../form/js/render.js" \ + "../form/js/serialize.js" \ + "../form/js/errors.js" \ + "../form/js/post.js" \ + "../form/js/main.js" \ > "$js_raw" escape_js_close_tags "$js_raw" "$js_temp" diff --git a/tables/css/table.css b/tables/css/table.css index 4366fe1..81158e0 100644 --- a/tables/css/table.css +++ b/tables/css/table.css @@ -27,12 +27,17 @@ margin: 0 0 var(--spacing-sm); } -.table-toolbar__left { +.table-toolbar__left, +.table-toolbar__right { display: flex; align-items: center; gap: var(--spacing-sm); } +#table-add-row { + text-decoration: none; +} + .table-rowcount { color: var(--color-text-muted); font-size: 0.9rem; diff --git a/tables/js/context.js b/tables/js/context.js index 25de86f..6f9d6c8 100644 --- a/tables/js/context.js +++ b/tables/js/context.js @@ -11,9 +11,11 @@ // to a non-empty object, return it as-is. // // 2. File-backed walk (the real-world path served by zddc-server): - // fetch /.zddc, find tables[], fetch the *.table.yaml - // spec, list //*.yaml row files, parse each, and - // assemble the same shape. + // page is at //table.html — fetch /table.yaml, + // list every other *.yaml in as a row file (filtering + // out table.yaml and form.yaml so they don't appear as rows), + // parse each, and assemble the same shape. The whole table + // lives in one directory. // // file:// mode without a directory handle is unsupported in v1 — the // walk only runs against http(s). file:// users must either inject an @@ -75,20 +77,18 @@ } const dir = probe.handle; - const zddcDoc = await readYaml(dir, '.zddc'); - const tablesMap = (zddcDoc && zddcDoc.tables) || {}; - const specRel = tablesMap[tableName]; - if (!specRel) { - throw new Error('No tables.' + tableName + ' declared in .zddc'); - } - const spec = await readYaml(dir, stripDotSlash(specRel)); + // Spec lives at /table.yaml — the page URL is + // /table.html, so the spec is right next door. + const spec = await readYaml(dir, 'table.yaml'); if (!spec || !Array.isArray(spec.columns)) { - throw new Error('Spec ' + specRel + ' missing columns[]'); + throw new Error('Spec table.yaml missing columns[]'); } - const rowsRel = stripDotSlash(spec.rows || ('./' + tableName)); - const rowsDir = await resolveDirectory(dir, rowsRel); - const rows = await readRows(rowsDir, rowsRel, tableName); + // Rows are every *.yaml in EXCEPT the spec + // (table.yaml) and the row-edit form (form.yaml). They live + // in the same directory by design — copying the directory + // copies the whole table. + const rows = await readRows(dir, '', tableName); return { title: spec.title, @@ -100,7 +100,9 @@ } function tableNameFromUrl(pathname) { - const m = String(pathname || '').match(/\/([^\/]+)\.table\.html$/); + // //...//table.html → name is the rows-dir's + // basename. + const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/); return m ? m[1] : null; } @@ -146,16 +148,19 @@ return cur; } - async function readRows(rowsDir, rowsRel, tableName) { + async function readRows(rowsDir, _rowsRel, _tableName) { const rows = []; for await (const entry of rowsDir.values()) { if (entry.kind !== 'file') continue; if (!entry.name.endsWith('.yaml')) continue; + // Skip the spec and the row-edit form — they live alongside + // the rows but aren't rows themselves. + if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue; try { const file = await (await rowsDir.getFileHandle(entry.name)).getFile(); const data = window.jsyaml.load(await file.text()); rows.push({ - url: rowEditUrl(rowsRel, tableName, entry.name), + url: rowEditUrl(entry.name), data: data || {}, editable: true }); @@ -166,14 +171,12 @@ return rows; } - // Build the form-handler URL for editing one row. The page is at - // /.table.html; the row file lives at - // //.yaml; the form re-edit URL is - // //.yaml.html. - function rowEditUrl(rowsRel, tableName, rowFileName) { - const pageDir = location.pathname.replace(/\/[^\/]+\.table\.html$/, '/'); - const rowsPath = pageDir + (rowsRel || tableName) + '/'; - return rowsPath + rowFileName + '.html'; + // Re-edit URL for one row. Page is at //table.html; row file + // lives at //.yaml; form re-edit URL is + // //.yaml.html — same directory. + function rowEditUrl(rowFileName) { + const pageDir = location.pathname.replace(/\/table\.html$/, '/'); + return pageDir + rowFileName + '.html'; } app.modules.context = { load: load }; diff --git a/tables/js/filters.js b/tables/js/filters.js index d50d672..4ae46d4 100644 --- a/tables/js/filters.js +++ b/tables/js/filters.js @@ -5,13 +5,17 @@ // - free-text: { kind: 'contains', value: '' } // - enum: { kind: 'enum', value: ['', ...] } // An empty value (empty string or empty array) matches everything. + // + // The render layer only emits the free-text shape; enum is kept here + // for back-compat with any inline-context test fixtures that seed + // filter state directly. defaultFilterFor always returns text. function isEnumColumn(col) { return Array.isArray(col.enum) && col.enum.length > 0; } - function defaultFilterFor(col) { - return isEnumColumn(col) ? { kind: 'enum', value: [] } : { kind: 'contains', value: '' }; + function defaultFilterFor(_col) { + return { kind: 'contains', value: '' }; } function rowMatches(filter, cellValue) { diff --git a/tables/js/main.js b/tables/js/main.js index 01afd01..cbe010a 100644 --- a/tables/js/main.js +++ b/tables/js/main.js @@ -2,6 +2,12 @@ 'use strict'; async function init() { + // Both apps (table + form) ship in the same bundle. Skip if + // mode dispatcher said this isn't our mode — form-mode requests + // are handled by formApp. + if (window.zddcMode === 'form') { + return; + } const ctx = await app.modules.context.load(); app.context = ctx; @@ -23,6 +29,22 @@ const emptyEl = document.getElementById('table-empty'); const countEl = document.getElementById('table-rowcount'); const clearBtn = document.getElementById('table-clear-filters'); + const addRowBtn = document.getElementById('table-add-row'); + + // Add-row button: link to .form.html, the form-system's + // empty-form URL for this table's row schema. POST creates a + // new submission and the server redirects to the row's edit + // URL. Hidden when we can't derive a table name from the + // pathname (e.g. inline-context test harness opening tables.html + // directly without a *.table.html URL). + if (addRowBtn) { + // Page is at /table.html; the row-creation form is at + // /form.html — same directory, just swap the basename. + if (/\/table\.html$/.test(location.pathname || '')) { + addRowBtn.href = 'form.html'; + addRowBtn.hidden = false; + } + } const columns = Array.isArray(ctx.columns) ? ctx.columns : []; const allRows = Array.isArray(ctx.rows) ? ctx.rows : []; @@ -40,14 +62,12 @@ if (seeded == null) { continue; } - if (app.modules.filters.isEnumColumn(col)) { - state.filter[col.field] = { - kind: 'enum', - value: Array.isArray(seeded) ? seeded.slice() : [String(seeded)] - }; - } else { - state.filter[col.field] = { kind: 'contains', value: String(seeded) }; - } + // Filter UI is uniformly text-contains. If the spec + // seeds an array (legacy enum-style), coerce to a + // comma-joined contains string — partial match on any + // listed value still narrows the table sensibly. + const seedStr = Array.isArray(seeded) ? seeded.join(',') : String(seeded); + state.filter[col.field] = { kind: 'contains', value: seedStr }; } } diff --git a/tables/js/mode.js b/tables/js/mode.js new file mode 100644 index 0000000..a5005bf --- /dev/null +++ b/tables/js/mode.js @@ -0,0 +1,76 @@ +// mode.js — picks table-mode vs form-mode at boot time and unhides the +// matching container. Both apps (tablesApp, formApp) ship in the same +// bundle but each only paints when its container is visible. +// +// Decision rule: +// //table.html → table mode +// //form.html → form mode (empty / create) +// //.yaml.html → form mode (re-edit) +// anything else / file:// → table mode (legacy default; tables tool +// was the original consumer of this bundle) +// +// In offline / file:// mode the inline-context placeholders decide: +// whichever blob is non-empty wins. Tests that inject only +// #form-context render in form mode; tests that inject only +// #table-context render in table mode. +(function () { + 'use strict'; + + function modeFromUrl() { + const path = String((typeof location !== 'undefined' && location.pathname) || ''); + if (/\/form\.html$/.test(path) || /\.yaml\.html$/.test(path)) { + return 'form'; + } + if (/\/table\.html$/.test(path)) { + return 'table'; + } + return null; // unknown — will be decided once DOM is parsed. + } + + function readInline(id) { + const el = document.getElementById(id); + if (!el) return null; + try { + return JSON.parse(el.textContent || '{}'); + } catch (_) { + return null; + } + } + + function modeFromInline() { + // file:// or unrecognised URL — whichever inline-context blob is + // non-empty wins. Tests that inject only #form-context render in + // form mode; tests that inject only #table-context render in + // table mode. Default to table for legacy compatibility. + const formCtx = readInline('form-context'); + if (formCtx && Object.keys(formCtx).length > 0) { + return 'form'; + } + return 'table'; + } + + // Best-effort synchronous decision so per-app boot guards can read + // window.zddcMode without waiting for DOM. URL-based decision is + // always known up-front; inline-context fallback only matters for + // file:// and is finalized at DOMContentLoaded. + window.zddcMode = modeFromUrl() || 'table'; + + function activate() { + if (modeFromUrl() == null) { + window.zddcMode = modeFromInline(); + } + const tableEl = document.getElementById('table-mode'); + const formEl = document.getElementById('form-mode'); + if (window.zddcMode === 'form' && formEl) { + formEl.hidden = false; + } else if (tableEl) { + tableEl.hidden = false; + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', activate, { once: true }); + } else { + activate(); + } +})(); diff --git a/tables/js/render.js b/tables/js/render.js index a5f49dc..485d8db 100644 --- a/tables/js/render.js +++ b/tables/js/render.js @@ -22,45 +22,23 @@ titleRow.appendChild(th); const td = util.h('td', { className: 'zddc-table__filter-cell' }); + // Every column gets the same text-contains filter input, even + // enum columns — keeps the filter row visually uniform and + // doesn't constrain users to picking from the enum (a + // case-insensitive substring match works for both free-text + // and enum data). const f = filterMap[col.field] || filters.defaultFilterFor(col); - if (filters.isEnumColumn(col)) { - const select = util.h('select', { - multiple: true, - 'aria-label': 'Filter ' + (col.title || col.field), - className: 'zddc-table__filter-enum', - onChange: function (ev) { - const opts = ev.target.options; - const picked = []; - for (let j = 0; j < opts.length; j++) { - if (opts[j].selected) { - picked.push(opts[j].value); - } - } - onFilterChange(col.field, { kind: 'enum', value: picked }); - } - }); - for (let j = 0; j < col.enum.length; j++) { - const v = col.enum[j]; - const opt = util.h('option', { value: v }, v); - if (Array.isArray(f.value) && f.value.indexOf(v) !== -1) { - opt.selected = true; - } - select.appendChild(opt); + const input = util.h('input', { + type: 'text', + className: 'zddc-table__filter-text', + placeholder: 'filter…', + 'aria-label': 'Filter ' + (col.title || col.field), + value: typeof f.value === 'string' ? f.value : '', + onInput: function (ev) { + onFilterChange(col.field, { kind: 'contains', value: ev.target.value }); } - td.appendChild(select); - } else { - const input = util.h('input', { - type: 'text', - className: 'zddc-table__filter-text', - placeholder: 'filter…', - 'aria-label': 'Filter ' + (col.title || col.field), - value: typeof f.value === 'string' ? f.value : '', - onInput: function (ev) { - onFilterChange(col.field, { kind: 'contains', value: ev.target.value }); - } - }); - td.appendChild(input); - } + }); + td.appendChild(input); filterRow.appendChild(td); } diff --git a/tables/template.html b/tables/template.html index b910319..4904b2f 100644 --- a/tables/template.html +++ b/tables/template.html @@ -31,7 +31,8 @@ -
+ +
@@ -39,6 +40,9 @@
+
+ +
@@ -49,6 +53,18 @@ + +
+ +
+
+ +
+
+
@@ -692,6 +902,18 @@ body.help-open .app-header { + +
+ +
+
+ +
+
+