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>
This commit is contained in:
ZDDC 2026-05-09 10:26:22 -05:00
parent e5bb7f216c
commit cd751eb604
8 changed files with 1160 additions and 3 deletions

View file

@ -41,6 +41,7 @@ concat_files \
"js/filters.js" \ "js/filters.js" \
"js/sort.js" \ "js/sort.js" \
"js/editor.js" \ "js/editor.js" \
"js/save.js" \
"js/render.js" \ "js/render.js" \
"js/main.js" \ "js/main.js" \
"../form/js/app.js" \ "../form/js/app.js" \

View file

@ -141,6 +141,47 @@
outline: none; outline: none;
} }
/* Row-save state markers (Phase 3). The first cell of the row gets a
left-border swatch; the row tooltip on hover surfaces the state.
Colors track the state's urgency: dirty (subtle), saving (info),
queued (warm), invalid/stale (warning), errored (alert). */
.zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
.zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); }
.zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); }
.zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); }
.zddc-table__row--invalid td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); }
.zddc-table__row--errored td:first-child { box-shadow: inset 3px 0 0 var(--color-error, #c14242); background: var(--color-bg-error, rgba(193, 66, 66, 0.06)); }
/* Per-cell invalid marker small red corner triangle, Excel-style.
The hover tooltip carries the validation message via title attr. */
.zddc-table__cell--invalid {
position: relative;
}
.zddc-table__cell--invalid::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 6px 6px 0;
border-color: transparent var(--color-error, #c14242) transparent transparent;
}
/* Status bar (table-status) when used as the stale-row prompt host. */
.table-status.table-status--prompt {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-warning, rgba(232, 163, 61, 0.08));
border: 1px solid var(--color-warning, #e8a33d);
border-radius: var(--radius-sm, 4px);
margin-bottom: var(--spacing-sm);
color: var(--color-text, #111);
}
.table-empty { .table-empty {
padding: var(--spacing-lg) var(--spacing-md); padding: var(--spacing-lg) var(--spacing-md);
text-align: center; text-align: center;

View file

@ -174,11 +174,21 @@
// the rows but aren't rows themselves. // the rows but aren't rows themselves.
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue; if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
try { try {
const file = await (await rowsDir.getFileHandle(entry.name)).getFile(); const handle = await rowsDir.getFileHandle(entry.name);
const file = await handle.getFile();
const data = window.jsyaml.load(await file.text()); const data = window.jsyaml.load(await file.text());
rows.push({ rows.push({
url: rowEditUrl(entry.name), 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 || {}, 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 editable: true
}); });
} catch (err) { } catch (err) {

View file

@ -139,6 +139,7 @@
const cols = colCount(); const cols = colCount();
if (total === 0 || cols === 0) { if (total === 0 || cols === 0) {
app.state.selected = null; app.state.selected = null;
notifySelectionChanged();
return; return;
} }
if (r < 0) r = 0; if (r < 0) r = 0;
@ -163,6 +164,16 @@
} }
} }
app.state.selected = { row: r, col: c }; app.state.selected = { row: r, col: c };
notifySelectionChanged();
}
function notifySelectionChanged() {
// Phase 3 wires the row-blur save trigger here. save module is
// optional in test fixtures that don't include it.
const save = app.modules.save;
if (save && typeof save.onSelectionChanged === 'function') {
save.onSelectionChanged(app.state.selected);
}
} }
function clearSelection() { function clearSelection() {

View file

@ -106,7 +106,17 @@
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true }); editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
} }
} }
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
// renderBody wiped them.
const save = app.modules.save;
if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows();
} }
}
// Public re-paint entry point so other modules (save.useMine /
// save.reload) can request a refresh after they mutate row state.
app.repaint = paint;
function onHeaderClick(field, shiftKey) { function onHeaderClick(field, shiftKey) {
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey); state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);

406
tables/js/save.js Normal file
View file

@ -0,0 +1,406 @@
// 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);

View file

