ZDDC/tables/js/row-ops.js
2026-06-11 13:32:31 -05:00

221 lines
8.2 KiB
JavaScript

// row-ops.js — row-level operations (delete, future: duplicate,
// copy-to-table, etc.). Surfaced via a right-click context menu on
// table rows; the editor's selection state determines which row the
// action targets when the menu is invoked from the keyboard or from a
// future toolbar button.
//
// The shared context-menu primitive (window.zddc.menu) drives the
// rendering and keyboard behaviour. This module owns the menu spec
// and the action handlers.
(function (app) {
'use strict';
function findRowById(rowId) {
const all = (app.state && app.state.rows) || [];
for (let i = 0; i < all.length; i++) {
const editor = app.modules.editor;
const key = editor ? editor.rowKey(all[i]) : (all[i].url || '');
if (key === rowId) return all[i];
}
return null;
}
function removeRowFromState(row) {
const all = app.state.rows || [];
const idx = all.indexOf(row);
if (idx >= 0) all.splice(idx, 1);
// Drop any drafts keyed on the row's url.
if (app.state.drafts && row.url) {
delete app.state.drafts[row.url];
}
}
function rowDisplayName(row) {
if (!row) return '(unknown)';
if (row.isNew) return '(unsaved new row)';
if (row.yamlUrl) {
const m = row.yamlUrl.match(/[^/]+$/);
if (m) return m[0];
}
return row.url || '(row)';
}
async function deleteRow(rowId) {
const row = findRowById(rowId);
if (!row) return { status: 'noop' };
if (row.editable === false) return { status: 'readonly' };
// Unsaved new row: just drop it. Nothing to call.
if (row.isNew) {
removeRowFromState(row);
if (typeof app.repaint === 'function') app.repaint();
return { status: 'ok-local' };
}
if (!row.yamlUrl) {
// file:// or fixture context — nothing to delete server-side.
removeRowFromState(row);
if (typeof app.repaint === 'function') app.repaint();
return { status: 'ok-local' };
}
const ok = window.confirm('Delete row "' + rowDisplayName(row) + '"?\n\nThis cannot be undone.');
if (!ok) return { status: 'cancelled' };
const headers = {};
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
let resp;
try {
resp = await fetch(row.yamlUrl, {
method: 'DELETE',
headers: headers,
credentials: 'same-origin'
});
} catch (err) {
window.alert('Delete failed: ' + (err && err.message ? err.message : err));
return { status: 'network-error', error: err };
}
if (resp.status === 200 || resp.status === 204) {
removeRowFromState(row);
if (typeof app.repaint === 'function') app.repaint();
return { status: 'ok' };
}
if (resp.status === 412) {
window.alert('Cannot delete: this row was changed since you loaded it. Reload to see the latest version.');
return { status: 'conflict' };
}
let body = '';
try { body = await resp.text(); } catch (_) { /* ignore */ }
window.alert('Delete failed (' + resp.status + '): ' + body);
return { status: 'http-error', code: resp.status };
}
// Returns the list of visible-row indices currently included in
// the editor's range selection. Empty when no range is active.
function rangeRowIndices() {
const range = app.state && app.state.range;
if (!range) return [];
const r0 = Math.min(range.anchor.row, range.focus.row);
const r1 = Math.max(range.anchor.row, range.focus.row);
const out = [];
for (let r = r0; r <= r1; r++) out.push(r);
return out;
}
// Map a visible-row index to its data-row-id (synthetic or real).
function rowIdAtIndex(idx) {
const trs = document.querySelectorAll('#table-root tbody > tr');
const tr = trs[idx];
return tr ? tr.getAttribute('data-row-id') : null;
}
async function deleteRows(rowIds) {
if (!rowIds || rowIds.length === 0) return { status: 'noop' };
if (rowIds.length === 1) return deleteRow(rowIds[0]);
const ok = window.confirm('Delete ' + rowIds.length + ' rows?\n\nThis cannot be undone.');
if (!ok) return { status: 'cancelled' };
// Walk back-to-front so removing by index from state.rows
// doesn't shift the indices of pending deletes.
let okCount = 0, failCount = 0;
for (let i = rowIds.length - 1; i >= 0; i--) {
const row = findRowById(rowIds[i]);
if (!row) continue;
if (row.isNew || !row.yamlUrl) {
removeRowFromState(row);
okCount++;
continue;
}
const headers = {};
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
try {
const resp = await fetch(row.yamlUrl, {
method: 'DELETE',
headers: headers,
credentials: 'same-origin'
});
if (resp.status === 200 || resp.status === 204) {
removeRowFromState(row);
okCount++;
} else {
failCount++;
}
} catch (_err) {
failCount++;
}
}
if (typeof app.repaint === 'function') app.repaint();
if (failCount > 0) {
window.alert('Deleted ' + okCount + ' row(s); ' + failCount + ' failed.');
}
return { status: 'ok', deleted: okCount, failed: failCount };
}
function buildRowMenu(ctx) {
const rangeRows = ctx.rangeRowIds || [];
const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1;
const targets = inRange ? rangeRows : [ctx.rowId];
const items = [];
// Edit row — opens the schema-driven form-mode editor for this
// row. row.url is the real <…>/<id>.yaml.html form URL (it carries
// the <party>/ prefix for aggregate rows, so it hits the real
// per-party path). Disabled on multi-row range and unsaved draft
// rows (no backing file yet).
const singleRow = targets.length === 1 ? ctx.row : null;
const editUrl = singleRow && !singleRow.isNew && singleRow.url ? singleRow.url : null;
items.push({
label: 'Edit row',
icon: '✎',
disabled: !editUrl,
action: function () {
if (editUrl) window.location.href = editUrl;
}
});
items.push({ separator: true });
const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row';
items.push({
label: label,
icon: '🗑',
danger: true,
disabled: !ctx.row || ctx.row.editable === false,
action: function () {
if (targets.length > 1) deleteRows(targets);
else deleteRow(targets[0]);
}
});
return items;
}
function onRowContext(ev) {
const tr = ev.target.closest('tr[data-row-id]');
if (!tr) return;
const rowId = tr.getAttribute('data-row-id');
const row = findRowById(rowId);
if (!row) return;
ev.preventDefault();
const menu = window.zddc && window.zddc.menu;
if (!menu || typeof menu.open !== 'function') return;
const rangeRowIds = rangeRowIndices().map(rowIdAtIndex).filter(Boolean);
menu.open({
x: ev.clientX,
y: ev.clientY,
items: buildRowMenu({ row: row, rowId: rowId, rangeRowIds: rangeRowIds }),
context: { row: row, rowId: rowId, rangeRowIds: rangeRowIds }
});
}
function attach() {
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
tbody.addEventListener('contextmenu', onRowContext);
}
app.modules.rowOps = {
attach: attach,
deleteRow: deleteRow,
deleteRows: deleteRows,
};
})(window.tablesApp);