Compare commits

...

5 commits

Author SHA1 Message Date
362f5bd036 chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 9s
2026-06-08 18:50:48 -05:00
0326d46826 feat(tables): "Add row" opens the compose form for record-tables
When a new record's identity is composed from required fields that aren't
table columns (e.g. the risk register's project/discipline/sequence
tracking-number components), those can't be supplied by typing into the grid.
"+ Add row" now navigates to the compose form (<dir>/form.html) for such
tables — the form composes the tracking number and the server creates the
record. Simple tables (all required fields are columns) keep the inline
draft-row flow. Server mode only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:49:29 -05:00
af91916b58 fix(tables): required-field check only enforces fields that are columns
The client-side required check was enforcing every field in the row schema's
`required` list — including ones with no table column (e.g. the risk register's
project/discipline/sequence tracking-number components, which are composed
server-side / set via the add-row form, not inline-editable). That blocked
saves naming fields the user can't fill in the grid ("Can't save — required:
party, project, discipline, sequence").

Now requiredFields() intersects `required` with the visible columns, so the
client only blocks on required fields the table can actually fill; non-column
required fields are left to the server (it composes them or returns a 422).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:24:27 -05:00
8b690b782f feat(tables): configurable column option source — dropdown from a live registry
A table column can declare `options_source: <peer>` and the server fills its
`enum` from the live entries under <project>/<peer>/ — so the row editor renders
a dropdown of the current registry instead of free text. Generic + configurable
in the spec; no hardcoding.

- Server (tablehandler.go): resolveDynamicEnums + registryEntries resolve the
  peer directory (its *.yaml basenames + subfolders, sorted, dot/spec entries
  skipped) into the column enum at ServeTable time, before the context inject.