@ -553,4 +553,203 @@ test.describe('tables/ — directory-of-YAML table view', () => {
const input = cell.locator('input.zddc-table__cell-input'); const input = cell.locator('input.zddc-table__cell-input');
await expect(input).toHaveAttribute('type', 'text'); await expect(input).toHaveAttribute('type', 'text');
}); });
// --- Phase 3: row-level save + ETag conflict UX -----------------------
// Loading the page via file:// means PUTs go to file:// URLs — no
// network. We intercept via page.route on the parent http://* URL
// by hosting the test fixture rows with absolute http URLs in their
// yamlUrl field.
function makeNetRow(over) {
const id = over.id || 'D-001';
return {
url: `http://test.local/Working/MDL/${id}.yaml.html`,
yamlUrl: `http://test.local/Working/MDL/${id}.yaml`,
data: Object.assign({
id, title: 'Sample', party: 'Acme', dueDate: '2026-05-12',
status: 'pending', priority: 3, done: false, tags: ['blue'],
owner: { name: 'Casey', email: 'c@example.com' },
}, over.data || {}),
etag: over.etag || 'v1',
editable: true,
};
}
async function setupSaveCapture(page) {
// Intercept PUTs on test.local. Tests configure responses by
// pushing into window.__saveResponses; tests inspect requests
// via window.__savePuts.
await page.route('http://test.local/**', async (route) => {
const req = route.request();
const method = req.method();
if (method !== 'PUT' && method !== 'GET') {
await route.continue();
return;
}
await page.evaluate(({ url, method, body, headers }) => {
window.__capturedRequests = window.__capturedRequests || [];
window.__capturedRequests.push({ url, method, body, headers });
}, {
url: req.url(),
method,
body: req.postData(),
headers: req.headers(),
});
const queued = await page.evaluate(() => window.__nextResponse || null);
if (queued) {
await page.evaluate(() => { window.__nextResponse = null; });
await route.fulfill({
status: queued.status,
headers: queued.headers || {},
body: queued.body || '',
});
return;
}
// Default success.
await route.fulfill({
status: 200,
headers: { 'ETag': '"v2"' },
body: '',
});
});
}
test('Phase 3: row-blur fires PUT with merged drafts + If-Match', async ({ page }) => {
await setupSaveCapture(page);
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
await page.evaluate(() => { window.__capturedRequests = []; });
// Edit a cell in row 0, then click row 1 to move selection
// (row-blur trigger).
const titleCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await titleCell.dblclick();
await page.keyboard.press('Control+a');
await page.keyboard.type('Edited title');
await page.keyboard.press('Enter');
// Enter committed + moved selection down to row 1 — that IS
// the row-blur. Wait briefly for the async fetch to fire.
await page.waitForFunction(() =>
(window.__capturedRequests || []).some(r => r.method === 'PUT'),
null, { timeout: 3000 });
const puts = await page.evaluate(() =>
(window.__capturedRequests || []).filter(r => r.method === 'PUT'));
expect(puts).toHaveLength(1);
expect(puts[0].url).toBe('http://test.local/Working/MDL/D-001.yaml');
expect(puts[0].body).toContain('Edited title');
expect(puts[0].body).toContain('id: D-001');
// If-Match present.
expect(puts[0].headers['if-match']).toBe('"v1"');
});
test('Phase 3: 412 conflict marks row stale + shows status prompt', async ({ page }) => {
await setupSaveCapture(page);
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
await page.evaluate(() => { window.__nextResponse = { status: 412 }; });
const titleCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await titleCell.dblclick();
await page.keyboard.press('Control+a');
await page.keyboard.type('My change');
await page.keyboard.press('Enter');
// Wait for the row to gain the stale class.
const row0 = page.locator('#table-root tbody tr').nth(0);
await expect(row0).toHaveClass(/zddc-table__row--stale/);
// Status prompt visible with both buttons.
const status = page.locator('#table-status');
await expect(status).toBeVisible();
await expect(status).toContainText('changed by someone else');
await expect(status.locator('button', { hasText: 'Use mine' })).toBeVisible();
await expect(status.locator('button', { hasText: 'Reload' })).toBeVisible();
// Drafts must STILL exist — never silently discard.
const draftCount = await page.evaluate(() =>
Object.keys(window.tablesApp.state.drafts).length);
expect(draftCount).toBe(1);
});
test('Phase 3: 422 validation errors mark cells invalid', async ({ page }) => {
await setupSaveCapture(page);
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
await page.evaluate(() => {
window.__nextResponse = {
status: 422,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
errors: [
{ path: '/priority', message: 'must be ≤ 5' },
{ path: '/title', message: 'required' },
],
}),
};
});
const titleCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await titleCell.dblclick();
await page.keyboard.press('Control+a');
await page.keyboard.type('x');
await page.keyboard.press('Enter');
// Enter committed + moved selection to row 1 → row-blur on row 0
// → PUT fires → 422 → cells marked.
await page.waitForFunction(() =>
document.querySelector('.zddc-table__cell--invalid'),
null, { timeout: 3000 });
const invalidCells = await page.locator('.zddc-table__cell--invalid').count();
expect(invalidCells).toBe(2);
});
test('Phase 3: Reload button drops drafts and refreshes', async ({ page }) => {
await setupSaveCapture(page);
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
await page.evaluate(() => { window.__nextResponse = { status: 412 }; });
const titleCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await titleCell.dblclick();
await page.keyboard.press('Control+a');
await page.keyboard.type('My change');
await page.keyboard.press('Enter');
const status = page.locator('#table-status');
await expect(status).toBeVisible();
// Queue a fresh GET response with new ETag.
await page.evaluate(() => {
window.__nextResponse = {
status: 200,
headers: { 'ETag': '"v3"', 'Content-Type': 'application/yaml' },
body: 'id: D-001\ntitle: Server-side new title\nparty: Acme\ndueDate: 2026-05-12\nstatus: pending\npriority: 3\ndone: false\ntags:\n - blue\nowner:\n name: Casey\n email: c@example.com\n',
};
});
await status.locator('button', { hasText: 'Reload' }).click();
await expect(status).toBeHidden();
// Drafts cleared.
const draftCount = await page.evaluate(() =>
Object.keys(window.tablesApp.state.drafts).length);
expect(draftCount).toBe(0);
});
}); });

