ZDDC/tables/js/row-ops.js
ZDDC f9ba493145 feat(tables): row context-menu opens the form, not raw YAML
Replace "Edit YAML" with "Edit row" — navigates to row.url, which
is already the schema-driven form-mode editor URL. The form handler
unwraps virtual-view URLs server-side so SSR and rollup rows route
to their per-party canonical paths automatically; no client-side
URL rewriting needed.

This fills the gap where row-click only opens the form for
complex-type cells (objects, arrays) — for plain scalars it enters
inline edit mode. Right-click → Edit row is now the discoverable
way to reach the full form for any row.

Raw YAML editing remains available via the browse tool directly
(navigate to the file's parent folder and click it in the tree).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:43:45 -05:00

222 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 already the <id>.yaml.html form URL
// (the form handler unwraps virtual-view URLs server-side, so
// SSR + rollup rows route to their per-party canonical paths
// automatically). 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);