The cell-editor was already complete (drafts, row-blur saves, etag concurrency, validation). This commit adds the missing row-level ops: - "+ Add row" appends a draft row inline; first cell focused. Row-blur POSTs to <dir>/form.html (the existing form-create endpoint); 201 swaps the synthetic id for the server-returned URL/ETag. Empty rows the user walks away from are silently discarded. - Right-click a row → "Delete row" (or "Delete N rows" when a cell range spans multiple rows). DELETE the row YAML with If-Match; 412 surfaces a conflict warning. - Multi-row clipboard paste creates new rows for grid content that extends past the last existing row, instead of dropping cells past the end. Each new row saves via its own row-blur. - Empty rows now have a 2.4em minimum height so a freshly-added row is visible. Without the floor it collapses to cell-padding (~8px) and looks like a divider line. Server-side: no new endpoints. Form-create (POST <dir>/form.html → 201 + Location) and file-API DELETE carry the new client capabilities. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
6.9 KiB
JavaScript
167 lines
6.9 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');
|
|
|
|
// 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).
|
|
if (addRowBtn) {
|
|
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
|
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
|
if (onHttp && hasCols) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
// 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);
|