- Default risk register: add a `package` column with `options_source: ssr`
  (dropdown of the project's SSR packages) + the matching form property. The
  spec comment documents the key so operators can source other registries.
- Test covering the resolver (entries, skips, untouched columns).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:36:42 -05:00
b11f165b26 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>
2026-06-08 15:29:55 -05:00
16 changed files with 521 additions and 43 deletions

View file

@ -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;

View file

@ -1,12 +1,15 @@
// add-row.js — inline new-row creation. // add-row.js — new-row creation.
// //
// Click "+ Add row" → append a draft row at the end of state.rows, // Two paths, chosen by the table's schema:
// focus its first editable cell, accumulate user typing into the // - Record-tables (identity composed from required fields that aren't
// drafts buffer like any other row. On row-blur, save.js detects the // columns — e.g. the risk register's tracking-number components): "+ Add
// row.isNew flag and POSTs to <dir>/form.html (the form-create // row" navigates to the compose form (<dir>/form.html), the only place
// endpoint). The 201 response carries the new row's Location; we swap // those components can be supplied. See needsComposeForm().
// the synthetic url/yamlUrl for the real ones and the draft row // - Simple tables (all required fields are columns): "+ Add row" appends a
// becomes a normal saved row. // draft row at the end of state.rows, focuses its first editable cell, and
// accumulates typing into the drafts buffer like any other row. On
// row-blur, save.js detects row.isNew and POSTs to <dir>/form.html; the
// 201 Location swaps the synthetic url and the draft becomes a saved row.
// //
// Synthetic identity: each new row gets a temporary "__new-<N>" url // Synthetic identity: each new row gets a temporary "__new-<N>" url
// so rowKey() returns something unique for selection + draft tracking. // so rowKey() returns something unique for selection + draft tracking.
@ -34,8 +37,29 @@
return dir + 'form.html'; return dir + 'form.html';
} }
// Create-and-paint: the user-facing path. // True when a new record's identity is composed from required fields that
// AREN'T table columns (e.g. the risk register's project/discipline/
// sequence tracking-number components). Such rows can't be created by
// typing into the grid — they need the compose form. Server mode only
// (the form handler is server-side).
function needsComposeForm() {
const ctx = app.context || {};
if (!app.state || app.state.source !== 'server') return false;
const req = (ctx.rowSchema && Array.isArray(ctx.rowSchema.required)) ? ctx.rowSchema.required : [];
if (!req.length) return false;
const colFields = {};
(ctx.columns || []).forEach(function (c) { if (c && c.field) colFields[c.field] = true; });
return req.some(function (f) { return !colFields[f]; });
}
// Create-and-paint: the user-facing path. Record-tables (composed identity)
// open the compose form directly — the grid can't supply their
// tracking-number components; simple tables append an inline draft row.
function invoke() { function invoke() {
if (needsComposeForm()) {
window.location.href = formCreateUrl();
return;
}
const key = createSilent(); const key = createSilent();
if (typeof app.repaint === 'function') app.repaint(); if (typeof app.repaint === 'function') app.repaint();
focusNewRow(key); focusNewRow(key);

View file

@ -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;

View file

@ -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' });

View file

@ -130,6 +130,85 @@
} }
} }
// --- Required-field validation + inline row errors ----------------
// Required fields the table can actually enforce: the row schema's
// `required` list intersected with the visible COLUMNS. Required fields
// that aren't columns (e.g. the risk register's project/discipline/
// sequence tracking-number components, composed server-side or set via the
// add-row form) are NOT inline-fillable, so the client must not block on
// them — the server validates those (and composes them) authoritatively.
function requiredFields() {
const ctx = app.context || {};
const req = (ctx.rowSchema && Array.isArray(ctx.rowSchema.required)) ? ctx.rowSchema.required : [];
const colFields = {};
(ctx.columns || []).forEach(function (c) { if (c && c.field) colFields[c.field] = true; });
return req.filter(function (f) { return colFields[f]; });
}
// 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) { 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 +292,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 +320,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, 'Couldnt save — network error. Your edits are kept; try again.');
return { status: 'network-error', error: err }; return { status: 'network-error', error: err };
} }
@ -266,6 +347,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 +389,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, 'Cant 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, 'Cant save — you dont 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 +416,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, 'Cant save — server error (HTTP ' + resp.status + ').');
return { status: 'http-error', code: resp.status }; return { status: 'http-error', code: resp.status };
} }
@ -346,6 +433,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 +491,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 +506,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, 'Cant 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, 'Cant add — you dont 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 +541,13 @@
clearCellInvalid(rowId); clearCellInvalid(rowId);
markCellInvalid(rowId, 'sequence', msg); markCellInvalid(rowId, 'sequence', msg);
setRowState(rowId, 'invalid'); setRowState(rowId, 'invalid');
showRowError(rowId, 'Cant 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, 'Cant add — server error (HTTP ' + resp.status + ').');
return { status: 'http-error', code: resp.status }; return { status: 'http-error', code: resp.status };
} }
@ -492,6 +587,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 +626,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;
} }

View file

@ -2665,7 +2665,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2772,7 +2772,7 @@ li.CodeMirror-hint-active {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>

View file

@ -1876,7 +1876,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -1619,7 +1619,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -2718,7 +2718,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7 archive=v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46
transmittal=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7 transmittal=v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46
classifier=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7 classifier=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
landing=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7 landing=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
form=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7 form=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
tables=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7 tables=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
browse=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7 browse=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46

View file

@ -106,6 +106,12 @@ schema:
type: string type: string
title: Category title: Category
description: Free-form grouping (schedule, cost, technical, regulatory, ...). description: Free-form grouping (schedule, cost, technical, regulatory, ...).
package:
type: string
title: Package
description: The SSR package this risk belongs to. The table view sources
the column's dropdown from the project's ssr/ registry
(table.yaml column options_source).
description: description:
type: string type: string
title: Description title: Description

View file

@ -24,6 +24,14 @@ columns:
- field: category - field: category
title: Category title: Category
width: 10em width: 10em
- field: package
title: Package
width: 12em
# Dropdown sourced from a live registry: `options_source: <peer>` fills
# this column's choices from the entries under <project>/<peer>/. Here it
# lists the project's SSR packages. Change `ssr` to source a different
# registry, or delete the key to make the column free text.
options_source: ssr
- field: likelihood - field: likelihood
title: L title: L
width: 4em width: 4em

View file

@ -33,6 +33,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -460,6 +461,105 @@ func injectTableContextObj(template []byte, ctx interface{}) ([]byte, error) {
return bytesReplace(template, needle, replacement), nil return bytesReplace(template, needle, replacement), nil
} }
// resolveDynamicEnums fills in `enum` for any column that declares
// `options_source: <peer>` (a configurable column key) from that peer
// registry's live entries under the project root. Example: the risk
// register's `package` column with `options_source: ssr` gets a dropdown of
// the project's registered SSR packages. Columns without the key are
// untouched; tableYAML is returned unchanged when there's nothing to resolve.
func resolveDynamicEnums(fsRoot, dir string, tableYAML []byte) []byte {
if len(tableYAML) == 0 {
return tableYAML
}
var spec map[string]interface{}
if err := yaml.Unmarshal(tableYAML, &spec); err != nil {
return tableYAML
}
cols, ok := spec["columns"].([]interface{})
if !ok {
return tableYAML
}
changed := false
for _, c := range cols {
col, ok := c.(map[string]interface{})
if !ok {
continue
}
src, _ := col["options_source"].(string)
if src == "" {
continue
}
entries := registryEntries(fsRoot, dir, src)
if entries == nil {
continue
}
arr := make([]interface{}, len(entries))
for i, e := range entries {
arr[i] = e
}
col["enum"] = arr
changed = true
}
if !changed {
return tableYAML
}
out, err := yaml.Marshal(spec)
if err != nil {
return tableYAML
}
return out
}
// registryEntries lists the names registered under <project>/<peer>/ — the
// .yaml row files (basename sans extension) and any subdirectories — sorted
// and de-duped, skipping dot/underscore names and the table/form specs. The
// project is the first path segment of dir under fsRoot. Returns nil when the
// peer directory doesn't exist.
func registryEntries(fsRoot, dir, peer string) []string {
rel, err := filepath.Rel(fsRoot, dir)
if err != nil || strings.HasPrefix(rel, "..") {
return nil
}
rel = filepath.ToSlash(rel)
project := rel
if i := strings.IndexByte(rel, '/'); i >= 0 {
project = rel[:i]
}
if project == "" || project == "." {
return nil
}
ents, err := os.ReadDir(filepath.Join(fsRoot, project, peer))
if err != nil {
return nil
}
seen := map[string]bool{}
var names []string
for _, e := range ents {
name := e.Name()
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
continue
}
if name == "table.yaml" || name == "form.yaml" {
continue
}
base := name
if !e.IsDir() {
low := strings.ToLower(name)
if !strings.HasSuffix(low, ".yaml") && !strings.HasSuffix(low, ".yml") {
continue
}
base = strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml")
}
if base == "" || seen[base] {
continue
}
seen[base] = true
names = append(names, base)
}
sort.Strings(names)
return names
}
// EmbeddedTablesHTML exposes the embedded tables renderer to sibling handlers // EmbeddedTablesHTML exposes the embedded tables renderer to sibling handlers
// (e.g. the token page) that render a server-injected collection through it. // (e.g. the token page) that render a server-injected collection through it.
func EmbeddedTablesHTML() []byte { return embeddedTablesHTML } func EmbeddedTablesHTML() []byte { return embeddedTablesHTML }
@ -495,6 +595,11 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *
body := embeddedTablesHTML body := embeddedTablesHTML
tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.yaml") tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.yaml")
formYAML := LoadViewSpec(cfg.Root, req.Dir, "form.yaml") formYAML := LoadViewSpec(cfg.Root, req.Dir, "form.yaml")
// Resolve any column whose options come from a live registry
// (column key `options_source`, e.g. the risk register's `package`
// column sourced from the project's `ssr` packages) into a concrete
// enum, so the row editor renders a dropdown of the current entries.
tableYAML = resolveDynamicEnums(cfg.Root, req.Dir, tableYAML)
if injected, ierr := injectTableContext(embeddedTablesHTML, tableYAML, formYAML); ierr == nil { if injected, ierr := injectTableContext(embeddedTablesHTML, tableYAML, formYAML); ierr == nil {
body = injected body = injected
} }

View file

