@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/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/package.json +1 -1
- package/src/runtime/browser/runtime.browser.js +131 -17
- package/src/runtime/browser/runtime.browser.test.ts +43 -1
- package/src/runtime/core/modules/pages.test.ts +83 -3
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
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",
|
|
@@ -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) :
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
1649
|
-
|
|
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
|
-
/**
|
|
1691
|
-
|
|
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 (
|
|
1694
|
-
const href = String(row[
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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 ?? "";
|