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:
parent
e5bb7f216c
commit
cd751eb604
8 changed files with 1160 additions and 3 deletions
|
|
@ -41,6 +41,7 @@ concat_files \
|
|||
"js/filters.js" \
|
||||
"js/sort.js" \
|
||||
"js/editor.js" \
|
||||
"js/save.js" \
|
||||
"js/render.js" \
|
||||
"js/main.js" \
|
||||
"../form/js/app.js" \
|
||||
|
|
|
|||
|
|
@ -141,6 +141,47 @@
|
|||
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 {
|
||||
padding: var(--spacing-lg) var(--spacing-md);
|
||||
text-align: center;
|
||||
|
|
|
|||
|
|
@ -174,11 +174,21 @@
|
|||
// the rows but aren't rows themselves.
|
||||
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
|
||||
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());
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@
|
|||
const cols = colCount();
|
||||
if (total === 0 || cols === 0) {
|
||||
app.state.selected = null;
|
||||
notifySelectionChanged();
|
||||
return;
|
||||
}
|
||||
if (r < 0) r = 0;
|
||||
|
|
@ -163,6 +164,16 @@
|
|||
}
|
||||
}
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -106,8 +106,18 @@
|
|||
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) {
|
||||
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);
|
||||
paint();
|
||||
|
|
|
|||
406
tables/js/save.js
Normal file
406
tables/js/save.js
Normal 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);
|
||||
|
|
@ -553,4 +553,203 @@ test.describe('tables/ — directory-of-YAML table view', () => {
|
|||
const input = cell.locator('input.zddc-table__cell-input');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -668,6 +668,47 @@ body.help-open .app-header {
|
|||
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 {
|
||||
padding: var(--spacing-lg) var(--spacing-md);
|
||||
text-align: center;
|
||||
|
|
@ -891,7 +932,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 class="header-right">
|
||||
|
|
@ -2158,11 +2199,21 @@ body.help-open .app-header {
|
|||
// the rows but aren't rows themselves.
|
||||
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
|
||||
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());
|
||||
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) {
|
||||
|
|
@ -2654,6 +2705,7 @@ body.help-open .app-header {
|
|||
const cols = colCount();
|
||||
if (total === 0 || cols === 0) {
|
||||
app.state.selected = null;
|
||||
notifySelectionChanged();
|
||||
return;
|
||||
}
|
||||
if (r < 0) r = 0;
|
||||
|
|
@ -2678,6 +2730,16 @@ body.help-open .app-header {
|
|||
}
|
||||
}
|
||||
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() {
|
||||
|
|
@ -3147,6 +3209,413 @@ body.help-open .app-header {
|
|||
};
|
||||
})(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) {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -3353,8 +3822,18 @@ body.help-open .app-header {
|
|||
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) {
|
||||
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);
|
||||
paint();
|
||||
|
|
|
|||
Loading…
Reference in a new issue