feat(tables): explicit Save button + clearer dirty-row marker

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-19 08:38:35 -05:00
parent 1604b62477
commit 1721b4b1db
6 changed files with 239 additions and 3 deletions

View file

@ -160,7 +160,11 @@
left-border swatch; the row tooltip on hover surfaces the state. left-border swatch; the row tooltip on hover surfaces the state.
Colors track the state's urgency: dirty (subtle), saving (info), Colors track the state's urgency: dirty (subtle), saving (info),
queued (warm), invalid/stale (warning), errored (alert). */ 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--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--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--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)); }

View file

@ -109,6 +109,7 @@
app.state.drafts[rowId] = {}; app.state.drafts[rowId] = {};
} }
app.state.drafts[rowId][field] = value; app.state.drafts[rowId][field] = value;
notifyDraftsChanged();
} }
function clearDraftField(rowId, field) { function clearDraftField(rowId, field) {
@ -118,6 +119,17 @@
if (Object.keys(r).length === 0) { if (Object.keys(r).length === 0) {
delete app.state.drafts[rowId]; 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) { function effectiveCellValue(row, col) {

View file

@ -31,6 +31,7 @@
const clearBtn = document.getElementById('table-clear-filters'); const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row'); const addRowBtn = document.getElementById('table-add-row');
const exportBtn = document.getElementById('table-export-csv'); const exportBtn = document.getElementById('table-export-csv');
const saveBtn = document.getElementById('table-save');
// Add-row button: appends a draft row inline. Save fires on // Add-row button: appends a draft row inline. Save fires on
// row-blur, which POSTs to <dir>/form.html and swaps the // row-blur, which POSTs to <dir>/form.html and swaps the
@ -39,6 +40,50 @@
// context loaded with columns) — the test-fixture inline-context // context loaded with columns) — the test-fixture inline-context
// harness opens tables.html directly with no URL shape, so we // harness opens tables.html directly with no URL shape, so we
// gate on having a column list AND running over http(s). // 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 + // Export CSV: client-side build of the current view (filtered +
// sorted columns + values). No server round-trip, no auth gate // sorted columns + values). No server round-trip, no auth gate
// — the user already has the data on screen. Shown on every // — the user already has the data on screen. Shown on every
@ -155,6 +200,11 @@
if (save && typeof save.markAllDirtyRows === 'function') { if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows(); 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 / // Public re-paint entry point so other modules (save.useMine /

View file

@ -244,6 +244,7 @@
// If a status prompt was up for this row, drop it. // If a status prompt was up for this row, drop it.
const sb = document.getElementById('table-status'); const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus(); if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
updateSaveButton();
return { status: 'ok' }; return { status: 'ok' };
} }
@ -254,6 +255,7 @@
row.data = merged; row.data = merged;
delete app.state.drafts[rowId]; delete app.state.drafts[rowId];
setRowState(rowId, 'queued'); setRowState(rowId, 'queued');
updateSaveButton();
return { status: 'queued' }; 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 // Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. The PUT uses keepalive:true so // doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap. // it survives navigation; that comes with a 64 KB body cap.
@ -496,7 +543,11 @@
useMine: useMine, useMine: useMine,
reload: reload, reload: reload,
onSelectionChanged: onSelectionChanged, onSelectionChanged: onSelectionChanged,
onDraftsChanged: onDraftsChanged,
markAllDirtyRows: markAllDirtyRows, markAllDirtyRows: markAllDirtyRows,
updateSaveButton: updateSaveButton,
flushAll: flushAll,
dirtyCount: dirtyCount,
flushAllDrafts: flushAllDrafts, flushAllDrafts: flushAllDrafts,
}; };
})(window.tablesApp); })(window.tablesApp);

View file

@ -47,6 +47,7 @@
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button> <button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
</div> </div>
<div class="table-toolbar__right"> <div class="table-toolbar__right">
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button> <button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a> <a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div> </div>

View file

@ -1253,7 +1253,11 @@ body.is-elevated::after {
left-border swatch; the row tooltip on hover surfaces the state. left-border swatch; the row tooltip on hover surfaces the state.
Colors track the state's urgency: dirty (subtle), saving (info), Colors track the state's urgency: dirty (subtle), saving (info),
queued (warm), invalid/stale (warning), errored (alert). */ 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--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--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--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 {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 13:30:29 · f3d334a-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 13:37:29 · 1604b62-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -1536,6 +1540,7 @@ body.is-elevated::after {
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button> <button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
</div> </div>
<div class="table-toolbar__right"> <div class="table-toolbar__right">
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button> <button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a> <a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div> </div>
@ -4105,6 +4110,7 @@ body.is-elevated::after {
app.state.drafts[rowId] = {}; app.state.drafts[rowId] = {};
} }
app.state.drafts[rowId][field] = value; app.state.drafts[rowId][field] = value;
notifyDraftsChanged();
} }
function clearDraftField(rowId, field) { function clearDraftField(rowId, field) {
@ -4114,6 +4120,17 @@ body.is-elevated::after {
if (Object.keys(r).length === 0) { if (Object.keys(r).length === 0) {
delete app.state.drafts[rowId]; 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) { function effectiveCellValue(row, col) {
@ -5340,6 +5357,7 @@ body.is-elevated::after {
// If a status prompt was up for this row, drop it. // If a status prompt was up for this row, drop it.
const sb = document.getElementById('table-status'); const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus(); if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
updateSaveButton();
return { status: 'ok' }; return { status: 'ok' };
} }
@ -5350,6 +5368,7 @@ body.is-elevated::after {
row.data = merged; row.data = merged;
delete app.state.drafts[rowId]; delete app.state.drafts[rowId];
setRowState(rowId, 'queued'); setRowState(rowId, 'queued');
updateSaveButton();
return { status: 'queued' }; 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 // Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. The PUT uses keepalive:true so // doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap. // it survives navigation; that comes with a 64 KB body cap.
@ -5592,7 +5656,11 @@ body.is-elevated::after {
useMine: useMine, useMine: useMine,
reload: reload, reload: reload,
onSelectionChanged: onSelectionChanged, onSelectionChanged: onSelectionChanged,
onDraftsChanged: onDraftsChanged,
markAllDirtyRows: markAllDirtyRows, markAllDirtyRows: markAllDirtyRows,
updateSaveButton: updateSaveButton,
flushAll: flushAll,
dirtyCount: dirtyCount,
flushAllDrafts: flushAllDrafts, flushAllDrafts: flushAllDrafts,
}; };
})(window.tablesApp); })(window.tablesApp);
@ -6355,6 +6423,7 @@ body.is-elevated::after {
const clearBtn = document.getElementById('table-clear-filters'); const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row'); const addRowBtn = document.getElementById('table-add-row');
const exportBtn = document.getElementById('table-export-csv'); const exportBtn = document.getElementById('table-export-csv');
const saveBtn = document.getElementById('table-save');
// Add-row button: appends a draft row inline. Save fires on // Add-row button: appends a draft row inline. Save fires on
// row-blur, which POSTs to <dir>/form.html and swaps the // row-blur, which POSTs to <dir>/form.html and swaps the
@ -6363,6 +6432,50 @@ body.is-elevated::after {
// context loaded with columns) — the test-fixture inline-context // context loaded with columns) — the test-fixture inline-context
// harness opens tables.html directly with no URL shape, so we // harness opens tables.html directly with no URL shape, so we
// gate on having a column list AND running over http(s). // 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 + // Export CSV: client-side build of the current view (filtered +
// sorted columns + values). No server round-trip, no auth gate // sorted columns + values). No server round-trip, no auth gate
// — the user already has the data on screen. Shown on every // — 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') { if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows(); 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 / // Public re-paint entry point so other modules (save.useMine /