From 1721b4b1dbaca0b2e3f7cf242f4321b812ebd4ce Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 19 May 2026 08:38:35 -0500 Subject: [PATCH] feat(tables): explicit Save button + clearer dirty-row marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three triggers for flushing pending edits: - Save button in the toolbar — shown only when ≥1 row is dirty, label reads "Save (N unsaved)". Disappears after a clean settle. - Ctrl+S (Cmd+S) anywhere on the page, capturing-phase so it beats the browser's "Save Page As" default. - focusout of #table-root with a relatedTarget outside the grid — catches "edit cell, click a header link, expect it to save". The row-blur trigger stays — moving between rows still flushes. The new triggers fill the gap when the user edits one row and then leaves the grid entirely without first navigating to another row. Dirty marker gets a 4px (was 3px) left swatch AND a faint blue background tint on the row, so "unsaved" reads as a row state rather than a small marker on the edge. editor.setDraft / clearDraftField notify save.onDraftsChanged, which refreshes the Save button + reapplies the dirty class. saveRow on 200/201/202 also refreshes the button so it disappears the moment its row settles. Co-Authored-By: Claude Opus 4.7 (1M context) --- tables/css/table.css | 6 +- tables/js/editor.js | 12 +++ tables/js/main.js | 50 ++++++++++++ tables/js/save.js | 51 +++++++++++++ tables/template.html | 1 + zddc/internal/handler/tables.html | 122 +++++++++++++++++++++++++++++- 6 files changed, 239 insertions(+), 3 deletions(-) diff --git a/tables/css/table.css b/tables/css/table.css index 2309ca9..9aa07b5 100644 --- a/tables/css/table.css +++ b/tables/css/table.css @@ -160,7 +160,11 @@ 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); } +/* Dirty row gets a wider swatch (4px → easier to see at a glance) AND + a faint blue background so the unsaved state reads as "row is in a + different state" not "small marker on the edge". */ +.zddc-table__row--dirty td:first-child { box-shadow: inset 4px 0 0 var(--color-info, #4a90e2); } +.zddc-table__row--dirty { background: var(--color-bg-info, rgba(74, 144, 226, 0.08)); } .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)); } diff --git a/tables/js/editor.js b/tables/js/editor.js index a079c63..86edf5a 100644 --- a/tables/js/editor.js +++ b/tables/js/editor.js @@ -109,6 +109,7 @@ app.state.drafts[rowId] = {}; } app.state.drafts[rowId][field] = value; + notifyDraftsChanged(); } function clearDraftField(rowId, field) { @@ -118,6 +119,17 @@ if (Object.keys(r).length === 0) { delete app.state.drafts[rowId]; } + notifyDraftsChanged(); + } + + // Notify the save module that drafts changed so it can update the + // toolbar Save button + count. Save module is optional in test + // fixtures, so the call is guarded. + function notifyDraftsChanged() { + const save = app.modules.save; + if (save && typeof save.onDraftsChanged === 'function') { + save.onDraftsChanged(); + } } function effectiveCellValue(row, col) { diff --git a/tables/js/main.js b/tables/js/main.js index 2b0e5bc..2171d19 100644 --- a/tables/js/main.js +++ b/tables/js/main.js @@ -31,6 +31,7 @@ const clearBtn = document.getElementById('table-clear-filters'); const addRowBtn = document.getElementById('table-add-row'); const exportBtn = document.getElementById('table-export-csv'); + const saveBtn = document.getElementById('table-save'); // Add-row button: appends a draft row inline. Save fires on // row-blur, which POSTs to /form.html and swaps the @@ -39,6 +40,50 @@ // context loaded with columns) — the test-fixture inline-context // harness opens tables.html directly with no URL shape, so we // gate on having a column list AND running over http(s). + // Save: explicit flush of every dirty row. The button is + // hidden until a draft exists; save.onDraftsChanged() (called + // from editor.setDraft / clearDraftField) toggles visibility + + // updates the count label. Backstop for the row-blur trigger, + // which only fires when the user navigates to a different + // ROW in the table — clicking outside the grid entirely never + // fired a save without this. + if (saveBtn) { + saveBtn.addEventListener('click', function () { + const save = app.modules.save; + if (save && typeof save.flushAll === 'function') { + save.flushAll(); + } + }); + } + + // Ctrl+S (Cmd+S on mac) flushes all dirty rows. Capturing + // phase so we beat the browser's "Save Page As" default. + window.addEventListener('keydown', function (ev) { + if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) { + const save = app.modules.save; + if (save && typeof save.dirtyCount === 'function' && save.dirtyCount() > 0) { + ev.preventDefault(); + save.flushAll(); + } + } + }); + + // Auto-save when focus leaves the grid entirely (the user + // clicked a header link, the URL bar, etc. without moving to + // another row first). focusout fires for cell-to-cell moves + // too — relatedTarget being outside #table-root distinguishes. + const tableRoot = document.getElementById('table-root'); + if (tableRoot) { + tableRoot.addEventListener('focusout', function (ev) { + const next = ev.relatedTarget; + if (next && tableRoot.contains(next)) return; + const save = app.modules.save; + if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) { + save.flushAll(); + } + }); + } + // Export CSV: client-side build of the current view (filtered + // sorted columns + values). No server round-trip, no auth gate // — the user already has the data on screen. Shown on every @@ -155,6 +200,11 @@ if (save && typeof save.markAllDirtyRows === 'function') { save.markAllDirtyRows(); } + // Refresh the Save button visibility + count after every + // paint — save flow may have settled drafts in the meantime. + if (save && typeof save.updateSaveButton === 'function') { + save.updateSaveButton(); + } } // Public re-paint entry point so other modules (save.useMine / diff --git a/tables/js/save.js b/tables/js/save.js index 5c3f1cb..c6f0f2c 100644 --- a/tables/js/save.js +++ b/tables/js/save.js @@ -244,6 +244,7 @@ // 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(); + updateSaveButton(); return { status: 'ok' }; } @@ -254,6 +255,7 @@ row.data = merged; delete app.state.drafts[rowId]; setRowState(rowId, 'queued'); + updateSaveButton(); return { status: 'queued' }; } @@ -484,6 +486,51 @@ } } + // flushAll fires saves for every dirty row and returns when they + // all settle. Used by the explicit Save button and the auto-save + // when focus leaves the grid. Unlike flushAllDrafts, this is NOT + // keepalive — the page isn't going anywhere, so we wait for real + // responses and surface errors normally. + async function flushAll() { + const drafts = app.state.drafts || {}; + const ids = Object.keys(drafts).filter(id => drafts[id] && Object.keys(drafts[id]).length > 0); + if (ids.length === 0) return { status: 'noop' }; + const results = await Promise.allSettled(ids.map(id => saveRow(id))); + const ok = results.filter(r => r.status === 'fulfilled' && r.value && r.value.status === 'ok').length; + return { status: 'done', total: ids.length, ok: ok, failed: ids.length - ok }; + } + + // Count rows that have at least one unsaved field. + function dirtyCount() { + const drafts = app.state.drafts || {}; + let n = 0; + for (const id in drafts) { + if (drafts[id] && Object.keys(drafts[id]).length > 0) n++; + } + return n; + } + + // Update the toolbar Save button visibility + label from current + // draft state. Called from editor.js whenever drafts mutate; also + // safe to call anytime (e.g. after a paint). + function updateSaveButton() { + const btn = document.getElementById('table-save'); + if (!btn) return; + const n = dirtyCount(); + if (n === 0) { + btn.hidden = true; + btn.textContent = 'Save'; + return; + } + btn.hidden = false; + btn.textContent = n === 1 ? 'Save (1 unsaved)' : 'Save (' + n + ' unsaved)'; + } + + function onDraftsChanged() { + updateSaveButton(); + markAllDirtyRows(); + } + // 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. @@ -496,7 +543,11 @@ useMine: useMine, reload: reload, onSelectionChanged: onSelectionChanged, + onDraftsChanged: onDraftsChanged, markAllDirtyRows: markAllDirtyRows, + updateSaveButton: updateSaveButton, + flushAll: flushAll, + dirtyCount: dirtyCount, flushAllDrafts: flushAllDrafts, }; })(window.tablesApp); diff --git a/tables/template.html b/tables/template.html index 09eda85..65e484e 100644 --- a/tables/template.html +++ b/tables/template.html @@ -47,6 +47,7 @@
+
diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index deac1d1..a37dc9c 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1253,7 +1253,11 @@ body.is-elevated::after { 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); } +/* Dirty row gets a wider swatch (4px → easier to see at a glance) AND + a faint blue background so the unsaved state reads as "row is in a + different state" not "small marker on the edge". */ +.zddc-table__row--dirty td:first-child { box-shadow: inset 4px 0 0 var(--color-info, #4a90e2); } +.zddc-table__row--dirty { background: var(--color-bg-info, rgba(74, 144, 226, 0.08)); } .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)); } @@ -1511,7 +1515,7 @@ body.is-elevated::after {
ZDDC Table - v0.0.17-alpha · 2026-05-19 13:30:29 · f3d334a-dirty + v0.0.17-alpha · 2026-05-19 13:37:29 · 1604b62-dirty
@@ -1536,6 +1540,7 @@ body.is-elevated::after {
+
@@ -4105,6 +4110,7 @@ body.is-elevated::after { app.state.drafts[rowId] = {}; } app.state.drafts[rowId][field] = value; + notifyDraftsChanged(); } function clearDraftField(rowId, field) { @@ -4114,6 +4120,17 @@ body.is-elevated::after { if (Object.keys(r).length === 0) { delete app.state.drafts[rowId]; } + notifyDraftsChanged(); + } + + // Notify the save module that drafts changed so it can update the + // toolbar Save button + count. Save module is optional in test + // fixtures, so the call is guarded. + function notifyDraftsChanged() { + const save = app.modules.save; + if (save && typeof save.onDraftsChanged === 'function') { + save.onDraftsChanged(); + } } function effectiveCellValue(row, col) { @@ -5340,6 +5357,7 @@ body.is-elevated::after { // 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(); + updateSaveButton(); return { status: 'ok' }; } @@ -5350,6 +5368,7 @@ body.is-elevated::after { row.data = merged; delete app.state.drafts[rowId]; setRowState(rowId, 'queued'); + updateSaveButton(); return { status: 'queued' }; } @@ -5580,6 +5599,51 @@ body.is-elevated::after { } } + // flushAll fires saves for every dirty row and returns when they + // all settle. Used by the explicit Save button and the auto-save + // when focus leaves the grid. Unlike flushAllDrafts, this is NOT + // keepalive — the page isn't going anywhere, so we wait for real + // responses and surface errors normally. + async function flushAll() { + const drafts = app.state.drafts || {}; + const ids = Object.keys(drafts).filter(id => drafts[id] && Object.keys(drafts[id]).length > 0); + if (ids.length === 0) return { status: 'noop' }; + const results = await Promise.allSettled(ids.map(id => saveRow(id))); + const ok = results.filter(r => r.status === 'fulfilled' && r.value && r.value.status === 'ok').length; + return { status: 'done', total: ids.length, ok: ok, failed: ids.length - ok }; + } + + // Count rows that have at least one unsaved field. + function dirtyCount() { + const drafts = app.state.drafts || {}; + let n = 0; + for (const id in drafts) { + if (drafts[id] && Object.keys(drafts[id]).length > 0) n++; + } + return n; + } + + // Update the toolbar Save button visibility + label from current + // draft state. Called from editor.js whenever drafts mutate; also + // safe to call anytime (e.g. after a paint). + function updateSaveButton() { + const btn = document.getElementById('table-save'); + if (!btn) return; + const n = dirtyCount(); + if (n === 0) { + btn.hidden = true; + btn.textContent = 'Save'; + return; + } + btn.hidden = false; + btn.textContent = n === 1 ? 'Save (1 unsaved)' : 'Save (' + n + ' unsaved)'; + } + + function onDraftsChanged() { + updateSaveButton(); + markAllDirtyRows(); + } + // 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. @@ -5592,7 +5656,11 @@ body.is-elevated::after { useMine: useMine, reload: reload, onSelectionChanged: onSelectionChanged, + onDraftsChanged: onDraftsChanged, markAllDirtyRows: markAllDirtyRows, + updateSaveButton: updateSaveButton, + flushAll: flushAll, + dirtyCount: dirtyCount, flushAllDrafts: flushAllDrafts, }; })(window.tablesApp); @@ -6355,6 +6423,7 @@ body.is-elevated::after { const clearBtn = document.getElementById('table-clear-filters'); const addRowBtn = document.getElementById('table-add-row'); const exportBtn = document.getElementById('table-export-csv'); + const saveBtn = document.getElementById('table-save'); // Add-row button: appends a draft row inline. Save fires on // row-blur, which POSTs to /form.html and swaps the @@ -6363,6 +6432,50 @@ body.is-elevated::after { // context loaded with columns) — the test-fixture inline-context // harness opens tables.html directly with no URL shape, so we // gate on having a column list AND running over http(s). + // Save: explicit flush of every dirty row. The button is + // hidden until a draft exists; save.onDraftsChanged() (called + // from editor.setDraft / clearDraftField) toggles visibility + + // updates the count label. Backstop for the row-blur trigger, + // which only fires when the user navigates to a different + // ROW in the table — clicking outside the grid entirely never + // fired a save without this. + if (saveBtn) { + saveBtn.addEventListener('click', function () { + const save = app.modules.save; + if (save && typeof save.flushAll === 'function') { + save.flushAll(); + } + }); + } + + // Ctrl+S (Cmd+S on mac) flushes all dirty rows. Capturing + // phase so we beat the browser's "Save Page As" default. + window.addEventListener('keydown', function (ev) { + if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) { + const save = app.modules.save; + if (save && typeof save.dirtyCount === 'function' && save.dirtyCount() > 0) { + ev.preventDefault(); + save.flushAll(); + } + } + }); + + // Auto-save when focus leaves the grid entirely (the user + // clicked a header link, the URL bar, etc. without moving to + // another row first). focusout fires for cell-to-cell moves + // too — relatedTarget being outside #table-root distinguishes. + const tableRoot = document.getElementById('table-root'); + if (tableRoot) { + tableRoot.addEventListener('focusout', function (ev) { + const next = ev.relatedTarget; + if (next && tableRoot.contains(next)) return; + const save = app.modules.save; + if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) { + save.flushAll(); + } + }); + } + // Export CSV: client-side build of the current view (filtered + // sorted columns + values). No server round-trip, no auth gate // — the user already has the data on screen. Shown on every @@ -6479,6 +6592,11 @@ body.is-elevated::after { if (save && typeof save.markAllDirtyRows === 'function') { save.markAllDirtyRows(); } + // Refresh the Save button visibility + count after every + // paint — save flow may have settled drafts in the meantime. + if (save && typeof save.updateSaveButton === 'function') { + save.updateSaveButton(); + } } // Public re-paint entry point so other modules (save.useMine /