ZDDC/tables/js/save.js
ZDDC b4c0327f63 feat(tables): row editor — inline Add Row, Delete, multi-row paste, min row height
The cell-editor was already complete (drafts, row-blur saves, etag
concurrency, validation). This commit adds the missing row-level ops:

- "+ Add row" appends a draft row inline; first cell focused. Row-blur
  POSTs to <dir>/form.html (the existing form-create endpoint); 201
  swaps the synthetic id for the server-returned URL/ETag. Empty rows
  the user walks away from are silently discarded.
- Right-click a row → "Delete row" (or "Delete N rows" when a cell
  range spans multiple rows). DELETE the row YAML with If-Match; 412
  surfaces a conflict warning.
- Multi-row clipboard paste creates new rows for grid content that
  extends past the last existing row, instead of dropping cells past
  the end. Each new row saves via its own row-blur.
- Empty rows now have a 2.4em minimum height so a freshly-added row
  is visible. Without the floor it collapses to cell-padding (~8px)
  and looks like a divider line.

Server-side: no new endpoints. Form-create (POST <dir>/form.html →
201 + Location) and file-API DELETE carry the new client capabilities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:07:28 -05:00

502 lines
21 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) return { status: 'noop' };
const hasDrafts = drafts && Object.keys(drafts).length > 0;
// New (unsaved) rows: if the user added a row and then moved on
// without typing anything, drop the empty placeholder rather
// than POST an empty body that fails schema validation.
if (row.isNew && !hasDrafts) {
const addRow = app.modules.addRow;
if (addRow && typeof addRow.discardEmpty === 'function') {
addRow.discardEmpty(rowId);
}
return { status: 'discarded-empty' };
}
if (!hasDrafts) return { status: 'noop' };
if (row.isNew) {
return createRow(rowId, row, drafts, opts);
}
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 };
}
// createRow handles the POST path for an isNew row. Body is YAML of
// the row's draft data (no row.data yet — it's a fresh row). Success
// is 201 + Location pointing at the new <id>.yaml; we swap the
// synthetic url/yamlUrl for the real ones and clear isNew so the
// row behaves like any other from this point on.
async function createRow(rowId, row, drafts, opts) {
const addRow = app.modules.addRow;
if (!addRow || typeof addRow.formCreateUrl !== 'function') {
setRowState(rowId, 'errored');
return { status: 'no-create-url' };
}
const createUrl = addRow.formCreateUrl();
const merged = mergeRow(row.data, drafts);
const yamlBody = window.jsyaml.dump(merged);
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
const fetchOpts = {
method: 'POST',
body: yamlBody,
headers: headers,
credentials: 'same-origin',
};
if (opts && opts.keepalive) fetchOpts.keepalive = true;
setRowState(rowId, 'saving');
let resp;
try {
resp = await fetch(createUrl, fetchOpts);
} catch (err) {
console.error('[tables] createRow network error', err);
setRowState(rowId, 'errored');
return { status: 'network-error', error: err };
}
if (resp.status === 201) {
// Server wrote the row. Body is {location, filename}; we
// also accept the Location header if the body isn't JSON.
let body = {};
try { body = await resp.json(); } catch (_) { /* ignore */ }
const location = body.location || resp.headers.get('Location') || '';
const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, '');
row.yamlUrl = location;
row.url = location ? location + '.html' : row.url;
row.data = merged;
row.etag = newEtag || null;
row.isNew = false;
// Move the drafts entry (was keyed on the synthetic id) to
// the new url, then clear it (data has the merged values).
delete app.state.drafts[rowId];
clearCellInvalid(rowId);
setRowState(rowId, '');
const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
// Re-paint so the row picks up its new data-row-id and any
// server-supplied default fields surface.
if (typeof app.repaint === 'function') app.repaint();
return { status: 'ok' };
}
if (resp.status === 422) {
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 };
}
console.warn('[tables] createRow 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);