From e6d99665934a9bb57763a9c81ef326db3174b2be Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 09:15:26 -0500 Subject: [PATCH] refactor(tables): in-dir convention + unified table+form HTML bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two intertwined refactors that share too many files to split cleanly. Both are described separately below. PART 1 — in-dir convention for table+form spec files Old layout had the spec at the parent and rows in a child: archive// mdl.table.yaml spec mdl.form.yaml row-edit form mdl/ rows-dir row-001.yaml ... URLs were //mdl.table.html and //mdl.form.html. Copying mdl/ elsewhere lost the spec and form because they lived next door. New layout collapses everything into the rows-dir: archive//mdl/ self-contained table.yaml spec form.yaml row-edit form row-001.yaml ... rows URLs become //mdl/table.html and //mdl/form.html. The "copying-the-folder-takes-everything" property the user asked for falls out by construction; the row-edit URL //.yaml.html keeps the same shape (spec is now in the same dir, not the grandparent). Server changes: - internal/handler/tablehandler.go RecognizeTableRequest fires on //table.html when /table.yaml exists. The .zddc.tables alias map is gone — pure presence-based discovery now matches the form system's existing convention. Default-MDL fallback at archive//mdl/ stays for the virgin-archive case (the rows-dir need not exist on disk; the URL renders fully virtually). - internal/handler/formhandler.go RecognizeFormRequest fires on //form.html and //.yaml.html with spec at /form.yaml. specEligible accepts on-disk files OR the default-MDL virtual path so an empty mdl/ dir still surfaces the add-row form. - internal/handler/tablehandler.go IsDefaultMdlSpec moves to serving archive//mdl/{table,form}.yaml (5 segments after ZDDC_ROOT). New isAtArchivePartyMdlLevel predicate; new isAtArchivePartyMdlDir for directory-based recognition. New IsDefaultMdlSpecAbs accessor for callers that hold an abs path rather than a URL (formhandler). - internal/handler/formhandler.go loadFormSpec(fsRoot, path) falls back to embedded default-MDL bytes when os.ReadFile returns NotExist AND the path matches the archive-party-mdl shape. Three call sites updated to pass cfg.Root. - internal/handler/formhandler.go serveFormCreate writes submissions to filepath.Dir(req.SpecPath) — the spec, the form, and rows all live in one directory. The submissionsDir creation is idempotent (MkdirAll); cascade falls back one level for ACL evaluation when the dir hasn't been materialized yet. - internal/handler/tablehandler.go tableRowsRedirect now points at //table.html (was /.table.html) when the directory request maps to a recognized table. - cmd/zddc-server/main.go dispatch synth flips from urlPath + ".table.html" to urlPath + "/table.html" for the no-trailing-slash → tables-app routing. - internal/apps/availability.go DefaultAppAt comment clarified that the dir at archive//mdl/ IS the table (not a child). Client changes: - tables/js/context.js walkServer fetches /table.yaml directly — no .zddc walk for table declarations. Rows are every *.yaml in current dir EXCLUDING table.yaml and form.yaml. The .zddc fetch-for-aliases is gated on file:// (online mode 404s on .zddc reads via the dispatcher's reserve guard, so skipping the request avoids browser console noise). - tables/js/main.js add-row button links to relative form.html (same dir). - tables/js/render.js + filters.js: every column's autofilter is uniformly a text-contains input, even enum columns — keeps the filter row visually consistent and doesn't constrain users to the enum vocabulary. PART 2 — unified table+form HTML bundle The form-render and table-render code paths share field schemas, the cell editor for excel-mode IS a form widget, and the form system's POST-back / validation already exists. Combining the two HTMLs eliminates duplicating jsyaml/jsonschema/theme/source- detection/.zddc-parsing across two single-file tools. - tables/template.html grows two top-level mode containers: #table-mode (toolbar + sortable table) and #form-mode (form + submit button). Both hidden at parse time; the dispatcher unhides one. The shared #form-context placeholder was added here so the server's existing injectFormContext target resolves. - tables/js/mode.js (new) sets window.zddcMode synchronously based on URL pattern: /form.html or /.yaml.html → form, /table.html → table, else inline-context fallback for file:// (whichever context blob is non-empty wins). Unhides the matching container at DOMContentLoaded. - tables/js/main.js init() and form/js/main.js boot() each guard early when mode isn't theirs. Both apps live on different globals (window.tablesApp vs window.formApp) so module registration doesn't collide. - form/js/main.js title write falls back from #form-title to #table-title (the unified bundle's shared header element) when the dedicated id isn't present. - tables/build.sh concatenates form modules (widgets, render, object, array, errors, post, serialize, util) and form CSS. No new external deps. Bundle grows from ~95KB to ~120KB. - internal/handler/formhandler.go drops the //go:embed form.html directive; serveFormRender now writes embeddedTablesHTML via a small formRenderHTML() accessor (var declared in tablehandler.go, same package). The embedded form.html file is removed. - build script: cp form/dist/form.html → internal/handler/form.html step is gone (file no longer exists in the source tree). cp tables/dist/tables.html → internal/handler/tables.html now runs unconditionally rather than only on beta/stable cuts — the renderer is a fixed binary component and dev iteration needs the embedded copy refreshed every build. Channel-cascaded apps (internal/apps/embedded/) stay channel-gated as before. - form/dist/form.html still builds for standalone offline-only use (downloadable from /releases/), but no longer goes into the binary. Tests: - internal/handler/tablehandler_test.go and formhandler_test.go rewritten for the in-dir layout. New test TestRecognizeFormRequest_DefaultMdlAtArchiveParty covers empty-form, create POST, re-edit row, and the negative cases (Working/, non-mdl name) where the fallback must NOT fire. - internal/handler/directory_test.go updated for the new //table.html redirect target. - cmd/zddc-server/main_test.go TestDispatchSlashRouting Location expectation updated. - tests/form-safety.spec.js loads tables/dist/tables.html (named form.html in the temp dir to trigger form-mode in the dispatcher) so it tests the same bytes the server returns. Title-element selector switches to #table-title. - tests/tables.spec.js updates the status-filter test for the uniform text-input filter. Docs: - AGENTS.md form-data system rewrites the URL conventions and storage layout for in-dir; gains a Tables system section parallel to forms describing the self-contained-directory property; subfolder rules ("one table per folder by construction; subfolders allowed and silently ignored as rows — legitimate uses: nested sub-tables, per-row attachments, drafts, future history sidecars") so we don't re-derive this. Not included (deferred): - ACL gating on cell-level writes — not relevant until Phase 3. - Editable cells UI — separate commit (Phase 1). Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 45 +- build | 21 +- form/js/main.js | 14 +- tables/build.sh | 18 + tables/css/table.css | 7 +- tables/js/context.js | 53 +- tables/js/filters.js | 8 +- tables/js/main.js | 36 +- tables/js/mode.js | 76 + tables/js/render.js | 52 +- tables/template.html | 24 +- tests/form-safety.spec.js | 10 +- tests/tables.spec.js | 12 +- zddc/cmd/zddc-server/main.go | 10 +- zddc/cmd/zddc-server/main_test.go | 2 +- zddc/internal/apps/availability.go | 4 +- zddc/internal/handler/directory_test.go | 37 +- zddc/internal/handler/form.html | 1726 -------------------- zddc/internal/handler/formhandler.go | 109 +- zddc/internal/handler/formhandler_test.go | 114 +- zddc/internal/handler/tablehandler.go | 181 +- zddc/internal/handler/tablehandler_test.go | 105 +- zddc/internal/handler/tables.html | 1257 +++++++++++++- 23 files changed, 1798 insertions(+), 2123 deletions(-) create mode 100644 tables/js/mode.js delete mode 100644 zddc/internal/handler/form.html 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 { + +
+ +
+
+ +
+
+