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:
parent
0d052a20c3
commit
b11f165b26
4 changed files with 130 additions and 6 deletions
|
|
@ -203,6 +203,28 @@
|
||||||
color: var(--color-text, #111);
|
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 {
|
.table-empty {
|
||||||
padding: var(--spacing-lg) var(--spacing-md);
|
padding: var(--spacing-lg) var(--spacing-md);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
const tbody = t.querySelector('tbody');
|
const tbody = t.querySelector('tbody');
|
||||||
if (!tbody) return null;
|
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;
|
if (!tr) return null;
|
||||||
return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]');
|
return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]');
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +57,7 @@
|
||||||
function rowCount() {
|
function rowCount() {
|
||||||
const t = tableEl();
|
const t = tableEl();
|
||||||
if (!t) return 0;
|
if (!t) return 0;
|
||||||
return t.querySelectorAll('tbody > tr').length;
|
return t.querySelectorAll('tbody > tr[data-row-id]').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function colCount() {
|
function colCount() {
|
||||||
|
|
@ -75,7 +77,7 @@
|
||||||
// in sync by main.js paint()).
|
// in sync by main.js paint()).
|
||||||
const t = tableEl();
|
const t = tableEl();
|
||||||
if (!t) return null;
|
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;
|
if (!tr) return null;
|
||||||
const rowId = tr.getAttribute('data-row-id');
|
const rowId = tr.getAttribute('data-row-id');
|
||||||
if (rowId == null) return null;
|
if (rowId == null) return null;
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,28 @@
|
||||||
const sort = app.modules.sort;
|
const sort = app.modules.sort;
|
||||||
theadEl.innerHTML = '';
|
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 titleRow = util.h('tr', { className: 'zddc-table__title-row' });
|
||||||
const filterRow = util.h('tr', { className: 'zddc-table__filter-row' });
|
const filterRow = util.h('tr', { className: 'zddc-table__filter-row' });
|
||||||
|
|
||||||
for (let i = 0; i < columns.length; i++) {
|
for (let i = 0; i < columns.length; i++) {
|
||||||
const col = columns[i];
|
const col = columns[i];
|
||||||
const indicator = sort.indicator(sortState, col.field);
|
const indicator = sort.indicator(sortState, col.field);
|
||||||
|
const isReq = !!requiredSet[col.field];
|
||||||
const th = util.h('th', {
|
const th = util.h('th', {
|
||||||
className: 'zddc-table__th',
|
className: 'zddc-table__th' + (isReq ? ' zddc-table__th--required' : ''),
|
||||||
'data-field': col.field,
|
'data-field': col.field,
|
||||||
|
title: isReq ? 'Required' : null,
|
||||||
style: col.width ? 'width:' + col.width : null,
|
style: col.width ? 'width:' + col.width : null,
|
||||||
onClick: function (ev) { onHeaderClick(col.field, ev.shiftKey); }
|
onClick: function (ev) { onHeaderClick(col.field, ev.shiftKey); }
|
||||||
}, col.title || col.field, indicator);
|
}, 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);
|
titleRow.appendChild(th);
|
||||||
|
|
||||||
const td = util.h('td', { className: 'zddc-table__filter-cell' });
|
const td = util.h('td', { className: 'zddc-table__filter-cell' });
|
||||||
|
|
|
||||||
|
|
@ -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, 'Can’t 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) {
|
function cssEscape(s) {
|
||||||
// CSS.escape if available; otherwise a defensive escape for
|
// CSS.escape if available; otherwise a defensive escape for
|
||||||
// the characters that appear in URL paths used as data-row-id
|
// the characters that appear in URL paths used as data-row-id
|
||||||
|
|
@ -213,8 +284,9 @@
|
||||||
return { status: 'readonly' };
|
return { status: 'readonly' };
|
||||||
}
|
}
|
||||||
|
|
||||||
setRowState(rowId, 'saving');
|
|
||||||
const merged = mergeRow(row.data, drafts);
|
const merged = mergeRow(row.data, drafts);
|
||||||
|
if (validateRequired(rowId, merged)) return { status: 'invalid-required' };
|
||||||
|
setRowState(rowId, 'saving');
|
||||||
const yamlBody = window.jsyaml.dump(merged);
|
const yamlBody = window.jsyaml.dump(merged);
|
||||||
|
|
||||||
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
|
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
|
||||||
|
|
@ -240,6 +312,7 @@
|
||||||
// network error. Mark errored, drafts stay.
|
// network error. Mark errored, drafts stay.
|
||||||
console.error('[tables] save network error', err);
|
console.error('[tables] save network error', err);
|
||||||
setRowState(rowId, 'errored');
|
setRowState(rowId, 'errored');
|
||||||
|
showRowError(rowId, 'Couldn’t save — network error. Your edits are kept; try again.');
|
||||||
return { status: 'network-error', error: err };
|
return { status: 'network-error', error: err };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,6 +339,7 @@
|
||||||
row.data = serverData || merged;
|
row.data = serverData || merged;
|
||||||
delete app.state.drafts[rowId];
|
delete app.state.drafts[rowId];
|
||||||
clearCellInvalid(rowId);
|
clearCellInvalid(rowId);
|
||||||
|
clearRowError(rowId);
|
||||||
setRowState(rowId, '');
|
setRowState(rowId, '');
|
||||||
// If a status prompt was up for this row, drop it.
|
// If a status prompt was up for this row, drop it.
|
||||||
const sb = document.getElementById('table-status');
|
const sb = document.getElementById('table-status');
|
||||||
|
|
@ -307,17 +381,21 @@
|
||||||
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
||||||
clearCellInvalid(rowId);
|
clearCellInvalid(rowId);
|
||||||
const errs = body.errors || [];
|
const errs = body.errors || [];
|
||||||
|
const parts = [];
|
||||||
for (let i = 0; i < errs.length; i++) {
|
for (let i = 0; i < errs.length; i++) {
|
||||||
const e = errs[i];
|
const e = errs[i];
|
||||||
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
|
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
|
||||||
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
|
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
|
||||||
|
parts.push((field ? colTitle(field) + ': ' : '') + (e.message || 'invalid'));
|
||||||
}
|
}
|
||||||
setRowState(rowId, 'invalid');
|
setRowState(rowId, 'invalid');
|
||||||
|
showRowError(rowId, 'Can’t save — ' + (parts.length ? parts.join('; ') : 'validation failed.'));
|
||||||
return { status: 'invalid', errors: errs };
|
return { status: 'invalid', errors: errs };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.status === 403) {
|
if (resp.status === 403) {
|
||||||
setRowState(rowId, 'errored');
|
setRowState(rowId, 'errored');
|
||||||
|
showRowError(rowId, 'Can’t save — you don’t have permission to write here.');
|
||||||
if (window.zddc && window.zddc.cap) {
|
if (window.zddc && window.zddc.cap) {
|
||||||
window.zddc.cap.handleForbidden(resp, {
|
window.zddc.cap.handleForbidden(resp, {
|
||||||
context: 'Save row',
|
context: 'Save row',
|
||||||
|
|
@ -330,6 +408,7 @@
|
||||||
// Other status — generic error.
|
// Other status — generic error.
|
||||||
console.warn('[tables] save returned', resp.status);
|
console.warn('[tables] save returned', resp.status);
|
||||||
setRowState(rowId, 'errored');
|
setRowState(rowId, 'errored');
|
||||||
|
showRowError(rowId, 'Can’t save — server error (HTTP ' + resp.status + ').');
|
||||||
return { status: 'http-error', code: resp.status };
|
return { status: 'http-error', code: resp.status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -346,6 +425,7 @@
|
||||||
}
|
}
|
||||||
const createUrl = addRow.formCreateUrl();
|
const createUrl = addRow.formCreateUrl();
|
||||||
const merged = mergeRow(row.data, drafts);
|
const merged = mergeRow(row.data, drafts);
|
||||||
|
if (validateRequired(rowId, merged)) return { status: 'invalid-required' };
|
||||||
const yamlBody = window.jsyaml.dump(merged);
|
const yamlBody = window.jsyaml.dump(merged);
|
||||||
|
|
||||||
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
|
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
|
||||||
|
|
@ -403,6 +483,7 @@
|
||||||
// the new url, then clear it (data has the merged values).
|
// the new url, then clear it (data has the merged values).
|
||||||
delete app.state.drafts[rowId];
|
delete app.state.drafts[rowId];
|
||||||
clearCellInvalid(rowId);
|
clearCellInvalid(rowId);
|
||||||
|
clearRowError(rowId);
|
||||||
setRowState(rowId, '');
|
setRowState(rowId, '');
|
||||||
const sb = document.getElementById('table-status');
|
const sb = document.getElementById('table-status');
|
||||||
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
|
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
|
||||||
|
|
@ -417,17 +498,21 @@
|
||||||
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
||||||
clearCellInvalid(rowId);
|
clearCellInvalid(rowId);
|
||||||
const errs = body.errors || [];
|
const errs = body.errors || [];
|
||||||
|
const parts = [];
|
||||||
for (let i = 0; i < errs.length; i++) {
|
for (let i = 0; i < errs.length; i++) {
|
||||||
const e = errs[i];
|
const e = errs[i];
|
||||||
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
|
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
|
||||||
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
|
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
|
||||||
|
parts.push((field ? colTitle(field) + ': ' : '') + (e.message || 'invalid'));
|
||||||
}
|
}
|
||||||
setRowState(rowId, 'invalid');
|
setRowState(rowId, 'invalid');
|
||||||
|
showRowError(rowId, 'Can’t add — ' + (parts.length ? parts.join('; ') : 'validation failed.'));
|
||||||
return { status: 'invalid', errors: errs };
|
return { status: 'invalid', errors: errs };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.status === 403) {
|
if (resp.status === 403) {
|
||||||
setRowState(rowId, 'errored');
|
setRowState(rowId, 'errored');
|
||||||
|
showRowError(rowId, 'Can’t add — you don’t have permission to create rows here.');
|
||||||
if (window.zddc && window.zddc.cap) {
|
if (window.zddc && window.zddc.cap) {
|
||||||
window.zddc.cap.handleForbidden(resp, {
|
window.zddc.cap.handleForbidden(resp, {
|
||||||
context: 'Add row',
|
context: 'Add row',
|
||||||
|
|
@ -448,11 +533,13 @@
|
||||||
clearCellInvalid(rowId);
|
clearCellInvalid(rowId);
|
||||||
markCellInvalid(rowId, 'sequence', msg);
|
markCellInvalid(rowId, 'sequence', msg);
|
||||||
setRowState(rowId, 'invalid');
|
setRowState(rowId, 'invalid');
|
||||||
|
showRowError(rowId, 'Can’t add — ' + msg);
|
||||||
return { status: 'duplicate', message: msg };
|
return { status: 'duplicate', message: msg };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('[tables] createRow returned', resp.status);
|
console.warn('[tables] createRow returned', resp.status);
|
||||||
setRowState(rowId, 'errored');
|
setRowState(rowId, 'errored');
|
||||||
|
showRowError(rowId, 'Can’t add — server error (HTTP ' + resp.status + ').');
|
||||||
return { status: 'http-error', code: resp.status };
|
return { status: 'http-error', code: resp.status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -492,6 +579,7 @@
|
||||||
} catch (_) { return; }
|
} catch (_) { return; }
|
||||||
delete app.state.drafts[rowId];
|
delete app.state.drafts[rowId];
|
||||||
clearCellInvalid(rowId);
|
clearCellInvalid(rowId);
|
||||||
|
clearRowError(rowId);
|
||||||
setRowState(rowId, '');
|
setRowState(rowId, '');
|
||||||
clearStatus();
|
clearStatus();
|
||||||
// Trigger a re-paint via the public app callback if one exists.
|
// Trigger a re-paint via the public app callback if one exists.
|
||||||
|
|
@ -530,7 +618,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowIdAtIndex(visibleRowIdx) {
|
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;
|
return tr ? tr.getAttribute('data-row-id') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue