Two server-aligned signals on save paths:
- +Add row button: fetches /.profile/access?path=<current dir> via
zddc.cap.at() once on load; if path_verbs doesn't include 'c'
the button disables with a tooltip ("You don't have create
access in this folder."). Async race-window is the same as any
other path-scoped fetch — server still gates the POST so a
stale client gets a 403 toast on click rather than a silent
accept.
- 403 on save/create: previously fell into the generic
"http-error" bucket with a console warn; now branches into
zddc.cap.handleForbidden which renders an error toast naming the
missing verb. When the path-scoped view reports an elevation
grant covering that verb, the toast appends an Elevate button.
Per-row writability stays computed server-side for now — tables
walks rows via FS-API-style handles that don't surface the listing
verbs string. A follow-on pass can switch the row walk to raw
listing entries and gate row.editable on each entry's verbs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
266 lines
12 KiB
JavaScript
266 lines
12 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);
|
|
});
|
|
|
|
// Permission gate: fetch the path-scoped verbs for the
|
|
// current directory and disable + Add row when the
|
|
// cascade denies create. Async — the button shows up
|
|
// optimistically and disables a tick later if the
|
|
// server says no, which is the same race window every
|
|
// path-scoped fetch has. Server still gates the POST,
|
|
// so the worst case is a 403 toast on click.
|
|
if (window.zddc && window.zddc.cap) {
|
|
window.zddc.cap.at(location.pathname).then(function (view) {
|
|
if (!view) return;
|
|
var verbs = view.path_verbs || '';
|
|
if (verbs.indexOf('c') === -1) {
|
|
addRowBtn.classList.add('is-disabled');
|
|
addRowBtn.setAttribute('aria-disabled', 'true');
|
|
addRowBtn.title = "You don't have create access in this folder.";
|
|
// Swallow clicks so the no-op feedback is the
|
|
// tooltip, not a 403 toast on submission.
|
|
addRowBtn.addEventListener('click', function (ev) {
|
|
if (addRowBtn.classList.contains('is-disabled')) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
}
|
|
}, true);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|