feat(tables): new sortable/filterable grid tool for directories of YAML files
Tables is the eighth HTML tool: a read-only tabular view over a
directory of YAML files declared via `tables:` in `.zddc`. Anchor use
case is the Master Deliverables List, where each row is one
`<tracking>.yaml` under `Archive/<Party>/MDL/`. Rows click through to
the existing form renderer for editing.
Schema (zddc/internal/zddc/file.go)
- New `Tables map[string]string` on ZddcFile. Map key becomes the URL
stem (`tables[MDL]` → `<dir>/MDL.table.html`); the value is a path
relative to the .zddc pointing at a `*.table.yaml` spec describing
columns + the rows directory. No upward cascade in v1 — each
directory hosting a table declares it directly.
Server handler (zddc/internal/handler/tablehandler.go)
- `RecognizeTableRequest` matches GET `/<dir>/<name>.table.html`
against the cascade's `tables:` declarations. Dispatch routes
table requests before the form-system intercept.
- `ServeTable` ACL-gates with `policy.ActionRead` and serves the
embedded `tables.html` template; client walks the directory itself
via the listing JSON or FS Access API.
- tables.html embedded via //go:embed — same pattern as form.html.
Frontend (tables/)
- Vanilla JS: app/context/util/filters/sort/render/main modules.
- Reads spec + row YAML files via window.zddc.source (HTTP polyfill
or local FS handle); js-yaml 4.1.0 vendored in shared/vendor for
client-side parsing.
- Sample fixtures under tables/sample/ for local testing.
Build + CI
- Lockstep build registers tables alongside the other 7 tools (HTML
output, embed mirror, versions.txt, release-output, tags).
- Playwright project added; `npx playwright test --project=tables`
is part of `npm test`.
Drive-by: rename mdedit Playwright selectors `#select-directory` →
`#addDirectoryBtn` to fix three pre-existing failing tests.
Drive-by: ignore locally-built `zddc/zddc-server` binary so it doesn't
get accidentally staged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2b17c9f030
commit
9ca36f25d8
34 changed files with 4415 additions and 26 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -30,6 +30,12 @@ test-results/
|
|||
# and reproducible from any <tool>-vX.Y.Z tag.
|
||||
dist/
|
||||
|
||||
# Locally-compiled zddc-server binary. `(cd zddc && go build ./cmd/zddc-server)`
|
||||
# drops the binary at zddc/zddc-server; the canonical released artifacts live
|
||||
# under dist/release-output/zddc-server_* with platform suffixes and signing.
|
||||
zddc/zddc-server
|
||||
zddc/zddc-server.exe
|
||||
|
||||
# IDE and project files
|
||||
.opencode/
|
||||
opencode.json
|
||||
|
|
|
|||
67
AGENTS.md
67
AGENTS.md
|
|
@ -27,7 +27,7 @@
|
|||
./deploy --releases # only dist/release-output/ → /srv/zddc/releases/
|
||||
|
||||
# Single-tool dev build for testing (does NOT touch dist/release-output/):
|
||||
sh tool/build.sh # archive|transmittal|classifier|mdedit|landing|form
|
||||
sh tool/build.sh # archive|transmittal|classifier|mdedit|landing|form|tables|browse
|
||||
|
||||
# Single-tool release (rare; prefer ./build alpha|beta|release so versions
|
||||
# don't drift between tools). Same flag form as before.
|
||||
|
|
@ -38,7 +38,7 @@ sh tool/build.sh --release [<version>|alpha|beta]
|
|||
npm test
|
||||
|
||||
# Test single tool
|
||||
npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety
|
||||
npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety | tables
|
||||
|
||||
# Dev server (cache-busting HTTP, on port 8000)
|
||||
./dev-server start
|
||||
|
|
@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug.
|
|||
|
||||
## Architecture
|
||||
|
||||
Six independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`). 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. The sixth tool, `form`, is the schema-driven renderer used by zddc-server's form-data system; see "Form-data 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 declared in `.zddc tables:` as a sortable table whose rows click through to the form editor (see "Form-data system" and "Tables system" below).
|
||||
|
||||
```
|
||||
tool/
|
||||
|
|
@ -202,20 +202,20 @@ Format: `trackingNumber_revision (status) - title.extension`
|
|||
|
||||
- Feature-branch workflow; squash-merge feature branches to `main`
|
||||
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
|
||||
- Release tags: `<tool>-v<X.Y.Z>` per tool, all seven sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `mdedit-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `zddc-server-v0.0.8`)
|
||||
- Release tags: `<tool>-v<X.Y.Z>` per tool, all nine sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `mdedit-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`)
|
||||
- `dist/` is gitignored. Build artifacts (per-tool `dist/<tool>.html` and `dist/release-output/`) are NOT committed to this repo. Reproduce them from a tag with `./build release X.Y.Z`
|
||||
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
|
||||
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
|
||||
|
||||
### Releasing — lockstep, channels, layout
|
||||
|
||||
**Lockstep convention.** Every release cut bumps all seven artifacts (6 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all seven tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`. Channel cuts (alpha/beta) follow the same lockstep — every tool's channel mirror is overwritten in step. Three channels, ordered: **alpha** (dev iteration) → **beta** (general testing) → **stable** (ship).
|
||||
**Lockstep convention.** Every release cut bumps all nine artifacts (8 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all nine tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`. Channel cuts (alpha/beta) follow the same lockstep — every tool's channel mirror is overwritten in step. Three channels, ordered: **alpha** (dev iteration) → **beta** (general testing) → **stable** (ship).
|
||||
|
||||
**Storage model.** All release artifacts live on the deploy host at `/srv/zddc/releases/` (Caddy bind-mount, served as `https://zddc.varasys.io/releases/`). Locally they materialize in this repo's `dist/release-output/` (gitignored) when `./build alpha|beta|release` runs; `./deploy` rsyncs them out. **No git history holds release artifacts** — older versions are reproducible from any `<tool>-vX.Y.Z` tag (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS, no third-party mirrors.
|
||||
|
||||
| Artifact | Type | Layout |
|
||||
|---|---|---|
|
||||
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form |
|
||||
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form, tables, browse |
|
||||
| `<tool>_v<X.Y>.html`, `<tool>_v<X>.html` | symlinks | partial-version pins |
|
||||
| `<tool>_<channel>.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} |
|
||||
| `zddc-server_v<X.Y.Z>_<platform>` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} |
|
||||
|
|
@ -367,15 +367,58 @@ Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --brow
|
|||
zddc-server ships as a cross-compiled binary, not a container image. There's no Containerfile or compose file in this repo (the chart Dockerfiles compile from source at deploy time at the right tag).
|
||||
|
||||
```sh
|
||||
# Compile a local binary for the host platform (requires Go 1.24+)
|
||||
(cd zddc && go build -o zddc-server ./cmd/zddc-server)
|
||||
# Compile a local binary for the host platform via the build image.
|
||||
# Same flag pattern as Test below — see that subsection for why.
|
||||
podman run --rm --network=host -v "$PWD":/src:Z -v /tmp/gocache:/root/go/pkg/mod:Z \
|
||||
-w /src/zddc -e GOPROXY=https://proxy.golang.org -e GOSUMDB=off -e GOPRIVATE= \
|
||||
localhost/zddc-go:1.24 go build -o zddc-server ./cmd/zddc-server
|
||||
|
||||
# Or run directly without producing a binary
|
||||
(cd zddc && go run ./cmd/zddc-server)
|
||||
# `go run` is normally a one-liner but in-container `go run` of a network
|
||||
# server is awkward — for dev iteration, build with the line above and
|
||||
# launch the binary on the host (`./zddc/zddc-server`).
|
||||
```
|
||||
|
||||
The repo's top-level `./build` cross-compiles the four release binaries (linux/amd64, darwin/amd64, darwin/arm64, windows/amd64) into `zddc/dist/` via a containerized Go toolchain (podman or docker). On `./build alpha|beta|release` it also promotes those binaries to `dist/release-output/` with the matching symlink chain and stub pages — same lockstep flow as the HTML tools. `./deploy` rsyncs the bundle to `/srv/zddc/releases/`.
|
||||
|
||||
### Test
|
||||
|
||||
Go is **not** installed on the dev shell host directly — run `go test` (and `go build`) through a golang-alpine image. `./build`'s containerized cross-compile already pulls `golang:1.24-alpine` as a build stage; tag it for reuse:
|
||||
|
||||
```sh
|
||||
# One-time: locate the golang-alpine image (~810 MB, untagged after a build run)
|
||||
# and give it a stable name. The size and `golang/.../go test` lines distinguish
|
||||
# it from the small ~18 MB zddc-server runtime image.
|
||||
podman images --filter dangling=true --format '{{.Size}}\t{{.ID}}' | sort -h | tail
|
||||
podman tag <id> localhost/zddc-go:1.24
|
||||
|
||||
# If you have no <none> 810 MB image (fresh machine), pull directly:
|
||||
podman pull docker.io/library/golang:1.24-alpine
|
||||
podman tag docker.io/library/golang:1.24-alpine localhost/zddc-go:1.24
|
||||
```
|
||||
|
||||
Canonical invocation:
|
||||
|
||||
```sh
|
||||
podman run --rm --network=host \
|
||||
-v "$PWD":/src:Z \
|
||||
-v /tmp/gocache:/root/go/pkg/mod:Z \
|
||||
-w /src/zddc \
|
||||
-e GOPROXY=https://proxy.golang.org \
|
||||
-e GOSUMDB=off \
|
||||
-e GOPRIVATE= \
|
||||
localhost/zddc-go:1.24 \
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Why each flag:
|
||||
- `--network=host` — the alpine image's TLS chain can't shake hands with `gopkg.in` directly (sandbox hits "Connection reset by peer"); host networking + the proxy below works around it.
|
||||
- `GOPROXY=https://proxy.golang.org` — fetch via the public proxy. Without this, the build-image's baked `GOPRIVATE=*` forces direct VCS, which fails on `gopkg.in/natefinch/lumberjack.v2`.
|
||||
- `GOSUMDB=off` — sum.golang.org isn't reachable from the sandbox either; we already trust the proxy.
|
||||
- `GOPRIVATE=` (empty) — explicit override of the image's `GOPRIVATE=*`, which is a leftover from how `./build` does in-container compilation and would otherwise re-trigger direct fetch.
|
||||
- `/tmp/gocache` mount — persistent module cache across runs.
|
||||
|
||||
Run-it-once-per-session pattern: alias it. Do **not** `apt install golang` on the host — the image is the source of truth for the version pin, so dev and CI compile against the same Go.
|
||||
|
||||
### Run (development)
|
||||
|
||||
```sh
|
||||
|
|
@ -432,9 +475,9 @@ local path that fails loudly and visibly on the developer's terminal.
|
|||
|
||||
### Notes
|
||||
|
||||
- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/` (requires Go 1.24+)
|
||||
- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/`. The Go toolchain is not on the host; use the `localhost/zddc-go:1.24` image as documented in the **Test** subsection above.
|
||||
- Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories
|
||||
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two redirect entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
|
||||
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both **serve in place** — the handler streams the first chronologically received copy's bytes back at the `.archive/` URL without redirecting. The per-transmittal URL is intentionally hidden so external links of the form `.archive/<tracking>.html#section` keep tracking the latest revision (a redirect would expose the snapshot URL and people would forward THAT instead). Cache-Control is `no-cache` so each load revalidates against the on-disk file's Last-Modified/ETag; when a new revision lands the resolver picks it and the browser refetches. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
|
||||
- ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model."
|
||||
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
||||
- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ The name "Zero Day Document Control" comes from the convention itself — adopt
|
|||
| **Transmittal Creator** | Self-contained HTML transmittal records with SHA-256 checksums and optional digital signatures. |
|
||||
| **Document Classifier** | Spreadsheet-like bulk-renamer that copy/pastes with Excel and writes back to disk. |
|
||||
| **Markdown Editor** | Browser-based markdown editor with YAML front matter, TOC, and direct local file access. |
|
||||
| **Form Renderer** | Schema-driven `*.form.yaml` editor — every form spec auto-mounts an editable form at `<name>.form.html`. |
|
||||
| **Tables** | Read-only grid view of a directory of YAML files with sort + filter; click row → edit in the form renderer. Declared per-directory in `.zddc`. |
|
||||
|
||||
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Tools auto-appear at folder-name-driven paths (archive everywhere; classifier in `Incoming`/`Working`/`Staging`; mdedit in `Working`; transmittal in `Staging`). Override per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path). URL overrides are fetched once and cached in `<ZDDC_ROOT>/_app/`; drop a real `.html` file at any path to override entirely.
|
||||
|
||||
|
|
|
|||
19
build
19
build
|
|
@ -157,6 +157,7 @@ sh "$SCRIPT_DIR/classifier/build.sh" $TOOL_RELEASE_ARGS
|
|||
sh "$SCRIPT_DIR/mdedit/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/landing/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/tables/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/browse/build.sh" $TOOL_RELEASE_ARGS
|
||||
|
||||
echo ""
|
||||
|
|
@ -177,8 +178,9 @@ cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/
|
|||
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html"
|
||||
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html"
|
||||
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/dist/web/form.html"
|
||||
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/dist/web/tables.html"
|
||||
cp "$SCRIPT_DIR/browse/dist/browse.html" "$SCRIPT_DIR/zddc/dist/web/browse.html"
|
||||
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,browse}.html"
|
||||
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,tables,browse}.html"
|
||||
|
||||
# Mirror the five cascade-served HTMLs into the apps embed source dir so the
|
||||
# next `go build` of zddc-server picks them up via //go:embed. ONLY happens
|
||||
|
|
@ -203,6 +205,12 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
|
|||
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/internal/handler/form.html"
|
||||
echo "Populated zddc/internal/handler/form.html for //go:embed"
|
||||
|
||||
# Same pattern for the tables renderer — embedded directly into the
|
||||
# handler package (read-only directory-of-YAML view; not subject to
|
||||
# per-folder version overrides).
|
||||
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html"
|
||||
echo "Populated zddc/internal/handler/tables.html for //go:embed"
|
||||
|
||||
# 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
|
||||
# this via //go:embed in internal/apps/versions.go and surfaces it in
|
||||
|
|
@ -210,7 +218,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
|
|||
VERSIONS_FILE="$EMBED_DIR/versions.txt"
|
||||
{
|
||||
echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
|
||||
for _tool in archive transmittal classifier mdedit landing form browse; do
|
||||
for _tool in archive transmittal classifier mdedit landing form tables browse; do
|
||||
_label_file="$BUILD_LABELS_DIR/${_tool}.label"
|
||||
if [ -f "$_label_file" ]; then
|
||||
_label=$(cat "$_label_file")
|
||||
|
|
@ -959,7 +967,8 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then
|
|||
# that the binary needs at //go:embed time + the embedded form
|
||||
# template.
|
||||
git -C "$SCRIPT_DIR" add "$EMBED_DIR/" \
|
||||
"$SCRIPT_DIR/zddc/internal/handler/form.html"
|
||||
"$SCRIPT_DIR/zddc/internal/handler/form.html" \
|
||||
"$SCRIPT_DIR/zddc/internal/handler/tables.html"
|
||||
|
||||
if ! git -C "$SCRIPT_DIR" diff --cached --quiet; then
|
||||
git -C "$SCRIPT_DIR" commit -m "release: v${RELEASE_VERSION} lockstep"
|
||||
|
|
@ -971,7 +980,7 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then
|
|||
# Tag the seven artifacts at HEAD. Pre-flight already validated that
|
||||
# any pre-existing tag is in HEAD's history, so this is safe.
|
||||
_head=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
|
||||
for _t in archive transmittal classifier mdedit landing form browse zddc-server; do
|
||||
for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do
|
||||
_tag="${_t}-v${RELEASE_VERSION}"
|
||||
if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then
|
||||
_existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag")
|
||||
|
|
@ -1005,7 +1014,7 @@ else
|
|||
echo "Version: v$RELEASE_VERSION"
|
||||
echo ""
|
||||
echo "Tags created locally on main (push when ready):"
|
||||
for _t in archive transmittal classifier mdedit landing form browse zddc-server; do
|
||||
for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do
|
||||
echo " ${_t}-v${RELEASE_VERSION}"
|
||||
done
|
||||
echo " git push origin main && git push origin --tags"
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ export default defineConfig({
|
|||
name: 'form-safety',
|
||||
testMatch: 'form-safety.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'tables',
|
||||
testMatch: 'tables.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'zddc-filter',
|
||||
testMatch: 'zddc-filter.spec.js',
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ _emit_build_label_sidecar() {
|
|||
# Tools that participate in the lockstep release. Source of truth — used
|
||||
# by helpers that enumerate "all release artifacts" (matrix render,
|
||||
# coordinated next-stable, channel-link verifier).
|
||||
ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form browse zddc-server"
|
||||
ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form tables browse zddc-server"
|
||||
|
||||
# Compute the next-stable target for a single tool — patch-bump of its own
|
||||
# latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's
|
||||
|
|
@ -663,7 +663,7 @@ verify_channel_links() {
|
|||
_missing=0
|
||||
_verified=0
|
||||
|
||||
for _t in archive transmittal classifier mdedit landing form browse; do
|
||||
for _t in archive transmittal classifier mdedit landing form tables browse; do
|
||||
for _ch in stable beta alpha; do
|
||||
_f="$_rdir/${_t}_${_ch}.html"
|
||||
if [ -e "$_f" ]; then
|
||||
|
|
|
|||
2
shared/vendor/js-yaml.min.js
vendored
Normal file
2
shared/vendor/js-yaml.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
94
tables/README.md
Normal file
94
tables/README.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# ZDDC Tables
|
||||
|
||||
[← Back to ZDDC](../README.md)
|
||||
|
||||
Render a directory of YAML files as a sortable, filterable table — read-only, with click-row → edit-in-form integration. Backed by `zddc-server`'s form handler so the table view and the form editor are two sides of the same data.
|
||||
|
||||
**Anchor use case.** A Master Deliverables List (MDL) under `Archive/<Party>/MDL/`, where each `.yaml` file is one expected deliverable. Multiple parties keep their own MDLs side by side; the table aggregates within a single party's directory.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Storage** is file-per-row YAML — one `*.yaml` file in a directory per table row. Concurrent edits don't collide on a shared blob, every row has independent git history, and per-row ACL inherits from the cascading `.zddc` chain.
|
||||
- **Discovery** is `.zddc`-declarative. Drop a `tables:` entry in the directory's `.zddc` to register the table; no file-presence auto-mount, no phantom tables from rogue YAML drops.
|
||||
- **Rendering** is server-side: `zddc-server` reads every `*.yaml` under the rows directory, normalizes them into a JSON list, and inlines the list into the page on render. The browser does sorting, filtering, and click-row navigation locally — no further server round-trips for those.
|
||||
- **Editing** is delegated to the existing form tool. Each row's click target is the form's re-edit URL (`<dir>/<name>/<basename>.yaml.html`), which `zddc-server` already serves via the form handler. The table itself never writes.
|
||||
|
||||
## Setup (for an MDL at `Archive/Acme/MDL/`)
|
||||
|
||||
```
|
||||
Archive/Acme/
|
||||
├── .zddc # declares: tables: { MDL: ./MDL.table.yaml }
|
||||
├── MDL.table.yaml # column spec + rows path + row schema reference
|
||||
├── MDL.form.yaml # JSON Schema for one row (used by both the table and the form editor)
|
||||
└── MDL/
|
||||
├── D-001.yaml # one row
|
||||
├── D-002.yaml # one row
|
||||
└── ...
|
||||
```
|
||||
|
||||
Visit `Archive/Acme/MDL.table.html` and the table renders. Visit `Archive/Acme/MDL.form.html` to add a new row (the form handler creates a YAML in `MDL/`).
|
||||
|
||||
### `.zddc` declaration
|
||||
|
||||
```yaml
|
||||
tables:
|
||||
MDL: ./MDL.table.yaml
|
||||
```
|
||||
|
||||
The map key (`MDL`) becomes the URL stem and must match both the rows directory name and the form spec name. v1 enforces this with a load-time spec-validation error.
|
||||
|
||||
### Table spec (`MDL.table.yaml`)
|
||||
|
||||
```yaml
|
||||
title: Master Deliverables List
|
||||
description: Optional description shown above the table.
|
||||
rowSchema: ./MDL.form.yaml # path to the row's JSON Schema (form-spec format)
|
||||
rows: ./MDL # directory of *.yaml row files (non-recursive in v1)
|
||||
columns:
|
||||
- field: id # top-level key OR JSON Pointer (e.g. /nested/path)
|
||||
title: ID
|
||||
width: 7em
|
||||
sort: asc # default sort key (overridden by defaults.sort below)
|
||||
- field: title
|
||||
title: Deliverable
|
||||
- field: dueDate
|
||||
title: Due
|
||||
format: date # date | datetime | number | bool
|
||||
- field: status
|
||||
title: Status
|
||||
enum: [pending, submitted, accepted, rejected] # constrains values + enables enum filter
|
||||
defaults:
|
||||
sort:
|
||||
- { field: dueDate, dir: asc }
|
||||
filter:
|
||||
status: [pending, submitted] # initial filter state; clear with the toolbar button
|
||||
```
|
||||
|
||||
Columns are explicit — the renderer does not auto-derive from the row schema. Pick the subset you want to display.
|
||||
|
||||
## ACL behavior
|
||||
|
||||
- The page-level read check uses the cascade at the spec directory; a caller without `r` gets a 403.
|
||||
- Per-row "edit" affordance is recomputed against the row's own parent dir. If the user has `w` there, the row is clickable; otherwise it's plain text. Hard enforcement remains on the form-handler side (the form's POST will refuse a write the cascade denies).
|
||||
- `Issued`/`Received` archive folders are server-enforced WORM. The decider strips `w/d/a` from non-admin grants under those subtrees, so an MDL placed inside `Issued/` shows every row as read-only with no special-casing in the table tool.
|
||||
|
||||
## v1 limits
|
||||
|
||||
- Read-only grid; click-row opens the form editor. Inline cell editing is a v2 candidate (would PUT each edit through the new file API in `zddc/internal/handler/fileapi.go`).
|
||||
- One directory of `*.yaml` per table; cross-directory aggregation (`Archive/*/MDL/*.yaml` as one combined view) is not yet supported.
|
||||
- No virtualization — large tables (>1000 rows) will be slow.
|
||||
- No multi-row bulk operations, no add-row UI inside the table (use the form editor at `<name>.form.html`).
|
||||
- `.zddc tables:` declarations are direct-lookup only; no upward cascade. Each directory hosting a table needs its own declaration.
|
||||
|
||||
## Build & develop
|
||||
|
||||
```bash
|
||||
sh tables/build.sh # build (writes tables/dist/tables.html)
|
||||
sh tables/build.sh --release alpha # cut alpha
|
||||
sh ./build # full lockstep build (all tools + zddc-server)
|
||||
|
||||
(cd zddc && go test ./internal/handler/... ./internal/zddc/...)
|
||||
npx playwright test --project=tables
|
||||
```
|
||||
|
||||
Authoritative architecture and build docs are in [`../AGENTS.md`](../AGENTS.md) and [`../ARCHITECTURE.md`](../ARCHITECTURE.md).
|
||||
78
tables/build.sh
Executable file
78
tables/build.sh
Executable file
|
|
@ -0,0 +1,78 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
. "$root_dir/../shared/build-lib.sh"
|
||||
|
||||
src_html="$root_dir/template.html"
|
||||
output_dir="$root_dir/dist"
|
||||
output_html="$output_dir/tables.html"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
ensure_exists "$src_html"
|
||||
|
||||
css_temp=$(mktemp)
|
||||
js_raw=$(mktemp)
|
||||
js_temp=$(mktemp)
|
||||
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
concat_files \
|
||||
"../shared/base.css" \
|
||||
"css/table.css" \
|
||||
> "$css_temp"
|
||||
|
||||
concat_files \
|
||||
"../shared/vendor/js-yaml.min.js" \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/zddc-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/help.js" \
|
||||
"js/app.js" \
|
||||
"js/context.js" \
|
||||
"js/util.js" \
|
||||
"js/filters.js" \
|
||||
"js/sort.js" \
|
||||
"js/render.js" \
|
||||
"js/main.js" \
|
||||
> "$js_raw"
|
||||
|
||||
escape_js_close_tags "$js_raw" "$js_temp"
|
||||
|
||||
compute_build_label "tables" "${1:-}" "${2:-}"
|
||||
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" '
|
||||
/\{\{CSS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
close(css_file)
|
||||
next
|
||||
}
|
||||
/\{\{JS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < js_file) > 0) print line
|
||||
close(js_file)
|
||||
next
|
||||
}
|
||||
/\{\{BUILD_LABEL\}\}/ {
|
||||
if (is_red == "1") {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
|
||||
} else {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
|
||||
}
|
||||
print
|
||||
next
|
||||
}
|
||||
/\{\{FAVICON\}\}/ {
|
||||
gsub(/\{\{FAVICON\}\}/, favicon_uri)
|
||||
print
|
||||
next
|
||||
}
|
||||
/<script src="https?:\/\// { next }
|
||||
/<link rel="stylesheet" href="https?:\/\// { next }
|
||||
{ print }
|
||||
' "$src_html" > "$output_html"
|
||||
|
||||
echo "Wrote $output_html"
|
||||
|
||||
if [ "$is_release" = "1" ]; then
|
||||
promote_release "tables"
|
||||
fi
|
||||
124
tables/css/table.css
Normal file
124
tables/css/table.css
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
|
||||
|
||||
.table-main {
|
||||
padding: var(--spacing-md);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.table-description {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.table-status {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-bg-warning, #fff8e6);
|
||||
border: 1px solid var(--color-border, #d6cfa3);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
.table-toolbar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.table-rowcount {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 200px);
|
||||
border: 1px solid var(--color-border, #d8d8d8);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.zddc-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.zddc-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--color-bg-elevated, #f5f5f5);
|
||||
}
|
||||
|
||||
.zddc-table__title-row .zddc-table__th {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--color-border, #d8d8d8);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zddc-table__title-row .zddc-table__th:hover {
|
||||
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.zddc-table__filter-row .zddc-table__filter-cell {
|
||||
padding: 4px var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--color-border, #d8d8d8);
|
||||
background: var(--color-bg-elevated, #f5f5f5);
|
||||
}
|
||||
|
||||
.zddc-table__filter-text,
|
||||
.zddc-table__filter-enum {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 2px 4px;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--color-border, #d0d0d0);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text, #111);
|
||||
}
|
||||
|
||||
.zddc-table__filter-enum {
|
||||
min-height: 1.8em;
|
||||
}
|
||||
|
||||
.zddc-table__row:nth-child(even) {
|
||||
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
|
||||
.zddc-table__row--editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zddc-table__row--editable:hover {
|
||||
background: var(--color-bg-hover, rgba(50, 100, 200, 0.08));
|
||||
}
|
||||
|
||||
.zddc-table__row--readonly {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.zddc-table__cell {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border-soft, rgba(0, 0, 0, 0.06));
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
padding: var(--spacing-lg) var(--spacing-md);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
15
tables/js/app.js
Normal file
15
tables/js/app.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
(function (global) {
|
||||
'use strict';
|
||||
if (global.tablesApp) {
|
||||
return;
|
||||
}
|
||||
global.tablesApp = {
|
||||
context: null,
|
||||
state: {
|
||||
rows: [],
|
||||
sort: [],
|
||||
filter: {}
|
||||
},
|
||||
modules: {}
|
||||
};
|
||||
})(window);
|
||||
180
tables/js/context.js
Normal file
180
tables/js/context.js
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
// load() resolves to the table context the rest of the app renders:
|
||||
// { title?, description?, columns, rows, defaults? }
|
||||
//
|
||||
// Two paths:
|
||||
//
|
||||
// 1. Inline JSON (test seam, and also any host that wants to
|
||||
// pre-render a context server-side): if #table-context parses
|
||||
// to a non-empty object, return it as-is.
|
||||
//
|
||||
// 2. File-backed walk (the real-world path served by zddc-server):
|
||||
// fetch <dir>/.zddc, find tables[<name>], fetch the *.table.yaml
|
||||
// spec, list <dir>/<name>/*.yaml row files, parse each, and
|
||||
// assemble the same shape.
|
||||
//
|
||||
// file:// mode without a directory handle is unsupported in v1 — the
|
||||
// walk only runs against http(s). file:// users must either inject an
|
||||
// inline context (tests) or open the page through zddc-server.
|
||||
async function load() {
|
||||
const inline = readInlineContext();
|
||||
if (inline && Object.keys(inline).length > 0) {
|
||||
return inline;
|
||||
}
|
||||
if (typeof location !== 'undefined' &&
|
||||
(location.protocol === 'http:' || location.protocol === 'https:')) {
|
||||
try {
|
||||
const walked = await walkServer();
|
||||
if (walked) {
|
||||
return walked;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[tables] failed to load table from server', err);
|
||||
showStatus('Could not load table: ' + (err && err.message ? err.message : err));
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function readInlineContext() {
|
||||
const el = document.getElementById('table-context');
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(el.textContent || '{}');
|
||||
} catch (err) {
|
||||
console.error('[tables] failed to parse #table-context', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(msg) {
|
||||
const el = document.getElementById('table-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
async function walkServer() {
|
||||
const source = window.zddc && window.zddc.source;
|
||||
if (!source) {
|
||||
throw new Error('zddc.source not available');
|
||||
}
|
||||
const tableName = tableNameFromUrl(location.pathname);
|
||||
if (!tableName) {
|
||||
throw new Error('Unrecognized table URL: ' + location.pathname);
|
||||
}
|
||||
const probe = await source.detectServerRoot();
|
||||
if (!probe.handle) {
|
||||
throw new Error(probe.status === 403
|
||||
? 'No permission to list this directory'
|
||||
: 'Server unreachable');
|
||||
}
|
||||
const dir = probe.handle;
|
||||
|
||||
const zddcDoc = await readYaml(dir, '.zddc');
|
||||
const tablesMap = (zddcDoc && zddcDoc.tables) || {};
|
||||
const specRel = tablesMap[tableName];
|
||||
if (!specRel) {
|
||||
throw new Error('No tables.' + tableName + ' declared in .zddc');
|
||||
}
|
||||
const spec = await readYaml(dir, stripDotSlash(specRel));
|
||||
if (!spec || !Array.isArray(spec.columns)) {
|
||||
throw new Error('Spec ' + specRel + ' missing columns[]');
|
||||
}
|
||||
|
||||
const rowsRel = stripDotSlash(spec.rows || ('./' + tableName));
|
||||
const rowsDir = await resolveDirectory(dir, rowsRel);
|
||||
const rows = await readRows(rowsDir, rowsRel, tableName);
|
||||
|
||||
return {
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
columns: spec.columns,
|
||||
defaults: spec.defaults,
|
||||
rows: rows
|
||||
};
|
||||
}
|
||||
|
||||
function tableNameFromUrl(pathname) {
|
||||
const m = String(pathname || '').match(/\/([^\/]+)\.table\.html$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function stripDotSlash(p) {
|
||||
let out = String(p || '');
|
||||
if (out.startsWith('./')) out = out.slice(2);
|
||||
if (out.startsWith('/')) out = out.slice(1);
|
||||
if (out.endsWith('/')) out = out.slice(0, -1);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readYaml(dir, relPath) {
|
||||
const fileHandle = await resolveFile(dir, relPath);
|
||||
const file = await fileHandle.getFile();
|
||||
const text = await file.text();
|
||||
if (!window.jsyaml) {
|
||||
throw new Error('js-yaml not loaded');
|
||||
}
|
||||
return window.jsyaml.load(text);
|
||||
}
|
||||
|
||||
// Walk a "/"-separated relative path under dir, returning the
|
||||
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
|
||||
async function resolveFile(dir, relPath) {
|
||||
const parts = relPath.split('/').filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
throw new Error('Empty file path');
|
||||
}
|
||||
const fileName = parts.pop();
|
||||
let cur = dir;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
cur = await cur.getDirectoryHandle(parts[i]);
|
||||
}
|
||||
return cur.getFileHandle(fileName);
|
||||
}
|
||||
|
||||
async function resolveDirectory(dir, relPath) {
|
||||
const parts = relPath.split('/').filter(Boolean);
|
||||
let cur = dir;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
cur = await cur.getDirectoryHandle(parts[i]);
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
async function readRows(rowsDir, rowsRel, tableName) {
|
||||
const rows = [];
|
||||
for await (const entry of rowsDir.values()) {
|
||||
if (entry.kind !== 'file') continue;
|
||||
if (!entry.name.endsWith('.yaml')) continue;
|
||||
try {
|
||||
const file = await (await rowsDir.getFileHandle(entry.name)).getFile();
|
||||
const data = window.jsyaml.load(await file.text());
|
||||
rows.push({
|
||||
url: rowEditUrl(rowsRel, tableName, entry.name),
|
||||
data: data || {},
|
||||
editable: true
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[tables] skipping unparseable row', entry.name, err);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Build the form-handler URL for editing one row. The page is at
|
||||
// <dir>/<tableName>.table.html; the row file lives at
|
||||
// <dir>/<rowsRel>/<basename>.yaml; the form re-edit URL is
|
||||
// <dir>/<rowsRel>/<basename>.yaml.html.
|
||||
function rowEditUrl(rowsRel, tableName, rowFileName) {
|
||||
const pageDir = location.pathname.replace(/\/[^\/]+\.table\.html$/, '/');
|
||||
const rowsPath = pageDir + (rowsRel || tableName) + '/';
|
||||
return rowsPath + rowFileName + '.html';
|
||||
}
|
||||
|
||||
app.modules.context = { load: load };
|
||||
})(window.tablesApp);
|
||||
64
tables/js/filters.js
Normal file
64
tables/js/filters.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
// A filter is per-column and has one of two shapes:
|
||||
// - free-text: { kind: 'contains', value: '<string>' }
|
||||
// - enum: { kind: 'enum', value: ['<choice>', ...] }
|
||||
// An empty value (empty string or empty array) matches everything.
|
||||
|
||||
function isEnumColumn(col) {
|
||||
return Array.isArray(col.enum) && col.enum.length > 0;
|
||||
}
|
||||
|
||||
function defaultFilterFor(col) {
|
||||
return isEnumColumn(col) ? { kind: 'enum', value: [] } : { kind: 'contains', value: '' };
|
||||
}
|
||||
|
||||
function rowMatches(filter, cellValue) {
|
||||
if (filter.kind === 'enum') {
|
||||
if (!Array.isArray(filter.value) || filter.value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const s = cellValue == null ? '' : String(cellValue);
|
||||
return filter.value.indexOf(s) !== -1;
|
||||
}
|
||||
// contains
|
||||
if (!filter.value) {
|
||||
return true;
|
||||
}
|
||||
const needle = String(filter.value).toLowerCase();
|
||||
const hay = cellValue == null ? '' : String(cellValue).toLowerCase();
|
||||
return hay.indexOf(needle) !== -1;
|
||||
}
|
||||
|
||||
function isEmpty(filter) {
|
||||
if (filter.kind === 'enum') {
|
||||
return !Array.isArray(filter.value) || filter.value.length === 0;
|
||||
}
|
||||
return !filter.value;
|
||||
}
|
||||
|
||||
function apply(rows, columns, filterMap, resolveField) {
|
||||
return rows.filter(function (row) {
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const col = columns[i];
|
||||
const filter = filterMap[col.field];
|
||||
if (!filter || isEmpty(filter)) {
|
||||
continue;
|
||||
}
|
||||
const cellValue = resolveField(row.data, col.field);
|
||||
if (!rowMatches(filter, cellValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
app.modules.filters = {
|
||||
defaultFilterFor: defaultFilterFor,
|
||||
isEnumColumn: isEnumColumn,
|
||||
isEmpty: isEmpty,
|
||||
apply: apply
|
||||
};
|
||||
})(window.tablesApp);
|
||||
104
tables/js/main.js
Normal file
104
tables/js/main.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
async function init() {
|
||||
const ctx = await app.modules.context.load();
|
||||
app.context = ctx;
|
||||
|
||||
const titleEl = document.getElementById('table-title');
|
||||
if (ctx.title && titleEl) {
|
||||
titleEl.textContent = ctx.title;
|
||||
document.title = 'ZDDC — ' + ctx.title;
|
||||
}
|
||||
|
||||
const descEl = document.getElementById('table-description');
|
||||
if (descEl && ctx.description) {
|
||||
descEl.textContent = ctx.description;
|
||||
descEl.hidden = false;
|
||||
}
|
||||
|
||||
const tableEl = document.getElementById('table-root');
|
||||
const theadEl = tableEl.querySelector('thead');
|
||||
const tbodyEl = tableEl.querySelector('tbody');
|
||||
const emptyEl = document.getElementById('table-empty');
|
||||
const countEl = document.getElementById('table-rowcount');
|
||||
const clearBtn = document.getElementById('table-clear-filters');
|
||||
|
||||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
||||
|
||||
const state = app.state;
|
||||
state.rows = allRows;
|
||||
state.sort = app.modules.sort.defaultsFromContext(ctx);
|
||||
state.filter = {};
|
||||
|
||||
// Seed default filters from context.defaults.filter (per-column).
|
||||
if (ctx.defaults && ctx.defaults.filter && typeof ctx.defaults.filter === 'object') {
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const col = columns[i];
|
||||
const seeded = ctx.defaults.filter[col.field];
|
||||
if (seeded == null) {
|
||||
continue;
|
||||
}
|
||||
if (app.modules.filters.isEnumColumn(col)) {
|
||||
state.filter[col.field] = {
|
||||
kind: 'enum',
|
||||
value: Array.isArray(seeded) ? seeded.slice() : [String(seeded)]
|
||||
};
|
||||
} else {
|
||||
state.filter[col.field] = { kind: 'contains', value: String(seeded) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function anyFilterActive() {
|
||||
const filters = app.modules.filters;
|
||||
const keys = Object.keys(state.filter);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (!filters.isEmpty(state.filter[keys[i]])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function paint() {
|
||||
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, app.modules.util.resolveField);
|
||||
const sorted = app.modules.sort.apply(filtered, state.sort, columns, app.modules.util);
|
||||
app.modules.render.header(theadEl, columns, state.sort, state.filter, onHeaderClick, onFilterChange);
|
||||
app.modules.render.body(tbodyEl, sorted, columns);
|
||||
app.modules.render.rowCount(countEl, sorted.length, state.rows.length);
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = sorted.length > 0 || state.rows.length === 0;
|
||||
}
|
||||
if (clearBtn) {
|
||||
clearBtn.hidden = !anyFilterActive();
|
||||
}
|
||||
}
|
||||
|
||||
function onHeaderClick(field, shiftKey) {
|
||||
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);
|
||||
paint();
|
||||
}
|
||||
|
||||
function onFilterChange(field, value) {
|
||||
state.filter[field] = value;
|
||||
paint();
|
||||
}
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function () {
|
||||
state.filter = {};
|
||||
paint();
|
||||
});
|
||||
}
|
||||
|
||||
paint();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(window.tablesApp);
|
||||
118
tables/js/render.js
Normal file
118
tables/js/render.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
function renderHeader(theadEl, columns, sortState, filterMap, onHeaderClick, onFilterChange) {
|
||||
const util = app.modules.util;
|
||||
const filters = app.modules.filters;
|
||||
const sort = app.modules.sort;
|
||||
theadEl.innerHTML = '';
|
||||
|
||||
const titleRow = util.h('tr', { className: 'zddc-table__title-row' });
|
||||
const filterRow = util.h('tr', { className: 'zddc-table__filter-row' });
|
||||
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const col = columns[i];
|
||||
const indicator = sort.indicator(sortState, col.field);
|
||||
const th = util.h('th', {
|
||||
className: 'zddc-table__th',
|
||||
'data-field': col.field,
|
||||
style: col.width ? 'width:' + col.width : null,
|
||||
onClick: function (ev) { onHeaderClick(col.field, ev.shiftKey); }
|
||||
}, col.title || col.field, indicator);
|
||||
titleRow.appendChild(th);
|
||||
|
||||
const td = util.h('td', { className: 'zddc-table__filter-cell' });
|
||||
const f = filterMap[col.field] || filters.defaultFilterFor(col);
|
||||
if (filters.isEnumColumn(col)) {
|
||||
const select = util.h('select', {
|
||||
multiple: true,
|
||||
'aria-label': 'Filter ' + (col.title || col.field),
|
||||
className: 'zddc-table__filter-enum',
|
||||
onChange: function (ev) {
|
||||
const opts = ev.target.options;
|
||||
const picked = [];
|
||||
for (let j = 0; j < opts.length; j++) {
|
||||
if (opts[j].selected) {
|
||||
picked.push(opts[j].value);
|
||||
}
|
||||
}
|
||||
onFilterChange(col.field, { kind: 'enum', value: picked });
|
||||
}
|
||||
});
|
||||
for (let j = 0; j < col.enum.length; j++) {
|
||||
const v = col.enum[j];
|
||||
const opt = util.h('option', { value: v }, v);
|
||||
if (Array.isArray(f.value) && f.value.indexOf(v) !== -1) {
|
||||
opt.selected = true;
|
||||
}
|
||||
select.appendChild(opt);
|
||||
}
|
||||
td.appendChild(select);
|
||||
} else {
|
||||
const input = util.h('input', {
|
||||
type: 'text',
|
||||
className: 'zddc-table__filter-text',
|
||||
placeholder: 'filter…',
|
||||
'aria-label': 'Filter ' + (col.title || col.field),
|
||||
value: typeof f.value === 'string' ? f.value : '',
|
||||
onInput: function (ev) {
|
||||
onFilterChange(col.field, { kind: 'contains', value: ev.target.value });
|
||||
}
|
||||
});
|
||||
td.appendChild(input);
|
||||
}
|
||||
filterRow.appendChild(td);
|
||||
}
|
||||
|
||||
theadEl.appendChild(titleRow);
|
||||
theadEl.appendChild(filterRow);
|
||||
}
|
||||
|
||||
function renderBody(tbodyEl, rows, columns) {
|
||||
const util = app.modules.util;
|
||||
tbodyEl.innerHTML = '';
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const tr = util.h('tr', {
|
||||
className: 'zddc-table__row' + (row.editable ? ' zddc-table__row--editable' : ' zddc-table__row--readonly'),
|
||||
'data-url': row.url,
|
||||
'data-editable': row.editable ? '1' : '0',
|
||||
onClick: function (ev) {
|
||||
const target = ev.currentTarget;
|
||||
const editable = target.getAttribute('data-editable') === '1';
|
||||
const url = target.getAttribute('data-url');
|
||||
if (editable && url) {
|
||||
// Indirection so tests can intercept without
|
||||
// fighting Chromium's location.assign property
|
||||
// descriptor. Production calls window.location.assign.
|
||||
const nav = (window.tablesApp && window.tablesApp.navigateTo) ||
|
||||
function (u) { window.location.assign(u); };
|
||||
nav(url);
|
||||
}
|
||||
}
|
||||
});
|
||||
for (let c = 0; c < columns.length; c++) {
|
||||
const col = columns[c];
|
||||
const raw = util.resolveField(row.data, col.field);
|
||||
const text = util.formatCell(raw, col.format);
|
||||
tr.appendChild(util.h('td', { className: 'zddc-table__cell' }, text));
|
||||
}
|
||||
tbodyEl.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRowCount(el, displayed, total) {
|
||||
if (!el) return;
|
||||
if (displayed === total) {
|
||||
el.textContent = total + (total === 1 ? ' row' : ' rows');
|
||||
} else {
|
||||
el.textContent = displayed + ' of ' + total + ' rows';
|
||||
}
|
||||
}
|
||||
|
||||
app.modules.render = {
|
||||
header: renderHeader,
|
||||
body: renderBody,
|
||||
rowCount: renderRowCount
|
||||
};
|
||||
})(window.tablesApp);
|
||||
108
tables/js/sort.js
Normal file
108
tables/js/sort.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
// Sort state is an ordered list of {field, dir} keys; the first is
|
||||
// primary, additional keys break ties.
|
||||
|
||||
function defaultsFromContext(ctx) {
|
||||
const defaults = ctx.defaults || {};
|
||||
if (Array.isArray(defaults.sort) && defaults.sort.length > 0) {
|
||||
return defaults.sort.slice();
|
||||
}
|
||||
// Fall back to any column with `sort:` set.
|
||||
const fromCols = (ctx.columns || []).filter(function (c) { return c.sort; });
|
||||
if (fromCols.length > 0) {
|
||||
return fromCols.map(function (c) {
|
||||
const dir = c.sort === 'desc' ? 'desc' : 'asc';
|
||||
return { field: c.field, dir: dir };
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function findColumn(columns, field) {
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
if (columns[i].field === field) {
|
||||
return columns[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Click handler for a header: cycle the sort state for `field`.
|
||||
// - Not currently a sort key → add as primary, asc
|
||||
// - Currently primary asc → flip to desc
|
||||
// - Currently primary desc → remove
|
||||
// - Currently secondary → promote to primary, asc
|
||||
// Shift-click is meant for additional accumulation but we keep the
|
||||
// single-click semantics simple; advanced multi-sort can be a
|
||||
// follow-up.
|
||||
function cycle(state, field, multi) {
|
||||
const idx = state.findIndex(function (s) { return s.field === field; });
|
||||
if (multi) {
|
||||
if (idx === -1) {
|
||||
return state.concat([{ field: field, dir: 'asc' }]);
|
||||
}
|
||||
const cur = state[idx];
|
||||
if (cur.dir === 'asc') {
|
||||
const next = state.slice();
|
||||
next[idx] = { field: field, dir: 'desc' };
|
||||
return next;
|
||||
}
|
||||
return state.slice(0, idx).concat(state.slice(idx + 1));
|
||||
}
|
||||
if (idx === -1) {
|
||||
return [{ field: field, dir: 'asc' }];
|
||||
}
|
||||
if (idx === 0) {
|
||||
const cur = state[0];
|
||||
if (cur.dir === 'asc') {
|
||||
return [{ field: field, dir: 'desc' }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return [{ field: field, dir: 'asc' }];
|
||||
}
|
||||
|
||||
function apply(rows, sortState, columns, util) {
|
||||
if (!sortState || sortState.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
const out = rows.slice();
|
||||
out.sort(function (a, b) {
|
||||
for (let i = 0; i < sortState.length; i++) {
|
||||
const key = sortState[i];
|
||||
const col = findColumn(columns, key.field);
|
||||
const fmt = col ? col.format : '';
|
||||
const av = util.resolveField(a.data, key.field);
|
||||
const bv = util.resolveField(b.data, key.field);
|
||||
const cmp = util.compareCells(av, bv, fmt);
|
||||
if (cmp !== 0) {
|
||||
return key.dir === 'desc' ? -cmp : cmp;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function indicator(sortState, field) {
|
||||
for (let i = 0; i < sortState.length; i++) {
|
||||
if (sortState[i].field === field) {
|
||||
const arrow = sortState[i].dir === 'desc' ? ' ▼' : ' ▲';
|
||||
if (sortState.length > 1) {
|
||||
return arrow + (i + 1);
|
||||
}
|
||||
return arrow;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
app.modules.sort = {
|
||||
defaultsFromContext: defaultsFromContext,
|
||||
cycle: cycle,
|
||||
apply: apply,
|
||||
indicator: indicator
|
||||
};
|
||||
})(window.tablesApp);
|
||||
151
tables/js/util.js
Normal file
151
tables/js/util.js
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
const util = {};
|
||||
|
||||
util.h = function (tag, attrs) {
|
||||
const el = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const k of Object.keys(attrs)) {
|
||||
const v = attrs[k];
|
||||
if (v == null || v === false) {
|
||||
continue;
|
||||
}
|
||||
if (k === 'className') {
|
||||
el.className = v;
|
||||
} else if (k.length > 2 && k.slice(0, 2) === 'on' && typeof v === 'function') {
|
||||
el.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
} else if (v === true) {
|
||||
el.setAttribute(k, '');
|
||||
} else {
|
||||
el.setAttribute(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 2; i < arguments.length; i++) {
|
||||
const c = arguments[i];
|
||||
if (c == null || c === false) {
|
||||
continue;
|
||||
}
|
||||
if (typeof c === 'string' || typeof c === 'number') {
|
||||
el.appendChild(document.createTextNode(String(c)));
|
||||
} else {
|
||||
el.appendChild(c);
|
||||
}
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
// Resolve a column's `field` against a row data object.
|
||||
// - "" or "/" → the whole object
|
||||
// - "/foo/bar" → JSON Pointer (RFC 6901) lookup
|
||||
// - "foo" → top-level key
|
||||
util.resolveField = function (data, field) {
|
||||
if (data == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!field || field === '/') {
|
||||
return data;
|
||||
}
|
||||
if (field.charAt(0) !== '/') {
|
||||
return data[field];
|
||||
}
|
||||
const segments = field.split('/').slice(1).map(function (s) {
|
||||
return s.replace(/~1/g, '/').replace(/~0/g, '~');
|
||||
});
|
||||
let cur = data;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
if (cur == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(cur)) {
|
||||
const idx = parseInt(segments[i], 10);
|
||||
if (Number.isNaN(idx)) {
|
||||
return undefined;
|
||||
}
|
||||
cur = cur[idx];
|
||||
} else if (typeof cur === 'object') {
|
||||
cur = cur[segments[i]];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
|
||||
// Format a raw cell value per column's `format` hint.
|
||||
util.formatCell = function (value, format) {
|
||||
if (value == null || value === '') {
|
||||
return '';
|
||||
}
|
||||
if (format === 'date') {
|
||||
const d = new Date(value);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (format === 'datetime') {
|
||||
const d = new Date(value);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleString();
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (format === 'number') {
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(n)) {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (format === 'bool' || typeof value === 'boolean') {
|
||||
return value ? '✓' : '';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (e) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// Compare two cell values for sorting. null/undefined sort last.
|
||||
// Numbers compared numerically, dates compared as Date, otherwise string compare.
|
||||
util.compareCells = function (a, b, format) {
|
||||
const aMissing = a == null || a === '';
|
||||
const bMissing = b == null || b === '';
|
||||
if (aMissing && bMissing) {
|
||||
return 0;
|
||||
}
|
||||
if (aMissing) {
|
||||
return 1;
|
||||
}
|
||||
if (bMissing) {
|
||||
return -1;
|
||||
}
|
||||
if (format === 'date' || format === 'datetime') {
|
||||
const da = new Date(a).getTime();
|
||||
const db = new Date(b).getTime();
|
||||
if (!isNaN(da) && !isNaN(db)) {
|
||||
return da - db;
|
||||
}
|
||||
}
|
||||
if (format === 'number' || (typeof a === 'number' && typeof b === 'number')) {
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
if (Number.isFinite(na) && Number.isFinite(nb)) {
|
||||
return na - nb;
|
||||
}
|
||||
}
|
||||
const sa = String(a).toLowerCase();
|
||||
const sb = String(b).toLowerCase();
|
||||
if (sa < sb) return -1;
|
||||
if (sa > sb) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
app.modules.util = util;
|
||||
})(window.tablesApp);
|
||||
18
tables/sample/.zddc
Normal file
18
tables/sample/.zddc
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Sample .zddc declaring a table view.
|
||||
#
|
||||
# Tables are declared explicitly per directory; there is no auto-mount on
|
||||
# file presence. The map key becomes the URL stem: `MDL` here means the
|
||||
# table view is served at <this-dir>/MDL.table.html. The value points at
|
||||
# the *.table.yaml spec file (relative to this .zddc).
|
||||
#
|
||||
# Drop this directory under any project with a .zddc cascade granting
|
||||
# read+write to the operator's email. The form re-edit URL the table
|
||||
# links to (<dir>/MDL/<basename>.yaml.html) is the existing form
|
||||
# handler's pattern; no separate routing is required.
|
||||
|
||||
title: Sample MDL — deliverables tracker
|
||||
acl:
|
||||
permissions:
|
||||
"*@example.com": rwcd
|
||||
tables:
|
||||
MDL: ./MDL.table.yaml
|
||||
39
tables/sample/MDL.form.yaml
Normal file
39
tables/sample/MDL.form.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Row schema for one Master Deliverables List entry. The table tool
|
||||
# uses this as both the column-type source (formats / enums) and the
|
||||
# "click-row → edit" form spec. The form handler also serves it
|
||||
# directly as MDL.form.html for adding new entries.
|
||||
|
||||
title: Deliverable
|
||||
description: One expected deliverable, tracked across parties.
|
||||
|
||||
schema:
|
||||
type: object
|
||||
required: [id, title, party, dueDate, status]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
title: ID
|
||||
description: Tracking identifier (e.g. D-001).
|
||||
minLength: 1
|
||||
title:
|
||||
type: string
|
||||
title: Deliverable
|
||||
description: Short description of what is to be delivered.
|
||||
minLength: 1
|
||||
party:
|
||||
type: string
|
||||
title: Party
|
||||
enum: [Acme, Beta, Gamma]
|
||||
dueDate:
|
||||
type: string
|
||||
format: date
|
||||
title: Due date
|
||||
status:
|
||||
type: string
|
||||
title: Status
|
||||
enum: [pending, submitted, accepted, rejected]
|
||||
notes:
|
||||
type: string
|
||||
title: Notes
|
||||
description: Free-text notes (optional).
|
||||
41
tables/sample/MDL.table.yaml
Normal file
41
tables/sample/MDL.table.yaml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Master Deliverables List — table spec.
|
||||
#
|
||||
# Columns are an explicit ordered subset of the row schema. The renderer
|
||||
# does not auto-derive columns from the schema. `field` is either a
|
||||
# top-level key on the row YAML or a JSON Pointer (RFC 6901) for nested
|
||||
# values.
|
||||
|
||||
title: Master Deliverables List
|
||||
description: Sample MDL spec used for development and tests.
|
||||
|
||||
# Path to the form spec that defines each row's JSON schema. Click-row
|
||||
# in the table opens this form for the picked row. The form spec must be
|
||||
# named <name>.form.yaml where <name> matches both the rows directory
|
||||
# basename and the .zddc declaration key (here: MDL).
|
||||
rowSchema: ./MDL.form.yaml
|
||||
|
||||
# Directory of *.yaml row files, relative to this spec. Non-recursive.
|
||||
rows: ./MDL
|
||||
|
||||
columns:
|
||||
- field: id
|
||||
title: ID
|
||||
width: 7em
|
||||
sort: asc
|
||||
- field: title
|
||||
title: Deliverable
|
||||
- field: party
|
||||
title: Party
|
||||
enum: [Acme, Beta, Gamma]
|
||||
- field: dueDate
|
||||
title: Due
|
||||
format: date
|
||||
- field: status
|
||||
title: Status
|
||||
enum: [pending, submitted, accepted, rejected]
|
||||
|
||||
defaults:
|
||||
sort:
|
||||
- { field: dueDate, dir: asc }
|
||||
filter:
|
||||
status: [pending, submitted]
|
||||
6
tables/sample/MDL/D-001.yaml
Normal file
6
tables/sample/MDL/D-001.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
id: D-001
|
||||
title: Site survey report
|
||||
party: Acme
|
||||
dueDate: 2026-05-12
|
||||
status: pending
|
||||
notes: Initial walkthrough completed; report drafting underway.
|
||||
6
tables/sample/MDL/D-002.yaml
Normal file
6
tables/sample/MDL/D-002.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
id: D-002
|
||||
title: Foundation drawings rev. A
|
||||
party: Beta
|
||||
dueDate: 2026-05-20
|
||||
status: submitted
|
||||
notes: Submitted via transmittal T-2026-014. Awaiting reviewer assignment.
|
||||
6
tables/sample/MDL/D-003.yaml
Normal file
6
tables/sample/MDL/D-003.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
id: D-003
|
||||
title: Procurement schedule
|
||||
party: Acme
|
||||
dueDate: 2026-05-08
|
||||
status: accepted
|
||||
notes: Accepted on first review.
|
||||
6
tables/sample/MDL/D-004.yaml
Normal file
6
tables/sample/MDL/D-004.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
id: D-004
|
||||
title: Safety plan
|
||||
party: Gamma
|
||||
dueDate: 2026-05-15
|
||||
status: rejected
|
||||
notes: Missing PPE inventory section; resubmit by EOM.
|
||||
5
tables/sample/MDL/D-005.yaml
Normal file
5
tables/sample/MDL/D-005.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
id: D-005
|
||||
title: Geotechnical report
|
||||
party: Beta
|
||||
dueDate: 2026-05-30
|
||||
status: pending
|
||||
108
tables/template.html
Normal file
108
tables/template.html
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Table</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
|
||||
<style>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="table-main">
|
||||
<div id="table-description" class="table-description" hidden></div>
|
||||
<div id="table-status" class="table-status" hidden></div>
|
||||
<div class="table-toolbar" id="table-toolbar">
|
||||
<div class="table-toolbar__left">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table id="table-root" class="zddc-table" aria-describedby="table-description">
|
||||
<thead></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="table-empty" class="table-empty" hidden>No rows match the current filters.</div>
|
||||
</main>
|
||||
|
||||
<!-- Help Panel -->
|
||||
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
||||
<div class="help-panel__header">
|
||||
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Table</h2>
|
||||
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="help-panel__body">
|
||||
<h3>What is this table?</h3>
|
||||
<p>Each row in this table is one YAML file in the source directory.
|
||||
Tables are declared in <code>.zddc</code> via a
|
||||
<code>tables:</code> map. The columns and row schema come from
|
||||
a <code>*.table.yaml</code> spec file.</p>
|
||||
|
||||
<h3>Sorting</h3>
|
||||
<p>Click a column header to sort by that column. Click again to
|
||||
toggle direction. Shift-click another header to add a secondary
|
||||
sort key.</p>
|
||||
|
||||
<h3>Filtering</h3>
|
||||
<p>Type in the box under a column header to filter rows whose
|
||||
value contains your text (case-insensitive). For columns with a
|
||||
fixed enum, the box becomes a multi-select — leave it empty to
|
||||
show every value.</p>
|
||||
|
||||
<h3>Editing a row</h3>
|
||||
<p>Click a row to open its YAML in the form editor. Whether the
|
||||
row is editable depends on the cascading <code>.zddc</code>
|
||||
permissions for the row's path. Rows in <code>Issued</code> or
|
||||
<code>Received</code> archives are read-only by design (WORM).</p>
|
||||
|
||||
<h3>Header buttons</h3>
|
||||
<dl>
|
||||
<dt>◐ Theme</dt>
|
||||
<dd>Cycle auto / light / dark.</dd>
|
||||
<dt>? Help</dt>
|
||||
<dd>This panel. Press <kbd>Esc</kbd> to close.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!--
|
||||
Server injects the table context here on render. Shape:
|
||||
{
|
||||
"title": "Optional page title override",
|
||||
"description": "Optional description shown above the table",
|
||||
"columns": [{field, title, width?, format?, filter?, sort?, enum?}],
|
||||
"rows": [{url, data, editable}],
|
||||
"defaults": {sort?: [{field, dir}], filter?: {field: value}}
|
||||
}
|
||||
-->
|
||||
<script id="table-context" type="application/json">{}</script>
|
||||
|
||||
<script>
|
||||
{{JS_PLACEHOLDER}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -19,15 +19,15 @@ test.describe('Markdown Editor', () => {
|
|||
await expect(page.locator(`.file-item[data-path="__scratchpad__"]`)).toBeVisible();
|
||||
await expect(page.locator('#content-container')).toBeVisible();
|
||||
|
||||
// Select Directory button is present and enabled
|
||||
const selectDirBtn = page.locator('#select-directory');
|
||||
await expect(selectDirBtn).toBeVisible();
|
||||
await expect(selectDirBtn).not.toBeDisabled();
|
||||
// Add Local Directory button is present and enabled
|
||||
const addDirBtn = page.locator('#addDirectoryBtn');
|
||||
await expect(addDirBtn).toBeVisible();
|
||||
await expect(addDirBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('renders a file tree from a mock directory', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('#select-directory', { timeout: 15000 });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
// Set up mock directory before triggering the picker
|
||||
await page.evaluate(() => {
|
||||
|
|
@ -37,7 +37,7 @@ test.describe('Markdown Editor', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
await page.locator('#select-directory').click();
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
|
||||
// File tree should populate with the two files
|
||||
await page.waitForFunction(
|
||||
|
|
@ -54,7 +54,7 @@ test.describe('Markdown Editor', () => {
|
|||
page.on('console', msg => msg.type() === 'log' && logs.push(msg.text()));
|
||||
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('#select-directory', { timeout: 15000 });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
const probe = await page.evaluate(() => ({
|
||||
debugDefined: typeof DEBUG !== 'undefined',
|
||||
|
|
|
|||
207
tests/tables.spec.js
Normal file
207
tests/tables.spec.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const HTML_PATH = path.resolve('tables/dist/tables.html');
|
||||
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
|
||||
|
||||
const MDL_COLUMNS = [
|
||||
{ field: 'id', title: 'ID', width: '6em' },
|
||||
{ field: 'title', title: 'Deliverable' },
|
||||
{ field: 'party', title: 'Party', enum: ['Acme', 'Beta', 'Gamma'] },
|
||||
{ field: 'dueDate', title: 'Due', format: 'date' },
|
||||
{ field: 'status', title: 'Status', enum: ['pending', 'submitted', 'accepted'] },
|
||||
];
|
||||
|
||||
function makeRow(id, title, party, dueDate, status, editable = true) {
|
||||
return {
|
||||
url: `/Working/MDL/${id}.yaml.html`,
|
||||
data: { id, title, party, dueDate, status },
|
||||
editable,
|
||||
};
|
||||
}
|
||||
|
||||
const ROWS = [
|
||||
makeRow('D-001', 'Site survey report', 'Acme', '2026-05-12', 'pending'),
|
||||
makeRow('D-002', 'Foundation drawings A', 'Beta', '2026-05-20', 'submitted'),
|
||||
makeRow('D-003', 'Procurement schedule', 'Acme', '2026-05-08', 'accepted'),
|
||||
makeRow('D-004', 'Safety plan', 'Gamma', '2026-05-15', 'pending'),
|
||||
makeRow('D-005', 'Geotechnical report', 'Beta', '2026-05-30', 'submitted'),
|
||||
];
|
||||
|
||||
// Inject a complete table context into the page. Same pattern as
|
||||
// form-safety.spec.js: write a patched copy of tables.html to a temp
|
||||
// file and navigate via file://.
|
||||
async function loadTableWithContext(page, context) {
|
||||
const ctxJson = JSON.stringify(context).replace(/<\//g, '<\\/');
|
||||
const replacement = `<script id="table-context" type="application/json">${ctxJson}</script>`;
|
||||
const patched = HTML_RAW.replace(
|
||||
/<script id="table-context" type="application\/json">[\s\S]*?<\/script>/,
|
||||
replacement,
|
||||
);
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tables-spec-'));
|
||||
const tmpPath = path.join(tmpDir, 'tables.html');
|
||||
fs.writeFileSync(tmpPath, patched);
|
||||
await page.goto(`file://${tmpPath}`, { waitUntil: 'load' });
|
||||
}
|
||||
|
||||
test.describe('tables/ — directory-of-YAML table view', () => {
|
||||
test('renders header with column titles and rows from context', async ({ page }) => {
|
||||
page.on('pageerror', e => console.log('[pageerror]', e.message));
|
||||
await loadTableWithContext(page, {
|
||||
title: 'Master Deliverables List',
|
||||
columns: MDL_COLUMNS,
|
||||
rows: ROWS,
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#table-root tbody').children.length > 0,
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
// Header cells.
|
||||
const headers = page.locator('.zddc-table__title-row .zddc-table__th');
|
||||
await expect(headers).toHaveCount(MDL_COLUMNS.length);
|
||||
await expect(headers.nth(0)).toContainText('ID');
|
||||
await expect(headers.nth(1)).toContainText('Deliverable');
|
||||
|
||||
// Title in the page header.
|
||||
await expect(page.locator('#table-title')).toContainText('Master Deliverables List');
|
||||
|
||||
// Row count.
|
||||
await expect(page.locator('#table-root tbody tr')).toHaveCount(ROWS.length);
|
||||
await expect(page.locator('#table-rowcount')).toContainText(`${ROWS.length} rows`);
|
||||
});
|
||||
|
||||
test('default sort puts dueDate ascending when configured', async ({ page }) => {
|
||||
await loadTableWithContext(page, {
|
||||
columns: MDL_COLUMNS,
|
||||
rows: ROWS,
|
||||
defaults: { sort: [{ field: 'dueDate', dir: 'asc' }] },
|
||||
});
|
||||
|
||||
await page.waitForSelector('#table-root tbody tr');
|
||||
const ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
|
||||
// Sorted ascending by dueDate: D-003 (5/8), D-001 (5/12), D-004 (5/15), D-002 (5/20), D-005 (5/30).
|
||||
expect(ids).toEqual(['D-003', 'D-001', 'D-004', 'D-002', 'D-005']);
|
||||
});
|
||||
|
||||
test('clicking a column header sorts by that column and toggles direction', async ({ page }) => {
|
||||
await loadTableWithContext(page, {
|
||||
columns: MDL_COLUMNS,
|
||||
rows: ROWS,
|
||||
});
|
||||
|
||||
await page.waitForSelector('#table-root tbody tr');
|
||||
|
||||
// Click the ID header → sort ascending.
|
||||
await page.locator('.zddc-table__th[data-field="id"]').click();
|
||||
let ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
|
||||
expect(ids).toEqual(['D-001', 'D-002', 'D-003', 'D-004', 'D-005']);
|
||||
|
||||
// Click again → descending.
|
||||
await page.locator('.zddc-table__th[data-field="id"]').click();
|
||||
ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
|
||||
expect(ids).toEqual(['D-005', 'D-004', 'D-003', 'D-002', 'D-001']);
|
||||
});
|
||||
|
||||
test('free-text filter narrows visible rows', async ({ page }) => {
|
||||
await loadTableWithContext(page, {
|
||||
columns: MDL_COLUMNS,
|
||||
rows: ROWS,
|
||||
});
|
||||
await page.waitForSelector('#table-root tbody tr');
|
||||
|
||||
// Type "report" in the title column's filter — should match the
|
||||
// two rows whose title contains "report" (Site survey report,
|
||||
// Geotechnical report).
|
||||
const titleFilter = page.locator('.zddc-table__th[data-field="title"]')
|
||||
.locator('..')
|
||||
.locator('xpath=following-sibling::tr[1]')
|
||||
.locator('input[type="text"]')
|
||||
.nth(0);
|
||||
// Simpler selector: nth filter input under the filter row.
|
||||
const filterInputs = page.locator('.zddc-table__filter-row input[type="text"]');
|
||||
await filterInputs.nth(1).fill('report'); // index 1 = title column
|
||||
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('enum filter limits rows to selected values', async ({ page }) => {
|
||||
await loadTableWithContext(page, {
|
||||
columns: MDL_COLUMNS,
|
||||
rows: ROWS,
|
||||
});
|
||||
await page.waitForSelector('#table-root tbody tr');
|
||||
|
||||
// Status column is enum; pick "pending" only.
|
||||
const statusSelect = page.locator('.zddc-table__filter-row select').nth(1); // 0=party, 1=status
|
||||
await statusSelect.selectOption({ value: 'pending' });
|
||||
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('click on editable row navigates to the row URL', async ({ page }) => {
|
||||
await loadTableWithContext(page, {
|
||||
columns: MDL_COLUMNS,
|
||||
rows: ROWS,
|
||||
});
|
||||
await page.waitForSelector('#table-root tbody tr');
|
||||
|
||||
// Stub the navigate seam render.js consults before falling back
|
||||
// to window.location.assign (which Chromium won't let us override
|
||||
// directly via a plain property assignment).
|
||||
await page.evaluate(() => {
|
||||
window.__navTarget = null;
|
||||
window.tablesApp.navigateTo = url => { window.__navTarget = url; };
|
||||
});
|
||||
|
||||
await page.locator('#table-root tbody tr').first().click();
|
||||
const target = await page.evaluate(() => window.__navTarget);
|
||||
expect(target).toBeTruthy();
|
||||
expect(target).toContain('.yaml.html');
|
||||
});
|
||||
|
||||
test('non-editable rows do not navigate on click', async ({ page }) => {
|
||||
const readOnlyRows = ROWS.map(r => ({ ...r, editable: false }));
|
||||
await loadTableWithContext(page, {
|
||||
columns: MDL_COLUMNS,
|
||||
rows: readOnlyRows,
|
||||
});
|
||||
await page.waitForSelector('#table-root tbody tr');
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__navTarget = null;
|
||||
window.tablesApp.navigateTo = url => { window.__navTarget = url; };
|
||||
});
|
||||
|
||||
await page.locator('#table-root tbody tr').first().click();
|
||||
const target = await page.evaluate(() => window.__navTarget);
|
||||
expect(target).toBeNull();
|
||||
|
||||
// Read-only rows should also lack the editable visual class.
|
||||
await expect(page.locator('#table-root tbody tr.zddc-table__row--editable')).toHaveCount(0);
|
||||
await expect(page.locator('#table-root tbody tr.zddc-table__row--readonly')).toHaveCount(ROWS.length);
|
||||
});
|
||||
|
||||
test('default filters seed the visible row count from defaults.filter', async ({ page }) => {
|
||||
await loadTableWithContext(page, {
|
||||
columns: MDL_COLUMNS,
|
||||
rows: ROWS,
|
||||
defaults: { filter: { status: ['pending'] } },
|
||||
});
|
||||
await page.waitForSelector('#table-root tbody tr');
|
||||
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('empty rows list shows the empty-state notice', async ({ page }) => {
|
||||
await loadTableWithContext(page, {
|
||||
columns: MDL_COLUMNS,
|
||||
rows: [],
|
||||
});
|
||||
// Wait briefly for init.
|
||||
await page.waitForTimeout(50);
|
||||
await expect(page.locator('#table-root tbody tr')).toHaveCount(0);
|
||||
await expect(page.locator('#table-empty')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
|
@ -479,6 +479,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
}
|
||||
}
|
||||
|
||||
// Tables-system intercept: *.table.html is a virtual URL that the
|
||||
// table handler renders inline, reading rows from a directory of
|
||||
// *.yaml files declared in the directory's .zddc tables: map.
|
||||
// Discovery is .zddc-declarative — no auto-mount on file presence —
|
||||
// so RecognizeTableRequest returns nil whenever there's no matching
|
||||
// declaration and the URL falls through to the static-file path
|
||||
// (or to the form intercept below for *.form.html / *.yaml.html).
|
||||
if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil {
|
||||
handler.ServeTable(cfg, tableReq, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Form-system intercept: *.form.html and *.yaml.html under a sibling form
|
||||
// folder are virtual URLs that the form handler renders inline, reading
|
||||
// the underlying *.form.yaml spec (and, for re-edit, the *.yaml data) from
|
||||
|
|
|
|||
138
zddc/internal/handler/tablehandler.go
Normal file
138
zddc/internal/handler/tablehandler.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// Package handler — tablehandler.go: directory-of-YAML table view.
|
||||
//
|
||||
// URL convention:
|
||||
//
|
||||
// GET /<dir>/<name>.table.html → tables.html, only when <dir>/.zddc
|
||||
// declares tables: { <name>: <spec-path> }
|
||||
// AND the spec file exists.
|
||||
//
|
||||
// Discovery is .zddc-declarative (no auto-mount on file presence). The
|
||||
// handler's only jobs are:
|
||||
//
|
||||
// 1. Recognize the URL — does <dir>/.zddc declare a table named <name>,
|
||||
// and does the referenced *.table.yaml spec actually exist? If not,
|
||||
// fall through to the static-file pipeline.
|
||||
// 2. Gate on the cascading ACL at the request directory (read action).
|
||||
// 3. Serve the static tables.html bytes.
|
||||
//
|
||||
// All rendering is client-side. The page (built from tables/) detects
|
||||
// HTTP vs file:// mode, then walks the directory via shared/zddc-source.js
|
||||
// (HttpDirectoryHandle in HTTP mode, real FileSystemDirectoryHandle in
|
||||
// file mode), reads .zddc, parses the spec, and renders rows in the
|
||||
// browser. The server does not pre-parse the spec, list rows, or compute
|
||||
// per-row "editable" — the client does, and ACL is enforced naturally:
|
||||
// HTTP-mode reads/writes go through the cascade-aware file API, and
|
||||
// local-mode reads/writes are bounded by whatever the OS gave the
|
||||
// FS-Access handle.
|
||||
package handler
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
//go:embed tables.html
|
||||
var embeddedTablesHTML []byte
|
||||
|
||||
// TableRequest describes a recognized table-system request.
|
||||
type TableRequest struct {
|
||||
// Name is the table's URL stem (the key declared in .zddc tables).
|
||||
Name string
|
||||
// SpecPath is the absolute filesystem path to the *.table.yaml.
|
||||
// Validated to exist at recognition time.
|
||||
SpecPath string
|
||||
// Dir is the absolute path to the request directory (where the
|
||||
// .zddc declared the table).
|
||||
Dir string
|
||||
}
|
||||
|
||||
// RecognizeTableRequest classifies r as a table-system request, or
|
||||
// returns nil if it falls through to other handlers. Discovery is
|
||||
// strictly .zddc-declarative — a *.table.html URL with no matching
|
||||
// `tables:` entry in <dir>/.zddc returns nil so it falls through to
|
||||
// the static-file path (404 unless an operator dropped a real file).
|
||||
//
|
||||
// Methods other than GET return nil — the table is read-only at the
|
||||
// URL level. Writes go through the file API directly.
|
||||
func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
||||
if method != http.MethodGet {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(urlPath, ".table.html") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split <dir>/<name>.table.html into dir + name.
|
||||
stem := strings.TrimSuffix(urlPath, ".table.html")
|
||||
if stem == "" || stem == "/" {
|
||||
return nil
|
||||
}
|
||||
dirRel := filepath.Dir(filepath.FromSlash(strings.TrimPrefix(stem, "/")))
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
zddcPath := filepath.Join(dirAbs, ".zddc")
|
||||
zf, err := zddc.ParseFile(zddcPath)
|
||||
if err != nil {
|
||||
// Malformed .zddc — log and pass through; static handler will 500
|
||||
// if it cares. Recognition just says "not a declared table here."
|
||||
slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err)
|
||||
return nil
|
||||
}
|
||||
specRel, ok := zf.Tables[name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel))
|
||||
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
|
||||
return nil
|
||||
}
|
||||
if !fileExists(specAbs) {
|
||||
return nil
|
||||
}
|
||||
return &TableRequest{
|
||||
Name: name,
|
||||
SpecPath: specAbs,
|
||||
Dir: dirAbs,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeTable serves the static tables.html bytes for a recognized
|
||||
// request. ACL gate is the read action at the request directory; on
|
||||
// allow, the embedded HTML is written verbatim. The client takes over
|
||||
// from there — see tables/js/main.js.
|
||||
func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) {
|
||||
email := EmailFromContext(r)
|
||||
decider := DeciderFromContext(r)
|
||||
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, req.Dir)
|
||||
if err != nil {
|
||||
slog.Warn("table: policy error", "path", req.Dir, "err", err)
|
||||
}
|
||||
if allowed, _ := policy.AllowActionFromChain(r.Context(), decider, chain, email, r.URL.Path, policy.ActionRead); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if len(embeddedTablesHTML) == 0 {
|
||||
http.Error(w, "table renderer not built into this binary", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(embeddedTablesHTML)
|
||||
}
|
||||
229
zddc/internal/handler/tablehandler_test.go
Normal file
229
zddc/internal/handler/tablehandler_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
const sampleTableSpec = `title: Master Deliverables List
|
||||
description: Sample MDL.
|
||||
rowSchema: ./MDL.form.yaml
|
||||
rows: ./MDL
|
||||
columns:
|
||||
- field: id
|
||||
title: ID
|
||||
width: 6em
|
||||
- field: title
|
||||
title: Deliverable
|
||||
- field: status
|
||||
title: Status
|
||||
enum: [pending, submitted, accepted]
|
||||
defaults:
|
||||
sort:
|
||||
- { field: id, dir: asc }
|
||||
`
|
||||
|
||||
const sampleRowFormSpec = `title: Deliverable
|
||||
schema:
|
||||
type: object
|
||||
required: [id, title]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, submitted, accepted]
|
||||
`
|
||||
|
||||
// tableTestSetup writes a directory tree under a temp root with:
|
||||
//
|
||||
// <root>/Working/.zddc → declares tables: { MDL: ./MDL.table.yaml }
|
||||
// <root>/Working/MDL.table.yaml → spec
|
||||
// <root>/Working/MDL.form.yaml → row schema
|
||||
// <root>/Working/MDL/<file>.yaml → row data (one per entry in rows)
|
||||
//
|
||||
// Optional extra .zddc files at relative paths can be supplied via zddcFiles.
|
||||
// Returns (config, do) where do dispatches a request through ServeTable via
|
||||
// the same recognize → serve path the production catch-all uses.
|
||||
//
|
||||
// Note: under the client-side rendering architecture the handler does not
|
||||
// parse the spec or list row files — the rows/spec on disk are written
|
||||
// only because the ACL cascade may evaluate paths under them.
|
||||
func tableTestSetup(t *testing.T, rows map[string]string, zddcFiles map[string]string) (config.Config, func(method, target, email string) *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
working := filepath.Join(root, "Working")
|
||||
if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
|
||||
t.Fatalf("write spec: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
|
||||
t.Fatalf("write form spec: %v", err)
|
||||
}
|
||||
for name, body := range rows {
|
||||
if err := os.WriteFile(filepath.Join(working, "MDL", name), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write row %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, ok := zddcFiles["Working"]; !ok {
|
||||
if zddcFiles == nil {
|
||||
zddcFiles = make(map[string]string)
|
||||
}
|
||||
zddcFiles["Working"] = `acl:
|
||||
permissions:
|
||||
"*@example.com": rwcd
|
||||
tables:
|
||||
MDL: ./MDL.table.yaml
|
||||
`
|
||||
}
|
||||
for rel, body := range zddcFiles {
|
||||
dir := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
zddc.InvalidateCache(dir)
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write .zddc: %v", err)
|
||||
}
|
||||
}
|
||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||
|
||||
do := func(method, target, email string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, bytes.NewReader(nil))
|
||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
tableReq := RecognizeTableRequest(cfg.Root, method, target)
|
||||
if tableReq == nil {
|
||||
rec.WriteHeader(http.StatusNotFound)
|
||||
return rec
|
||||
}
|
||||
ServeTable(cfg, tableReq, rec, req)
|
||||
return rec
|
||||
}
|
||||
return cfg, do
|
||||
}
|
||||
|
||||
func TestRecognizeTableRequest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
working := filepath.Join(root, "Working")
|
||||
if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(working, ".zddc"), []byte(`tables:
|
||||
MDL: ./MDL.table.yaml
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(working)
|
||||
|
||||
cases := []struct {
|
||||
method, url string
|
||||
wantNil bool
|
||||
wantSpec string
|
||||
wantName string
|
||||
}{
|
||||
{"GET", "/Working/MDL.table.html", false, "Working/MDL.table.yaml", "MDL"},
|
||||
// Same URL but POST → tables are read-only at the URL level.
|
||||
{"POST", "/Working/MDL.table.html", true, "", ""},
|
||||
{"PUT", "/Working/MDL.table.html", true, "", ""},
|
||||
{"DELETE", "/Working/MDL.table.html", true, "", ""},
|
||||
// Not declared in .zddc → not a table request.
|
||||
{"GET", "/Working/Other.table.html", true, "", ""},
|
||||
// No .zddc at the dir → not a table request.
|
||||
{"GET", "/Other/MDL.table.html", true, "", ""},
|
||||
// Random .html → falls through.
|
||||
{"GET", "/index.html", true, "", ""},
|
||||
// .form.html (form territory) → falls through to form handler.
|
||||
{"GET", "/Working/MDL.form.html", true, "", ""},
|
||||
// Path traversal attempt.
|
||||
{"GET", "/../etc/passwd.table.html", true, "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.method+" "+tc.url, func(t *testing.T) {
|
||||
got := RecognizeTableRequest(root, tc.method, tc.url)
|
||||
if tc.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("got %+v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("got nil, want a TableRequest")
|
||||
}
|
||||
if got.Name != tc.wantName {
|
||||
t.Errorf("Name = %q want %q", got.Name, tc.wantName)
|
||||
}
|
||||
wantSpec := filepath.Join(root, tc.wantSpec)
|
||||
if got.SpecPath != wantSpec {
|
||||
t.Errorf("SpecPath = %q want %q", got.SpecPath, wantSpec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the
|
||||
// embedded tables.html bytes verbatim, with the empty inline context
|
||||
// placeholder intact (so the client knows to walk the directory).
|
||||
func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
|
||||
rows := map[string]string{
|
||||
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
|
||||
}
|
||||
_, do := tableTestSetup(t, rows, nil)
|
||||
rec := do(http.MethodGet, "/Working/MDL.table.html", "casey@example.com")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q want text/html…", ct)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `<table id="table-root"`) {
|
||||
t.Error("body missing #table-root markup; embedded HTML may be stale or empty")
|
||||
}
|
||||
if !strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
|
||||
t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeTable_ACLForbidden(t *testing.T) {
|
||||
zddcs := map[string]string{
|
||||
"Working": `acl:
|
||||
permissions:
|
||||
"root@example.com": rwcd
|
||||
tables:
|
||||
MDL: ./MDL.table.yaml
|
||||
`,
|
||||
}
|
||||
_, do := tableTestSetup(t, map[string]string{"D.yaml": "id: D\n"}, zddcs)
|
||||
rec := do(http.MethodGet, "/Working/MDL.table.html", "stranger@example.com")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
2382
zddc/internal/handler/tables.html
Normal file
2382
zddc/internal/handler/tables.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -84,6 +84,14 @@ type ZddcFile struct {
|
|||
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
|
||||
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
|
||||
|
||||
// Tables declares directory-of-YAML table views available at this
|
||||
// directory. The map key becomes the URL stem: tables[MDL] is served
|
||||
// at <dir>/MDL.table.html. The value is a path (relative to this
|
||||
// .zddc) to a *.table.yaml spec describing columns and the rows
|
||||
// directory. There is no upward cascade for tables in v1 — each
|
||||
// directory that hosts a table declares it directly.
|
||||
Tables map[string]string `yaml:"tables,omitempty" json:"tables,omitempty"`
|
||||
|
||||
// Roles are named principal groups available at this level and below.
|
||||
// See Role for member syntax.
|
||||
Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"`
|
||||
|
|
|
|||
76
zddc/internal/zddc/file_test.go
Normal file
76
zddc/internal/zddc/file_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseFile_TablesRoundTrip exercises the Tables field added to
|
||||
// support the table tool. A .zddc with a tables: map should round-trip
|
||||
// through ParseFile cleanly without disturbing existing fields.
|
||||
func TestParseFile_TablesRoundTrip(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
body := `acl:
|
||||
permissions:
|
||||
"*@example.com": rwcd
|
||||
title: Demo
|
||||
apps:
|
||||
archive: stable
|
||||
tables:
|
||||
MDL: ./MDL.table.yaml
|
||||
Subcontracts: ./contracts/subs.table.yaml
|
||||
roles:
|
||||
reviewers:
|
||||
members: ["bob@example.com"]
|
||||
`
|
||||
path := filepath.Join(root, ".zddc")
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write .zddc: %v", err)
|
||||
}
|
||||
zf, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
if got := zf.Tables["MDL"]; got != "./MDL.table.yaml" {
|
||||
t.Errorf("Tables[MDL] = %q want %q", got, "./MDL.table.yaml")
|
||||
}
|
||||
if got := zf.Tables["Subcontracts"]; got != "./contracts/subs.table.yaml" {
|
||||
t.Errorf("Tables[Subcontracts] = %q want %q", got, "./contracts/subs.table.yaml")
|
||||
}
|
||||
// Sibling fields should still parse.
|
||||
if zf.Title != "Demo" {
|
||||
t.Errorf("Title = %q want %q", zf.Title, "Demo")
|
||||
}
|
||||
if got := zf.Apps["archive"]; got != "stable" {
|
||||
t.Errorf("Apps[archive] = %q want %q", got, "stable")
|
||||
}
|
||||
if r, ok := zf.Roles["reviewers"]; !ok || len(r.Members) != 1 {
|
||||
t.Errorf("Roles[reviewers] = %+v want one member", r)
|
||||
}
|
||||
if got := zf.ACL.Permissions["*@example.com"]; got != "rwcd" {
|
||||
t.Errorf("ACL.Permissions[*@example.com] = %q want rwcd", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFile_TablesEmptyOmitted confirms that a .zddc without a
|
||||
// tables: key parses with a nil Tables map (omitempty round-trip).
|
||||
func TestParseFile_TablesEmptyOmitted(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
body := `title: NoTables
|
||||
acl:
|
||||
permissions:
|
||||
"*@example.com": r
|
||||
`
|
||||
path := filepath.Join(root, ".zddc")
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write .zddc: %v", err)
|
||||
}
|
||||
zf, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
if zf.Tables != nil {
|
||||
t.Errorf("Tables = %+v want nil for absent tables: key", zf.Tables)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue