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>
This commit is contained in:
parent
167a56dc07
commit
b4c0327f63
8 changed files with 1440 additions and 43 deletions
|
|
@ -24,6 +24,7 @@ concat_files \
|
||||||
"../shared/elevation.css" \
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
"../shared/nav.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
|
"../shared/context-menu.css" \
|
||||||
"css/table.css" \
|
"css/table.css" \
|
||||||
"../form/css/form.css" \
|
"../form/css/form.css" \
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
@ -43,6 +44,7 @@ concat_files \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
"../shared/context-menu.js" \
|
||||||
"js/mode.js" \
|
"js/mode.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/context.js" \
|
"js/context.js" \
|
||||||
|
|
@ -51,7 +53,9 @@ concat_files \
|
||||||
"js/sort.js" \
|
"js/sort.js" \
|
||||||
"js/editor.js" \
|
"js/editor.js" \
|
||||||
"js/undo.js" \
|
"js/undo.js" \
|
||||||
|
"js/add-row.js" \
|
||||||
"js/save.js" \
|
"js/save.js" \
|
||||||
|
"js/row-ops.js" \
|
||||||
"js/clipboard.js" \
|
"js/clipboard.js" \
|
||||||
"js/render.js" \
|
"js/render.js" \
|
||||||
"js/main.js" \
|
"js/main.js" \
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,14 @@
|
||||||
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
|
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Minimum row height so a freshly-added row (every cell empty) stays
|
||||||
|
visible — without this the row collapses to just cell padding and
|
||||||
|
looks like a thin divider line. Acts as a floor; rows with content
|
||||||
|
grow naturally to fit the text. */
|
||||||
|
.zddc-table__row {
|
||||||
|
height: 2.4em;
|
||||||
|
}
|
||||||
|
|
||||||
.zddc-table__row--readonly {
|
.zddc-table__row--readonly {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
tables/js/add-row.js
Normal file
109
tables/js/add-row.js
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
// add-row.js — inline new-row creation.
|
||||||
|
//
|
||||||
|
// Click "+ Add row" → append a draft row at the end of state.rows,
|
||||||
|
// focus its first editable cell, accumulate user typing into the
|
||||||
|
// drafts buffer like any other row. On row-blur, save.js detects the
|
||||||
|
// row.isNew flag and POSTs to <dir>/form.html (the form-create
|
||||||
|
// endpoint). The 201 response carries the new row's Location; we swap
|
||||||
|
// the synthetic url/yamlUrl for the real ones and the draft row
|
||||||
|
// becomes a normal saved row.
|
||||||
|
//
|
||||||
|
// Synthetic identity: each new row gets a temporary "__new-<N>" url
|
||||||
|
// so rowKey() returns something unique for selection + draft tracking.
|
||||||
|
// The temporary url is replaced after a successful POST. There is no
|
||||||
|
// "save on click" UX — the existing row-blur trigger is the save path,
|
||||||
|
// same as for edits.
|
||||||
|
(function (app) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let _counter = 0;
|
||||||
|
|
||||||
|
function makeSyntheticKey() {
|
||||||
|
_counter += 1;
|
||||||
|
return '__new-' + _counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the form-create URL for the current page. Both
|
||||||
|
// /<dir>/table.html and /<dir>/ (default_tool: tables) shape work;
|
||||||
|
// /<dir>/form.html is the form handler's "create" endpoint either
|
||||||
|
// way (the form handler keys off the in-dir convention, not the
|
||||||
|
// visiting URL shape).
|
||||||
|
function formCreateUrl() {
|
||||||
|
let dir = (location.pathname || '/').replace(/\/table\.html$/, '/');
|
||||||
|
if (!dir.endsWith('/')) dir += '/';
|
||||||
|
return dir + 'form.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create-and-paint: the user-facing path.
|
||||||
|
function invoke() {
|
||||||
|
const key = createSilent();
|
||||||
|
if (typeof app.repaint === 'function') app.repaint();
|
||||||
|
focusNewRow(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a draft row WITHOUT painting or focusing. Used by multi-row
|
||||||
|
// paste (clipboard.js) to create N rows in a single batch, with one
|
||||||
|
// paint at the end. Returns the synthetic url so callers can address
|
||||||
|
// the new row in their draft writes.
|
||||||
|
function createSilent() {
|
||||||
|
const key = makeSyntheticKey();
|
||||||
|
const draftRow = {
|
||||||
|
url: key,
|
||||||
|
yamlUrl: null,
|
||||||
|
data: {},
|
||||||
|
etag: null,
|
||||||
|
editable: true,
|
||||||
|
isNew: true,
|
||||||
|
};
|
||||||
|
if (!Array.isArray(app.state.rows)) {
|
||||||
|
app.state.rows = [];
|
||||||
|
}
|
||||||
|
app.state.rows.push(draftRow);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNewRow(key) {
|
||||||
|
// After repaint, find the tr with our synthetic data-row-id and
|
||||||
|
// tell the editor to select its first cell. Filtering may have
|
||||||
|
// hidden the new row if a default filter excludes it; we accept
|
||||||
|
// that — clearing filters surfaces it.
|
||||||
|
const tbody = document.querySelector('#table-root tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
const trs = tbody.querySelectorAll('tr');
|
||||||
|
for (let i = 0; i < trs.length; i++) {
|
||||||
|
if (trs[i].getAttribute('data-row-id') === key) {
|
||||||
|
const editor = app.modules.editor;
|
||||||
|
if (editor && typeof editor.setSelected === 'function') {
|
||||||
|
// Scroll into view so the user sees the new row.
|
||||||
|
trs[i].scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||||
|
editor.setSelected(i, 0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel-new-row helper: drop the synthetic row entirely. Used when
|
||||||
|
// the user adds a row, makes no edits, and clicks Add again or
|
||||||
|
// navigates away — there's nothing to save and an empty draft just
|
||||||
|
// clutters the table. The save module calls this from row-blur when
|
||||||
|
// it sees a new row with no drafts.
|
||||||
|
function discardEmpty(rowId) {
|
||||||
|
const rows = app.state.rows || [];
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
if (rows[i].isNew && rows[i].url === rowId) {
|
||||||
|
rows.splice(i, 1);
|
||||||
|
if (typeof app.repaint === 'function') app.repaint();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.modules.addRow = {
|
||||||
|
invoke: invoke,
|
||||||
|
createSilent: createSilent,
|
||||||
|
formCreateUrl: formCreateUrl,
|
||||||
|
discardEmpty: discardEmpty,
|
||||||
|
};
|
||||||
|
})(window.tablesApp);
|
||||||
|
|
@ -119,17 +119,32 @@
|
||||||
// --- Apply paste --------------------------------------------------
|
// --- Apply paste --------------------------------------------------
|
||||||
|
|
||||||
function applyPaste(anchorRowIdx, anchorColIdx, grid) {
|
function applyPaste(anchorRowIdx, anchorColIdx, grid) {
|
||||||
// grid is string[][]. Returns {applied: int, skipped: int}.
|
// grid is string[][]. Returns {applied: int, skipped: int, created: int}.
|
||||||
|
// When the paste extends past the last existing row, the
|
||||||
|
// add-row module creates new draft rows on the fly so an Excel
|
||||||
|
// copy lands as a complete data set, not a clipped one. Each
|
||||||
|
// new row will save on its own row-blur (POST to form-create).
|
||||||
const ed = editor();
|
const ed = editor();
|
||||||
const totalRows = visibleRowCount();
|
const totalRows = visibleRowCount();
|
||||||
const cols = (app.context && app.context.columns) || [];
|
const cols = (app.context && app.context.columns) || [];
|
||||||
const totalCols = cols.length;
|
const totalCols = cols.length;
|
||||||
let applied = 0, skipped = 0;
|
const addRow = app.modules.addRow;
|
||||||
|
let applied = 0, skipped = 0, created = 0;
|
||||||
|
|
||||||
for (let r = 0; r < grid.length; r++) {
|
for (let r = 0; r < grid.length; r++) {
|
||||||
const dstR = anchorRowIdx + r;
|
const dstR = anchorRowIdx + r;
|
||||||
if (dstR >= totalRows) { skipped += grid[r].length; continue; }
|
let row = null;
|
||||||
const row = rowDataAtIndex(dstR);
|
if (dstR < totalRows) {
|
||||||
|
row = rowDataAtIndex(dstR);
|
||||||
|
} else if (addRow && typeof addRow.createSilent === 'function') {
|
||||||
|
addRow.createSilent();
|
||||||
|
created++;
|
||||||
|
// After createSilent the new row is at the end of
|
||||||
|
// state.rows but the DOM hasn't repainted yet — pull
|
||||||
|
// straight from state.rows to address it.
|
||||||
|
const all = (app.state && app.state.rows) || [];
|
||||||
|
row = all[all.length - 1];
|
||||||
|
}
|
||||||
if (!row) { skipped += grid[r].length; continue; }
|
if (!row) { skipped += grid[r].length; continue; }
|
||||||
for (let c = 0; c < grid[r].length; c++) {
|
for (let c = 0; c < grid[r].length; c++) {
|
||||||
const dstC = anchorColIdx + c;
|
const dstC = anchorColIdx + c;
|
||||||
|
|
@ -141,7 +156,7 @@
|
||||||
applied++;
|
applied++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { applied: applied, skipped: skipped };
|
return { applied: applied, skipped: skipped, created: created };
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleRowCount() {
|
function visibleRowCount() {
|
||||||
|
|
@ -208,11 +223,15 @@
|
||||||
const result = applyPaste(r, c, grid);
|
const result = applyPaste(r, c, grid);
|
||||||
// Trigger a re-paint so draft values display.
|
// Trigger a re-paint so draft values display.
|
||||||
if (typeof app.repaint === 'function') app.repaint();
|
if (typeof app.repaint === 'function') app.repaint();
|
||||||
|
let msg = 'Pasted ' + result.applied + ' cell' + plural(result.applied);
|
||||||
|
if (result.created > 0) {
|
||||||
|
msg += ' into ' + result.created + ' new row' + plural(result.created);
|
||||||
|
}
|
||||||
if (result.skipped > 0) {
|
if (result.skipped > 0) {
|
||||||
notifyToast(
|
msg += '; ' + result.skipped + ' dropped (out of bounds)';
|
||||||
'Pasted ' + result.applied + ' cell' + plural(result.applied) +
|
}
|
||||||
'; ' + result.skipped + ' dropped (out of bounds)'
|
if (result.created > 0 || result.skipped > 0) {
|
||||||
);
|
notifyToast(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,33 @@
|
||||||
const clearBtn = document.getElementById('table-clear-filters');
|
const clearBtn = document.getElementById('table-clear-filters');
|
||||||
const addRowBtn = document.getElementById('table-add-row');
|
const addRowBtn = document.getElementById('table-add-row');
|
||||||
|
|
||||||
// Add-row button: link to <name>.form.html, the form-system's
|
// Add-row button: appends a draft row inline. Save fires on
|
||||||
// empty-form URL for this table's row schema. POST creates a
|
// row-blur, which POSTs to <dir>/form.html and swaps the
|
||||||
// new submission and the server redirects to the row's edit
|
// synthetic row id for the server's response. The button shows
|
||||||
// URL. Hidden when we can't derive a table name from the
|
// whenever the page is a real table view (http(s) + a table
|
||||||
// pathname (e.g. inline-context test harness opening tables.html
|
// context loaded with columns) — the test-fixture inline-context
|
||||||
// directly without a *.table.html URL).
|
// harness opens tables.html directly with no URL shape, so we
|
||||||
|
// gate on having a column list AND running over http(s).
|
||||||
if (addRowBtn) {
|
if (addRowBtn) {
|
||||||
// Page is at <dir>/table.html; the row-creation form is at
|
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||||||
// <dir>/form.html — same directory, just swap the basename.
|
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||||||
if (/\/table\.html$/.test(location.pathname || '')) {
|
if (onHttp && hasCols) {
|
||||||
addRowBtn.href = 'form.html';
|
|
||||||
addRowBtn.hidden = false;
|
addRowBtn.hidden = false;
|
||||||
|
addRowBtn.removeAttribute('href');
|
||||||
|
addRowBtn.setAttribute('role', 'button');
|
||||||
|
addRowBtn.setAttribute('tabindex', '0');
|
||||||
|
addRowBtn.style.cursor = 'pointer';
|
||||||
|
const handleAdd = function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const addRow = app.modules.addRow;
|
||||||
|
if (addRow && typeof addRow.invoke === 'function') {
|
||||||
|
addRow.invoke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
addRowBtn.addEventListener('click', handleAdd);
|
||||||
|
addRowBtn.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,6 +121,12 @@
|
||||||
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
|
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Row context menu re-attaches each paint — renderBody wipes
|
||||||
|
// the tbody, taking listeners with it.
|
||||||
|
const rowOps = app.modules.rowOps;
|
||||||
|
if (rowOps && typeof rowOps.attach === 'function') {
|
||||||
|
rowOps.attach();
|
||||||
|
}
|
||||||
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
|
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
|
||||||
// renderBody wiped them.
|
// renderBody wiped them.
|
||||||
const save = app.modules.save;
|
const save = app.modules.save;
|
||||||
|
|
|
||||||
201
tables/js/row-ops.js
Normal file
201
tables/js/row-ops.js
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
// 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);
|
||||||
|
|
@ -177,8 +177,21 @@
|
||||||
async function saveRow(rowId, opts) {
|
async function saveRow(rowId, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
const { row, drafts } = rowFromState(rowId);
|
const { row, drafts } = rowFromState(rowId);
|
||||||
if (!row || !drafts || Object.keys(drafts).length === 0) {
|
if (!row) return { status: 'noop' };
|
||||||
return { status: 'noop' };
|
const hasDrafts = drafts && Object.keys(drafts).length > 0;
|
||||||
|
// New (unsaved) rows: if the user added a row and then moved on
|
||||||
|
// without typing anything, drop the empty placeholder rather
|
||||||
|
// than POST an empty body that fails schema validation.
|
||||||
|
if (row.isNew && !hasDrafts) {
|
||||||
|
const addRow = app.modules.addRow;
|
||||||
|
if (addRow && typeof addRow.discardEmpty === 'function') {
|
||||||
|
addRow.discardEmpty(rowId);
|
||||||
|
}
|
||||||
|
return { status: 'discarded-empty' };
|
||||||
|
}
|
||||||
|
if (!hasDrafts) return { status: 'noop' };
|
||||||
|
if (row.isNew) {
|
||||||
|
return createRow(rowId, row, drafts, opts);
|
||||||
}
|
}
|
||||||
if (!row.yamlUrl) {
|
if (!row.yamlUrl) {
|
||||||
// file:// mode or rows from inline-context test fixtures
|
// file:// mode or rows from inline-context test fixtures
|
||||||
|
|
@ -281,6 +294,84 @@
|
||||||
return { status: 'http-error', code: resp.status };
|
return { status: 'http-error', code: resp.status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createRow handles the POST path for an isNew row. Body is YAML of
|
||||||
|
// the row's draft data (no row.data yet — it's a fresh row). Success
|
||||||
|
// is 201 + Location pointing at the new <id>.yaml; we swap the
|
||||||
|
// synthetic url/yamlUrl for the real ones and clear isNew so the
|
||||||
|
// row behaves like any other from this point on.
|
||||||
|
async function createRow(rowId, row, drafts, opts) {
|
||||||
|
const addRow = app.modules.addRow;
|
||||||
|
if (!addRow || typeof addRow.formCreateUrl !== 'function') {
|
||||||
|
setRowState(rowId, 'errored');
|
||||||
|
return { status: 'no-create-url' };
|
||||||
|
}
|
||||||
|
const createUrl = addRow.formCreateUrl();
|
||||||
|
const merged = mergeRow(row.data, drafts);
|
||||||
|
const yamlBody = window.jsyaml.dump(merged);
|
||||||
|
|
||||||
|
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
|
||||||
|
const fetchOpts = {
|
||||||
|
method: 'POST',
|
||||||
|
body: yamlBody,
|
||||||
|
headers: headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
};
|
||||||
|
if (opts && opts.keepalive) fetchOpts.keepalive = true;
|
||||||
|
|
||||||
|
setRowState(rowId, 'saving');
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(createUrl, fetchOpts);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[tables] createRow network error', err);
|
||||||
|
setRowState(rowId, 'errored');
|
||||||
|
return { status: 'network-error', error: err };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status === 201) {
|
||||||
|
// Server wrote the row. Body is {location, filename}; we
|
||||||
|
// also accept the Location header if the body isn't JSON.
|
||||||
|
let body = {};
|
||||||
|
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
||||||
|
const location = body.location || resp.headers.get('Location') || '';
|
||||||
|
const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, '');
|
||||||
|
row.yamlUrl = location;
|
||||||
|
row.url = location ? location + '.html' : row.url;
|
||||||
|
row.data = merged;
|
||||||
|
row.etag = newEtag || null;
|
||||||
|
row.isNew = false;
|
||||||
|
// Move the drafts entry (was keyed on the synthetic id) to
|
||||||
|
// the new url, then clear it (data has the merged values).
|
||||||
|
delete app.state.drafts[rowId];
|
||||||
|
clearCellInvalid(rowId);
|
||||||
|
setRowState(rowId, '');
|
||||||
|
const sb = document.getElementById('table-status');
|
||||||
|
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
|
||||||
|
// Re-paint so the row picks up its new data-row-id and any
|
||||||
|
// server-supplied default fields surface.
|
||||||
|
if (typeof app.repaint === 'function') app.repaint();
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status === 422) {
|
||||||
|
let body = {};
|
||||||
|
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
||||||
|
clearCellInvalid(rowId);
|
||||||
|
const errs = body.errors || [];
|
||||||
|
for (let i = 0; i < errs.length; i++) {
|
||||||
|
const e = errs[i];
|
||||||
|
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
|
||||||
|
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
|
||||||
|
}
|
||||||
|
setRowState(rowId, 'invalid');
|
||||||
|
return { status: 'invalid', errors: errs };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[tables] createRow returned', resp.status);
|
||||||
|
setRowState(rowId, 'errored');
|
||||||
|
return { status: 'http-error', code: resp.status };
|
||||||
|
}
|
||||||
|
|
||||||
async function useMine(rowId) {
|
async function useMine(rowId) {
|
||||||
const { row, drafts } = rowFromState(rowId);
|
const { row, drafts } = rowFromState(rowId);
|
||||||
if (!row || !drafts) return;
|
if (!row || !drafts) return;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue