@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.
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
1685
|
-
|
|
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
|
-
/**
|
|
1727
|
-
|
|
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 (
|
|
1730
|
-
const href = String(row[
|
|
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
|
|
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
|
|
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 ?? "";
|