refactor(tables): in-dir convention + unified table+form HTML bundle
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/<party>/
mdl.table.yaml spec
mdl.form.yaml row-edit form
mdl/ rows-dir
row-001.yaml ...
URLs were /<dir>/mdl.table.html and /<dir>/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/<party>/mdl/ self-contained
table.yaml spec
form.yaml row-edit form
row-001.yaml ... rows
URLs become /<dir>/mdl/table.html and /<dir>/mdl/form.html. The
"copying-the-folder-takes-everything" property the user asked for
falls out by construction; the row-edit URL /<dir>/<id>.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
/<dir>/table.html when <dir>/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/<party>/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
/<dir>/form.html and /<dir>/<id>.yaml.html with spec at
<dir>/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/<party>/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
/<dir>/table.html (was /<dir>.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/<party>/mdl/ IS the table (not a child).
Client changes:
- tables/js/context.js walkServer fetches <currentdir>/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 /<id>.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
/<dir>/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) <noreply@anthropic.com>
This commit is contained in:
parent
2ce5336289
commit
e6d9966593
23 changed files with 1798 additions and 2123 deletions
45
AGENTS.md
45
AGENTS.md
|
|
@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug.
|
||||||
|
|
||||||
## Architecture
|
## 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 `<name>.table.yaml` next to a sibling `<name>/` rows-dir (see "Form-data system" and "Tables system" below).
|
||||||
|
|
||||||
```
|
```
|
||||||
tool/
|
tool/
|
||||||
|
|
@ -333,13 +333,13 @@ A schema-driven form renderer used to collect structured data into YAML files in
|
||||||
|
|
||||||
**Form spec**: `<name>.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.
|
**Form spec**: `<name>.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`):
|
**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 /<path>/<name>.form.html` — render empty form
|
- `GET /<dir>/form.html` — render empty form
|
||||||
- `POST /<path>/<name>.form.html` — create new submission → 201 + Location capability URL
|
- `POST /<dir>/form.html` — create new submission → 201 + Location capability URL pointing at the new `<dir>/<id>.yaml`
|
||||||
- `GET /<path>/<name>/<id>.yaml.html` — render form pre-filled from `<id>.yaml`
|
- `GET /<dir>/<id>.yaml.html` — render form pre-filled from `<id>.yaml`
|
||||||
- `POST /<path>/<name>/<id>.yaml.html` — overwrite that submission → 200
|
- `POST /<dir>/<id>.yaml.html` — overwrite that submission → 200
|
||||||
|
|
||||||
**Storage**: spec at `<dir>/<name>.form.yaml`, submissions at `<dir>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml`. Submissions folder is created lazily; ACL applies via the existing `.zddc` cascade.
|
**Storage**: spec at `<dir>/form.yaml`, submissions at `<dir>/<YYYY-MM-DD>-<email-sanitized>.yaml` (siblings of the spec). Copying `<dir>` 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.
|
**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.
|
**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 `<name>.form.yaml` into any path users can write to (per `.zddc` ACL). No code change required. Visit `<that-path>/<name>.form.html`.
|
**Adding a new form**: create a directory `<dir>/` and drop `form.yaml` into it (per `.zddc` ACL). No code change required. Visit `<dir>/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 `<id>.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 `<dir>/table.yaml` on disk auto-mounts at `<dir>/table.html`. The directory is the table.
|
||||||
|
|
||||||
|
**Storage** (self-contained directory):
|
||||||
|
|
||||||
|
```
|
||||||
|
<dir>/
|
||||||
|
table.yaml ← spec
|
||||||
|
form.yaml ← row-edit form (paired with table.yaml)
|
||||||
|
<id>.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 `<dir>/` 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** — `<dir>/sub-list/table.yaml` is its own self-contained table at `<dir>/sub-list/table.html`. Composition, not violation.
|
||||||
|
- **Per-row attachments** — `<dir>/<id>.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path.
|
||||||
|
- **Drafts / staging** — `<dir>/.drafts/<id>.yaml` (dot-prefix → hidden from listings as well as from the table).
|
||||||
|
- **Future per-row history** — `<dir>/.history/<id>/<timestamp>.yaml` if/when version sidecars are added.
|
||||||
|
|
||||||
|
**Default-MDL fallback at `archive/<party>/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/<party>/mdl/`, presence-based discovery is the rule.
|
||||||
|
|
||||||
|
**Adding a new table**: create a directory `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`.
|
||||||
|
|
||||||
## Implementation-vs-dependency policy
|
## Implementation-vs-dependency policy
|
||||||
|
|
||||||
|
|
|
||||||
21
build
21
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/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
|
||||||
cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html"
|
cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html"
|
||||||
echo "Populated $EMBED_DIR/ for //go:embed"
|
echo "Populated $EMBED_DIR/ for //go:embed"
|
||||||
|
fi
|
||||||
|
|
||||||
# The form renderer lives next to its handler (no cascade needed — it's a
|
# The unified tables renderer ships both table-mode and form-mode in
|
||||||
# fixed renderer, not a per-folder-override tool).
|
# one HTML — see tables/template.html and tables/js/mode.js. The Go
|
||||||
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/internal/handler/form.html"
|
# server embeds a single tables.html (//go:embed in tablehandler.go);
|
||||||
echo "Populated zddc/internal/handler/form.html for //go:embed"
|
# 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
|
if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
|
||||||
# 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"
|
|
||||||
|
|
||||||
# Assemble the embedded versions manifest from the per-tool .label sidecars
|
# 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
|
# written by shared/build-lib.sh's compute_build_label. The Go side reads
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,22 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
function boot() {
|
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();
|
app.context = app.modules.context.load();
|
||||||
|
|
||||||
if (app.context.title) {
|
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) {
|
if (t) {
|
||||||
t.textContent = app.context.title;
|
t.textContent = app.context.title;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,21 @@ trap cleanup EXIT
|
||||||
concat_files \
|
concat_files \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"css/table.css" \
|
"css/table.css" \
|
||||||
|
"../form/css/form.css" \
|
||||||
> "$css_temp"
|
> "$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 \
|
concat_files \
|
||||||
"../shared/vendor/js-yaml.min.js" \
|
"../shared/vendor/js-yaml.min.js" \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/zddc-source.js" \
|
"../shared/zddc-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
|
"js/mode.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/context.js" \
|
"js/context.js" \
|
||||||
"js/util.js" \
|
"js/util.js" \
|
||||||
|
|
@ -35,6 +42,17 @@ concat_files \
|
||||||
"js/sort.js" \
|
"js/sort.js" \
|
||||||
"js/render.js" \
|
"js/render.js" \
|
||||||
"js/main.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"
|
> "$js_raw"
|
||||||
|
|
||||||
escape_js_close_tags "$js_raw" "$js_temp"
|
escape_js_close_tags "$js_raw" "$js_temp"
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,17 @@
|
||||||
margin: 0 0 var(--spacing-sm);
|
margin: 0 0 var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-toolbar__left {
|
.table-toolbar__left,
|
||||||
|
.table-toolbar__right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#table-add-row {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.table-rowcount {
|
.table-rowcount {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@
|
||||||
// to a non-empty object, return it as-is.
|
// to a non-empty object, return it as-is.
|
||||||
//
|
//
|
||||||
// 2. File-backed walk (the real-world path served by zddc-server):
|
// 2. File-backed walk (the real-world path served by zddc-server):
|
||||||
// fetch <dir>/.zddc, find tables[<name>], fetch the *.table.yaml
|
// page is at /<dir>/table.html — fetch <dir>/table.yaml,
|
||||||
// spec, list <dir>/<name>/*.yaml row files, parse each, and
|
// list every other *.yaml in <dir> as a row file (filtering
|
||||||
// assemble the same shape.
|
// 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
|
// file:// mode without a directory handle is unsupported in v1 — the
|
||||||
// walk only runs against http(s). file:// users must either inject an
|
// walk only runs against http(s). file:// users must either inject an
|
||||||
|
|
@ -75,20 +77,18 @@
|
||||||
}
|
}
|
||||||
const dir = probe.handle;
|
const dir = probe.handle;
|
||||||
|
|
||||||
const zddcDoc = await readYaml(dir, '.zddc');
|
// Spec lives at <currentdir>/table.yaml — the page URL is
|
||||||
const tablesMap = (zddcDoc && zddcDoc.tables) || {};
|
// <currentdir>/table.html, so the spec is right next door.
|
||||||
const specRel = tablesMap[tableName];
|
const spec = await readYaml(dir, 'table.yaml');
|
||||||
if (!specRel) {
|
|
||||||
throw new Error('No tables.' + tableName + ' declared in .zddc');
|
|
||||||
}
|
|
||||||
const spec = await readYaml(dir, stripDotSlash(specRel));
|
|
||||||
if (!spec || !Array.isArray(spec.columns)) {
|
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));
|
// Rows are every *.yaml in <currentdir> EXCEPT the spec
|
||||||
const rowsDir = await resolveDirectory(dir, rowsRel);
|
// (table.yaml) and the row-edit form (form.yaml). They live
|
||||||
const rows = await readRows(rowsDir, rowsRel, tableName);
|
// in the same directory by design — copying the directory
|
||||||
|
// copies the whole table.
|
||||||
|
const rows = await readRows(dir, '', tableName);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: spec.title,
|
title: spec.title,
|
||||||
|
|
@ -100,7 +100,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function tableNameFromUrl(pathname) {
|
function tableNameFromUrl(pathname) {
|
||||||
const m = String(pathname || '').match(/\/([^\/]+)\.table\.html$/);
|
// /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
|
||||||
|
// basename.
|
||||||
|
const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
|
||||||
return m ? m[1] : null;
|
return m ? m[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,16 +148,19 @@
|
||||||
return cur;
|
return cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readRows(rowsDir, rowsRel, tableName) {
|
async function readRows(rowsDir, _rowsRel, _tableName) {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for await (const entry of rowsDir.values()) {
|
for await (const entry of rowsDir.values()) {
|
||||||
if (entry.kind !== 'file') continue;
|
if (entry.kind !== 'file') continue;
|
||||||
if (!entry.name.endsWith('.yaml')) 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 {
|
try {
|
||||||
const file = await (await rowsDir.getFileHandle(entry.name)).getFile();
|
const file = await (await rowsDir.getFileHandle(entry.name)).getFile();
|
||||||
const data = window.jsyaml.load(await file.text());
|
const data = window.jsyaml.load(await file.text());
|
||||||
rows.push({
|
rows.push({
|
||||||
url: rowEditUrl(rowsRel, tableName, entry.name),
|
url: rowEditUrl(entry.name),
|
||||||
data: data || {},
|
data: data || {},
|
||||||
editable: true
|
editable: true
|
||||||
});
|
});
|
||||||
|
|
@ -166,14 +171,12 @@
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the form-handler URL for editing one row. The page is at
|
// Re-edit URL for one row. Page is at /<dir>/table.html; row file
|
||||||
// <dir>/<tableName>.table.html; the row file lives at
|
// lives at /<dir>/<basename>.yaml; form re-edit URL is
|
||||||
// <dir>/<rowsRel>/<basename>.yaml; the form re-edit URL is
|
// /<dir>/<basename>.yaml.html — same directory.
|
||||||
// <dir>/<rowsRel>/<basename>.yaml.html.
|
function rowEditUrl(rowFileName) {
|
||||||
function rowEditUrl(rowsRel, tableName, rowFileName) {
|
const pageDir = location.pathname.replace(/\/table\.html$/, '/');
|
||||||
const pageDir = location.pathname.replace(/\/[^\/]+\.table\.html$/, '/');
|
return pageDir + rowFileName + '.html';
|
||||||
const rowsPath = pageDir + (rowsRel || tableName) + '/';
|
|
||||||
return rowsPath + rowFileName + '.html';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.modules.context = { load: load };
|
app.modules.context = { load: load };
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,17 @@
|
||||||
// - free-text: { kind: 'contains', value: '<string>' }
|
// - free-text: { kind: 'contains', value: '<string>' }
|
||||||
// - enum: { kind: 'enum', value: ['<choice>', ...] }
|
// - enum: { kind: 'enum', value: ['<choice>', ...] }
|
||||||
// An empty value (empty string or empty array) matches everything.
|
// 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) {
|
function isEnumColumn(col) {
|
||||||
return Array.isArray(col.enum) && col.enum.length > 0;
|
return Array.isArray(col.enum) && col.enum.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultFilterFor(col) {
|
function defaultFilterFor(_col) {
|
||||||
return isEnumColumn(col) ? { kind: 'enum', value: [] } : { kind: 'contains', value: '' };
|
return { kind: 'contains', value: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowMatches(filter, cellValue) {
|
function rowMatches(filter, cellValue) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
async function init() {
|
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();
|
const ctx = await app.modules.context.load();
|
||||||
app.context = ctx;
|
app.context = ctx;
|
||||||
|
|
||||||
|
|
@ -23,6 +29,22 @@
|
||||||
const emptyEl = document.getElementById('table-empty');
|
const emptyEl = document.getElementById('table-empty');
|
||||||
const countEl = document.getElementById('table-rowcount');
|
const countEl = document.getElementById('table-rowcount');
|
||||||
const clearBtn = document.getElementById('table-clear-filters');
|
const clearBtn = document.getElementById('table-clear-filters');
|
||||||
|
const addRowBtn = document.getElementById('table-add-row');
|
||||||
|
|
||||||
|
// Add-row button: link to <name>.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 <dir>/table.html; the row-creation form is at
|
||||||
|
// <dir>/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 columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||||
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
||||||
|
|
@ -40,14 +62,12 @@
|
||||||
if (seeded == null) {
|
if (seeded == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (app.modules.filters.isEnumColumn(col)) {
|
// Filter UI is uniformly text-contains. If the spec
|
||||||
state.filter[col.field] = {
|
// seeds an array (legacy enum-style), coerce to a
|
||||||
kind: 'enum',
|
// comma-joined contains string — partial match on any
|
||||||
value: Array.isArray(seeded) ? seeded.slice() : [String(seeded)]
|
// listed value still narrows the table sensibly.
|
||||||
};
|
const seedStr = Array.isArray(seeded) ? seeded.join(',') : String(seeded);
|
||||||
} else {
|
state.filter[col.field] = { kind: 'contains', value: seedStr };
|
||||||
state.filter[col.field] = { kind: 'contains', value: String(seeded) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
76
tables/js/mode.js
Normal file
76
tables/js/mode.js
Normal file
|
|
@ -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:
|
||||||
|
// /<dir>/table.html → table mode
|
||||||
|
// /<dir>/form.html → form mode (empty / create)
|
||||||
|
// /<dir>/<id>.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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -22,45 +22,23 @@
|
||||||
titleRow.appendChild(th);
|
titleRow.appendChild(th);
|
||||||
|
|
||||||
const td = util.h('td', { className: 'zddc-table__filter-cell' });
|
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);
|
const f = filterMap[col.field] || filters.defaultFilterFor(col);
|
||||||
if (filters.isEnumColumn(col)) {
|
const input = util.h('input', {
|
||||||
const select = util.h('select', {
|
type: 'text',
|
||||||
multiple: true,
|
className: 'zddc-table__filter-text',
|
||||||
'aria-label': 'Filter ' + (col.title || col.field),
|
placeholder: 'filter…',
|
||||||
className: 'zddc-table__filter-enum',
|
'aria-label': 'Filter ' + (col.title || col.field),
|
||||||
onChange: function (ev) {
|
value: typeof f.value === 'string' ? f.value : '',
|
||||||
const opts = ev.target.options;
|
onInput: function (ev) {
|
||||||
const picked = [];
|
onFilterChange(col.field, { kind: 'contains', value: ev.target.value });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
td.appendChild(select);
|
});
|
||||||
} else {
|
td.appendChild(input);
|
||||||
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);
|
|
||||||
}
|
|
||||||
filterRow.appendChild(td);
|
filterRow.appendChild(td);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="table-main">
|
<!-- Table mode: shown for /<dir>/table.html requests. -->
|
||||||
|
<main id="table-mode" class="table-main" hidden>
|
||||||
<div id="table-description" class="table-description" hidden></div>
|
<div id="table-description" class="table-description" hidden></div>
|
||||||
<div id="table-status" class="table-status" hidden></div>
|
<div id="table-status" class="table-status" hidden></div>
|
||||||
<div class="table-toolbar" id="table-toolbar">
|
<div class="table-toolbar" id="table-toolbar">
|
||||||
|
|
@ -39,6 +40,9 @@
|
||||||
<span id="table-rowcount" class="table-rowcount" aria-live="polite"></span>
|
<span id="table-rowcount" class="table-rowcount" aria-live="polite"></span>
|
||||||
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
|
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="table-toolbar__right">
|
||||||
|
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
<table id="table-root" class="zddc-table" aria-describedby="table-description">
|
<table id="table-root" class="zddc-table" aria-describedby="table-description">
|
||||||
|
|
@ -49,6 +53,18 @@
|
||||||
<div id="table-empty" class="table-empty" hidden>No rows match the current filters.</div>
|
<div id="table-empty" class="table-empty" hidden>No rows match the current filters.</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Form mode: shown for /<dir>/form.html and /<dir>/<id>.yaml.html
|
||||||
|
requests. Same bundle ships both modes so a row's "+ Add row"
|
||||||
|
and click-to-edit reuse the table tool's spec, validator, and
|
||||||
|
file-IO instead of duplicating them in a separate form HTML. -->
|
||||||
|
<main id="form-mode" class="form-main" hidden>
|
||||||
|
<div id="form-status" class="form-status" hidden></div>
|
||||||
|
<form id="form-root" class="form-root" novalidate></form>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" id="submit-btn" class="btn btn-primary">Submit</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- Help Panel -->
|
<!-- Help Panel -->
|
||||||
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
||||||
<div class="help-panel__header">
|
<div class="help-panel__header">
|
||||||
|
|
@ -101,6 +117,12 @@
|
||||||
-->
|
-->
|
||||||
<script id="table-context" type="application/json">{}</script>
|
<script id="table-context" type="application/json">{}</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Form mode context — server injects this for /<dir>/form.html and
|
||||||
|
/<dir>/<id>.yaml.html. Empty in table-mode renders.
|
||||||
|
-->
|
||||||
|
<script id="form-context" type="application/json">{}</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
{{JS_PLACEHOLDER}}
|
{{JS_PLACEHOLDER}}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const HTML_PATH = path.resolve('form/dist/form.html');
|
// Form mode is hosted by the unified tables.html bundle — same bytes the
|
||||||
|
// server returns for /<dir>/form.html and /<dir>/<id>.yaml.html. Loading
|
||||||
|
// tables/dist/tables.html via file:// (named form.html in the temp dir
|
||||||
|
// so the URL pathname triggers form-mode in the dispatcher) is the
|
||||||
|
// closest offline mirror of what online callers actually receive.
|
||||||
|
const HTML_PATH = path.resolve('tables/dist/tables.html');
|
||||||
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
|
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
|
||||||
|
|
||||||
const SAFETY_SCHEMA = {
|
const SAFETY_SCHEMA = {
|
||||||
|
|
@ -82,7 +87,8 @@ test.describe('form/ — safety check-in renderer', () => {
|
||||||
await expect(radios).toHaveCount(3); // Site A / B / C
|
await expect(radios).toHaveCount(3); // Site A / B / C
|
||||||
await expect(page.locator('#form-root textarea')).toHaveCount(1);
|
await expect(page.locator('#form-root textarea')).toHaveCount(1);
|
||||||
await expect(page.locator('#form-root .form-array__add')).toHaveCount(1);
|
await expect(page.locator('#form-root .form-array__add')).toHaveCount(1);
|
||||||
await expect(page.locator('#form-title')).toContainText('Safety Check-In');
|
// Title element is shared across modes in the unified bundle.
|
||||||
|
await expect(page.locator('#table-title')).toContainText('Safety Check-In');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add/remove hazard rows works', async ({ page }) => {
|
test('add/remove hazard rows works', async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -128,16 +128,20 @@ test.describe('tables/ — directory-of-YAML table view', () => {
|
||||||
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
|
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('enum filter limits rows to selected values', async ({ page }) => {
|
test('text filter on an enum column does substring match', async ({ page }) => {
|
||||||
|
// Filter row is uniformly text-contains across all columns —
|
||||||
|
// even for columns declared with `enum:` in the spec. Enum
|
||||||
|
// metadata still informs validation/sort but not filter UI.
|
||||||
await loadTableWithContext(page, {
|
await loadTableWithContext(page, {
|
||||||
columns: MDL_COLUMNS,
|
columns: MDL_COLUMNS,
|
||||||
rows: ROWS,
|
rows: ROWS,
|
||||||
});
|
});
|
||||||
await page.waitForSelector('#table-root tbody tr');
|
await page.waitForSelector('#table-root tbody tr');
|
||||||
|
|
||||||
// Status column is enum; pick "pending" only.
|
const filterInputs = page.locator('.zddc-table__filter-row input[type="text"]');
|
||||||
const statusSelect = page.locator('.zddc-table__filter-row select').nth(1); // 0=party, 1=status
|
// Spec columns: 0=id, 1=title, 2=party, 3=dueDate, 4=status.
|
||||||
await statusSelect.selectOption({ value: 'pending' });
|
const statusInput = filterInputs.nth(4);
|
||||||
|
await statusInput.fill('pending');
|
||||||
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
|
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -874,11 +874,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
switch apps.DefaultAppAt(cfg.Root, absPath) {
|
switch apps.DefaultAppAt(cfg.Root, absPath) {
|
||||||
case "tables":
|
case "tables":
|
||||||
// Tables aren't an apps-subsystem app — the table
|
// Tables aren't an apps-subsystem app — the table
|
||||||
// handler responds to <dir>/<name>.table.html. Serve
|
// handler responds to /<dir>/table.html. Serve the
|
||||||
// the equivalent table view inline at the bare-mdl
|
// equivalent table view inline at the bare URL by
|
||||||
// URL by routing through the handler with the
|
// synthesizing the canonical /table.html suffix.
|
||||||
// canonical .table.html name appended.
|
synth := urlPath + "/table.html"
|
||||||
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+".table.html"); tr != nil {
|
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
|
||||||
handler.ServeTable(cfg, tr, w, r)
|
handler.ServeTable(cfg, tr, w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,7 @@ func TestDispatchSlashRouting(t *testing.T) {
|
||||||
// Trailing-slash form on a tables rows-dir bounces to the canonical
|
// Trailing-slash form on a tables rows-dir bounces to the canonical
|
||||||
// .table.html URL so users land on the table view rather than a
|
// .table.html URL so users land on the table view rather than a
|
||||||
// browse listing of the row-yaml files.
|
// browse listing of the row-yaml files.
|
||||||
{"archive/<party>/mdl slash → 302 .table.html", "/Project/archive/Acme/mdl/", http.StatusFound, false, "/Project/archive/Acme/mdl.table.html"},
|
{"archive/<party>/mdl slash → 302 in-dir table.html", "/Project/archive/Acme/mdl/", http.StatusFound, false, "/Project/archive/Acme/mdl/table.html"},
|
||||||
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""},
|
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""},
|
||||||
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
|
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
|
||||||
{"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false, ""},
|
{"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false, ""},
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,9 @@ func inAncestorWithName(root, requestDir string, names ...string) bool {
|
||||||
//
|
//
|
||||||
// The mdl rule wins over the broader archive rule because the table
|
// The mdl rule wins over the broader archive rule because the table
|
||||||
// editor is a more specific surface for browsing planned deliverables
|
// editor is a more specific surface for browsing planned deliverables
|
||||||
// than the archive index.
|
// than the archive index. Note: the dir at archive/<party>/mdl/
|
||||||
|
// itself IS the table — its table.yaml + form.yaml + row YAMLs all
|
||||||
|
// live there together (self-contained directory).
|
||||||
//
|
//
|
||||||
// requestDir and root are absolute filesystem paths; requestDir must
|
// requestDir and root are absolute filesystem paths; requestDir must
|
||||||
// be under root (otherwise "" is returned).
|
// be under root (otherwise "" is returned).
|
||||||
|
|
|
||||||
|
|
@ -129,29 +129,27 @@ func TestServeDirectoryRootIsPublic(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServeDirectoryRedirectsTableRowsDir asserts that an HTML GET on a
|
// TestServeDirectoryRedirectsTableRowsDir asserts that an HTML GET on a
|
||||||
// directory that is the rows-dir of a registered table bounces to the
|
// directory containing a table.yaml bounces to that dir's table.html URL
|
||||||
// canonical <parent>/<name>.table.html URL. JSON GETs on the same URL
|
// (in-dir convention: /<dir>/table.html serves the table view, the row
|
||||||
// fall through to the listing so the table client can still enumerate
|
// YAMLs are siblings of table.yaml). JSON GETs fall through to the
|
||||||
// row files.
|
// listing so the table client can still enumerate row files.
|
||||||
func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) {
|
func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
working := filepath.Join(root, "Working")
|
mdlDir := filepath.Join(root, "Working", "MDL")
|
||||||
if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil {
|
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"),
|
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"),
|
||||||
[]byte(sampleTableSpec), 0o644); err != nil {
|
[]byte(sampleTableSpec), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"),
|
if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"),
|
||||||
[]byte(sampleRowFormSpec), 0o644); err != nil {
|
[]byte(sampleRowFormSpec), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(working, ".zddc"), []byte(`acl:
|
if err := os.WriteFile(filepath.Join(root, "Working", ".zddc"), []byte(`acl:
|
||||||
permissions:
|
permissions:
|
||||||
"*@example.com": rwcda
|
"*@example.com": rwcda
|
||||||
tables:
|
|
||||||
MDL: ./MDL.table.yaml
|
|
||||||
`), 0o644); err != nil {
|
`), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +157,7 @@ tables:
|
||||||
|
|
||||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||||
|
|
||||||
t.Run("HTML GET on rows-dir redirects to .table.html", func(t *testing.T) {
|
t.Run("HTML GET on dir with table.yaml redirects to table.html", func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil)
|
||||||
req.Header.Set("Accept", "text/html")
|
req.Header.Set("Accept", "text/html")
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
||||||
|
|
@ -169,12 +167,12 @@ tables:
|
||||||
if rec.Code != http.StatusFound {
|
if rec.Code != http.StatusFound {
|
||||||
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
if got, want := rec.Header().Get("Location"), "/Working/MDL.table.html"; got != want {
|
if got, want := rec.Header().Get("Location"), "/Working/MDL/table.html"; got != want {
|
||||||
t.Errorf("Location = %q, want %q", got, want)
|
t.Errorf("Location = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("JSON GET on rows-dir falls through to listing", func(t *testing.T) {
|
t.Run("JSON GET on dir with table.yaml falls through to listing", func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
||||||
|
|
@ -190,9 +188,8 @@ tables:
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("HTML GET on plain dir is not redirected", func(t *testing.T) {
|
t.Run("HTML GET on plain dir is not redirected", func(t *testing.T) {
|
||||||
// Sibling of the rows dir — same parent .zddc, but the dir name
|
// Sibling dir without a table.yaml — no redirect.
|
||||||
// "Other" isn't declared as a table key.
|
if err := os.MkdirAll(filepath.Join(root, "Working", "Other"), 0o755); err != nil {
|
||||||
if err := os.MkdirAll(filepath.Join(working, "Other"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest(http.MethodGet, "/Working/Other/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/Working/Other/", nil)
|
||||||
|
|
@ -208,8 +205,8 @@ tables:
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServeDirectoryRedirectsDefaultMdl covers the default-MDL fallback:
|
// TestServeDirectoryRedirectsDefaultMdl covers the default-MDL fallback:
|
||||||
// archive/<party>/mdl/ with no operator .zddc declaration still redirects
|
// archive/<party>/mdl/ with no on-disk table.yaml still redirects
|
||||||
// to <party>/mdl.table.html (the table handler serves embedded defaults).
|
// to mdl/table.html (the table handler serves embedded defaults).
|
||||||
func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) {
|
func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||||
|
|
@ -233,7 +230,7 @@ func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) {
|
||||||
if rec.Code != http.StatusFound {
|
if rec.Code != http.StatusFound {
|
||||||
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
if got, want := rec.Header().Get("Location"), "/Project/archive/Acme/mdl.table.html"; got != want {
|
if got, want := rec.Header().Get("Location"), "/Project/archive/Acme/mdl/table.html"; got != want {
|
||||||
t.Errorf("Location = %q, want %q", got, want)
|
t.Errorf("Location = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -19,7 +19,6 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -39,8 +38,17 @@ import (
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed form.html
|
// Form-mode rendering shares the unified `tables.html` bundle: one HTML
|
||||||
var embeddedFormHTML []byte
|
// hosts both apps (tablesApp, formApp) so the table view's "+ Add row"
|
||||||
|
// link, the empty-form / create URL, and the row-edit URL all return
|
||||||
|
// the same bytes. A small mode dispatcher in the bundle picks which
|
||||||
|
// app paints based on the URL pattern. This eliminates a second
|
||||||
|
// embedded HTML and lets future editable-cell mode reuse the form
|
||||||
|
// validator + write path without IPC across two SPAs.
|
||||||
|
//
|
||||||
|
// formRenderHTML is the source of bytes for serveFormRender; it's the
|
||||||
|
// same package var as embeddedTablesHTML (declared in tablehandler.go).
|
||||||
|
func formRenderHTML() []byte { return embeddedTablesHTML }
|
||||||
|
|
||||||
// FormSpec is the YAML envelope of a <name>.form.yaml file.
|
// FormSpec is the YAML envelope of a <name>.form.yaml file.
|
||||||
//
|
//
|
||||||
|
|
@ -97,17 +105,40 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
||||||
}
|
}
|
||||||
underlying := strings.TrimSuffix(urlPath, ".html")
|
underlying := strings.TrimSuffix(urlPath, ".html")
|
||||||
|
|
||||||
// Form-spec URL: <name>.form.html → spec at <name>.form.yaml.
|
// specEligible accepts a spec path that exists on disk OR matches
|
||||||
// Data URL: <name>/<id>.yaml.html → underlying ends in .yaml → spec at
|
// the default-MDL virtual-fallback shape at archive/<party>/mdl/.
|
||||||
// <name>.form.yaml at the parent's parent.
|
// Without this, the default-MDL row form would 404 on a fresh
|
||||||
if strings.HasSuffix(underlying, ".form") {
|
// archive even though the table view renders.
|
||||||
// <name>.form.html — empty form / create.
|
specEligible := func(specAbs string) bool {
|
||||||
specRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) + ".yaml"
|
if fileExists(specAbs) {
|
||||||
specAbs := filepath.Join(fsRoot, specRel)
|
return true
|
||||||
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
|
}
|
||||||
|
if _, ok := IsDefaultMdlSpecAbs(fsRoot, specAbs); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-dir convention: spec, form, and rows all live in one
|
||||||
|
// directory. URLs:
|
||||||
|
// /<dir>/form.html — empty form / create
|
||||||
|
// /<dir>/<id>.yaml.html — re-edit / update one row
|
||||||
|
// Spec is always <dir>/form.yaml relative to the URL.
|
||||||
|
if strings.HasSuffix(underlying, "/form") || underlying == "/form" {
|
||||||
|
// /<dir>/form.html — empty form / create.
|
||||||
|
dirRel := strings.TrimSuffix(strings.TrimPrefix(underlying, "/"), "/form")
|
||||||
|
dirRel = strings.TrimSuffix(dirRel, "form") // root case "/form" → ""
|
||||||
|
dirRel = strings.Trim(dirRel, "/")
|
||||||
|
if dirRel == "" {
|
||||||
|
// /form.html at root has no rows-dir to bind a spec to.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !fileExists(specAbs) {
|
dirAbs := filepath.Join(fsRoot, filepath.FromSlash(dirRel))
|
||||||
|
if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
specAbs := filepath.Join(dirAbs, "form.yaml")
|
||||||
|
if !specEligible(specAbs) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
kind := "render-empty"
|
kind := "render-empty"
|
||||||
|
|
@ -122,19 +153,15 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(underlying, ".yaml") {
|
if strings.HasSuffix(underlying, ".yaml") {
|
||||||
// <name>/<id>.yaml.html — re-edit / update.
|
// /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
|
||||||
|
// SAME directory as the row file (<dir>/form.yaml).
|
||||||
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
|
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
|
||||||
dataAbs := filepath.Join(fsRoot, dataRel)
|
dataAbs := filepath.Join(fsRoot, dataRel)
|
||||||
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
|
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Spec lives at the parent's parent: <dir>/<name>/<id>.yaml →
|
specPath := filepath.Join(filepath.Dir(dataAbs), "form.yaml")
|
||||||
// <dir>/<name>.form.yaml.
|
if !specEligible(specPath) {
|
||||||
parentDir := filepath.Dir(dataAbs)
|
|
||||||
formName := filepath.Base(parentDir)
|
|
||||||
grandparent := filepath.Dir(parentDir)
|
|
||||||
specPath := filepath.Join(grandparent, formName+".form.yaml")
|
|
||||||
if !fileExists(specPath) {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
kind := "render-edit"
|
kind := "render-edit"
|
||||||
|
|
@ -193,12 +220,12 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(embeddedFormHTML) == 0 {
|
if len(formRenderHTML()) == 0 {
|
||||||
http.Error(w, "form renderer not built into this binary", http.StatusServiceUnavailable)
|
http.Error(w, "form renderer not built into this binary", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
spec, err := loadFormSpec(req.SpecPath)
|
spec, err := loadFormSpec(cfg.Root, req.SpecPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("form: spec parse error", "path", req.SpecPath, "err", err)
|
slog.Warn("form: spec parse error", "path", req.SpecPath, "err", err)
|
||||||
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
|
@ -232,7 +259,7 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
||||||
Errors: validationErrs,
|
Errors: validationErrs,
|
||||||
}
|
}
|
||||||
|
|
||||||
html, err := injectFormContext(embeddedFormHTML, ctx)
|
html, err := injectFormContext(formRenderHTML(), ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "render: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "render: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -250,15 +277,18 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
formName := strings.TrimSuffix(filepath.Base(req.SpecPath), ".form.yaml")
|
// In-dir convention: spec, form, and rows live in one directory.
|
||||||
specDir := filepath.Dir(req.SpecPath)
|
// New submissions land alongside the spec; submissionsDir IS the
|
||||||
submissionsDir := filepath.Join(specDir, formName)
|
// directory holding form.yaml.
|
||||||
|
submissionsDir := filepath.Dir(req.SpecPath)
|
||||||
|
|
||||||
// ACL: write-rights at submissions dir. The dir may not exist yet; the
|
// ACL: write-rights at the directory where the row YAML will land.
|
||||||
// cascade chain falls back to the parent.
|
// In the default-MDL fallback case the directory may not exist
|
||||||
|
// yet; cascade up to the closest existing ancestor for the policy
|
||||||
|
// chain.
|
||||||
gateDir := submissionsDir
|
gateDir := submissionsDir
|
||||||
if !fileExists(submissionsDir) {
|
if !fileExists(submissionsDir) {
|
||||||
gateDir = specDir
|
gateDir = filepath.Dir(submissionsDir)
|
||||||
}
|
}
|
||||||
chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
|
chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -275,7 +305,7 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
spec, err := loadFormSpec(req.SpecPath)
|
spec, err := loadFormSpec(cfg.Root, req.SpecPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -358,7 +388,7 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
spec, err := loadFormSpec(req.SpecPath)
|
spec, err := loadFormSpec(cfg.Root, req.SpecPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -386,10 +416,23 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
||||||
|
|
||||||
// --- Helpers -----------------------------------------------------------------
|
// --- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
func loadFormSpec(path string) (*FormSpec, error) {
|
func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
// Default-MDL virtual fallback: when the operator hasn't placed
|
||||||
|
// an mdl.form.yaml under archive/<party>/, serve the embedded
|
||||||
|
// default. Mirrors the static-handler fallback for direct YAML
|
||||||
|
// fetches so the form recognizer and the loader agree on what
|
||||||
|
// "this spec exists" means.
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if bytes, ok := IsDefaultMdlSpecAbs(fsRoot, path); ok {
|
||||||
|
data = bytes
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var spec FormSpec
|
var spec FormSpec
|
||||||
if err := yaml.Unmarshal(data, &spec); err != nil {
|
if err := yaml.Unmarshal(data, &spec); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,13 @@ func formTestSetup(t *testing.T, zddcFiles map[string]string) (config.Config, fu
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
// Always seed the form spec at /Working/safety.form.yaml.
|
// Always seed the form spec at /Working/safety/form.yaml — in-dir
|
||||||
working := filepath.Join(root, "Working")
|
// convention puts the spec inside the rows-dir alongside row YAMLs.
|
||||||
if err := os.MkdirAll(working, 0o755); err != nil {
|
safetyDir := filepath.Join(root, "Working", "safety")
|
||||||
|
if err := os.MkdirAll(safetyDir, 0o755); err != nil {
|
||||||
t.Fatalf("mkdir: %v", err)
|
t.Fatalf("mkdir: %v", err)
|
||||||
}
|
}
|
||||||
specPath := filepath.Join(working, "safety.form.yaml")
|
specPath := filepath.Join(safetyDir, "form.yaml")
|
||||||
if err := os.WriteFile(specPath, []byte(sampleFormSpec), 0o644); err != nil {
|
if err := os.WriteFile(specPath, []byte(sampleFormSpec), 0o644); err != nil {
|
||||||
t.Fatalf("write spec: %v", err)
|
t.Fatalf("write spec: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +100,7 @@ func TestRecognizeFormRequest(t *testing.T) {
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Working", "safety"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Working", "safety"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(root, "Working", "safety.form.yaml"), []byte("schema:\n type: object\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "Working", "safety", "form.yaml"), []byte("schema:\n type: object\n"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(root, "Working", "safety", "2026-05-01-casey.yaml"), []byte("date: 2026-05-01\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "Working", "safety", "2026-05-01-casey.yaml"), []byte("date: 2026-05-01\n"), 0o644); err != nil {
|
||||||
|
|
@ -107,25 +108,25 @@ func TestRecognizeFormRequest(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
method, url string
|
method, url string
|
||||||
wantKind string // "" means expect nil
|
wantKind string // "" means expect nil
|
||||||
wantSpec string
|
wantSpec string
|
||||||
wantData string
|
wantData string
|
||||||
}{
|
}{
|
||||||
{"GET", "/Working/safety.form.html", "render-empty", "Working/safety.form.yaml", ""},
|
{"GET", "/Working/safety/form.html", "render-empty", "Working/safety/form.yaml", ""},
|
||||||
{"POST", "/Working/safety.form.html", "create", "Working/safety.form.yaml", ""},
|
{"POST", "/Working/safety/form.html", "create", "Working/safety/form.yaml", ""},
|
||||||
{"GET", "/Working/safety/2026-05-01-casey.yaml.html", "render-edit", "Working/safety.form.yaml", "Working/safety/2026-05-01-casey.yaml"},
|
{"GET", "/Working/safety/2026-05-01-casey.yaml.html", "render-edit", "Working/safety/form.yaml", "Working/safety/2026-05-01-casey.yaml"},
|
||||||
{"POST", "/Working/safety/2026-05-01-casey.yaml.html", "update", "Working/safety.form.yaml", "Working/safety/2026-05-01-casey.yaml"},
|
{"POST", "/Working/safety/2026-05-01-casey.yaml.html", "update", "Working/safety/form.yaml", "Working/safety/2026-05-01-casey.yaml"},
|
||||||
// No spec → not a form request.
|
// No spec → not a form request.
|
||||||
{"GET", "/Working/missing.form.html", "", "", ""},
|
{"GET", "/Working/missing/form.html", "", "", ""},
|
||||||
// Bare .yaml (not .yaml.html) → not a form request, falls through to static.
|
// Bare .yaml (not .yaml.html) → not a form request, falls through to static.
|
||||||
{"GET", "/Working/safety/2026-05-01-casey.yaml", "", "", ""},
|
{"GET", "/Working/safety/2026-05-01-casey.yaml", "", "", ""},
|
||||||
// Random .html → falls through.
|
// Random .html → falls through.
|
||||||
{"GET", "/index.html", "", "", ""},
|
{"GET", "/index.html", "", "", ""},
|
||||||
// Wrong method.
|
// Wrong method.
|
||||||
{"DELETE", "/Working/safety.form.html", "", "", ""},
|
{"DELETE", "/Working/safety/form.html", "", "", ""},
|
||||||
// Path traversal attempt.
|
// Path traversal attempt.
|
||||||
{"GET", "/../etc/passwd.form.html", "", "", ""},
|
{"GET", "/../etc/passwd/form.html", "", "", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|
@ -159,13 +160,78 @@ func TestRecognizeFormRequest(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRecognizeFormRequest_DefaultMdlAtArchiveParty: archive/<party>/mdl/
|
||||||
|
// is the one place a form can be served fully virtually — the embedded
|
||||||
|
// default form.yaml fills in for a missing on-disk spec so the
|
||||||
|
// "+ Add row" link from the default-MDL table view resolves on a fresh
|
||||||
|
// archive. Outside archive/<party>/mdl/ the recognizer still requires
|
||||||
|
// a real spec on disk.
|
||||||
|
func TestRecognizeFormRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustMkdir(t, filepath.Join(root, "Project", "archive", "PartyA"))
|
||||||
|
|
||||||
|
// Empty form / create at archive/<party>/mdl/form.html — no spec
|
||||||
|
// on disk, no mdl/ dir on disk, default-MDL fallback applies.
|
||||||
|
got := RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/mdl/form.html")
|
||||||
|
if got == nil || got.Kind != "render-empty" {
|
||||||
|
t.Fatalf("GET mdl/form.html: got %+v want render-empty via default-MDL fallback", got)
|
||||||
|
}
|
||||||
|
if got.SpecPath != filepath.Join(root, "Project", "archive", "PartyA", "mdl", "form.yaml") {
|
||||||
|
t.Errorf("SpecPath = %q", got.SpecPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST → create.
|
||||||
|
got = RecognizeFormRequest(root, "POST", "/Project/archive/PartyA/mdl/form.html")
|
||||||
|
if got == nil || got.Kind != "create" {
|
||||||
|
t.Fatalf("POST mdl/form.html: got %+v want create", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-edit (<id>.yaml.html) at archive/<party>/mdl/ — same default
|
||||||
|
// spec applies. The data file itself must exist on disk; the spec
|
||||||
|
// is the embedded default in the same directory.
|
||||||
|
mustMkdir(t, filepath.Join(root, "Project", "archive", "PartyA", "mdl"))
|
||||||
|
mustWrite(t, filepath.Join(root, "Project", "archive", "PartyA", "mdl", "row-001.yaml"), "trackingNumber: TR-001\n")
|
||||||
|
got = RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/mdl/row-001.yaml.html")
|
||||||
|
if got == nil || got.Kind != "render-edit" {
|
||||||
|
t.Fatalf("GET row-001.yaml.html: got %+v want render-edit", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOT at archive/<party>/mdl/ — default doesn't apply, still 404.
|
||||||
|
got = RecognizeFormRequest(root, "GET", "/Project/Working/mdl/form.html")
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("Working/mdl/form.html: got %+v want nil (no default outside archive-party-mdl)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-mdl directory at archive/<party>/ — no default for arbitrary names.
|
||||||
|
got = RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/safety/form.html")
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("safety/form.html: got %+v want nil (only mdl has a default)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustMkdir / mustWrite — local copies (the cmd/zddc-server package
|
||||||
|
// has them but they're test-internal there).
|
||||||
|
func mustMkdir(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustWrite(t *testing.T, path, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderEmptyForm(t *testing.T) {
|
func TestRenderEmptyForm(t *testing.T) {
|
||||||
_, do := formTestSetup(t, map[string]string{
|
_, do := formTestSetup(t, map[string]string{
|
||||||
"": `acl:
|
"": `acl:
|
||||||
allow: ["*@example.com"]
|
allow: ["*@example.com"]
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
rec := do(http.MethodGet, "/Working/safety.form.html", "casey@example.com", "")
|
rec := do(http.MethodGet, "/Working/safety/form.html", "casey@example.com", "")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -189,7 +255,7 @@ func TestRenderEmptyForm_ACLDeny(t *testing.T) {
|
||||||
allow: ["root@example.com"]
|
allow: ["root@example.com"]
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
rec := do(http.MethodGet, "/Working/safety.form.html", "stranger@example.com", "")
|
rec := do(http.MethodGet, "/Working/safety/form.html", "stranger@example.com", "")
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
|
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +269,7 @@ func TestCreateSubmission_Valid(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
body := `{"date":"2026-05-01","location":"Site A","severity":3,"notes":"all clear"}`
|
body := `{"date":"2026-05-01","location":"Site A","severity":3,"notes":"all clear"}`
|
||||||
rec := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body)
|
rec := do(http.MethodPost, "/Working/safety/form.html", "casey@example.com", body)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("status = %d want 201; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d want 201; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +310,7 @@ func TestCreateSubmission_Invalid_Returns422(t *testing.T) {
|
||||||
|
|
||||||
// Missing required `location`, severity out of range.
|
// Missing required `location`, severity out of range.
|
||||||
body := `{"date":"2026-05-01","severity":99}`
|
body := `{"date":"2026-05-01","severity":99}`
|
||||||
rec := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body)
|
rec := do(http.MethodPost, "/Working/safety/form.html", "casey@example.com", body)
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
t.Fatalf("status = %d want 422; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d want 422; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -280,7 +346,7 @@ func TestCreateSubmission_ACLDeny(t *testing.T) {
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
body := `{"date":"2026-05-01","location":"Site A"}`
|
body := `{"date":"2026-05-01","location":"Site A"}`
|
||||||
rec := do(http.MethodPost, "/Working/safety.form.html", "stranger@example.com", body)
|
rec := do(http.MethodPost, "/Working/safety/form.html", "stranger@example.com", body)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
|
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +359,7 @@ func TestCreateSubmission_NoAuth_Returns401(t *testing.T) {
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
body := `{"date":"2026-05-01","location":"Site A"}`
|
body := `{"date":"2026-05-01","location":"Site A"}`
|
||||||
rec := do(http.MethodPost, "/Working/safety.form.html", "", body)
|
rec := do(http.MethodPost, "/Working/safety/form.html", "", body)
|
||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d want 401; body = %s", rec.Code, rec.Body.String())
|
t.Errorf("status = %d want 401; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -307,11 +373,11 @@ func TestCreateSubmission_FilenameCollision(t *testing.T) {
|
||||||
})
|
})
|
||||||
body := `{"date":"2026-05-01","location":"Site A"}`
|
body := `{"date":"2026-05-01","location":"Site A"}`
|
||||||
|
|
||||||
first := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body)
|
first := do(http.MethodPost, "/Working/safety/form.html", "casey@example.com", body)
|
||||||
if first.Code != http.StatusCreated {
|
if first.Code != http.StatusCreated {
|
||||||
t.Fatalf("first submit: status = %d; body = %s", first.Code, first.Body.String())
|
t.Fatalf("first submit: status = %d; body = %s", first.Code, first.Body.String())
|
||||||
}
|
}
|
||||||
second := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body)
|
second := do(http.MethodPost, "/Working/safety/form.html", "casey@example.com", body)
|
||||||
if second.Code != http.StatusCreated {
|
if second.Code != http.StatusCreated {
|
||||||
t.Fatalf("second submit: status = %d; body = %s", second.Code, second.Body.String())
|
t.Fatalf("second submit: status = %d; body = %s", second.Code, second.Body.String())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,25 +55,29 @@ func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable }
|
||||||
// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes.
|
// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes.
|
||||||
func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm }
|
func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm }
|
||||||
|
|
||||||
// IsDefaultMdlSpec reports whether (urlPath, dirAbs) describes a request
|
// IsDefaultMdlSpec reports whether urlPath is one of the default-MDL
|
||||||
// for the default mdl.table.yaml or mdl.form.yaml under archive/<party>/
|
// virtual files served when no operator file exists on disk:
|
||||||
// where no operator file exists. Caller is the static-file handler.
|
|
||||||
//
|
//
|
||||||
// Returns the embedded bytes + true when the fallback should fire.
|
// <project>/archive/<party>/mdl/table.yaml
|
||||||
// Returns nil + false when an operator-supplied file exists or the path
|
// <project>/archive/<party>/mdl/form.yaml
|
||||||
// is not eligible for the fallback.
|
//
|
||||||
|
// The MDL files live INSIDE the rows-dir along with row YAMLs so the
|
||||||
|
// whole directory is self-contained — copying mdl/ moves the spec,
|
||||||
|
// the form, and all rows together. Returns embedded bytes + true when
|
||||||
|
// the fallback should fire; nil + false when an operator-supplied
|
||||||
|
// file exists or the path is not eligible.
|
||||||
func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
|
func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
|
||||||
base := strings.ToLower(filepath.Base(urlPath))
|
base := strings.ToLower(filepath.Base(urlPath))
|
||||||
var bytes []byte
|
var bytes []byte
|
||||||
switch base {
|
switch base {
|
||||||
case "mdl.table.yaml":
|
case "table.yaml":
|
||||||
bytes = embeddedDefaultMdlTable
|
bytes = embeddedDefaultMdlTable
|
||||||
case "mdl.form.yaml":
|
case "form.yaml":
|
||||||
bytes = embeddedDefaultMdlForm
|
bytes = embeddedDefaultMdlForm
|
||||||
default:
|
default:
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if !isAtArchivePartyLevel(fsRoot, urlPath) {
|
if !isAtArchivePartyMdlLevel(fsRoot, urlPath) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
// Operator file wins if it exists on disk.
|
// Operator file wins if it exists on disk.
|
||||||
|
|
@ -88,6 +92,22 @@ func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
|
||||||
return bytes, true
|
return bytes, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsDefaultMdlSpecAbs is the abs-path-keyed variant of IsDefaultMdlSpec.
|
||||||
|
// Used by handlers that hold a filesystem path rather than a URL.
|
||||||
|
// Returns the embedded default bytes + true when absPath is the
|
||||||
|
// virtual archive/<party>/{mdl.table.yaml, mdl.form.yaml} fallback.
|
||||||
|
func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) {
|
||||||
|
if !strings.HasPrefix(absPath, fsRoot+string(filepath.Separator)) && absPath != fsRoot {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(fsRoot, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
urlPath := "/" + filepath.ToSlash(rel)
|
||||||
|
return IsDefaultMdlSpec(fsRoot, urlPath)
|
||||||
|
}
|
||||||
|
|
||||||
// isAtArchivePartyLevel reports whether urlPath refers to a file
|
// isAtArchivePartyLevel reports whether urlPath refers to a file
|
||||||
// directly under <project>/archive/<party>/ (depth-3 directory). The
|
// directly under <project>/archive/<party>/ (depth-3 directory). The
|
||||||
// canonical-folder names are case-folded.
|
// canonical-folder names are case-folded.
|
||||||
|
|
@ -101,6 +121,20 @@ func isAtArchivePartyLevel(fsRoot, urlPath string) bool {
|
||||||
return strings.EqualFold(parts[1], "archive")
|
return strings.EqualFold(parts[1], "archive")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isAtArchivePartyMdlLevel reports whether urlPath refers to a file
|
||||||
|
// directly under <project>/archive/<party>/mdl/ (depth-4 directory).
|
||||||
|
// Used by the default-MDL fallback after the spec/form moved INSIDE
|
||||||
|
// the rows-dir for self-containment.
|
||||||
|
func isAtArchivePartyMdlLevel(fsRoot, urlPath string) bool {
|
||||||
|
rel := strings.Trim(filepath.ToSlash(urlPath), "/")
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
// <project>/archive/<party>/mdl/<file> = 5 segments
|
||||||
|
if len(parts) != 5 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl")
|
||||||
|
}
|
||||||
|
|
||||||
// TableRequest describes a recognized table-system request.
|
// TableRequest describes a recognized table-system request.
|
||||||
type TableRequest struct {
|
type TableRequest struct {
|
||||||
// Name is the table's URL stem (the key declared in .zddc tables).
|
// Name is the table's URL stem (the key declared in .zddc tables).
|
||||||
|
|
@ -113,31 +147,24 @@ type TableRequest struct {
|
||||||
Dir string
|
Dir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// tableRowsRedirect reports the canonical .table.html URL to redirect
|
// tableRowsRedirect reports the canonical /<dir>/table.html URL to
|
||||||
// to when (urlPath) names a directory that is the rows-dir of a
|
// redirect to when (urlPath) names a directory that contains a
|
||||||
// registered table. Returns "" when no redirect should fire.
|
// table.yaml (or matches the default-MDL fallback). Returns "" when
|
||||||
|
// no redirect should fire.
|
||||||
//
|
//
|
||||||
// Recognition reuses RecognizeTableRequest by synthesizing the
|
// Recognition reuses RecognizeTableRequest by synthesizing the
|
||||||
// equivalent <parent>/<name>.table.html URL from urlPath and asking
|
// equivalent <urlPath>table.html and asking the recognizer whether
|
||||||
// the table-recognizer whether it's a real, declared (or default-MDL)
|
// it's a real (or default-MDL) table. Single source of truth for
|
||||||
// table. This keeps validation in one place — operator-declared tables
|
// validation.
|
||||||
// require both a `tables:` entry AND an existing spec file.
|
|
||||||
func tableRowsRedirect(fsRoot, urlPath string) string {
|
func tableRowsRedirect(fsRoot, urlPath string) string {
|
||||||
// urlPath is the directory request — e.g. "/proj/archive/Acme/mdl/".
|
// urlPath is the directory request — e.g. "/proj/archive/Acme/mdl/".
|
||||||
trimmed := strings.TrimSuffix(urlPath, "/")
|
if urlPath == "" || urlPath == "/" {
|
||||||
if trimmed == "" || trimmed == "/" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
slash := strings.LastIndex(trimmed, "/")
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
if slash < 0 {
|
urlPath += "/"
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
parent := trimmed[:slash+1] // includes trailing slash
|
synthesized := urlPath + "table.html"
|
||||||
name := trimmed[slash+1:]
|
|
||||||
if name == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
synthesized := parent + name + ".table.html"
|
|
||||||
if RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) == nil {
|
if RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -146,9 +173,16 @@ func tableRowsRedirect(fsRoot, urlPath string) string {
|
||||||
|
|
||||||
// RecognizeTableRequest classifies r as a table-system request, or
|
// RecognizeTableRequest classifies r as a table-system request, or
|
||||||
// returns nil if it falls through to other handlers. Discovery is
|
// returns nil if it falls through to other handlers. Discovery is
|
||||||
// strictly .zddc-declarative — a *.table.html URL with no matching
|
// presence-based and self-contained: a /<dir>/table.html URL fires
|
||||||
// `tables:` entry in <dir>/.zddc returns nil so it falls through to
|
// when <dir>/table.yaml exists on disk, or when the default-MDL
|
||||||
// the static-file path (404 unless an operator dropped a real file).
|
// fallback at archive/<party>/mdl/ applies.
|
||||||
|
//
|
||||||
|
// The spec, the row-edit form, and all rows live together in <dir>.
|
||||||
|
// Copying <dir> elsewhere copies everything needed to re-host the
|
||||||
|
// table — that's the whole point of the in-dir layout.
|
||||||
|
//
|
||||||
|
// The table's "name" is the directory's basename (so the URL
|
||||||
|
// /<parent>/mdl/table.html names the "mdl" table, with rows in mdl/).
|
||||||
//
|
//
|
||||||
// Methods other than GET return nil — the table is read-only at the
|
// Methods other than GET return nil — the table is read-only at the
|
||||||
// URL level. Writes go through the file API directly.
|
// URL level. Writes go through the file API directly.
|
||||||
|
|
@ -156,64 +190,61 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
||||||
if method != http.MethodGet {
|
if method != http.MethodGet {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(urlPath, ".table.html") {
|
if !strings.HasSuffix(urlPath, "/table.html") && urlPath != "/table.html" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split <dir>/<name>.table.html into dir + name.
|
// Strip /table.html — what remains is the rows-dir.
|
||||||
stem := strings.TrimSuffix(urlPath, ".table.html")
|
rel := strings.TrimSuffix(strings.TrimPrefix(urlPath, "/"), "/table.html")
|
||||||
if stem == "" || stem == "/" {
|
rel = strings.TrimSuffix(rel, "table.html") // handles "/table.html" at root → ""
|
||||||
|
rel = strings.Trim(rel, "/")
|
||||||
|
if rel == "" {
|
||||||
|
// /table.html at root has no rows-dir to name.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
dirRel := filepath.Dir(filepath.FromSlash(strings.TrimPrefix(stem, "/")))
|
dirAbs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
||||||
name := filepath.Base(filepath.FromSlash(strings.TrimPrefix(stem, "/")))
|
|
||||||
if name == "" || name == "." || name == "/" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
dirAbs := filepath.Join(fsRoot, dirRel)
|
|
||||||
if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot {
|
if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
zddcPath := filepath.Join(dirAbs, ".zddc")
|
name := filepath.Base(dirAbs)
|
||||||
zf, err := zddc.ParseFile(zddcPath)
|
|
||||||
if err != nil && !isNotExistError(err) {
|
specAbs := filepath.Join(dirAbs, "table.yaml")
|
||||||
// Malformed .zddc — log and pass through; static handler will 500
|
|
||||||
// if it cares. Recognition just says "not a declared table here."
|
// Presence-based discovery: <dir>/table.yaml on disk.
|
||||||
slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err)
|
if fileExists(specAbs) {
|
||||||
return nil
|
return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
|
||||||
}
|
}
|
||||||
if specRel, ok := zf.Tables[name]; ok {
|
|
||||||
// Operator explicitly declared this table — honour it strictly.
|
// Default-MDL virtual-spec fallback at archive/<party>/mdl/. The
|
||||||
// If the declared spec file is missing, return nil so the URL
|
// spec bytes come from IsDefaultMdlSpec via the static-file
|
||||||
// 404s rather than silently falling back to the default. This
|
// dispatcher when no on-disk file exists at that path; the rows-dir
|
||||||
// keeps a typo in the operator's .zddc visible.
|
// itself doesn't need to exist either (fully virtual archive).
|
||||||
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel))
|
if isAtArchivePartyMdlDir(fsRoot, dirAbs) {
|
||||||
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
|
return &TableRequest{Name: "mdl", SpecPath: specAbs, Dir: dirAbs}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !fileExists(specAbs) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &TableRequest{
|
|
||||||
Name: name,
|
|
||||||
SpecPath: specAbs,
|
|
||||||
Dir: dirAbs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No operator declaration — apply the default MDL spec fallback at
|
|
||||||
// archive/<party>/. The spec bytes are served by IsDefaultMdlSpec via
|
|
||||||
// the static-file dispatcher.
|
|
||||||
if strings.EqualFold(name, "mdl") && isArchivePartyDir(fsRoot, dirAbs) {
|
|
||||||
return &TableRequest{
|
|
||||||
Name: "mdl",
|
|
||||||
SpecPath: filepath.Join(dirAbs, "mdl.table.yaml"), // virtual; static handler may serve embedded bytes
|
|
||||||
Dir: dirAbs,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isAtArchivePartyMdlDir reports whether dirAbs is exactly
|
||||||
|
// <fsRoot>/<project>/archive/<party>/mdl. Used by the default-MDL
|
||||||
|
// fallback to recognize the virtual rows-dir whether or not it
|
||||||
|
// exists on disk.
|
||||||
|
func isAtArchivePartyMdlDir(fsRoot, dirAbs string) bool {
|
||||||
|
rel, err := filepath.Rel(fsRoot, dirAbs)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
if len(parts) != 4 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl")
|
||||||
|
}
|
||||||
|
|
||||||
// isNotExistError reports whether err indicates a missing file. Local
|
// isNotExistError reports whether err indicates a missing file. Local
|
||||||
// helper to avoid pulling errors.Is into the handler package.
|
// helper to avoid pulling errors.Is into the handler package.
|
||||||
func isNotExistError(err error) bool {
|
func isNotExistError(err error) bool {
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ import (
|
||||||
|
|
||||||
const sampleTableSpec = `title: Master Deliverables List
|
const sampleTableSpec = `title: Master Deliverables List
|
||||||
description: Sample MDL.
|
description: Sample MDL.
|
||||||
rowSchema: ./MDL.form.yaml
|
|
||||||
rows: ./MDL
|
|
||||||
columns:
|
columns:
|
||||||
- field: id
|
- field: id
|
||||||
title: ID
|
title: ID
|
||||||
|
|
@ -47,12 +45,12 @@ schema:
|
||||||
enum: [pending, submitted, accepted]
|
enum: [pending, submitted, accepted]
|
||||||
`
|
`
|
||||||
|
|
||||||
// tableTestSetup writes a directory tree under a temp root with:
|
// tableTestSetup writes a directory tree under a temp root with the
|
||||||
|
// in-dir layout:
|
||||||
//
|
//
|
||||||
// <root>/Working/.zddc → declares tables: { MDL: ./MDL.table.yaml }
|
// <root>/Working/MDL/table.yaml → spec
|
||||||
// <root>/Working/MDL.table.yaml → spec
|
// <root>/Working/MDL/form.yaml → row schema
|
||||||
// <root>/Working/MDL.form.yaml → row schema
|
// <root>/Working/MDL/<file>.yaml → row data (one per entry in rows)
|
||||||
// <root>/Working/MDL/<file>.yaml → row data (one per entry in rows)
|
|
||||||
//
|
//
|
||||||
// Optional extra .zddc files at relative paths can be supplied via zddcFiles.
|
// Optional extra .zddc files at relative paths can be supplied via zddcFiles.
|
||||||
// Returns (config, do) where do dispatches a request through ServeTable via
|
// Returns (config, do) where do dispatches a request through ServeTable via
|
||||||
|
|
@ -65,18 +63,18 @@ func tableTestSetup(t *testing.T, rows map[string]string, zddcFiles map[string]s
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
working := filepath.Join(root, "Working")
|
mdlDir := filepath.Join(root, "Working", "MDL")
|
||||||
if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil {
|
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
||||||
t.Fatalf("mkdir: %v", err)
|
t.Fatalf("mkdir: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
|
||||||
t.Fatalf("write spec: %v", err)
|
t.Fatalf("write spec: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
|
||||||
t.Fatalf("write form spec: %v", err)
|
t.Fatalf("write form spec: %v", err)
|
||||||
}
|
}
|
||||||
for name, body := range rows {
|
for name, body := range rows {
|
||||||
if err := os.WriteFile(filepath.Join(working, "MDL", name), []byte(body), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(mdlDir, name), []byte(body), 0o644); err != nil {
|
||||||
t.Fatalf("write row %s: %v", name, err)
|
t.Fatalf("write row %s: %v", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,8 +85,6 @@ func tableTestSetup(t *testing.T, rows map[string]string, zddcFiles map[string]s
|
||||||
zddcFiles["Working"] = `acl:
|
zddcFiles["Working"] = `acl:
|
||||||
permissions:
|
permissions:
|
||||||
"*@example.com": rwcd
|
"*@example.com": rwcd
|
||||||
tables:
|
|
||||||
MDL: ./MDL.table.yaml
|
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
for rel, body := range zddcFiles {
|
for rel, body := range zddcFiles {
|
||||||
|
|
@ -125,22 +121,17 @@ tables:
|
||||||
|
|
||||||
func TestRecognizeTableRequest(t *testing.T) {
|
func TestRecognizeTableRequest(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
working := filepath.Join(root, "Working")
|
mdlDir := filepath.Join(root, "Working", "MDL")
|
||||||
if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil {
|
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(working, ".zddc"), []byte(`tables:
|
zddc.InvalidateCache(mdlDir)
|
||||||
MDL: ./MDL.table.yaml
|
|
||||||
`), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(working)
|
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
method, url string
|
method, url string
|
||||||
|
|
@ -148,21 +139,21 @@ func TestRecognizeTableRequest(t *testing.T) {
|
||||||
wantSpec string
|
wantSpec string
|
||||||
wantName string
|
wantName string
|
||||||
}{
|
}{
|
||||||
{"GET", "/Working/MDL.table.html", false, "Working/MDL.table.yaml", "MDL"},
|
{"GET", "/Working/MDL/table.html", false, "Working/MDL/table.yaml", "MDL"},
|
||||||
// Same URL but POST → tables are read-only at the URL level.
|
// Same URL but POST → tables are read-only at the URL level.
|
||||||
{"POST", "/Working/MDL.table.html", true, "", ""},
|
{"POST", "/Working/MDL/table.html", true, "", ""},
|
||||||
{"PUT", "/Working/MDL.table.html", true, "", ""},
|
{"PUT", "/Working/MDL/table.html", true, "", ""},
|
||||||
{"DELETE", "/Working/MDL.table.html", true, "", ""},
|
{"DELETE", "/Working/MDL/table.html", true, "", ""},
|
||||||
// Not declared in .zddc → not a table request.
|
// No table.yaml in this dir → not a table request.
|
||||||
{"GET", "/Working/Other.table.html", true, "", ""},
|
{"GET", "/Working/Other/table.html", true, "", ""},
|
||||||
// No .zddc at the dir → not a table request.
|
// No table.yaml anywhere → not a table request.
|
||||||
{"GET", "/Other/MDL.table.html", true, "", ""},
|
{"GET", "/Other/MDL/table.html", true, "", ""},
|
||||||
// Random .html → falls through.
|
// Random .html → falls through.
|
||||||
{"GET", "/index.html", true, "", ""},
|
{"GET", "/index.html", true, "", ""},
|
||||||
// .form.html (form territory) → falls through to form handler.
|
// /form.html in the same dir is form territory, not a table.
|
||||||
{"GET", "/Working/MDL.form.html", true, "", ""},
|
{"GET", "/Working/MDL/form.html", true, "", ""},
|
||||||
// Path traversal attempt.
|
// Path traversal attempt.
|
||||||
{"GET", "/../etc/passwd.table.html", true, "", ""},
|
{"GET", "/../etc/passwd/table.html", true, "", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|
@ -196,7 +187,7 @@ func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
|
||||||
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
|
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
|
||||||
}
|
}
|
||||||
_, do := tableTestSetup(t, rows, nil)
|
_, do := tableTestSetup(t, rows, nil)
|
||||||
rec := do(http.MethodGet, "/Working/MDL.table.html", "casey@example.com")
|
rec := do(http.MethodGet, "/Working/MDL/table.html", "casey@example.com")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +213,7 @@ tables:
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
_, do := tableTestSetup(t, map[string]string{"D.yaml": "id: D\n"}, zddcs)
|
_, do := tableTestSetup(t, map[string]string{"D.yaml": "id: D\n"}, zddcs)
|
||||||
rec := do(http.MethodGet, "/Working/MDL.table.html", "stranger@example.com")
|
rec := do(http.MethodGet, "/Working/MDL/table.html", "stranger@example.com")
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
|
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -276,7 +267,7 @@ func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(m
|
||||||
func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
||||||
_, do := archivePartyTestSetup(t, "")
|
_, do := archivePartyTestSetup(t, "")
|
||||||
|
|
||||||
rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com")
|
rec := do(http.MethodGet, "/Project/archive/Acme/mdl/table.html", "alice@example.com")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -286,31 +277,17 @@ func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecognizeTableRequest_OperatorOverrideWins(t *testing.T) {
|
|
||||||
// Operator declared a custom mdl spec that points at a non-existent
|
|
||||||
// file. The fallback should NOT fire, because the operator
|
|
||||||
// explicitly took control. RecognizeTableRequest returns nil.
|
|
||||||
root, do := archivePartyTestSetup(t, `tables:
|
|
||||||
mdl: ./custom-mdl.table.yaml
|
|
||||||
`)
|
|
||||||
_ = root
|
|
||||||
rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("operator declaration with missing spec should fall through to 404, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
|
func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
|
||||||
// Default fallback is scoped to <project>/archive/<party>/. A
|
// Default fallback is scoped to <project>/archive/<party>/. A
|
||||||
// request at a deeper path (e.g. archive/Acme/mdl/sub/) or a
|
// request at a deeper path (e.g. archive/Acme/mdl/sub/) or a
|
||||||
// non-archive path should return nil (no recognition).
|
// non-archive path should return nil (no recognition).
|
||||||
_, do := archivePartyTestSetup(t, "")
|
_, do := archivePartyTestSetup(t, "")
|
||||||
|
|
||||||
rec := do(http.MethodGet, "/Project/archive/Acme/incoming/mdl.table.html", "alice@example.com")
|
rec := do(http.MethodGet, "/Project/archive/Acme/incoming/mdl/table.html", "alice@example.com")
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("mdl deeper than party level should not recognise; got %d", rec.Code)
|
t.Errorf("mdl deeper than party level should not recognise; got %d", rec.Code)
|
||||||
}
|
}
|
||||||
rec = do(http.MethodGet, "/Project/working/mdl.table.html", "alice@example.com")
|
rec = do(http.MethodGet, "/Project/working/mdl/table.html", "alice@example.com")
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("mdl outside archive/ should not recognise; got %d", rec.Code)
|
t.Errorf("mdl outside archive/ should not recognise; got %d", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
@ -318,12 +295,12 @@ func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
|
||||||
|
|
||||||
func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
|
func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
// archive/Acme/ exists but no mdl.table.yaml on disk.
|
// archive/Acme/ exists but no mdl/table.yaml on disk.
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml")
|
bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected fallback to fire")
|
t.Fatalf("expected fallback to fire")
|
||||||
}
|
}
|
||||||
|
|
@ -331,7 +308,7 @@ func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
|
||||||
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
||||||
}
|
}
|
||||||
|
|
||||||
bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.form.yaml")
|
bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/form.yaml")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected form fallback to fire")
|
t.Fatalf("expected form fallback to fire")
|
||||||
}
|
}
|
||||||
|
|
@ -342,14 +319,14 @@ func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
|
||||||
|
|
||||||
func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
|
func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
dir := filepath.Join(root, "Project", "archive", "Acme")
|
mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, "mdl.table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml"); ok {
|
if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok {
|
||||||
t.Errorf("operator file should win over embedded fallback")
|
t.Errorf("operator file should win over embedded fallback")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -357,9 +334,9 @@ func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
|
||||||
func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) {
|
func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"/Project/working/mdl.table.yaml",
|
"/Project/working/mdl/table.yaml",
|
||||||
"/Project/archive/mdl.table.yaml", // depth 3 — no party segment
|
"/Project/archive/mdl/table.yaml", // depth 3 — no party segment
|
||||||
"/Project/archive/Acme/sub/mdl.table.yaml",
|
"/Project/archive/Acme/sub/mdl/table.yaml",
|
||||||
}
|
}
|
||||||
for _, p := range cases {
|
for _, p := range cases {
|
||||||
if _, ok := IsDefaultMdlSpec(root, p); ok {
|
if _, ok := IsDefaultMdlSpec(root, p); ok {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue