@nanobpm/urban 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -321,6 +321,36 @@ function interpTemplate(tpl, row) {
321
321
  })
322
322
  : tpl;
323
323
  }
324
+
325
+ // Format a single-field cell value per a column's opt-in `col.format` (issue
326
+ // #327). This runs in the browser, so it is the ONLY place that can render a
327
+ // stored UTC ISO timestamp in the VIEWER'S local timezone — the server never
328
+ // knows the viewer's zone, so a server-formatted string would always be wrong
329
+ // for someone. Currently the sole format is "datetime": a parseable ISO/date
330
+ // value → "11:42am Aug 19" (h:mm + lowercase am/pm, then short-month day), both
331
+ // parts in local time. Defensive by construction: an empty value, an unparseable
332
+ // date, or any unknown format returns the raw text verbatim — a mis-set format
333
+ // never blanks a cell or throws, matching how an unrecognised link.kind degrades
334
+ // to plain text. Display-only: the untransformed rawText still drives the badge
335
+ // gate/tooltip and any sort/groupBy (those key off the real field).
336
+ /** @param {any} format
337
+ * @param {string} raw
338
+ * @returns {string} */
339
+ function fmtCellValue(format, raw) {
340
+ if (format !== "datetime" || raw === "") return raw;
341
+ const d = new Date(raw);
342
+ if (Number.isNaN(d.getTime())) return raw;
343
+ // Present in a stable en-US format ("11:42am Aug 19") regardless of the
344
+ // viewer's locale ordering (e.g. en-GB would emit "19 Aug"), while the ABSENCE
345
+ // of a `timeZone` option keeps both parts in the viewer's LOCAL zone. hour12
346
+ // forces am/pm; the replace lowercases "AM"/"a.m."/… to "am"/"pm" and drops the
347
+ // separating space so the output is exactly "11:42am".
348
+ const time = d
349
+ .toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })
350
+ .replace(/\s*([AaPp])\.?\s*[Mm]\.?$/, (_m, p) => p.toLowerCase() + "m");
351
+ const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
352
+ return time + " " + date;
353
+ }
324
354
  /** @param {any} node */
325
355
  function renderText(node) {
326
356
  const v = node.props.variant;
@@ -641,9 +671,15 @@ function gridCell(col, row, role) {
641
671
  // field-derived defaults. Kept separate from the (possibly templated) display
642
672
  // text so a template never re-lights or blanks a badge.
643
673
  const rawText = row[col.field] == null ? "" : String(row[col.field]);
674
+ // Per-column display format (issue #327): an opt-in `col.format` transforms the
675
+ // single-field value for display only (e.g. "datetime" → the viewer's local
676
+ // time). rawText above stays untransformed so the badge gate/tooltip and any
677
+ // sort/groupBy still key off the real field. A col.template composes its own
678
+ // {{field}} tokens and therefore wins over the formatted field text below.
679
+ const fieldText = col.format ? fmtCellValue(col.format, rawText) : rawText;
644
680
  // Visible text: a col.template (interpolated from the row) wins over the
645
- // raw field value, else the field value is shown verbatim.
646
- const text = typeof col.template === "string" ? interpTemplate(col.template, row) : rawText;
681
+ // formatted/raw field value, else the field value is shown verbatim.
682
+ const text = typeof col.template === "string" ? interpTemplate(col.template, row) : fieldText;
647
683
  // 1. badge — a compact status indicator. When the row's field value is
648
684
  // non-empty (truthy after trimming) render a small circular badge (e.g. a
649
685
  // red "1" dot flagging an incident) whose tooltip is the full field text;
@@ -1595,10 +1631,13 @@ function renderDataGrid(node) {
1595
1631
  }
1596
1632
 
1597
1633
  /** @param {Record<string, any>} row
1634
+ * @param {any} formCfg the panel's own form config (top-level or child grid); a
1635
+ * missing/undefined form means this panel has no form — it must NOT fall back to
1636
+ * the top-level grid's form, or a child panel would render the parent's form.
1598
1637
  * @returns {HTMLElement|null}
1599
1638
  */
1600
- function detailForm(row) {
1601
- const f = detail.form;
1639
+ function detailForm(row, formCfg) {
1640
+ const f = formCfg;
1602
1641
  if (!f || !row[f.showWhenField]) return null;
1603
1642
  const box = el("div", { class: "pc-subform" });
1604
1643
  if (f.title) box.append(el("div", { class: "pc-subform-title" }, f.title));
@@ -1637,21 +1676,61 @@ function renderDataGrid(node) {
1637
1676
  const ccols = cg.columns || [];
1638
1677
  const croles = classifyColumns(ccols);
1639
1678
  const chasHidden = croles.indexOf("hidden") >= 0;
1679
+ // Per-row expansion for child grids: a `detail` (alias `expandable`) block
1680
+ // gives each child row a chevron that reveals the same detailPanel() content
1681
+ // the top-level grid uses (link + fields + nested child grids + form). Rows are
1682
+ // collapsed by default; `detail.defaultCollapsed`/`detail.collapsed` (default
1683
+ // true) seeds the initial state, and — when the child grid declares a `rowKey`
1684
+ // — the per-row collapse is persisted in localStorage keyed by page + node +
1685
+ // parent rowKey + child rowKey, so it survives the top-level grid's refresh
1686
+ // poll (which rebuilds this whole subtree) and a full reload, mirroring how
1687
+ // groupBy group-collapse is persisted. Absent a `detail` block the child grid
1688
+ // is byte-for-byte unchanged (backward compatible).
1689
+ const cdetail = cg.detail || cg.expandable || null;
1690
+ const chasExpand = cdetail != null;
1691
+ const cextra = (cg.lazyField ? 1 : 0) + (chasExpand ? 1 : 0);
1692
+ const cdefaultCollapsed = chasExpand
1693
+ ? (cdetail.defaultCollapsed != null
1694
+ ? !!cdetail.defaultCollapsed
1695
+ : cdetail.collapsed != null
1696
+ ? !!cdetail.collapsed
1697
+ : true)
1698
+ : true;
1699
+ const cnodeId = node.id || p.title || "grid";
1700
+ const cchildId = cg.node || cg.table || cg.title || "child";
1701
+ // The parent-row discriminator is the value this child grid is queried by
1702
+ // (row[cg.parentField]) — NOT the top-level grid's rowKey. That keeps per-row
1703
+ // collapse state unique per parent row even when the parent grid declares no
1704
+ // rowKey (rowKeyOf(row) would then be null and every parent row would collide
1705
+ // under the literal "_"). When no parent discriminator is available there is no
1706
+ // safe key, so persistence is disabled (ckeyBase null) instead of colliding.
1707
+ const cparentKey = cg.parentField != null && row[cg.parentField] != null
1708
+ ? String(row[cg.parentField]) : null;
1709
+ const ckeyBase = cparentKey == null ? null
1710
+ : "pc:collapsed:" + CURRENT + ":" + cnodeId + ":" + cchildId + ":" + cparentKey + ":r:";
1711
+ /** @param {Record<string, any>} cr @returns {string|null} */
1712
+ const crowKeyOf = (cr) => {
1713
+ if (!cg.rowKey) return null;
1714
+ const v = cr[cg.rowKey];
1715
+ return v == null ? null : String(v);
1716
+ };
1640
1717
  const cbody = el("tbody", {});
1641
- const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
1718
+ const ccolgroup = buildColgroup(ccols, cextra);
1642
1719
  const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
1643
- ...(cg.lazyField ? [el("th", {}, "")] : [])));
1720
+ ...(cg.lazyField ? [el("th", {}, "")] : []),
1721
+ ...(chasExpand ? [el("th", {}, "")] : [])));
1644
1722
  const ctable = ccolgroup
1645
1723
  ? el("table", { class: "pc-grid" }, ccolgroup, cthead, cbody)
1646
1724
  : el("table", { class: "pc-grid" }, cthead, cbody);
1647
1725
  wrap.append(ctable);
1648
- /** @type {any} */
1649
- var cspan;
1726
+ // Compute the colspan eagerly (before the fetch) so BOTH the success path and
1727
+ // the catch's error row have a valid, table-spanning colspan — if getJSON()
1728
+ // throws, a lazily-assigned cspan would still be undefined here.
1729
+ const cspan = String((ccols.length || 1) + cextra);
1650
1730
  try {
1651
1731
  /** @type {{ rows: Array<Record<string, any>> }} */
1652
1732
  const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,
1653
1733
  [{ field: cg.childField, eq: row[cg.parentField] }], cg.orderBy));
