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>
406 lines
16 KiB
JavaScript
406 lines
16 KiB
JavaScript
// save.js — Phase 3 of editable-cell mode.
|
||
//
|
||
// Row-level batch save on row-blur. While the user is editing cells
|
||
// inside a row, draft values accumulate in app.state.drafts. When the
|
||
// editor's selection moves to a different row (or focus leaves the
|
||
// grid entirely), this module fires one PUT for the row that lost
|
||
// focus, with full merged data + If-Match for the row's tracked ETag.
|
||
//
|
||
// Three response paths:
|
||
//
|
||
// - 200 / 201 / 202: success or queued-offline (cache outbox).
|
||
// Drafts clear, row.data merges, new ETag captured. Row's
|
||
// "dirty" indicator drops.
|
||
//
|
||
// - 412 Precondition Failed: someone else changed this row since
|
||
// we read it. Drafts STAY — never silently discard the user's
|
||
// typing. Row gets a "stale" badge with [Use mine] / [Reload]
|
||
// in the page status bar. "Use mine" re-GETs the row to pick up
|
||
// the new ETag and server data, replays drafts on top, re-PUTs
|
||
// (this is the client-side field-level LWW trick from the
|
||
// architecture report — fields the user didn't touch get the
|
||
// server's new values automatically). "Reload" drops drafts and
|
||
// refreshes from server.
|
||
//
|
||
// - 422 Unprocessable Entity: server-side schema validation failed.
|
||
// Body is {errors: [{path, message}, ...]}. Each path → field,
|
||
// marked with a red corner on the cell. Drafts stay so the user
|
||
// can correct in place.
|
||
//
|
||
// - Other (4xx / 5xx / network): row marked errored with the
|
||
// status code; drafts stay.
|
||
//
|
||
// Outbox transparency: when running through a downstream client, the
|
||
// PUT is intercepted by the cache layer; on local network failure
|
||
// it's queued and the response is 202 Accepted with X-ZDDC-Cache:
|
||
// queued. We treat 202 as success-ish — drafts clear, indicator
|
||
// shows a small "queued" badge so the user knows the write hasn't
|
||
// reached upstream yet.
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
function modules() {
|
||
return app.modules.editor;
|
||
}
|
||
|
||
function findRowById(rowId) {
|
||
const all = (app.state && app.state.rows) || [];
|
||
for (let i = 0; i < all.length; i++) {
|
||
if (modules().rowKey(all[i]) === rowId) return all[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function mergeRow(data, drafts) {
|
||
// Shallow merge: drafts are field-level overrides on the row's
|
||
// top-level data object. Phase 2's complex-type cells punt to
|
||
// form-mode and never produce drafts here, so drafts only
|
||
// contain primitive / string-array values that are safe to
|
||
// overwrite the corresponding top-level field.
|
||
return Object.assign({}, data || {}, drafts || {});
|
||
}
|
||
|
||
function rowFromState(rowId) {
|
||
return {
|
||
row: findRowById(rowId),
|
||
drafts: (app.state.drafts && app.state.drafts[rowId]) || null,
|
||
};
|
||
}
|
||
|
||
// --- Visual state markers ----------------------------------------
|
||
|
||
function setRowState(rowId, stateName) {
|
||
// Apply a CSS state class to the row matching rowId. States:
|
||
// "" / null — no marker
|
||
// "dirty" — has uncommitted drafts
|
||
// "saving" — PUT in flight
|
||
// "stale" — server returned 412
|
||
// "errored" — server returned 4xx/5xx other than 412/422
|
||
// "queued" — write went into the outbox
|
||
// "invalid" — server returned 422
|
||
const tbody = document.querySelector('#table-root tbody');
|
||
if (!tbody) return;
|
||
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
|
||
if (!tr) return;
|
||
const stateClasses = ['dirty', 'saving', 'stale', 'errored', 'queued', 'invalid'];
|
||
for (let i = 0; i < stateClasses.length; i++) {
|
||
tr.classList.remove('zddc-table__row--' + stateClasses[i]);
|
||
}
|
||
if (stateName) tr.classList.add('zddc-table__row--' + stateName);
|
||
}
|
||
|
||
function markCellInvalid(rowId, field, message) {
|
||
const tbody = document.querySelector('#table-root tbody');
|
||
if (!tbody) return;
|
||
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
|
||
if (!tr) return;
|
||
// Walk the column list to find the field's column index;
|
||
// data-col-idx is the numeric position rendered into each td.
|
||
const cols = (app.context && app.context.columns) || [];
|
||
let idx = -1;
|
||
for (let i = 0; i < cols.length; i++) {
|
||
if (cols[i].field === field) { idx = i; break; }
|
||
}
|
||
if (idx < 0) return;
|
||
const target = tr.querySelector('[role="gridcell"][data-col-idx="' + idx + '"]');
|
||
if (!target) return;
|
||
target.classList.add('zddc-table__cell--invalid');
|
||
if (message) target.setAttribute('title', message);
|
||
}
|
||
|
||
function clearCellInvalid(rowId) {
|
||
const tbody = document.querySelector('#table-root tbody');
|
||
if (!tbody) return;
|
||
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
|
||
if (!tr) return;
|
||
const invalids = tr.querySelectorAll('.zddc-table__cell--invalid');
|
||
for (let i = 0; i < invalids.length; i++) {
|
||
invalids[i].classList.remove('zddc-table__cell--invalid');
|
||
invalids[i].removeAttribute('title');
|
||
}
|
||
}
|
||
|
||
function cssEscape(s) {
|
||
// CSS.escape if available; otherwise a defensive escape for
|
||
// the characters that appear in URL paths used as data-row-id
|
||
// values. Browsers everywhere modern enough to support the
|
||
// FS Access API have CSS.escape, so this is mostly defensive.
|
||
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(s);
|
||
return String(s).replace(/[^a-zA-Z0-9_-]/g, function (ch) {
|
||
return '\\' + ch;
|
||
});
|
||
}
|
||
|
||
// --- Status bar (stale-row prompt) --------------------------------
|
||
|
||
function showStatusPrompt(rowId, message, actions) {
|
||
// Renders into #table-status (hidden by default per template).
|
||
// actions = [{label, onClick}, ...]
|
||
const el = document.getElementById('table-status');
|
||
if (!el) return;
|
||
el.textContent = '';
|
||
el.classList.add('table-status--prompt');
|
||
const span = document.createElement('span');
|
||
span.textContent = message;
|
||
el.appendChild(span);
|
||
for (let i = 0; i < (actions || []).length; i++) {
|
||
const a = actions[i];
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'btn btn-secondary btn-sm';
|
||
btn.textContent = a.label;
|
||
btn.addEventListener('click', a.onClick);
|
||
el.appendChild(btn);
|
||
}
|
||
const dismiss = document.createElement('button');
|
||
dismiss.type = 'button';
|
||
dismiss.className = 'btn btn-secondary btn-sm';
|
||
dismiss.textContent = '×';
|
||
dismiss.title = 'Dismiss';
|
||
dismiss.addEventListener('click', clearStatus);
|
||
el.appendChild(dismiss);
|
||
el.hidden = false;
|
||
el.setAttribute('data-row-id', rowId);
|
||
}
|
||
|
||
function clearStatus() {
|
||
const el = document.getElementById('table-status');
|
||
if (!el) return;
|
||
el.textContent = '';
|
||
el.hidden = true;
|
||
el.removeAttribute('data-row-id');
|
||
el.classList.remove('table-status--prompt');
|
||
}
|
||
|
||
// --- The save itself ---------------------------------------------
|
||
|
||
async function saveRow(rowId) {
|
||
const { row, drafts } = rowFromState(rowId);
|
||
if (!row || !drafts || Object.keys(drafts).length === 0) {
|
||
return { status: 'noop' };
|
||
}
|
||
if (!row.yamlUrl) {
|
||
// file:// mode or rows from inline-context test fixtures
|
||
// don't have a URL to PUT to — bail silently.
|
||
return { status: 'no-url' };
|
||
}
|
||
if (row.editable === false) {
|
||
// Row is read-only per the server. Don't even try.
|
||
return { status: 'readonly' };
|
||
}
|
||
|
||
setRowState(rowId, 'saving');
|
||
const merged = mergeRow(row.data, drafts);
|
||
const yamlBody = window.jsyaml.dump(merged);
|
||
|
||
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
|
||
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
|
||
|
||
let resp;
|
||
try {
|
||
resp = await fetch(row.yamlUrl, {
|
||
method: 'PUT',
|
||
body: yamlBody,
|
||
headers: headers,
|
||
credentials: 'same-origin',
|
||
});
|
||
} catch (err) {
|
||
// Network failure — outbox-fronted client should still
|
||
// resolve with 202; reaching here means a hard client-side
|
||
// network error. Mark errored, drafts stay.
|
||
console.error('[tables] save network error', err);
|
||
setRowState(rowId, 'errored');
|
||
return { status: 'network-error', error: err };
|
||
}
|
||
|
||
if (resp.status === 200 || resp.status === 201) {
|
||
// Success: clear drafts + invalid marks, capture new ETag.
|
||
const newEtag = resp.headers.get('ETag');
|
||
if (newEtag) row.etag = newEtag.replace(/"/g, '');
|
||
row.data = merged;
|
||
delete app.state.drafts[rowId];
|
||
clearCellInvalid(rowId);
|
||
setRowState(rowId, '');
|
||
// If a status prompt was up for this row, drop it.
|
||
const sb = document.getElementById('table-status');
|
||
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
|
||
return { status: 'ok' };
|
||
}
|
||
|
||
if (resp.status === 202) {
|
||
// Outbox queued. Drafts clear (they're persisted in the
|
||
// outbox; the server will replay them on reconnect), but
|
||
// the row stays marked queued so the user knows.
|
||
row.data = merged;
|
||
delete app.state.drafts[rowId];
|
||
setRowState(rowId, 'queued');
|
||
return { status: 'queued' };
|
||
}
|
||
|
||
if (resp.status === 412) {
|
||
// Precondition Failed — someone else changed the row.
|
||
// Drafts STAY. Surface the prompt.
|
||
setRowState(rowId, 'stale');
|
||
showStatusPrompt(
|
||
rowId,
|
||
'This row was changed by someone else. ',
|
||
[
|
||
{ label: 'Use mine', onClick: () => useMine(rowId) },
|
||
{ label: 'Reload', onClick: () => reload(rowId) },
|
||
]
|
||
);
|
||
return { status: 'conflict' };
|
||
}
|
||
|
||
if (resp.status === 422) {
|
||
// Validation errors. Body shape matches the form system's
|
||
// 422 response: {errors: [{path: "/field", message}, ...]}.
|
||
let body = {};
|
||
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
||
clearCellInvalid(rowId);
|
||
const errs = body.errors || [];
|
||
for (let i = 0; i < errs.length; i++) {
|
||
const e = errs[i];
|
||
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
|
||
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
|
||
}
|
||
setRowState(rowId, 'invalid');
|
||
return { status: 'invalid', errors: errs };
|
||
}
|
||
|
||
// Other status — generic error.
|
||
console.warn('[tables] save returned', resp.status);
|
||
setRowState(rowId, 'errored');
|
||
return { status: 'http-error', code: resp.status };
|
||
}
|
||
|
||
async function useMine(rowId) {
|
||
const { row, drafts } = rowFromState(rowId);
|
||
if (!row || !drafts) return;
|
||
// Re-GET the row to learn the latest server state + ETag.
|
||
try {
|
||
const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' });
|
||
if (!resp.ok) {
|
||
console.warn('[tables] reload on conflict failed', resp.status);
|
||
return;
|
||
}
|
||
const text = await resp.text();
|
||
const fresh = window.jsyaml.load(text) || {};
|
||
row.data = fresh;
|
||
const newEtag = resp.headers.get('ETag');
|
||
row.etag = newEtag ? newEtag.replace(/"/g, '') : null;
|
||
} catch (err) {
|
||
console.error('[tables] reload on conflict error', err);
|
||
return;
|
||
}
|
||
// Drafts preserved — replay against the new base.
|
||
return saveRow(rowId);
|
||
}
|
||
|
||
async function reload(rowId) {
|
||
const row = findRowById(rowId);
|
||
if (!row) return;
|
||
try {
|
||
const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' });
|
||
if (!resp.ok) return;
|
||
const text = await resp.text();
|
||
row.data = window.jsyaml.load(text) || {};
|
||
const newEtag = resp.headers.get('ETag');
|
||
row.etag = newEtag ? newEtag.replace(/"/g, '') : null;
|
||
} catch (_) { return; }
|
||
delete app.state.drafts[rowId];
|
||
clearCellInvalid(rowId);
|
||
setRowState(rowId, '');
|
||
clearStatus();
|
||
// Trigger a re-paint via the public app callback if one exists.
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
}
|
||
|
||
// --- Trigger: row-blur ------------------------------------------
|
||
|
||
let _previousSelectedRowId = null;
|
||
|
||
function trackSelectionChange(prevRowId, nextRowId) {
|
||
// Fires when the editor's selection changes rows. If prevRow
|
||
// had drafts, save it now. nextRow can be null (focus left
|
||
// the grid) — also a save trigger.
|
||
if (prevRowId && prevRowId !== nextRowId) {
|
||
const drafts = app.state.drafts && app.state.drafts[prevRowId];
|
||
if (drafts && Object.keys(drafts).length > 0) {
|
||
// Fire and forget. The user has moved on; we don't
|
||
// want to block their flow waiting for the server.
|
||
saveRow(prevRowId).catch(err => {
|
||
console.error('[tables] saveRow rejection', err);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function onSelectionChanged(selected) {
|
||
const prevRowId = _previousSelectedRowId;
|
||
const nextRowId = selected ? rowIdAtIndex(selected.row) : null;
|
||
if (prevRowId !== nextRowId) {
|
||
trackSelectionChange(prevRowId, nextRowId);
|
||
_previousSelectedRowId = nextRowId;
|
||
}
|
||
// Mark dirty rows visually whenever selection settles.
|
||
markAllDirtyRows();
|
||
}
|
||
|
||
function rowIdAtIndex(visibleRowIdx) {
|
||
const tr = document.querySelectorAll('#table-root tbody > tr')[visibleRowIdx];
|
||
return tr ? tr.getAttribute('data-row-id') : null;
|
||
}
|
||
|
||
function markAllDirtyRows() {
|
||
// After a re-paint or selection change, re-apply dirty state
|
||
// to any row that has drafts (CSS classes don't survive
|
||
// tbody.innerHTML='' in renderBody).
|
||
const drafts = app.state.drafts || {};
|
||
const tbody = document.querySelector('#table-root tbody');
|
||
if (!tbody) return;
|
||
const trs = tbody.querySelectorAll('tr');
|
||
for (let i = 0; i < trs.length; i++) {
|
||
const tr = trs[i];
|
||
const rowId = tr.getAttribute('data-row-id');
|
||
if (rowId && drafts[rowId] && Object.keys(drafts[rowId]).length > 0) {
|
||
if (!tr.classList.contains('zddc-table__row--saving') &&
|
||
!tr.classList.contains('zddc-table__row--stale') &&
|
||
!tr.classList.contains('zddc-table__row--invalid') &&
|
||
!tr.classList.contains('zddc-table__row--errored') &&
|
||
!tr.classList.contains('zddc-table__row--queued')) {
|
||
tr.classList.add('zddc-table__row--dirty');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function flushAllDrafts() {
|
||
// Page-unload safety net. Best-effort: any row with drafts
|
||
// gets one final save attempt. fetch() is async, the page may
|
||
// already be navigating; we just kick the requests off.
|
||
const drafts = app.state.drafts || {};
|
||
const ids = Object.keys(drafts);
|
||
for (let i = 0; i < ids.length; i++) {
|
||
saveRow(ids[i]).catch(() => {});
|
||
}
|
||
}
|
||
|
||
// Window unload handler — call any in-flight drafts so the user
|
||
// doesn't lose typing on tab-close. Best-effort; modern browsers
|
||
// limit what beforeunload can do but a fetch with keepalive: true
|
||
// gives us one shot. (TODO: switch to keepalive on save for the
|
||
// unload path.)
|
||
window.addEventListener('beforeunload', function (_ev) {
|
||
flushAllDrafts();
|
||
});
|
||
|
||
app.modules.save = {
|
||
saveRow: saveRow,
|
||
useMine: useMine,
|
||
reload: reload,
|
||
onSelectionChanged: onSelectionChanged,
|
||
markAllDirtyRows: markAllDirtyRows,
|
||
flushAllDrafts: flushAllDrafts,
|
||
};
|
||
})(window.tablesApp);
|