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>
210 lines
7.9 KiB
JavaScript
210 lines
7.9 KiB
JavaScript
(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):
|
|
// page is at /<dir>/table.html — fetch <dir>/table.yaml,
|
|
// list every other *.yaml in <dir> as a row file (filtering
|
|
// out table.yaml and form.yaml so they don't appear as rows),
|
|
// parse each, and assemble the same shape. The whole table
|
|
// lives in one directory.
|
|
//
|
|
// file:// mode without a directory handle is unsupported in v1 — the
|
|
// walk only runs against http(s). file:// users must either inject an
|
|
// 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;
|
|
|
|
// Spec lives at <currentdir>/table.yaml — the page URL is
|
|
// <currentdir>/table.html, so the spec is right next door.
|
|
const spec = await readYaml(dir, 'table.yaml');
|
|
if (!spec || !Array.isArray(spec.columns)) {
|
|
throw new Error('Spec table.yaml missing columns[]');
|
|
}
|
|
|
|
// Optional row schema from <dir>/form.yaml — same JSON Schema
|
|
// the form-mode renderer uses. Phase 2 derives per-cell editor
|
|
// widgets from it (text/number/date/select/checkbox).
|
|
// Best-effort: a directory with only table.yaml still renders
|
|
// as a sortable/filterable table; cells fall back to plain
|
|
// text inputs without per-property hints.
|
|
let rowSchema = null;
|
|
try {
|
|
const formSpec = await readYaml(dir, 'form.yaml');
|
|
if (formSpec && formSpec.schema) {
|
|
rowSchema = formSpec.schema;
|
|
}
|
|
} catch (_) {
|
|
// form.yaml missing or unreadable; carry on without it.
|
|
}
|
|
|
|
// Rows are every *.yaml in <currentdir> EXCEPT the spec
|
|
// (table.yaml) and the row-edit form (form.yaml). They live
|
|
// in the same directory by design — copying the directory
|
|
// copies the whole table.
|
|
const rows = await readRows(dir, '', tableName);
|
|
|
|
return {
|
|
title: spec.title,
|
|
description: spec.description,
|
|
columns: spec.columns,
|
|
defaults: spec.defaults,
|
|
rowSchema: rowSchema,
|
|
rows: rows
|
|
};
|
|
}
|
|
|
|
function tableNameFromUrl(pathname) {
|
|
// /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
|
|
// basename.
|
|
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;
|
|
// Skip the spec and the row-edit form — they live alongside
|
|
// the rows but aren't rows themselves.
|
|
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
|
|
try {
|
|
const handle = await rowsDir.getFileHandle(entry.name);
|
|
const file = await handle.getFile();
|
|
const data = window.jsyaml.load(await file.text());
|
|
rows.push({
|
|
url: rowEditUrl(entry.name),
|
|
// Underlying YAML URL — strip the trailing .html
|
|
// from the form-mode re-edit URL. Phase 3 PUTs to
|
|
// this URL with If-Match: <etag> for optimistic
|
|
// concurrency.
|
|
yamlUrl: rowEditUrl(entry.name).replace(/\.html$/, ''),
|
|
data: data || {},
|
|
// ETag captured by HttpFileHandle.getFile from the
|
|
// server's response header. null in offline / file://
|
|
// mode (no HTTP roundtrip happened).
|
|
etag: handle._etag || null,
|
|
editable: true
|
|
});
|
|
} catch (err) {
|
|
console.warn('[tables] skipping unparseable row', entry.name, err);
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
// Re-edit URL for one row. Page is at /<dir>/table.html; row file
|
|
// lives at /<dir>/<basename>.yaml; form re-edit URL is
|
|
// /<dir>/<basename>.yaml.html — same directory.
|
|
function rowEditUrl(rowFileName) {
|
|
const pageDir = location.pathname.replace(/\/table\.html$/, '/');
|
|
return pageDir + rowFileName + '.html';
|
|
}
|
|
|
|
app.modules.context = { load: load };
|
|
})(window.tablesApp);
|