ZDDC/tables
ZDDC 8e703dc61a feat(tables): editable cells phase 4 — copy/paste from Excel/Sheets
Bidirectional clipboard interop with Excel, Google Sheets, and any
other spreadsheet that uses RFC-4180-ish TSV on the text/plain
clipboard mime. Pasted cells write straight into the draft buffer
the same way per-key edits do; row-level save (Phase 3) picks them
up on the next row-blur with the same If-Match optimistic-
concurrency flow.

TSV parser (clipboard.js parseTSV):

- Tabs separate columns, \\n / \\r\\n separate rows.
- Quoted fields ("...") may contain tabs and newlines verbatim.
- Doubled \\"\\" inside a quoted field escapes a literal \\".
- Trailing empty row from a final \\n is dropped (Excel sends
  this; matching the convention avoids a phantom blank row at
  the end of every paste).

Apply-paste (clipboard.js applyPaste):

- Anchor = currently selected cell.
- 1×1 clipboard into selection → writes that one cell.
- N×M clipboard → SPILLS from the anchor down/right to
  (anchor.row + N - 1, anchor.col + M - 1). Cells past the end
  of either axis are silently dropped with a toast count.
- Each pasted value goes through coerceCell, which checks the
  column's row-schema property type:
    * number / integer → Number()
    * boolean          → "true"|"yes"|"1" → true; "false"|
                         "no"|"0"|""      → false
    * everything else  → raw string
  Drafts hold the right JS type so the row-PUT body matches the
  JSON Schema the server validates against.

Copy (clipboard.js onCopy):

- Single-cell selection: Ctrl/Cmd+C writes the cell's
  effectiveCellValue (draft if dirty, else stored) as text/plain
  via formatCell (RFC-4180 quoting on tab/newline/quote).
- Range copy is Phase 5 (depends on range-selection landing).

Event wiring:

- document.addEventListener('paste'/'copy') so events bubble
  from any cell with focus. Phase 1's roving tabindex moves
  focus around; per-cell binding would have to be re-applied
  after every paint.
- onPaste bails when an editor input is mounted (the input
  owns its own paste — typing into a cell editor that was just
  populated with a chunk of TSV would be a footgun).

Toast for partial pastes:

When applyPaste skipped any cells, a small message in
#table-status: "Pasted N cells; M dropped (out of bounds)".
Auto-clears after 4s. Coexists with Phase 3's stale-row prompt
(toast doesn't fire if a prompt is already up; prompt outranks
toast).

Tests (6 new Phase 4 specs, total 37 in tests/tables.spec.js):

- parseTSV handles tabs, newlines, and quoted fields — covers
  the parser edge cases including embedded \\n inside "..." and
  doubled "" escapes.
- paste single value into selected cell — the 1×1 path; verifies
  the draft buffer entry.
- paste 2×2 grid spills from anchor — the N×M spill semantic.
- paste coerces numeric/boolean values via row schema —
  verifies the draft holds typeof===number for an integer column
  and === true for a boolean column.
- paste out-of-bounds drops cells silently with toast — drives
  via dispatched ClipboardEvent('paste') (the only way to
  exercise onPaste end-to-end including the toast).
- copy single cell writes value to clipboard — synthesizes a
  ClipboardEvent('copy') with a writable DataTransfer payload
  and asserts the cell value lands in text/plain.

Bundle size: 134 KB → 138 KB.

Files:

- tables/js/clipboard.js (new) — parseTSV, formatTSV,
  applyPaste, onPaste/onCopy, toast helper.
- tables/build.sh — clipboard.js in concat list.
- zddc/internal/handler/tables.html — regenerated bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:30:05 -05:00
..
css feat(tables): editable cells phase 3 — row-level save + ETag conflict UX 2026-05-09 10:26:22 -05:00
js feat(tables): editable cells phase 4 — copy/paste from Excel/Sheets 2026-05-09 10:30:05 -05:00
sample feat(tables): new sortable/filterable grid tool for directories of YAML files 2026-05-05 20:32:01 -05:00
build.sh feat(tables): editable cells phase 4 — copy/paste from Excel/Sheets 2026-05-09 10:30:05 -05:00
README.md feat(tables): new sortable/filterable grid tool for directories of YAML files 2026-05-05 20:32:01 -05:00
template.html refactor(tables): in-dir convention + unified table+form HTML bundle 2026-05-09 09:15:26 -05:00

ZDDC Tables

← Back to ZDDC

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

tables:
  MDL: ./MDL.table.yaml

The map key (MDL) becomes the URL stem and must match both the rows directory name and the form spec name. v1 enforces this with a load-time spec-validation error.

Table spec (MDL.table.yaml)

title: Master Deliverables List
description: Optional description shown above the table.
rowSchema: ./MDL.form.yaml         # path to the row's JSON Schema (form-spec format)
rows: ./MDL                        # directory of *.yaml row files (non-recursive in v1)
columns:
  - field: id                      # top-level key OR JSON Pointer (e.g. /nested/path)
    title: ID
    width: 7em
    sort: asc                      # default sort key (overridden by defaults.sort below)
  - field: title
    title: Deliverable
  - field: dueDate
    title: Due
    format: date                   # date | datetime | number | bool
  - field: status
    title: Status
    enum: [pending, submitted, accepted, rejected]   # constrains values + enables enum filter
defaults:
  sort:
    - { field: dueDate, dir: asc }
  filter:
    status: [pending, submitted]   # initial filter state; clear with the toolbar button

Columns are explicit — the renderer does not auto-derive from the row schema. Pick the subset you want to display.

ACL behavior

  • The page-level read check uses the cascade at the spec directory; a caller without 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

sh tables/build.sh                       # build (writes tables/dist/tables.html)
sh tables/build.sh --release alpha       # cut alpha
sh ./build                               # full lockstep build (all tools + zddc-server)

(cd zddc && go test ./internal/handler/... ./internal/zddc/...)
npx playwright test --project=tables

Authoritative architecture and build docs are in ../AGENTS.md and ../ARCHITECTURE.md.