// 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);