diff --git a/tables/css/table.css b/tables/css/table.css index 4a3fbc8..28f4923 100644 --- a/tables/css/table.css +++ b/tables/css/table.css @@ -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; diff --git a/tables/js/editor.js b/tables/js/editor.js index f75b622..7221467 100644 --- a/tables/js/editor.js +++ b/tables/js/editor.js @@ -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; diff --git a/tables/js/render.js b/tables/js/render.js index 3d5d6cf..aef45f8 100644 --- a/tables/js/render.js +++ b/tables/js/render.js @@ -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' }); diff --git a/tables/js/save.js b/tables/js/save.js index 9374877..517a80b 100644 --- a/tables/js/save.js +++ b/tables/js/save.js @@ -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) { // 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, 'Couldn’t 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, 'Can’t save — ' + (parts.length ? parts.join('; ') : 'validation failed.')); return { status: 'invalid', errors: errs }; } if (resp.status === 403) { setRowState(rowId, 'errored'); + showRowError(rowId, 'Can’t save — you don’t 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, 'Can’t 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, 'Can’t add — ' + (parts.length ? parts.join('; ') : 'validation failed.')); return { status: 'invalid', errors: errs }; } if (resp.status === 403) { setRowState(rowId, 'errored'); + showRowError(rowId, 'Can’t add — you don’t 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, 'Can’t add — ' + msg); return { status: 'duplicate', message: msg }; } console.warn('[tables] createRow returned', resp.status); setRowState(rowId, 'errored'); + showRowError(rowId, 'Can’t 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; }