@ -10,6 +10,8 @@ import (
"strings" "strings"
"testing" "testing"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
@ -446,3 +448,48 @@ func TestIsDefaultSpec_ProjectLevel_OperatorOverrides(t *testing.T) {
t.Errorf("operator file should win at /Project/ssr/table.yaml") t.Errorf("operator file should win at /Project/ssr/table.yaml")
} }
} }
// TestResolveDynamicEnums verifies that a column declaring
// `options_source: <peer>` is filled with the live registry entries from
// <project>/<peer>/ — the configurable-source feature (e.g. the risk
// register's package column sourced from ssr).
func TestResolveDynamicEnums(t *testing.T) {
root := t.TempDir()
// <root>/Proj/ssr/{Acme,Globex}.yaml + a Beta/ folder; specs + dotfiles
// must be skipped.
ssr := filepath.Join(root, "Proj", "ssr")
if err := os.MkdirAll(filepath.Join(ssr, "Beta"), 0o755); err != nil {
t.Fatal(err)
}
for _, n := range []string{"Acme.yaml", "Globex.yaml", "table.yaml", ".hidden.yaml", "notes.txt"} {
if err := os.WriteFile(filepath.Join(ssr, n), []byte("x: 1\n"), 0o644); err != nil {
t.Fatal(err)
}
}
rskDir := filepath.Join(root, "Proj", "rsk", "Acme")
spec := []byte("columns:\n - field: package\n options_source: ssr\n - field: title\n")
out := resolveDynamicEnums(root, rskDir, spec)
var parsed map[string]interface{}
if err := yaml.Unmarshal(out, &parsed); err != nil {
t.Fatalf("re-parse: %v", err)
}
cols := parsed["columns"].([]interface{})
pkg := cols[0].(map[string]interface{})
enum, ok := pkg["enum"].([]interface{})
if !ok {
t.Fatalf("package column got no enum: %+v", pkg)
}
got := make([]string, len(enum))
for i, e := range enum {
got[i] = e.(string)
}
want := "Acme,Beta,Globex" // sorted; specs + dot/non-yaml skipped
if strings.Join(got, ",") != want {
t.Errorf("enum = %v, want %s", got, want)
}
// The non-sourced column is untouched.
if _, has := cols[1].(map[string]interface{})["enum"]; has {
t.Errorf("title column should not get an enum")
}
}

View file