View file

@ -668,6 +668,47 @@ body.help-open .app-header {
outline: none; outline: none;
} }
/* Row-save state markers (Phase 3). The first cell of the row gets a
left-border swatch; the row tooltip on hover surfaces the state.
Colors track the state's urgency: dirty (subtle), saving (info),
queued (warm), invalid/stale (warning), errored (alert). */
.zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
.zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); }
.zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); }
.zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); }
.zddc-table__row--invalid td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); }
.zddc-table__row--errored td:first-child { box-shadow: inset 3px 0 0 var(--color-error, #c14242); background: var(--color-bg-error, rgba(193, 66, 66, 0.06)); }
/* Per-cell invalid marker — small red corner triangle, Excel-style.
The hover tooltip carries the validation message via title attr. */
.zddc-table__cell--invalid {
position: relative;
}
.zddc-table__cell--invalid::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 6px 6px 0;
border-color: transparent var(--color-error, #c14242) transparent transparent;
}
/* Status bar (table-status) when used as the stale-row prompt host. */
.table-status.table-status--prompt {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-warning, rgba(232, 163, 61, 0.08));
border: 1px solid var(--color-warning, #e8a33d);
border-radius: var(--radius-sm, 4px);
margin-bottom: var(--spacing-sm);
color: var(--color-text, #111);
}
.table-empty { .table-empty {
padding: var(--spacing-lg) var(--spacing-md); padding: var(--spacing-lg) var(--spacing-md);
text-align: center; text-align: center;
@ -891,7 +932,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 15:17:08 · 08ce8a1-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 15:24:57 · e5bb7f2-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -2158,11 +2199,21 @@ body.help-open .app-header {
// the rows but aren't rows themselves. // the rows but aren't rows themselves.
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue; if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
try { try {
const file = await (await rowsDir.getFileHandle(entry.name)).getFile(); const handle = await rowsDir.getFileHandle(entry.name);
const file = await handle.getFile();
const data = window.jsyaml.load(await file.text()); const data = window.jsyaml.load(await file.text());
rows.push({ rows.push({
url: rowEditUrl(entry.name), 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 || {}, 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 editable: true
}); });
} catch (err) { } catch (err) {
@ -2654,6 +2705,7 @@ body.help-open .app-header {
const cols = colCount(); const cols = colCount();
if (total === 0 || cols === 0) { if (total === 0 || cols === 0) {
app.state.selected = null; app.state.selected = null;
notifySelectionChanged();
return; return;
} }
if (r < 0) r = 0; if (r < 0) r = 0;
@ -2678,6 +2730,16 @@ body.help-open .app-header {
} }
} }
app.state.selected = { row: r, col: c }; app.state.selected = { row: r, col: c };
notifySelectionChanged();
}
function notifySelectionChanged() {
// Phase 3 wires the row-blur save trigger here. save module is
// optional in test fixtures that don't include it.
const save = app.modules.save;
if (save && typeof save.onSelectionChanged === 'function') {
save.onSelectionChanged(app.state.selected);
}
} }
function clearSelection() { function clearSelection() {
@ -3147,6 +3209,413 @@ body.help-open .app-header {
}; };
})(window.tablesApp); })(window.tablesApp);
// 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);
(function (app) { (function (app) {
'use strict'; 'use strict';
@ -3353,7 +3822,17 @@ body.help-open .app-header {
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true }); editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
} }
} }
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
// renderBody wiped them.
const save = app.modules.save;
if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows();
} }
}
// Public re-paint entry point so other modules (save.useMine /
// save.reload) can request a refresh after they mutate row state.
app.repaint = paint;
function onHeaderClick(field, shiftKey) { function onHeaderClick(field, shiftKey) {
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey); state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);