ZDDC/tables/js/save.js
ZDDC bf5ea7aa4f fix(tables): use fetch keepalive on the beforeunload save path
The TODO at save.js's unload handler was "switch to keepalive on save
for the unload path." flushAllDrafts() kicks off saveRow() per dirty
row when the page is being navigated away from, but those fetches were
not flagged keepalive — modern browsers can cancel them mid-flight as
the page unloads, dropping the user's last typing.

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

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

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

411 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, opts) {
opts = opts || {};
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 + '"';
const fetchOpts = {
method: 'PUT',
body: yamlBody,
headers: headers,
credentials: 'same-origin',
};
// The unload path passes keepalive:true so the PUT outlives the
// page navigation. Subject to the spec's 64 KB body cap — large
// rows may fail in that path; normal saves are unaffected.
if (opts.keepalive) fetchOpts.keepalive = true;
let resp;
try {
resp = await fetch(row.yamlUrl, fetchOpts);
} 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], { keepalive: true }).catch(() => {});
}
}
// Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap.
window.addEventListener('beforeunload', function (_ev) {
flushAllDrafts();
});
app.modules.save = {
saveRow: saveRow,
useMine: useMine,
reload: reload,
onSelectionChanged: onSelectionChanged,
markAllDirtyRows: markAllDirtyRows,
flushAllDrafts: flushAllDrafts,
};
})(window.tablesApp);