ZDDC/tables/js/main.js
ZDDC 1721b4b1db 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>
2026-05-19 08:38:35 -05:00

239 lines
10 KiB
JavaScript

(function (app) {
'use strict';
async function init() {
// Both apps (table + form) ship in the same bundle. Skip if
// mode dispatcher said this isn't our mode — form-mode requests
// are handled by formApp.
if (window.zddcMode === 'form') {
return;
}
const ctx = await app.modules.context.load();
app.context = ctx;
const titleEl = document.getElementById('table-title');
if (ctx.title && titleEl) {
titleEl.textContent = ctx.title;
document.title = 'ZDDC — ' + ctx.title;
}
const descEl = document.getElementById('table-description');
if (descEl && ctx.description) {
descEl.textContent = ctx.description;
descEl.hidden = false;
}
const tableEl = document.getElementById('table-root');
const theadEl = tableEl.querySelector('thead');
const tbodyEl = tableEl.querySelector('tbody');
const emptyEl = document.getElementById('table-empty');
const countEl = document.getElementById('table-rowcount');
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 <dir>/form.html and swaps the
// synthetic row id for the server's response. The button shows
// whenever the page is a real table view (http(s) + a table
// 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
// table that loaded with columns, regardless of HTTP/file://.
if (exportBtn) {
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
if (hasCols) {
exportBtn.hidden = false;
exportBtn.addEventListener('click', function () {
const exp = app.modules.exportCsv;
if (exp && typeof exp.invoke === 'function') {
exp.invoke();
}
});
}
}
if (addRowBtn) {
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
// ctx.addable === false suppresses the affordance entirely.
// Used by project-rollup tables where the row's party
// affiliation is ambiguous (add at the per-party path).
const allowAdd = ctx.addable !== false;
if (onHttp && hasCols && allowAdd) {
addRowBtn.hidden = false;
addRowBtn.removeAttribute('href');
addRowBtn.setAttribute('role', 'button');
addRowBtn.setAttribute('tabindex', '0');
addRowBtn.style.cursor = 'pointer';
const handleAdd = function (ev) {
ev.preventDefault();
const addRow = app.modules.addRow;
if (addRow && typeof addRow.invoke === 'function') {
addRow.invoke();
}
};
addRowBtn.addEventListener('click', handleAdd);
addRowBtn.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
});
}
}
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
const state = app.state;
state.rows = allRows;
state.sort = app.modules.sort.defaultsFromContext(ctx);
state.filter = {};
// Seed default filters from context.defaults.filter (per-column).
if (ctx.defaults && ctx.defaults.filter && typeof ctx.defaults.filter === 'object') {
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const seeded = ctx.defaults.filter[col.field];
if (seeded == null) {
continue;
}
// Filter UI is uniformly text-contains. If the spec
// seeds an array (legacy enum-style), coerce to a
// comma-joined contains string — partial match on any
// listed value still narrows the table sensibly.
const seedStr = Array.isArray(seeded) ? seeded.join(',') : String(seeded);
state.filter[col.field] = { kind: 'contains', value: seedStr };
}
}
function anyFilterActive() {
const filters = app.modules.filters;
const keys = Object.keys(state.filter);
for (let i = 0; i < keys.length; i++) {
if (!filters.isEmpty(state.filter[keys[i]])) {
return true;
}
}
return false;
}
function paint() {
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, app.modules.util.resolveField);
const sorted = app.modules.sort.apply(filtered, state.sort, columns, app.modules.util);
app.modules.render.header(theadEl, columns, state.sort, state.filter, onHeaderClick, onFilterChange);
app.modules.render.body(tbodyEl, sorted, columns);
app.modules.render.rowCount(countEl, sorted.length, state.rows.length);
if (emptyEl) {
emptyEl.hidden = sorted.length > 0 || state.rows.length === 0;
}
if (clearBtn) {
clearBtn.hidden = !anyFilterActive();
}
// Restore the editor's selection across re-paints so a sort
// or filter change doesn't dump the user out of the cell
// they were on. Selected coords clamp to the new bounds in
// setSelected; if the row vanished (filter excluded it),
// we land on the last valid cell instead of clearing.
const editor = app.modules.editor;
if (editor) {
editor.attachToTable();
if (state.selected) {
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
}
}
// Row context menu re-attaches each paint — renderBody wipes
// the tbody, taking listeners with it.
const rowOps = app.modules.rowOps;
if (rowOps && typeof rowOps.attach === 'function') {
rowOps.attach();
}
// 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();
}
// 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 /
// 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();
}
function onFilterChange(field, value) {
state.filter[field] = value;
paint();
}
if (clearBtn) {
clearBtn.addEventListener('click', function () {
state.filter = {};
paint();
});
}
paint();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(window.tablesApp);