browse: the party picker reads the ssr/ registry (the authoritative party list) and creates at physical peer paths <project>/<peer>/<party>/…; "register new party" writes ssr/<party>.yaml first (party_source: ssr). stage.js + accept-transmittal.js repointed to the top-level workspace peers (working/staging/incoming) — received/issued + plan-review stay under the WORM archive. tables: mdl/ and rsk/ render the cross-party aggregate by recursing ONE level into the party subdirs CLIENT-side (works online AND offline), with $party from the server-injected row content (or derived from the subdir offline). Rows carry the <party>/ prefix so reads/edits hit the real per-party path. The server just lists the peer root normally (party subdirs + synthetic table.yaml/form.yaml) — the fs/tree flattening + ListRollupRows are dropped in favour of this dual-mode client recursion. Full Go suite + all 256 Playwright tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
8.2 KiB
JavaScript
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);
|