Commit graph

8 commits

Author SHA1 Message Date
9341c47937 feat(tables): read-only cells, show server-derived fields on create, clearer conflict
- editor.js: suppress edit entry for cells whose schema is readOnly
  (folder-bound originator, server-managed audit fields) — mirrors the
  $-prefixed synthesized-column guard. The server overwrites these, so
  inline-editing them was misleading and the value was silently lost.
- save.js createRow: on 201, re-fetch the written row so server-derived
  fields (originator from the party folder, the composed tracking
  number's components, audit stamps) surface immediately instead of
  staying blank until reload. Falls back to the local merge if the GET
  fails.
- save.js createRow: handle 409 (duplicate composed tracking number)
  with a clear message on the sequence cell instead of the generic
  errored state.

Test: tables.spec.js — a readOnly column doesn't mount an inline editor
while a normal sibling still edits. The 409 + re-fetch paths go through
the in-dir create POST (formCreateUrl), which the file:// Playwright
harness can't intercept; both are covered by the server e2e.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:34:22 -05:00
34208a5bd7 feat(tables): gate +Add row on path verbs.c + cap-toast on 403
Two server-aligned signals on save paths:

  - +Add row button: fetches /.profile/access?path=<current dir> via
    zddc.cap.at() once on load; if path_verbs doesn't include 'c'
    the button disables with a tooltip ("You don't have create
    access in this folder."). Async race-window is the same as any
    other path-scoped fetch — server still gates the POST so a
    stale client gets a 403 toast on click rather than a silent
    accept.

  - 403 on save/create: previously fell into the generic
    "http-error" bucket with a console warn; now branches into
    zddc.cap.handleForbidden which renders an error toast naming the
    missing verb. When the path-scoped view reports an elevation
    grant covering that verb, the toast appends an Elevate button.

Per-row writability stays computed server-side for now — tables
walks rows via FS-API-style handles that don't surface the listing
verbs string. A follow-on pass can switch the row walk to raw
listing entries and gate row.editable on each entry's verbs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:48:02 -05:00
59b5550872 refactor: nest lifecycle slots per-party + add virtual top-level aggregators
May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:

  ssr/mdl/rsk           tables rollups across parties with a
                        synthesised $party source-party column
  working/staging/      browse folder-nav listings of parties with
  reviewing             non-empty content in the slot; per-party
                        URLs 302-redirect to archive/<party>/<slot>/

Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.

Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.

document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:57:45 -05:00
83c3b332d5 feat(client): consume server-stamped audit body + render labels/readonly
tables/js/save.js: on 200/201, parse the YAML body the server now
echoes back (it carries the just-stamped audit fields) and replace
row.data with it. The previous local-merge code falsified the table
view — server stamping changes bytes the client doesn't predict
(revision, created_by, previous_sha), and the merge would have shown
stale values until a fresh GET. Falls back to local merge when the
response has no body (non-record write or older server).

form/js/widgets.js:
- honor schema.readOnly (alongside the existing ui:readonly UI
  override) so cascade-locked + audit fields render as disabled
- read x-labels for enums and render "<code> — <label>" in both
  <select> and radio variants (server-injected from
  field_codes:codes for human-readable dropdowns)
- propagate schema.pattern to <input pattern> as a UX hint
  (authoritative validation runs server-side via WriteWithHistory)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:00:10 -05:00
1721b4b1db feat(tables): explicit Save button + clearer dirty-row marker
Three triggers for flushing pending edits:
  - Save button in the toolbar — shown only when ≥1 row is dirty,
    label reads "Save (N unsaved)". Disappears after a clean settle.
  - Ctrl+S (Cmd+S) anywhere on the page, capturing-phase so it beats
    the browser's "Save Page As" default.
  - focusout of #table-root with a relatedTarget outside the grid —
    catches "edit cell, click a header link, expect it to save".

The row-blur trigger stays — moving between rows still flushes. The
new triggers fill the gap when the user edits one row and then leaves
the grid entirely without first navigating to another row.

Dirty marker gets a 4px (was 3px) left swatch AND a faint blue
background tint on the row, so "unsaved" reads as a row state rather
than a small marker on the edge.

editor.setDraft / clearDraftField notify save.onDraftsChanged,
which refreshes the Save button + reapplies the dirty class.
saveRow on 200/201/202 also refreshes the button so it disappears
the moment its row settles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:38:35 -05:00
b4c0327f63 feat(tables): row editor — inline Add Row, Delete, multi-row paste, min row height
The cell-editor was already complete (drafts, row-blur saves, etag
concurrency, validation). This commit adds the missing row-level ops:

- "+ Add row" appends a draft row inline; first cell focused. Row-blur
  POSTs to <dir>/form.html (the existing form-create endpoint); 201
  swaps the synthetic id for the server-returned URL/ETag. Empty rows
  the user walks away from are silently discarded.
- Right-click a row → "Delete row" (or "Delete N rows" when a cell
  range spans multiple rows). DELETE the row YAML with If-Match; 412
  surfaces a conflict warning.
- Multi-row clipboard paste creates new rows for grid content that
  extends past the last existing row, instead of dropping cells past
  the end. Each new row saves via its own row-blur.
- Empty rows now have a 2.4em minimum height so a freshly-added row
  is visible. Without the floor it collapses to cell-padding (~8px)
  and looks like a divider line.

Server-side: no new endpoints. Form-create (POST <dir>/form.html →
201 + Location) and file-API DELETE carry the new client capabilities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:07:28 -05:00
bf5ea7aa4f fix(tables): use fetch keepalive on the beforeunload save path
The TODO at save.js's unload handler was "switch to keepalive on save
for the unload path." flushAllDrafts() kicks off saveRow() per dirty
row when the page is being navigated away from, but those fetches were
not flagged keepalive — modern browsers can cancel them mid-flight as
the page unloads, dropping the user's last typing.

saveRow() now accepts an opts.keepalive flag that is passed through to
fetch(). flushAllDrafts() passes {keepalive: true} so the unload path
gets the keepalive guarantee. Normal saves are unaffected (keepalive
imposes a 64 KB body cap per the Fetch spec — only worth that trade
on the unload path).

Also refreshes the embedded zddc/internal/handler/tables.html bytes via
./build, which folds in this change plus the form welcome-state CSS
from c585112.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:39:42 -05:00
cd751eb604 feat(tables): editable cells phase 3 — row-level save + ETag conflict UX
Cell edits now actually persist. Row-level batch save fires on
row-blur (selection moves to a different row); the request is one
PUT with the full merged row (server-side data + client drafts)
and If-Match: <etag> for optimistic concurrency. Conflict and
validation responses are surfaced inline; drafts are NEVER silently
discarded — when the server says no, the user's typing stays put
until they explicitly reload or replay.

Architecture (per the research synthesis from earlier in this
sequence):

- ETag tracking: context.js readRows captures the per-row ETag
  from HttpFileHandle's response header on the initial GET.
  Stashed at row.etag alongside row.data and row.yamlUrl. Phase 3
  reads it; later phases (undo replay) inherit it.

- Row-blur trigger: editor.js setSelected calls a new
  notifySelectionChanged() hook after selection lands. save.js's
  onSelectionChanged tracks _previousSelectedRowId; when it
  changes AND the previous row had drafts, fires saveRow(prevId).
  Fire-and-forget — don't block the user's flow on the network.

- save.saveRow flow:
    1. mergeRow(row.data, drafts) → full updated row.
    2. js-yaml dump → wire body.
    3. PUT row.yamlUrl, body, headers={Content-Type, If-Match}.
    4. Branch on response status:
       - 200/201 → success: clear drafts + invalid marks, capture
         new ETag from response, replace row.data with merged.
       - 202     → outbox queued (downstream client offline):
         clear drafts (the outbox owns them now), mark row queued.
       - 412     → stale: drafts STAY; mark row stale; show
         status-bar prompt with [Use mine] / [Reload] buttons.
       - 422     → server validation failed; body has
         {errors: [{path, message}]}; mark each cell invalid via
         a red-corner CSS marker + title-attribute tooltip.
       - other   → mark errored; drafts stay.

- Conflict resolution UX:
    - "Use mine" replays the user's drafts onto fresh server
      state. Re-GETs the row to learn the new ETag + new server
      data, replaces row.data with the fresh server values, then
      re-PUTs the merge of fresh + drafts. This is client-side
      field-level last-writer-wins: fields the user did NOT
      touch get the server's new values automatically; only
      fields the user changed override server state. No JSON
      Patch endpoint required — pure client logic on top of the
      existing whole-row PUT path.
    - "Reload" drops drafts entirely, re-GETs the row, repaints.

- Validation error display: per-cell red-corner triangle
  (Excel-style) plus title-attribute tooltip on hover. Marker
  keyed off data-col-idx + the column's field; survives until
  the next edit on that cell or the next paint() cycle.

- beforeunload safety net: any rows with drafts at unload time
  get one fire-and-forget save attempt. Modern browsers limit
  what beforeunload can do; a follow-up could add fetch's
  keepalive flag for a more reliable last-shot.

UI surfaces:

- Per-row state classes drive a left-border swatch in the first
  cell:
    --dirty   subtle blue   (uncommitted changes)
    --saving  muted grey    (PUT in flight)
    --queued  warm yellow   (outbox accepted)
    --invalid orange        (server 422)
    --stale   warning amber (server 412 — also tints row bg)
    --errored red           (other failure — also tints row bg)
  These re-apply across re-paints via save.markAllDirtyRows()
  called from main.js's paint() hook (innerHTML='' wipes them).

- #table-status doubles as the conflict prompt host. When a row
  goes stale, the bar shows
    "This row was changed by someone else. [Use mine] [Reload] [×]"
  and the row-id it's bound to is stored on data-row-id so a
  successful reload of that row dismisses the prompt.

Outbox (downstream client) interaction:

The cache layer's PUT-replay queue intercepts saves transparently.
On local network failure the cache returns 202 with
X-ZDDC-Cache: queued; we treat 202 as "succeeded for now" —
drafts clear (the outbox owns them and will replay), but the
row stays marked --queued so the user knows the write hasn't
reached upstream yet. When the cache replays and gets a
real 200/201/412/etc., the row state will reflect that on next
read (next paint cycle / page refresh).

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

- row-blur fires PUT with merged drafts + If-Match. Edit a
  cell in row 0, Enter (commits + moves to row 1). Verifies
  PUT went out with the right URL, the merged YAML body
  contains the new value AND the unchanged fields, and the
  If-Match header carries the original ETag.

- 412 conflict marks row stale + shows status prompt. Verifies
  the row gains the stale class, the status bar appears with
  both [Use mine] and [Reload] buttons, AND the draft is
  preserved (never silently dropped on conflict).

- 422 validation errors mark cells invalid. Verifies multiple
  field errors → multiple red-corner cells.

- Reload button drops drafts and refreshes. Verifies the bar
  hides and drafts clear after a successful reload GET.

Setup: a small page.route helper intercepts http://test.local/*
PUTs and GETs, lets each test queue the next response via
window.__nextResponse, and captures requests at
window.__capturedRequests for inspection. Test fixtures use
absolute http URLs in row.yamlUrl so the route catches them.

Bundle size: 127 KB → 134 KB.

Files:

- tables/js/save.js (new) — saveRow, useMine, reload, status
  prompt, row-state markers, beforeunload flush.
- tables/js/editor.js — notifySelectionChanged hook.
- tables/js/context.js — etag + yamlUrl on each row.
- tables/js/main.js — paint() re-applies dirty markers via
  save.markAllDirtyRows; exposes app.repaint for save callbacks.
- tables/build.sh — save.js in concat list.
- tables/css/table.css — row-state classes + invalid-cell corner
  + status-bar prompt styling.
- zddc/internal/handler/tables.html — regenerated bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:26:22 -05:00