ZDDC/tables/js/row-ops.js
ZDDC b4c0327f63 feat(tables): row editor — inline Add Row, Delete, multi-row paste, min row height
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>
2026-05-15 16:07:28 -05:00

201 lines
7.4 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 label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row';
return [
{
label: label,
icon: '🗑',
danger: true,
disabled: !ctx.row || ctx.row.editable === false,
action: function () {
if (targets.length > 1) deleteRows(targets);
else deleteRow(targets[0]);
}
}
];
}
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);