1654
- cspan = String((ccols.length || 1) + (cg.lazyField ? 1 : 0));
1655
1734
  if (!rows.length) {
1656
1735
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
1657
1736
  }
@@ -1673,6 +1752,33 @@ function renderDataGrid(node) {
1673
1752
  }
1674
1753
  cells.push(cell);
1675
1754
  }
1755
+ // Per-row expander cell (trailing, after any lazyField cell): a chevron
1756
+ // that reveals this row's detailPanel in a following full-width row. The
1757
+ // panel is built lazily on first open (and eagerly when the row starts
1758
+ // expanded), matching the top-level grid.
1759
+ /** @type {any} */
1760
+ let cdtr = null;
1761
+ if (chasExpand) {
1762
+ const crk = crowKeyOf(cr);
1763
+ const cskey = ckeyBase && crk != null ? ckeyBase + crk : null;
1764
+ const collapsed = cskey ? readCollapsed(cskey, cdefaultCollapsed) : cdefaultCollapsed;
1765
+ const ctoggle = el("button", { class: "pc-btn pc-btn-sm pc-chevron" }, collapsed ? "▸" : "▾");
1766
+ cells.push(el("td", { class: "pc-row-actions" }, ctoggle));
1767
+ cdtr = el("tr", { hidden: collapsed ? "" : null }, el("td", { colspan: cspan }));
1768
+ let built = false;
1769
+ const build = () => {
1770
+ if (!built) { built = true; cdtr.firstChild.append(detailPanel(cr, cdetail)); }
1771
+ };
1772
+ if (!collapsed) build();
1773
+ ctoggle.addEventListener("click", /** @param {MouseEvent} ev */ (ev) => {
1774
+ ev.stopPropagation();
1775
+ const open = cdtr.hidden;
1776
+ cdtr.hidden = !open;
1777
+ ctoggle.textContent = open ? "▾" : "▸";
1778
+ if (open) build();
1779
+ if (cskey) writeCollapsed(cskey, !open);
1780
+ });
1781
+ }
1676
1782
  // Child grids classify columns into the same mobile roles (including
1677
1783
  // "hidden") as the top-level grid, so they need the same per-row "More"
1678
1784
  // toggle — without it a mobile:{priority:"hidden"} child column would be
@@ -1680,6 +1786,7 @@ function renderDataGrid(node) {
1680
1786
  const ctr = el("tr", {}, ...cells);
1681
1787
  if (chasHidden) ctr.append(mobileMoreCell(ctr));
1682
1788
  cbody.append(ctr);
1789
+ if (cdtr) cbody.append(cdtr);
1683
1790
  }
1684
1791
  } catch (e) {
1685
1792
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, String(e.message || e))));
@@ -1687,11 +1794,18 @@ function renderDataGrid(node) {
1687
1794
  return wrap;
1688
1795
  }
1689
1796
 
1690
- /** @param {Record<string, any>} row */
1691
- function detailPanel(row) {
1797
+ /** Build an expandable-row detail panel. Used by the top-level grid (with the
1798
+ * closure `detail` config) and by child grids (which pass their own `cg.detail`
1799
+ * as `cfg`) so both share one look/behaviour — link, fields, nested child grids
1800
+ * and an optional escalation form.
1801
+ * @param {Record<string, any>} row
1802
+ * @param {any} [cfg] detail config; defaults to the top-level grid's `detail`
1803
+ */
1804
+ function detailPanel(row, cfg) {
1805
+ const d = cfg || detail;
1692
1806
  const box = el("div", { class: "pc-detail" });
1693
- if (detail.linkField && row[detail.linkField]) {
1694
- const href = String(row[detail.linkField]);
1807
+ if (d.linkField && row[d.linkField]) {
1808
+ const href = String(row[d.linkField]);
1695
1809
  // Render as a link only for http(s); anything else (e.g. a javascript: URL
1696
1810
  // smuggled through row data) is shown as inert text. External links get
1697
1811
  // rel="noopener noreferrer" so the opened page can't reach window.opener.
@@ -1701,17 +1815,17 @@ function renderDataGrid(node) {
1701
1815
  box.append(el("span", { class: "pc-link" }, href));
1702
1816
  }
1703
1817
  }
1704
- for (const df of detail.fields || []) {
1818
+ for (const df of d.fields || []) {
1705
1819
  box.append(el("div", { class: "pc-detail-field" },
1706
1820
  el("span", { class: "pc-detail-label" }, df.label || df.field),
1707
1821
  el("span", {}, row[df.field] == null ? "" : String(row[df.field]))));
1708
1822
  }
1709
- for (const cg of detail.children || []) {
1823
+ for (const cg of d.children || []) {
1710
1824
  const holder = el("div", {});
1711
1825
  box.append(holder);
1712
1826
  childGrid(cg, row).then((w) => holder.replaceChildren(w));
1713
1827
  }
1714
- const form = detailForm(row);
1828
+ const form = detailForm(row, d.form);
1715
1829
  if (form) box.append(form);
1716
1830
  return box;
1717
1831
  }
@@ -2176,4 +2290,4 @@ function boot() {
2176
2290
  // renderer functions / RENDERERS registry can be imported directly.
2177
2291
  if (typeof document !== "undefined" && document.getElementById("page")) boot();
2178
2292
 
2179
- export { RENDERERS, renderText, renderButton, renderProse, renderNav };
2293
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav, fmtCellValue, gridCell };
@@ -7,7 +7,7 @@
7
7
  import { test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
9
  import { PAGE_NODE_TYPES } from "@nanobpm/nano-app-schema";
10
- import { RENDERERS, renderText } from "./runtime.browser.js";
10
+ import { RENDERERS, renderText, fmtCellValue, gridCell } from "./runtime.browser.js";
11
11
 
12
12
  // ── Minimal fake DOM ────────────────────────────────────────────────────────
13
13
  // Just enough of the Element/Document surface that el()/renderText touch:
@@ -116,3 +116,45 @@ test("#291: renderText degrades to empty text (never throws) on a missing text p
116
116
  assert.equal(node.tagName, "P");
117
117
  assert.equal(node.textContent, "");
118
118
  });
119
+
120
+ test('#327: fmtCellValue "datetime" renders an ISO timestamp in the viewer\'s local time as "h:mmam Mon D"', () => {
121
+ const iso = "2026-08-18T23:42:47.788Z";
122
+ const out = fmtCellValue("datetime", iso);
123
+ // Shape: h:mm + lowercase am/pm (no separating space), then short-month day.
124
+ assert.match(out, /^\d{1,2}:\d{2}(am|pm) [A-Z][a-z]{2} \d{1,2}$/, `unexpected shape: ${out}`);
125
+ // Local, not UTC: the hour/minute/am-pm/day track the runner's local zone, so
126
+ // the assertion is timezone-independent (derives the expectation from the same
127
+ // Date in local time) yet still proves the value isn't rendered as raw UTC.
128
+ const d = new Date(iso);
129
+ let h = d.getHours() % 12;
130
+ if (h === 0) h = 12;
131
+ const mm = String(d.getMinutes()).padStart(2, "0");
132
+ const ampm = d.getHours() < 12 ? "am" : "pm";
133
+ assert.ok(out.startsWith(`${h}:${mm}${ampm} `), `local time prefix wrong: ${out}`);
134
+ assert.ok(out.endsWith(` ${d.getDate()}`), `local day suffix wrong: ${out}`);
135
+ });
136
+
137
+ test("#327: fmtCellValue passes through empty, unparseable, and unknown-format values verbatim (never blank/throw)", () => {
138
+ assert.equal(fmtCellValue("datetime", ""), "");
139
+ assert.equal(fmtCellValue("datetime", "not-a-date"), "not-a-date");
140
+ // An unknown format is a no-op — a mis-set column shows its raw value, not blank.
141
+ assert.equal(fmtCellValue("nope", "2026-08-18T23:42:47.788Z"), "2026-08-18T23:42:47.788Z");
142
+ });
143
+
144
+ test("#327: gridCell applies col.format to the cell text, leaving an unformatted column verbatim", (t) => {
145
+ t.after(installFakeDom());
146
+ const iso = "2026-08-18T23:42:47.788Z";
147
+ const formatted = gridCell(
148
+ { field: "updated_at", header: "Updated", format: "datetime" },
149
+ { updated_at: iso },
150
+ "secondary",
151
+ );
152
+ assert.match(
153
+ formatted.textContent,
154
+ /^\d{1,2}:\d{2}(am|pm) [A-Z][a-z]{2} \d{1,2}$/,
155
+ `cell not local-formatted: ${formatted.textContent}`,
156
+ );
157
+ // No `format` → the field value renders exactly as stored (backward compatible).
158
+ const plain = gridCell({ field: "updated_at", header: "Updated" }, { updated_at: iso }, "secondary");
159
+ assert.equal(plain.textContent, iso);
160
+ });
@@ -378,10 +378,12 @@ test("renderer interpolates a column's per-cell template into DOM text (#214)",
378
378
  // resolving to prototype cruft — guard the own-property gate explicitly.
379
379
  assert.match(js, /Object\.prototype\.hasOwnProperty\.call\(row, key\) \? row\[key\] : null/);
380
380
  assert.match(js, /return v == null \? "" : String\(v\);/);
381
- // gridCell keeps the raw single-field value separate (drives the badge gate)
382
- // and lets a string template win for the rendered text.
381
+ // gridCell keeps the raw single-field value separate (drives the badge gate),
382
+ // applies an opt-in col.format to the field text (#327), and lets a string
383
+ // template win for the rendered text over that formatted/raw field value.
383
384
  assert.match(js, /const rawText = row\[col\.field\] == null \? "" : String\(row\[col\.field\]\);/);
384
- assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : rawText;/);
385
+ assert.match(js, /const fieldText = col\.format \? fmtCellValue\(col\.format, rawText\) : rawText;/);
386
+ assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : fieldText;/);
385
387
  });
386
388
 
387
389
  test("renderer renders a column's subtitle as a muted second line, never an href (#257)", async () => {
@@ -1312,6 +1314,84 @@ test("child grids reuse the same per-row More toggle for hidden columns (#268)",
1312
1314
  assert.match(js, /if \(chasHidden\) ctr\.append\(mobileMoreCell\(ctr\)\);/);
1313
1315
  });
1314
1316
 
1317
+ test("child grids support per-row detail expansion, collapsed by default (#332)", async () => {
1318
+ const res = await dispatch("GET", "/app/runtime.js");
1319
+ const js = res.body ?? "";
1320
+ // A child grid opts into per-row expansion with a `detail` (alias `expandable`)
1321
+ // block that reuses the top-level grid's detailPanel() — so a nested table (e.g.
1322
+ // a Convergence "Rounds" child) can render each row as an expandable, collapsed
1323
+ // affordance instead of dumping the whole history flat.
1324
+ assert.match(js, /const cdetail = cg\.detail \|\| cg\.expandable \|\| null;/);
1325
+ assert.match(js, /const chasExpand = cdetail != null;/);
1326
+ // The expander shares the top-level detail look: a pc-chevron button in a
1327
+ // pc-row-actions cell, revealing detailPanel(cr, cdetail) in a following row.
1328
+ assert.match(js, /class: "pc-btn pc-btn-sm pc-chevron" \}, collapsed \? "▸" : "▾"/);
1329
+ assert.match(js, /cells\.push\(el\("td", \{ class: "pc-row-actions" \}, ctoggle\)\);/);
1330
+ assert.match(js, /cdtr\.firstChild\.append\(detailPanel\(cr, cdetail\)\)/);
1331
+ // Rows are collapsed by default; detail.defaultCollapsed/detail.collapsed (both
1332
+ // defaulting to true) seed the initial state.
1333
+ assert.match(js, /cdetail\.defaultCollapsed != null/);
1334
+ assert.match(js, /cdetail\.collapsed != null/);
1335
+ // detailPanel is now config-parametrised so the top-level grid and child grids
1336
+ // share one implementation (link + fields + nested children + form).
1337
+ assert.match(js, /function detailPanel\(row, cfg\)/);
1338
+ assert.match(js, /const d = cfg \|\| detail;/);
1339
+ });
1340
+
1341
+ test("child-grid per-row collapse state is persisted across the refresh poll (#332)", async () => {
1342
+ const res = await dispatch("GET", "/app/runtime.js");
1343
+ const js = res.body ?? "";
1344
+ // The top-level grid rebuilds each child grid on every refreshMs poll, so an
1345
+ // open row would collapse on the next tick without durable state. Per-row
1346
+ // collapse is persisted in localStorage — keyed by page + node + parent
1347
+ // discriminator + child rowKey — via the same readCollapsed/writeCollapsed
1348
+ // helpers groupBy group-collapse uses, so it survives both the poll and a full
1349
+ // reload.
1350
+ assert.match(js, /const cskey = ckeyBase && crk != null \? ckeyBase \+ crk : null;/);
1351
+ assert.match(js, /readCollapsed\(cskey, cdefaultCollapsed\)/);
1352
+ assert.match(js, /if \(cskey\) writeCollapsed\(cskey, !open\);/);
1353
+ assert.match(js, /"pc:collapsed:" \+ CURRENT \+ ":" \+ cnodeId \+ ":" \+ cchildId/);
1354
+ });
1355
+
1356
+ test("child-grid collapse key is scoped by row[cg.parentField], not the parent grid's rowKey (#332)", async () => {
1357
+ const res = await dispatch("GET", "/app/runtime.js");
1358
+ const js = res.body ?? "";
1359
+ // The parent-row discriminator in the localStorage key MUST be the value the
1360
+ // child grid is queried by (row[cg.parentField]) — NOT rowKeyOf(row), which is
1361
+ // null whenever the top-level grid declares no `rowKey`. With the old rowKeyOf
1362
+ // key every parent row collapsed under the literal "_", so opening one parent's
1363
+ // child row silently toggled the same-keyed child row under every other parent.
1364
+ assert.match(
1365
+ js,
1366
+ /const cparentKey = cg\.parentField != null && row\[cg\.parentField\] != null\s*\?\s*String\(row\[cg\.parentField\]\) : null;/,
1367
+ );
1368
+ // No safe key ⇒ persistence disabled (ckeyBase null), never a colliding "_".
1369
+ assert.match(js, /const ckeyBase = cparentKey == null \? null/);
1370
+ assert.doesNotMatch(js, /cparentKey == null \? "_" : cparentKey/);
1371
+ });
1372
+
1373
+ test("child-grid detail panel never falls back to the top-level grid's form (#332)", async () => {
1374
+ const res = await dispatch("GET", "/app/runtime.js");
1375
+ const js = res.body ?? "";
1376
+ // detailPanel(cr, cdetail) renders a child grid's own detail config. Its form is
1377
+ // detailForm(row, d.form): when the child config declares no `form` the panel
1378
+ // must have NO form — it must NOT implicitly reuse the top-level grid's
1379
+ // detail.form. So detailForm uses its argument verbatim rather than falling back
1380
+ // to `detail && detail.form` when the argument is undefined.
1381
+ assert.match(js, /function detailForm\(row, formCfg\) \{\s*const f = formCfg;/);
1382
+ assert.doesNotMatch(js, /formCfg !== undefined \? formCfg : \(detail && detail\.form\)/);
1383
+ });
1384
+
1385
+ test("child-grid fetch failure renders a full-width error row (#332)", async () => {
1386
+ const res = await dispatch("GET", "/app/runtime.js");
1387
+ const js = res.body ?? "";
1388
+ // The child grid's colspan is computed eagerly, BEFORE the getJSON() fetch, so
1389
+ // the catch path's error row (colspan: cspan) spans the whole table even when
1390
+ // the fetch throws. A lazily-assigned cspan would be undefined in the catch.
1391
+ assert.match(js, /const cspan = String\(\(ccols\.length \|\| 1\) \+ cextra\);\s*try \{/);
1392
+ assert.match(js, /\} catch \(e\) \{\s*cbody\.append\(el\("tr", \{\}, el\("td", \{ colspan: cspan \}/);
1393
+ });
1394
+
1315
1395
  test("the renderer honours an optional Tier-2 page-level mobile layout variant (#268)", async () => {
1316
1396
  const res = await dispatch("GET", "/app/runtime.js");
1317
1397
  const js = res.body ?? "";