@ -1398,6 +1398,28 @@ body.is-elevated::after {
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;
@ -1648,7 +1670,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -4629,7 +4651,9 @@ body.is-elevated::after {
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 + '"]');
} }
@ -4648,7 +4672,7 @@ body.is-elevated::after {
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() {
@ -4668,7 +4692,7 @@ body.is-elevated::after {
// 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;
@ -5614,15 +5638,18 @@ body.is-elevated::after {
}; };
})(window.tablesApp); })(window.tablesApp);
// add-row.js — inline new-row creation. // add-row.js — new-row creation.
// //
// Click "+ Add row" → append a draft row at the end of state.rows, // Two paths, chosen by the table's schema:
// focus its first editable cell, accumulate user typing into the // - Record-tables (identity composed from required fields that aren't
// drafts buffer like any other row. On row-blur, save.js detects the // columns — e.g. the risk register's tracking-number components): "+ Add
// row.isNew flag and POSTs to <dir>/form.html (the form-create // row" navigates to the compose form (<dir>/form.html), the only place
// endpoint). The 201 response carries the new row's Location; we swap // those components can be supplied. See needsComposeForm().
// the synthetic url/yamlUrl for the real ones and the draft row // - Simple tables (all required fields are columns): "+ Add row" appends a
// becomes a normal saved row. // draft row at the end of state.rows, focuses its first editable cell, and
// accumulates typing into the drafts buffer like any other row. On
// row-blur, save.js detects row.isNew and POSTs to <dir>/form.html; the
// 201 Location swaps the synthetic url and the draft becomes a saved row.
// //
// Synthetic identity: each new row gets a temporary "__new-<N>" url // Synthetic identity: each new row gets a temporary "__new-<N>" url
// so rowKey() returns something unique for selection + draft tracking. // so rowKey() returns something unique for selection + draft tracking.
@ -5650,8 +5677,29 @@ body.is-elevated::after {
return dir + 'form.html'; return dir + 'form.html';
} }
// Create-and-paint: the user-facing path. // True when a new record's identity is composed from required fields that
// AREN'T table columns (e.g. the risk register's project/discipline/
// sequence tracking-number components). Such rows can't be created by
// typing into the grid — they need the compose form. Server mode only
// (the form handler is server-side).
function needsComposeForm() {
const ctx = app.context || {};
if (!app.state || app.state.source !== 'server') return false;
const req = (ctx.rowSchema && Array.isArray(ctx.rowSchema.required)) ? ctx.rowSchema.required : [];
if (!req.length) return false;
const colFields = {};
(ctx.columns || []).forEach(function (c) { if (c && c.field) colFields[c.field] = true; });
return req.some(function (f) { return !colFields[f]; });
}
// Create-and-paint: the user-facing path. Record-tables (composed identity)
// open the compose form directly — the grid can't supply their
// tracking-number components; simple tables append an inline draft row.
function invoke() { function invoke() {
if (needsComposeForm()) {
window.location.href = formCreateUrl();
return;
}
const key = createSilent(); const key = createSilent();
if (typeof app.repaint === 'function') app.repaint(); if (typeof app.repaint === 'function') app.repaint();
focusNewRow(key); focusNewRow(key);
@ -5856,6 +5904,85 @@ body.is-elevated::after {
} }
} }
// --- Required-field validation + inline row errors ----------------
// Required fields the table can actually enforce: the row schema's
// `required` list intersected with the visible COLUMNS. Required fields
// that aren't columns (e.g. the risk register's project/discipline/
// sequence tracking-number components, composed server-side or set via the
// add-row form) are NOT inline-fillable, so the client must not block on
// them — the server validates those (and composes them) authoritatively.
function requiredFields() {
const ctx = app.context || {};
const req = (ctx.rowSchema && Array.isArray(ctx.rowSchema.required)) ? ctx.rowSchema.required : [];
const colFields = {};
(ctx.columns || []).forEach(function (c) { if (c && c.field) colFields[c.field] = true; });
return req.filter(function (f) { return colFields[f]; });
}
// 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) { 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
@ -5939,8 +6066,9 @@ body.is-elevated::after {
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' };
@ -5966,6 +6094,7 @@ body.is-elevated::after {
// 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, 'Couldnt save — network error. Your edits are kept; try again.');
return { status: 'network-error', error: err }; return { status: 'network-error', error: err };
} }
@ -5992,6 +6121,7 @@ body.is-elevated::after {
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');
@ -6033,17 +6163,21 @@ body.is-elevated::after {
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, 'Cant 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, 'Cant save — you dont 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',
@ -6056,6 +6190,7 @@ body.is-elevated::after {
// 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, 'Cant save — server error (HTTP ' + resp.status + ').');
return { status: 'http-error', code: resp.status }; return { status: 'http-error', code: resp.status };
} }
@ -6072,6 +6207,7 @@ body.is-elevated::after {
} }
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' };
@ -6129,6 +6265,7 @@ body.is-elevated::after {
// 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();
@ -6143,17 +6280,21 @@ body.is-elevated::after {
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, 'Cant 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, 'Cant add — you dont 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',
@ -6174,11 +6315,13 @@ body.is-elevated::after {
clearCellInvalid(rowId); clearCellInvalid(rowId);
markCellInvalid(rowId, 'sequence', msg); markCellInvalid(rowId, 'sequence', msg);
setRowState(rowId, 'invalid'); setRowState(rowId, 'invalid');
showRowError(rowId, 'Cant 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, 'Cant add — server error (HTTP ' + resp.status + ').');
return { status: 'http-error', code: resp.status }; return { status: 'http-error', code: resp.status };
} }
@ -6218,6 +6361,7 @@ body.is-elevated::after {
} 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.
@ -6256,7 +6400,9 @@ body.is-elevated::after {
} }
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;
} }
@ -6968,18 +7114,28 @@ body.is-elevated::after {
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' });