feat(tables): mark mandatory fields + show why a row won't save, inline

- Header: required columns (from the row schema's `required` list) get a red
  `*` marker so mandatory fields are obvious.
- Save: before the PUT/POST, a client-side required-field check marks the empty
  cells and blocks the save with a clear reason; the server's 422 remains the
  authority and its messages surface the same way.
- Inline error row: when a row can't be saved (required-missing, 422 validation,
  409 duplicate, 403 permission, network, HTTP error), the reason now shows in a
  full-width message row directly beneath the offending row — not just a hover
  tooltip or the far-off status bar. Cleared on a successful save / reload.
- Editor row-indexing counts data rows only (tr[data-row-id]) so the inserted
  error rows never offset cell navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-08 15:29:55 -05:00
parent 0d052a20c3
commit b11f165b26
4 changed files with 130 additions and 6 deletions

View file

@ -203,6 +203,28 @@
color: var(--color-text, #111);
}
/* Mandatory-column marker in the header. */
.zddc-table__req {
color: var(--color-error, #c14242);
font-weight: 700;
}
.zddc-table__th--required {
/* subtle cue beyond the asterisk */
}
/* Inline error row a full-width message inserted directly beneath a row
that failed to save, so the reason is visible in place (not just a hover
tooltip or the status bar). */
.zddc-table__error-row > .zddc-table__error-cell {
padding: 5px var(--spacing-md, 0.8rem);
background: var(--color-bg-error, rgba(193, 66, 66, 0.10));
color: var(--color-error, #c14242);
border-bottom: 1px solid var(--color-error, #c14242);
font-size: 0.85rem;
line-height: 1.4;
white-space: normal;
}
.table-empty {
padding: var(--spacing-lg) var(--spacing-md);
text-align: center;

View file

@ -36,7 +36,9 @@
if (!t) return null;
const tbody = t.querySelector('tbody');
if (!tbody) return null;
const tr = tbody.children[r];
// Index over DATA rows only — inline error rows (no data-row-id,
// see save.js showRowError) must not shift the editor's row indices.
const tr = tbody.querySelectorAll('tr[data-row-id]')[r];
if (!tr) return null;
return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]');
}
@ -55,7 +57,7 @@
function rowCount() {
const t = tableEl();
if (!t) return 0;
return t.querySelectorAll('tbody > tr').length;
return t.querySelectorAll('tbody > tr[data-row-id]').length;
}
function colCount() {
@ -75,7 +77,7 @@
// in sync by main.js paint()).
const t = tableEl();
if (!t) return null;
const tr = t.querySelectorAll('tbody > tr')[r];
const tr = t.querySelectorAll('tbody > tr[data-row-id]')[r];
if (!tr) return null;
const rowId = tr.getAttribute('data-row-id');
if (rowId == null) return null;

View file

@ -7,18 +7,28 @@
const sort = app.modules.sort;
theadEl.innerHTML = '';
// Required fields come from the row schema (form.yaml schema.required).
const ctx = app.context || {};
const reqList = (ctx.rowSchema && Array.isArray(ctx.rowSchema.required)) ? ctx.rowSchema.required : [];
const requiredSet = {};
for (let r = 0; r < reqList.length; r++) requiredSet[reqList[r]] = true;
const titleRow = util.h('tr', { className: 'zddc-table__title-row' });
const filterRow = util.h('tr', { className: 'zddc-table__filter-row' });
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const indicator = sort.indicator(sortState, col.field);
const isReq = !!requiredSet[col.field];
const th = util.h('th', {
className: 'zddc-table__th',
className: 'zddc-table__th' + (isReq ? ' zddc-table__th--required' : ''),
'data-field': col.field,
title: isReq ? 'Required' : null,
style: col.width ? 'width:' + col.width : null,
onClick: function (ev) { onHeaderClick(col.field, ev.shiftKey); }
}, col.title || col.field, indicator);
// Mandatory marker — a red asterisk after the column title.
if (isReq) th.insertBefore(util.h('span', { className: 'zddc-table__req', 'aria-hidden': 'true' }, ' *'), th.childNodes[1] || null);
titleRow.appendChild(th);
const td = util.h('td', { className: 'zddc-table__filter-cell' });

View file

@ -130,6 +130,77 @@
}
}
// --- Required-field validation + inline row errors ----------------
// Field names the row schema marks required (form.yaml schema.required).
function requiredFields() {
const ctx = app.context || {};
return (ctx.rowSchema && Array.isArray(ctx.rowSchema.required)) ? ctx.rowSchema.required : [];
}
// Human label for a field — the column title, else the field name.
function colTitle(field) {
const cols = (app.context && app.context.columns) || [];
for (let i = 0; i < cols.length; i++) {
if (cols[i].field === field) return cols[i].title || field;
}
return field;
}
function isEmptyValue(v) {
return v === undefined || v === null
|| (typeof v === 'string' && v.trim() === '')
|| (Array.isArray(v) && v.length === 0);
}
// Client-side required check before a PUT/POST. Marks the empty required
// cells, shows an inline row error naming them, and returns true (invalid)
// so the caller skips the request. The server still validates (422) as the
// authority; this is immediate, names the fields, and avoids a round-trip.
function validateRequired(rowId, merged) {
const req = requiredFields();
if (!req.length) return false;
const missing = [];
for (let i = 0; i < req.length; i++) {
if (isEmptyValue(merged ? merged[req[i]] : undefined)) missing.push(req[i]);
}
if (!missing.length) return false;
clearCellInvalid(rowId);
for (let j = 0; j < missing.length; j++) markCellInvalid(rowId, missing[j], 'Required');
setRowState(rowId, 'invalid');
showRowError(rowId, 'Cant save — required: ' + missing.map(colTitle).join(', '));
return true;
}
// Inline error row: a full-width message inserted directly beneath the
// offending data row, so "why it won't save" is visible in place (not just
// a hover title or the far-off status bar). Carries data-error-for (NOT
// data-row-id) so the editor's row indexing skips it.
function showRowError(rowId, message) {
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
if (!tr) return;
clearRowError(rowId);
const cols = (app.context && app.context.columns) || [];
const er = document.createElement('tr');
er.className = 'zddc-table__error-row';
er.setAttribute('data-error-for', rowId);
const td = document.createElement('td');
td.className = 'zddc-table__error-cell';
td.colSpan = Math.max(1, cols.length);
td.textContent = '⚠ ' + message;
er.appendChild(td);
tr.parentNode.insertBefore(er, tr.nextSibling);
}
function clearRowError(rowId) {
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const er = tbody.querySelector('tr.zddc-table__error-row[data-error-for="' + cssEscape(rowId) + '"]');
if (er && er.parentNode) er.parentNode.removeChild(er);
}
function cssEscape(s) {
// CSS.escape if available; otherwise a defensive escape for
// the characters that appear in URL paths used as data-row-id
@ -213,8 +284,9 @@
return { status: 'readonly' };
}
setRowState(rowId, 'saving');
const merged = mergeRow(row.data, drafts);
if (validateRequired(rowId, merged)) return { status: 'invalid-required' };
setRowState(rowId, 'saving');
const yamlBody = window.jsyaml.dump(merged);
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
@ -240,6 +312,7 @@
// network error. Mark errored, drafts stay.
console.error('[tables] save network error', err);
setRowState(rowId, 'errored');
showRowError(rowId, 'Couldnt save — network error. Your edits are kept; try again.');
return { status: 'network-error', error: err };
}
@ -266,6 +339,7 @@
row.data = serverData || merged;
delete app.state.drafts[rowId];
clearCellInvalid(rowId);
clearRowError(rowId);
setRowState(rowId, '');
// If a status prompt was up for this row, drop it.
const sb = document.getElementById('table-status');
@ -307,17 +381,21 @@
try { body = await resp.json(); } catch (_) { /* ignore */ }
clearCellInvalid(rowId);
const errs = body.errors || [];
const parts = [];
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');
parts.push((field ? colTitle(field) + ': ' : '') + (e.message || 'invalid'));
}
setRowState(rowId, 'invalid');
showRowError(rowId, 'Cant save — ' + (parts.length ? parts.join('; ') : 'validation failed.'));
return { status: 'invalid', errors: errs };
}
if (resp.status === 403) {
setRowState(rowId, 'errored');
showRowError(rowId, 'Cant save — you dont have permission to write here.');
if (window.zddc && window.zddc.cap) {
window.zddc.cap.handleForbidden(resp, {
context: 'Save row',
@ -330,6 +408,7 @@
// Other status — generic error.
console.warn('[tables] save returned', resp.status);
setRowState(rowId, 'errored');
showRowError(rowId, 'Cant save — server error (HTTP ' + resp.status + ').');
return { status: 'http-error', code: resp.status };
}
@ -346,6 +425,7 @@
}
const createUrl = addRow.formCreateUrl();
const merged = mergeRow(row.data, drafts);
if (validateRequired(rowId, merged)) return { status: 'invalid-required' };
const yamlBody = window.jsyaml.dump(merged);
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
@ -403,6 +483,7 @@
// the new url, then clear it (data has the merged values).
delete app.state.drafts[rowId];
clearCellInvalid(rowId);
clearRowError(rowId);
setRowState(rowId, '');
const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
@ -417,17 +498,21 @@
try { body = await resp.json(); } catch (_) { /* ignore */ }
clearCellInvalid(rowId);
const errs = body.errors || [];
const parts = [];
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');
parts.push((field ? colTitle(field) + ': ' : '') + (e.message || 'invalid'));
}
setRowState(rowId, 'invalid');
showRowError(rowId, 'Cant add — ' + (parts.length ? parts.join('; ') : 'validation failed.'));
return { status: 'invalid', errors: errs };
}
if (resp.status === 403) {
setRowState(rowId, 'errored');
showRowError(rowId, 'Cant add — you dont have permission to create rows here.');
if (window.zddc && window.zddc.cap) {
window.zddc.cap.handleForbidden(resp, {
context: 'Add row',
@ -448,11 +533,13 @@
clearCellInvalid(rowId);
markCellInvalid(rowId, 'sequence', msg);
setRowState(rowId, 'invalid');
showRowError(rowId, 'Cant add — ' + msg);
return { status: 'duplicate', message: msg };
}
console.warn('[tables] createRow returned', resp.status);
setRowState(rowId, 'errored');
showRowError(rowId, 'Cant add — server error (HTTP ' + resp.status + ').');
return { status: 'http-error', code: resp.status };
}
@ -492,6 +579,7 @@
} catch (_) { return; }
delete app.state.drafts[rowId];
clearCellInvalid(rowId);
clearRowError(rowId);
setRowState(rowId, '');
clearStatus();
// Trigger a re-paint via the public app callback if one exists.
@ -530,7 +618,9 @@
}
function rowIdAtIndex(visibleRowIdx) {
const tr = document.querySelectorAll('#table-root tbody > tr')[visibleRowIdx];
// Data rows only — inline error rows have no data-row-id and must not
// offset the index.
const tr = document.querySelectorAll('#table-root tbody > tr[data-row-id]')[visibleRowIdx];
return tr ? tr.getAttribute('data-row-id') : null;
}