@nanobpm/urban 0.59.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.59.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",
@@ -1631,10 +1631,13 @@ function renderDataGrid(node) {
1631
1631
  }
1632
1632
 
1633
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.
1634
1637
  * @returns {HTMLElement|null}
1635
1638
  */
1636
- function detailForm(row) {
1637
- const f = detail.form;
1639
+ function detailForm(row, formCfg) {
1640
+ const f = formCfg;
1638
1641
  if (!f || !row[f.showWhenField]) return null;
1639
1642
  const box = el("div", { class: "pc-subform" });
1640
1643
  if (f.title) box.append(el("div", { class: "pc-subform-title" }, f.title));
@@ -1673,21 +1676,61 @@ function renderDataGrid(node) {
1673
1676
  const ccols = cg.columns || [];
1674
1677
  const croles = classifyColumns(ccols);
1675
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
+ };
1676
1717
  const cbody = el("tbody", {});
1677
- const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
1718
+ const ccolgroup = buildColgroup(ccols, cextra);
1678
1719
  const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
1679
- ...(cg.lazyField ? [el("th", {}, "")] : [])));
1720
+ ...(cg.lazyField ? [el("th", {}, "")] : []),
1721
+ ...(chasExpand ? [el("th", {}, "")] : [])));
1680
1722
  const ctable = ccolgroup
1681
1723
  ? el("table", { class: "pc-grid" }, ccolgroup, cthead, cbody)
1682
1724
  : el("table", { class: "pc-grid" }, cthead, cbody);
1683
1725
  wrap.append(ctable);
1684
- /** @type {any} */
1685
- 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);
1686
1730
  try {
1687
1731
  /** @type {{ rows: Array<Record<string, any>> }} */
1688
1732
  const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,
1689
1733
  [{ field: cg.childField, eq: row[cg.parentField] }], cg.orderBy));
1690
- cspan = String((ccols.length || 1) + (cg.lazyField ? 1 : 0));
1691
1734
  if (!rows.length) {
1692
1735
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
1693
1736
  }
@@ -1709,6 +1752,33 @@ function renderDataGrid(node) {
1709
1752
  }
1710
1753
  cells.push(cell);
1711
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
+ }
1712
1782
  // Child grids classify columns into the same mobile roles (including
1713
1783
  // "hidden") as the top-level grid, so they need the same per-row "More"
1714
1784
  // toggle — without it a mobile:{priority:"hidden"} child column would be
@@ -1716,6 +1786,7 @@ function renderDataGrid(node) {
1716
1786
  const ctr = el("tr", {}, ...cells);
1717
1787
  if (chasHidden) ctr.append(mobileMoreCell(ctr));
1718
1788
  cbody.append(ctr);
1789
+ if (cdtr) cbody.append(cdtr);
1719
1790
  }
1720
1791
  } catch (e) {
1721
1792
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, String(e.message || e))));
@@ -1723,11 +1794,18 @@ function renderDataGrid(node) {
1723
1794
  return wrap;
1724
1795
  }
1725
1796
 
1726
- /** @param {Record<string, any>} row */
1727
- 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;
1728
1806
  const box = el("div", { class: "pc-detail" });
1729
- if (detail.linkField && row[detail.linkField]) {
1730
- const href = String(row[detail.linkField]);
1807
+ if (d.linkField && row[d.linkField]) {
1808
+ const href = String(row[d.linkField]);
1731
1809
  // Render as a link only for http(s); anything else (e.g. a javascript: URL
1732
1810
  // smuggled through row data) is shown as inert text. External links get
1733
1811
  // rel="noopener noreferrer" so the opened page can't reach window.opener.
@@ -1737,17 +1815,17 @@ function renderDataGrid(node) {
1737
1815
  box.append(el("span", { class: "pc-link" }, href));
1738
1816
  }
1739
1817
  }
1740
- for (const df of detail.fields || []) {
1818
+ for (const df of d.fields || []) {
1741
1819
  box.append(el("div", { class: "pc-detail-field" },
1742
1820
  el("span", { class: "pc-detail-label" }, df.label || df.field),
1743
1821
  el("span", {}, row[df.field] == null ? "" : String(row[df.field]))));
1744
1822
  }
1745
- for (const cg of detail.children || []) {
1823
+ for (const cg of d.children || []) {
1746
1824
  const holder = el("div", {});
1747
1825
  box.append(holder);
1748
1826
  childGrid(cg, row).then((w) => holder.replaceChildren(w));
1749
1827
  }
1750
- const form = detailForm(row);
1828
+ const form = detailForm(row, d.form);
1751
1829
  if (form) box.append(form);
1752
1830
  return box;
1753
1831
  }
@@ -1314,6 +1314,84 @@ test("child grids reuse the same per-row More toggle for hidden columns (#268)",
1314
1314
  assert.match(js, /if \(chasHidden\) ctr\.append\(mobileMoreCell\(ctr\)\);/);
1315
1315
  });
1316
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
+
1317
1395
  test("the renderer honours an optional Tier-2 page-level mobile layout variant (#268)", async () => {
1318
1396
  const res = await dispatch("GET", "/app/runtime.js");
1319
1397
  const js = res.body ?? "";