From cd751eb6045ca216e1eb1730d1516ad73a0b6cd1 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 10:26:22 -0500 Subject: [PATCH] =?UTF-8?q?feat(tables):=20editable=20cells=20phase=203=20?= =?UTF-8?q?=E2=80=94=20row-level=20save=20+=20ETag=20conflict=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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) --- tables/build.sh | 1 + tables/css/table.css | 41 +++ tables/js/context.js | 12 +- tables/js/editor.js | 11 + tables/js/main.js | 10 + tables/js/save.js | 406 +++++++++++++++++++++++++ tests/tables.spec.js | 199 ++++++++++++ zddc/internal/handler/tables.html | 483 +++++++++++++++++++++++++++++- 8 files changed, 1160 insertions(+), 3 deletions(-) create mode 100644 tables/js/save.js diff --git a/tables/build.sh b/tables/build.sh index cc230b2..feb4b9d 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -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" \ diff --git a/tables/css/table.css b/tables/css/table.css index 7666e94..f02d934 100644 --- a/tables/css/table.css +++ b/tables/css/table.css @@ -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; diff --git a/tables/js/context.js b/tables/js/context.js index 296be1e..656cd33 100644 --- a/tables/js/context.js +++ b/tables/js/context.js @@ -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: 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) { diff --git a/tables/js/editor.js b/tables/js/editor.js index f28d2a2..eaa7860 100644 --- a/tables/js/editor.js +++ b/tables/js/editor.js @@ -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() { diff --git a/tables/js/main.js b/tables/js/main.js index cbe1fff..21d34d5 100644 --- a/tables/js/main.js +++ b/tables/js/main.js @@ -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(); diff --git a/tables/js/save.js b/tables/js/save.js new file mode 100644 index 0000000..d223b94 --- /dev/null +++ b/tables/js/save.js @@ -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); diff --git a/tests/tables.spec.js b/tests/tables.spec.js index 7fdc39b..79e90b6 100644 --- a/tests/tables.spec.js +++ b/tests/tables.spec.js @@ -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); + }); }); diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 8c32dfe..373baf3 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -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 {
ZDDC Table - v0.0.17-alpha · 2026-05-09 15:17:08 · 08ce8a1-dirty + v0.0.17-alpha · 2026-05-09 15:24:57 · e5bb7f2-dirty
@@ -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: 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();