Compare commits
No commits in common. "362f5bd03646c2272a7fcd488de2982a926e95d8" and "0d052a20c301de460d6750f777ccdfc76f2f3642" have entirely different histories.
362f5bd036
...
0d052a20c3
16 changed files with 43 additions and 521 deletions
|
|
@ -203,28 +203,6 @@
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
// add-row.js — new-row creation.
|
// add-row.js — inline new-row creation.
|
||||||
//
|
//
|
||||||
// Two paths, chosen by the table's schema:
|
// Click "+ Add row" → append a draft row at the end of state.rows,
|
||||||
// - Record-tables (identity composed from required fields that aren't
|
// focus its first editable cell, accumulate user typing into the
|
||||||
// columns — e.g. the risk register's tracking-number components): "+ Add
|
// drafts buffer like any other row. On row-blur, save.js detects the
|
||||||
// row" navigates to the compose form (<dir>/form.html), the only place
|
// row.isNew flag and POSTs to <dir>/form.html (the form-create
|
||||||
// those components can be supplied. See needsComposeForm().
|
// endpoint). The 201 response carries the new row's Location; we swap
|
||||||
// - Simple tables (all required fields are columns): "+ Add row" appends a
|
// the synthetic url/yamlUrl for the real ones and the draft row
|
||||||
// draft row at the end of state.rows, focuses its first editable cell, and
|
// becomes a normal saved row.
|
||||||
// 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.
|
||||||
|
|
@ -37,29 +34,8 @@
|
||||||
return dir + 'form.html';
|
return dir + 'form.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when a new record's identity is composed from required fields that
|
// Create-and-paint: the user-facing path.
|
||||||
// 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);
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,7 @@
|
||||||
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;
|
||||||
// Index over DATA rows only — inline error rows (no data-row-id,
|
const tr = tbody.children[r];
|
||||||
// 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 + '"]');
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +55,7 @@
|
||||||
function rowCount() {
|
function rowCount() {
|
||||||
const t = tableEl();
|
const t = tableEl();
|
||||||
if (!t) return 0;
|
if (!t) return 0;
|
||||||
return t.querySelectorAll('tbody > tr[data-row-id]').length;
|
return t.querySelectorAll('tbody > tr').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function colCount() {
|
function colCount() {
|
||||||
|
|
@ -77,7 +75,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[data-row-id]')[r];
|
const tr = t.querySelectorAll('tbody > tr')[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,28 +7,18 @@
|
||||||
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' + (isReq ? ' zddc-table__th--required' : ''),
|
className: 'zddc-table__th',
|
||||||
'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,85 +130,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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, '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
|
||||||
|
|
@ -292,9 +213,8 @@
|
||||||
return { status: 'readonly' };
|
return { status: 'readonly' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = mergeRow(row.data, drafts);
|
|
||||||
if (validateRequired(rowId, merged)) return { status: 'invalid-required' };
|
|
||||||
setRowState(rowId, 'saving');
|
setRowState(rowId, 'saving');
|
||||||
|
const merged = mergeRow(row.data, drafts);
|
||||||
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' };
|
||||||
|
|
@ -320,7 +240,6 @@
|
||||||
// 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,7 +266,6 @@
|
||||||
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');
|
||||||
|
|
@ -389,21 +307,17 @@
|
||||||
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',
|
||||||
|
|
@ -416,7 +330,6 @@
|
||||||
// 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,7 +346,6 @@
|
||||||
}
|
}
|
||||||
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' };
|
||||||
|
|
@ -491,7 +403,6 @@
|
||||||
// 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();
|
||||||
|
|
@ -506,21 +417,17 @@
|
||||||
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',
|
||||||
|
|
@ -541,13 +448,11 @@
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -587,7 +492,6 @@
|
||||||
} 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.
|
||||||
|
|
@ -626,9 +530,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowIdAtIndex(visibleRowIdx) {
|
function rowIdAtIndex(visibleRowIdx) {
|
||||||
// Data rows only — inline error rows have no data-row-id and must not
|
const tr = document.querySelectorAll('#table-root tbody > tr')[visibleRowIdx];
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 23:50:40 · 0326d46</span></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>
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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 23:50:41 · 0326d46</span></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>
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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 23:50:41 · 0326d46</span></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>
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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 23:50:41 · 0326d46</span></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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -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 23:50:40 · 0326d46</span></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>
|
||||||
</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;
|
||||||
|
|
|
||||||
|
|
@ -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 23:50:40 · 0326d46
|
archive=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7
|
||||||
transmittal=v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46
|
transmittal=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7
|
||||||
classifier=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
classifier=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||||
landing=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
landing=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||||
form=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
form=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||||
tables=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
tables=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||||
browse=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
browse=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -461,105 +460,6 @@ 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 }
|
||||||
|
|
@ -595,11 +495,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
@ -448,48 +446,3 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1398,28 +1398,6 @@ 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;
|
||||||
|
|
@ -1670,7 +1648,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 23:50:41 · 0326d46</span></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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -4651,9 +4629,7 @@ 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;
|
||||||
// Index over DATA rows only — inline error rows (no data-row-id,
|
const tr = tbody.children[r];
|
||||||
// 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 + '"]');
|
||||||
}
|
}
|
||||||
|
|
@ -4672,7 +4648,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[data-row-id]').length;
|
return t.querySelectorAll('tbody > tr').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function colCount() {
|
function colCount() {
|
||||||
|
|
@ -4692,7 +4668,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[data-row-id]')[r];
|
const tr = t.querySelectorAll('tbody > tr')[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;
|
||||||
|
|
@ -5638,18 +5614,15 @@ body.is-elevated::after {
|
||||||
};
|
};
|
||||||
})(window.tablesApp);
|
})(window.tablesApp);
|
||||||
|
|
||||||
// add-row.js — new-row creation.
|
// add-row.js — inline new-row creation.
|
||||||
//
|
//
|
||||||
// Two paths, chosen by the table's schema:
|
// Click "+ Add row" → append a draft row at the end of state.rows,
|
||||||
// - Record-tables (identity composed from required fields that aren't
|
// focus its first editable cell, accumulate user typing into the
|
||||||
// columns — e.g. the risk register's tracking-number components): "+ Add
|
// drafts buffer like any other row. On row-blur, save.js detects the
|
||||||
// row" navigates to the compose form (<dir>/form.html), the only place
|
// row.isNew flag and POSTs to <dir>/form.html (the form-create
|
||||||
// those components can be supplied. See needsComposeForm().
|
// endpoint). The 201 response carries the new row's Location; we swap
|
||||||
// - Simple tables (all required fields are columns): "+ Add row" appends a
|
// the synthetic url/yamlUrl for the real ones and the draft row
|
||||||
// draft row at the end of state.rows, focuses its first editable cell, and
|
// becomes a normal saved row.
|
||||||
// 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.
|
||||||
|
|
@ -5677,29 +5650,8 @@ body.is-elevated::after {
|
||||||
return dir + 'form.html';
|
return dir + 'form.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when a new record's identity is composed from required fields that
|
// Create-and-paint: the user-facing path.
|
||||||
// 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);
|
||||||
|
|
@ -5904,85 +5856,6 @@ 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, '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
|
||||||
|
|
@ -6066,9 +5939,8 @@ body.is-elevated::after {
|
||||||
return { status: 'readonly' };
|
return { status: 'readonly' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = mergeRow(row.data, drafts);
|
|
||||||
if (validateRequired(rowId, merged)) return { status: 'invalid-required' };
|
|
||||||
setRowState(rowId, 'saving');
|
setRowState(rowId, 'saving');
|
||||||
|
const merged = mergeRow(row.data, drafts);
|
||||||
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' };
|
||||||
|
|
@ -6094,7 +5966,6 @@ 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, 'Couldn’t save — network error. Your edits are kept; try again.');
|
|
||||||
return { status: 'network-error', error: err };
|
return { status: 'network-error', error: err };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6121,7 +5992,6 @@ 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');
|
||||||
|
|
@ -6163,21 +6033,17 @@ 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, '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',
|
||||||
|
|
@ -6190,7 +6056,6 @@ 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, 'Can’t save — server error (HTTP ' + resp.status + ').');
|
|
||||||
return { status: 'http-error', code: resp.status };
|
return { status: 'http-error', code: resp.status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6207,7 +6072,6 @@ 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' };
|
||||||
|
|
@ -6265,7 +6129,6 @@ 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();
|
||||||
|
|
@ -6280,21 +6143,17 @@ 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, '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',
|
||||||
|
|
@ -6315,13 +6174,11 @@ body.is-elevated::after {
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6361,7 +6218,6 @@ 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.
|
||||||
|
|
@ -6400,9 +6256,7 @@ body.is-elevated::after {
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowIdAtIndex(visibleRowIdx) {
|
function rowIdAtIndex(visibleRowIdx) {
|
||||||
// Data rows only — inline error rows have no data-row-id and must not
|
const tr = document.querySelectorAll('#table-root tbody > tr')[visibleRowIdx];
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7114,28 +6968,18 @@ 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' + (isReq ? ' zddc-table__th--required' : ''),
|
className: 'zddc-table__th',
|
||||||
'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' });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue