@nanobpm/urban 0.59.0 → 0.61.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.61.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
  }
@@ -1976,7 +2054,9 @@ function renderDataGrid(node) {
1976
2054
  // ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
1977
2055
  // current page) or to an external URL ({ label, href } -> a hardened new-tab
1978
2056
  // link; only http(s) is honoured). With items:"auto" (or omitted) the nav
1979
- // lists every page from the /app/pages index, using each page's title.
2057
+ // lists every page from the /app/pages index, using each page's title. An item
2058
+ // may also carry a `badge` ({ source, table, filter, tone, refreshMs,
2059
+ // hideWhenZero }) that renders a live count pill from the /app/data gateway.
1980
2060
  /** @param {any} item
1981
2061
  * @returns {HTMLAnchorElement|null}
1982
2062
  */
@@ -1995,8 +2075,87 @@ function navLink(item) {
1995
2075
  /** @type {Array<Node|string>} */
1996
2076
  const kids = [];
1997
2077
  if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
1998
- kids.push(el("span", { class: "pc-nav-label" }, String(item.label != null ? item.label : (item.page || item.href))));
1999
- return el("a", attrs, ...kids);
2078
+ const labelText = String(item.label != null ? item.label : (item.page || item.href));
2079
+ kids.push(el("span", { class: "pc-nav-label" }, labelText));
2080
+ const link = el("a", attrs, ...kids);
2081
+ // A nav item may carry a live count badge sourced from a datasource (e.g. the
2082
+ // Tasks tab showing how many escalations await a human). It reuses the same
2083
+ // /app/data gateway + .pc-badge tone pills as dataGrid, and polls on refreshMs.
2084
+ // Absent/invalid badge → the link is exactly as before (backward compatible).
2085
+ wireNavBadge(link, labelText, item.badge);
2086
+ return link;
2087
+ }
2088
+
2089
+ // Attach + drive a nav item's live count badge. Best-effort throughout: a bad
2090
+ // config or a failed fetch degrades to no badge, never a broken nav link. The
2091
+ // pill is appended hidden and only appears once a fetch yields a count (and, when
2092
+ // hideWhenZero, only when that count is > 0). The accessible name of the whole
2093
+ // link is kept in sync ("Tasks (3 open)") so assistive tech announces the count,
2094
+ // which the small "3" glyph alone would not convey; the pill itself is aria-hidden
2095
+ // to avoid a double announcement.
2096
+ /** @param {HTMLElement} link
2097
+ * @param {string} baseLabel
2098
+ * @param {any} badge
2099
+ */
2100
+ function wireNavBadge(link, baseLabel, badge) {
2101
+ if (!badge || typeof badge !== "object") return;
2102
+ if (typeof badge.table !== "string" || badge.table === "") return;
2103
+ const source = typeof badge.source === "string" && badge.source !== "" ? badge.source : "app";
2104
+ const t = badge.tone;
2105
+ const tone = t === "warn" || t === "ok" || t === "info" || t === "danger" ? t : "danger";
2106
+ const hideWhenZero = badge.hideWhenZero === true;
2107
+ const pill = el("span", { class: "pc-badge pc-nav-badge pc-badge-" + tone, "aria-hidden": "true" });
2108
+ pill.hidden = true;
2109
+ link.append(pill);
2110
+ const filter = Array.isArray(badge.filter) ? badge.filter : [];
2111
+ const paramScoped = filter.some(/** @param {any} f */(f) => f && f.eqParam);
2112
+ const refresh = () => {
2113
+ // A param-scoped badge ("count for the selected entity") with no route param
2114
+ // present would otherwise build the URL with dataUrl baking in an empty PARAM
2115
+ // and query field="", surfacing a wrong count. Match the dataGrid/list pollers:
2116
+ // degrade to 0 (hidden when hideWhenZero) without a request instead.
2117
+ if (paramScoped && PARAM === "") {
2118
+ applyNavBadge(link, pill, baseLabel, hideWhenZero, 0);
2119
+ return;
2120
+ }
2121
+ const url = dataUrl(source, badge.table, filter, null);
2122
+ const countUrl = url + (url.indexOf("?") >= 0 ? "&" : "?") + "count=1";
2123
+ getJSON(countUrl)
2124
+ .then(/** @param {any} res */(res) => applyNavBadge(link, pill, baseLabel, hideWhenZero, res && res.count))
2125
+ .catch(() => applyNavBadge(link, pill, baseLabel, hideWhenZero, NaN));
2126
+ };
2127
+ refresh();
2128
+ const ms = Number(badge.refreshMs);
2129
+ if (Number.isFinite(ms) && ms > 0) {
2130
+ const timer = setInterval(refresh, ms);
2131
+ disposers.push(() => clearInterval(timer));
2132
+ }
2133
+ }
2134
+
2135
+ // Reflect a fetched count into the pill + the link's accessible name. A
2136
+ // non-finite/negative count (a failed or malformed fetch) hides the pill and
2137
+ // restores the plain label — the nav degrades quietly rather than showing a
2138
+ // broken/"NaN" badge. hideWhenZero also hides the pill at exactly 0.
2139
+ /** @param {HTMLElement} link
2140
+ * @param {any} pill
2141
+ * @param {string} baseLabel
2142
+ * @param {boolean} hideWhenZero
2143
+ * @param {any} count
2144
+ */
2145
+ function applyNavBadge(link, pill, baseLabel, hideWhenZero, count) {
2146
+ const n = Number(count);
2147
+ if (!Number.isFinite(n) || n < 0 || (n === 0 && hideWhenZero)) {
2148
+ pill.hidden = true;
2149
+ pill.textContent = "";
2150
+ link.setAttribute("aria-label", baseLabel);
2151
+ return;
2152
+ }
2153
+ const shown = String(Math.floor(n));
2154
+ pill.hidden = false;
2155
+ pill.textContent = shown;
2156
+ const aria = baseLabel + " (" + shown + " open)";
2157
+ pill.setAttribute("title", aria);
2158
+ link.setAttribute("aria-label", aria);
2000
2159
  }
2001
2160
  /** @param {HTMLElement} list
2002
2161
  * @param {any[]} items
@@ -2212,4 +2371,4 @@ function boot() {
2212
2371
  // renderer functions / RENDERERS registry can be imported directly.
2213
2372
  if (typeof document !== "undefined" && document.getElementById("page")) boot();
2214
2373
 
2215
- export { RENDERERS, renderText, renderButton, renderProse, renderNav, fmtCellValue, gridCell };
2374
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, 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, fmtCellValue, gridCell } from "./runtime.browser.js";
10
+ import { RENDERERS, renderText, navLink, wireNavBadge, applyNavBadge, teardown, 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:
@@ -158,3 +158,183 @@ test("#327: gridCell applies col.format to the cell text, leaving an unformatted
158
158
  const plain = gridCell({ field: "updated_at", header: "Updated" }, { updated_at: iso }, "secondary");
159
159
  assert.equal(plain.textContent, iso);
160
160
  });
161
+
162
+ // Find the first descendant span carrying `cls` in a FakeElement tree. Accepts
163
+ // `any` so it can walk either a FakeElement or a renderer's DOM-typed return.
164
+ function findByClass(node: any, cls: string): FakeElement | null {
165
+ for (const kid of node.children) {
166
+ if (kid instanceof FakeElement) {
167
+ if (kid.className.split(/\s+/).includes(cls)) return kid;
168
+ const deep = findByClass(kid, cls);
169
+ if (deep) return deep;
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ // Install a fake `fetch` on globalThis returning a fixed JSON body, so navLink's
176
+ // badge poll resolves deterministically without a real network call. Returns a
177
+ // restore fn (register with t.after) so it never leaks into other tests.
178
+ function installFakeFetch(body: unknown): () => void {
179
+ const prior = Reflect.getOwnPropertyDescriptor(globalThis, "fetch");
180
+ Reflect.set(globalThis, "fetch", () =>
181
+ Promise.resolve({ ok: true, json: () => Promise.resolve(body) }),
182
+ );
183
+ return () => {
184
+ if (prior) Reflect.defineProperty(globalThis, "fetch", prior);
185
+ else Reflect.deleteProperty(globalThis, "fetch");
186
+ };
187
+ }
188
+
189
+ test("#338: navLink without a badge is unchanged — no pill, no aria-label override", (t) => {
190
+ t.after(installFakeDom());
191
+ const link = navLink({ label: "Home", page: "home" });
192
+ assert.ok(link, "expected a nav link");
193
+ assert.equal(link.tagName, "A");
194
+ assert.equal(findByClass(link, "pc-nav-badge"), null, "no badge pill without item.badge");
195
+ assert.equal(link.getAttribute("aria-label"), null, "aria-label untouched without a badge");
196
+ });
197
+
198
+ test("#338: navLink with a badge appends a hidden tone-classed pill and polls a count", async (t) => {
199
+ t.after(installFakeDom());
200
+ t.after(installFakeFetch({ count: 3 }));
201
+ const link = navLink({
202
+ label: "Tasks",
203
+ page: "tasks",
204
+ badge: { source: "app", table: "user_tasks", filter: [], tone: "danger" },
205
+ });
206
+ assert.ok(link);
207
+ const pill = findByClass(link, "pc-nav-badge");
208
+ assert.ok(pill, "expected a badge pill for item.badge");
209
+ assert.ok(pill.className.includes("pc-badge"), "reuses the .pc-badge pill");
210
+ assert.ok(pill.className.includes("pc-badge-danger"), "carries the requested tone class");
211
+ // The poll resolves asynchronously; flush microtasks then assert the live count.
212
+ await new Promise((r) => setTimeout(r, 0));
213
+ assert.equal(pill.hidden, false, "pill shows once a positive count arrives");
214
+ assert.equal(pill.textContent, "3");
215
+ assert.equal(link.getAttribute("aria-label"), "Tasks (3 open)", "accessible name carries the count");
216
+ });
217
+
218
+ test("#338: navLink badge degrades to no pill (never a broken nav) when the fetch fails", async (t) => {
219
+ t.after(installFakeDom());
220
+ const prior = Reflect.getOwnPropertyDescriptor(globalThis, "fetch");
221
+ Reflect.set(globalThis, "fetch", () => Promise.reject(new Error("boom")));
222
+ t.after(() => {
223
+ if (prior) Reflect.defineProperty(globalThis, "fetch", prior);
224
+ else Reflect.deleteProperty(globalThis, "fetch");
225
+ });
226
+ const link = navLink({ label: "Tasks", page: "tasks", badge: { table: "user_tasks" } });
227
+ const pill = findByClass(link, "pc-nav-badge");
228
+ assert.ok(pill);
229
+ await new Promise((r) => setTimeout(r, 0));
230
+ assert.equal(pill.hidden, true, "a failed fetch leaves the pill hidden");
231
+ assert.equal(pill.textContent, "");
232
+ });
233
+
234
+ test("#338: applyNavBadge honours hideWhenZero and mirrors the count into the accessible name", (t) => {
235
+ const restore = installFakeDom();
236
+ t.after(restore);
237
+ const doc: { createElement: (tag: string) => FakeElement } = Reflect.get(globalThis, "document");
238
+ // The renderer's applyNavBadge is DOM-typed (HTMLElement); the fake elements
239
+ // satisfy the subset it touches, so the locals are `any` (no banned `as` cast).
240
+ const mk = (): { link: any; pill: any } => ({ link: doc.createElement("a"), pill: doc.createElement("span") });
241
+
242
+ // Positive count → visible pill + "(n open)" accessible name.
243
+ const a = mk();
244
+ applyNavBadge(a.link, a.pill, "Tasks", true, 5);
245
+ assert.equal(a.pill.hidden, false);
246
+ assert.equal(a.pill.textContent, "5");
247
+ assert.equal(a.link.getAttribute("aria-label"), "Tasks (5 open)");
248
+
249
+ // Zero with hideWhenZero → nothing (a quiet nav stays clean), label reset.
250
+ const b = mk();
251
+ applyNavBadge(b.link, b.pill, "Tasks", true, 0);
252
+ assert.equal(b.pill.hidden, true);
253
+ assert.equal(b.pill.textContent, "");
254
+ assert.equal(b.link.getAttribute("aria-label"), "Tasks");
255
+
256
+ // Zero WITHOUT hideWhenZero → the pill still shows "0".
257
+ const c = mk();
258
+ applyNavBadge(c.link, c.pill, "Tasks", false, 0);
259
+ assert.equal(c.pill.hidden, false);
260
+ assert.equal(c.pill.textContent, "0");
261
+
262
+ // A non-finite/malformed count degrades to hidden, never "NaN".
263
+ const d = mk();
264
+ applyNavBadge(d.link, d.pill, "Tasks", false, Number.NaN);
265
+ assert.equal(d.pill.hidden, true);
266
+ assert.equal(d.pill.textContent, "");
267
+ });
268
+
269
+ test("#338: wireNavBadge registers its poll interval with the per-page teardown so page switches stop it", (t) => {
270
+ // Defect-class guard: any refreshMs poller MUST clear its interval on teardown,
271
+ // or navigating between pages leaks a setInterval that fetches forever and pins
272
+ // the old link/pill alive via closure. This drives wireNavBadge with stubbed
273
+ // timers + fetch and asserts teardown() actually clears the interval it started.
274
+ const restore = installFakeDom();
275
+ t.after(restore);
276
+ const doc: { createElement: (tag: string) => FakeElement } = Reflect.get(globalThis, "document");
277
+
278
+ const cleared: number[] = [];
279
+ let nextId = 1;
280
+ const started: number[] = [];
281
+ const priorSet = Reflect.getOwnPropertyDescriptor(globalThis, "setInterval");
282
+ const priorClear = Reflect.getOwnPropertyDescriptor(globalThis, "clearInterval");
283
+ const priorFetch = Reflect.getOwnPropertyDescriptor(globalThis, "fetch");
284
+ Reflect.set(globalThis, "setInterval", (): number => {
285
+ const id = nextId++;
286
+ started.push(id);
287
+ return id;
288
+ });
289
+ Reflect.set(globalThis, "clearInterval", (id: number): void => {
290
+ cleared.push(id);
291
+ });
292
+ // getJSON awaits fetch(); a rejection is swallowed by wireNavBadge's .catch, so
293
+ // the immediate poll degrades quietly without an unhandled rejection.
294
+ Reflect.set(globalThis, "fetch", () => Promise.reject(new Error("offline")));
295
+ t.after(() => {
296
+ if (priorSet) Reflect.defineProperty(globalThis, "setInterval", priorSet);
297
+ if (priorClear) Reflect.defineProperty(globalThis, "clearInterval", priorClear);
298
+ if (priorFetch) Reflect.defineProperty(globalThis, "fetch", priorFetch);
299
+ else Reflect.deleteProperty(globalThis, "fetch");
300
+ });
301
+
302
+ const link: any = doc.createElement("a");
303
+ wireNavBadge(link, "Tasks", { source: "app", table: "tasks", refreshMs: 5000 });
304
+ assert.equal(started.length, 1, "wireNavBadge should start exactly one poll interval");
305
+ assert.equal(cleared.length, 0, "interval must not be cleared before teardown");
306
+
307
+ teardown();
308
+ assert.deepEqual(cleared, started, "teardown() must clear the interval wireNavBadge started");
309
+ });
310
+
311
+ test("#338: a param-scoped nav badge with no route param degrades to 0 without querying field=\"\"", (t) => {
312
+ // Defect-class guard mirroring the dataGrid/list pollers: a badge whose filter is
313
+ // param-scoped ({ eqParam: true }) but with an empty route PARAM must NOT build a
314
+ // where=field: query (which the server reads as field equals empty string and can
315
+ // surface a wrong count). It must short-circuit to a 0/hidden badge with no fetch.
316
+ const restore = installFakeDom();
317
+ t.after(restore);
318
+ const doc: { createElement: (tag: string) => FakeElement } = Reflect.get(globalThis, "document");
319
+
320
+ let fetchCalls = 0;
321
+ const priorFetch = Reflect.getOwnPropertyDescriptor(globalThis, "fetch");
322
+ Reflect.set(globalThis, "fetch", () => {
323
+ fetchCalls++;
324
+ return Promise.reject(new Error("should not fetch"));
325
+ });
326
+ t.after(() => {
327
+ if (priorFetch) Reflect.defineProperty(globalThis, "fetch", priorFetch);
328
+ else Reflect.deleteProperty(globalThis, "fetch");
329
+ });
330
+
331
+ // PARAM defaults to "" (no renderPage has run), so a param-scoped badge is the
332
+ // empty-selection case the guard must short-circuit.
333
+ const link: any = doc.createElement("a");
334
+ wireNavBadge(link, "Tasks", { source: "app", table: "tasks", filter: [{ field: "owner", eqParam: true }] });
335
+
336
+ assert.equal(fetchCalls, 0, "param-scoped badge with empty PARAM must not issue a count query");
337
+ const pill: any = link.children[link.children.length - 1];
338
+ assert.equal(pill.textContent, "0", "badge degrades to 0 rather than an empty-string-filtered count");
339
+ assert.equal(pill.hidden, false, "0 is shown (hideWhenZero is off) rather than a stale count");
340
+ });
@@ -1047,6 +1047,33 @@ test("GET /app/data whitelists filter columns", async () => {
1047
1047
  assert.equal(bad.status, 400);
1048
1048
  });
1049
1049
 
1050
+ test("#338: GET /app/data?count=1 returns a COUNT(*), honouring the same whitelisted where", async () => {
1051
+ // count mode issues a COUNT(*) (not SELECT *) so a nav badge can show a live
1052
+ // number without transferring rows; the where filter is still column-whitelisted.
1053
+ let seenSql = "";
1054
+ let seenParams: unknown[] = [];
1055
+ const { router } = build({
1056
+ query: async (sql: string, params?: unknown[]) => {
1057
+ if (/PRAGMA table_info/i.test(sql)) {
1058
+ return [{ name: "id" }, { name: "status" }, { name: "total" }];
1059
+ }
1060
+ seenSql = sql;
1061
+ seenParams = params ?? [];
1062
+ return [{ n: 7 }];
1063
+ },
1064
+ });
1065
+ const res = await router(req("GET", "/app/data/app/orders", { query: "count=1&where=status:new" }));
1066
+ assert.equal(res.status, 200);
1067
+ assert.equal(JSON.parse(res.body ?? "{}").count, 7);
1068
+ assert.match(seenSql, /SELECT COUNT\(\*\) AS n FROM/i, "count mode must issue a COUNT(*), not SELECT *");
1069
+ assert.doesNotMatch(seenSql, /LIMIT/i, "a count has no LIMIT");
1070
+ assert.deepEqual(seenParams, ["new"], "the where value is still bound as a parameter");
1071
+
1072
+ // An unknown filter column is rejected in count mode too (no whitelist bypass).
1073
+ const bad = await router(req("GET", "/app/data/app/orders", { query: "count=1&where=evil:1" }));
1074
+ assert.equal(bad.status, 400);
1075
+ });
1076
+
1050
1077
  test("POST /app/actions/start/<process> creates an instance", async () => {
1051
1078
  const { router, calls } = build();
1052
1079
  const res = await router(req("POST", "/app/actions/start/my-proc", { body: { variables: { a: 1 } } }));
@@ -1314,6 +1341,84 @@ test("child grids reuse the same per-row More toggle for hidden columns (#268)",
1314
1341
  assert.match(js, /if \(chasHidden\) ctr\.append\(mobileMoreCell\(ctr\)\);/);
1315
1342
  });
1316
1343
 
1344
+ test("child grids support per-row detail expansion, collapsed by default (#332)", async () => {
1345
+ const res = await dispatch("GET", "/app/runtime.js");
1346
+ const js = res.body ?? "";
1347
+ // A child grid opts into per-row expansion with a `detail` (alias `expandable`)
1348
+ // block that reuses the top-level grid's detailPanel() — so a nested table (e.g.
1349
+ // a Convergence "Rounds" child) can render each row as an expandable, collapsed
1350
+ // affordance instead of dumping the whole history flat.
1351
+ assert.match(js, /const cdetail = cg\.detail \|\| cg\.expandable \|\| null;/);
1352
+ assert.match(js, /const chasExpand = cdetail != null;/);
1353
+ // The expander shares the top-level detail look: a pc-chevron button in a
1354
+ // pc-row-actions cell, revealing detailPanel(cr, cdetail) in a following row.
1355
+ assert.match(js, /class: "pc-btn pc-btn-sm pc-chevron" \}, collapsed \? "▸" : "▾"/);
1356
+ assert.match(js, /cells\.push\(el\("td", \{ class: "pc-row-actions" \}, ctoggle\)\);/);
1357
+ assert.match(js, /cdtr\.firstChild\.append\(detailPanel\(cr, cdetail\)\)/);
1358
+ // Rows are collapsed by default; detail.defaultCollapsed/detail.collapsed (both
1359
+ // defaulting to true) seed the initial state.
1360
+ assert.match(js, /cdetail\.defaultCollapsed != null/);
1361
+ assert.match(js, /cdetail\.collapsed != null/);
1362
+ // detailPanel is now config-parametrised so the top-level grid and child grids
1363
+ // share one implementation (link + fields + nested children + form).
1364
+ assert.match(js, /function detailPanel\(row, cfg\)/);
1365
+ assert.match(js, /const d = cfg \|\| detail;/);
1366
+ });
1367
+
1368
+ test("child-grid per-row collapse state is persisted across the refresh poll (#332)", async () => {
1369
+ const res = await dispatch("GET", "/app/runtime.js");
1370
+ const js = res.body ?? "";
1371
+ // The top-level grid rebuilds each child grid on every refreshMs poll, so an
1372
+ // open row would collapse on the next tick without durable state. Per-row
1373
+ // collapse is persisted in localStorage — keyed by page + node + parent
1374
+ // discriminator + child rowKey — via the same readCollapsed/writeCollapsed
1375
+ // helpers groupBy group-collapse uses, so it survives both the poll and a full
1376
+ // reload.
1377
+ assert.match(js, /const cskey = ckeyBase && crk != null \? ckeyBase \+ crk : null;/);
1378
+ assert.match(js, /readCollapsed\(cskey, cdefaultCollapsed\)/);
1379
+ assert.match(js, /if \(cskey\) writeCollapsed\(cskey, !open\);/);
1380
+ assert.match(js, /"pc:collapsed:" \+ CURRENT \+ ":" \+ cnodeId \+ ":" \+ cchildId/);
1381
+ });
1382
+
1383
+ test("child-grid collapse key is scoped by row[cg.parentField], not the parent grid's rowKey (#332)", async () => {
1384
+ const res = await dispatch("GET", "/app/runtime.js");
1385
+ const js = res.body ?? "";
1386
+ // The parent-row discriminator in the localStorage key MUST be the value the
1387
+ // child grid is queried by (row[cg.parentField]) — NOT rowKeyOf(row), which is
1388
+ // null whenever the top-level grid declares no `rowKey`. With the old rowKeyOf
1389
+ // key every parent row collapsed under the literal "_", so opening one parent's
1390
+ // child row silently toggled the same-keyed child row under every other parent.
1391
+ assert.match(
1392
+ js,
1393
+ /const cparentKey = cg\.parentField != null && row\[cg\.parentField\] != null\s*\?\s*String\(row\[cg\.parentField\]\) : null;/,
1394
+ );
1395
+ // No safe key ⇒ persistence disabled (ckeyBase null), never a colliding "_".
1396
+ assert.match(js, /const ckeyBase = cparentKey == null \? null/);
1397
+ assert.doesNotMatch(js, /cparentKey == null \? "_" : cparentKey/);
1398
+ });
1399
+
1400
+ test("child-grid detail panel never falls back to the top-level grid's form (#332)", async () => {
1401
+ const res = await dispatch("GET", "/app/runtime.js");
1402
+ const js = res.body ?? "";
1403
+ // detailPanel(cr, cdetail) renders a child grid's own detail config. Its form is
1404
+ // detailForm(row, d.form): when the child config declares no `form` the panel
1405
+ // must have NO form — it must NOT implicitly reuse the top-level grid's
1406
+ // detail.form. So detailForm uses its argument verbatim rather than falling back
1407
+ // to `detail && detail.form` when the argument is undefined.
1408
+ assert.match(js, /function detailForm\(row, formCfg\) \{\s*const f = formCfg;/);
1409
+ assert.doesNotMatch(js, /formCfg !== undefined \? formCfg : \(detail && detail\.form\)/);
1410
+ });
1411
+
1412
+ test("child-grid fetch failure renders a full-width error row (#332)", async () => {
1413
+ const res = await dispatch("GET", "/app/runtime.js");
1414
+ const js = res.body ?? "";
1415
+ // The child grid's colspan is computed eagerly, BEFORE the getJSON() fetch, so
1416
+ // the catch path's error row (colspan: cspan) spans the whole table even when
1417
+ // the fetch throws. A lazily-assigned cspan would be undefined in the catch.
1418
+ assert.match(js, /const cspan = String\(\(ccols\.length \|\| 1\) \+ cextra\);\s*try \{/);
1419
+ assert.match(js, /\} catch \(e\) \{\s*cbody\.append\(el\("tr", \{\}, el\("td", \{ colspan: cspan \}/);
1420
+ });
1421
+
1317
1422
  test("the renderer honours an optional Tier-2 page-level mobile layout variant (#268)", async () => {
1318
1423
  const res = await dispatch("GET", "/app/runtime.js");
1319
1424
  const js = res.body ?? "";
@@ -92,6 +92,15 @@ function javascript(
92
92
  * (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
93
93
  const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
94
94
 
95
+ // A query flag (e.g. ?count=1) is "on" when present and not one of the falsy
96
+ // spellings. A bare `?count` (empty string) still counts as on, so a caller can
97
+ // opt in without inventing a value.
98
+ function isTruthyParam(v: string | null): boolean {
99
+ if (v === null) return false;
100
+ const s = v.trim().toLowerCase();
101
+ return s !== "0" && s !== "false" && s !== "no";
102
+ }
103
+
95
104
  /**
96
105
  * Build the page-runtime routes over an injected datasource + engine. Pure over its deps
97
106
  * so it is unit-testable without a real host/server (see pages.test.ts).
@@ -272,6 +281,24 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
272
281
  orderSql = ` ORDER BY ${quoteIdent(field)} ${dir}`;
273
282
  }
274
283
  const whereSql = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
284
+ // ?count=1 → a lightweight COUNT(*) instead of the rows themselves. The same
285
+ // whitelisted where clauses apply, so a caller (e.g. a nav item's live count
286
+ // badge) gets exactly the number of matching rows without transferring or
287
+ // materialising them. ORDER BY / LIMIT are irrelevant to a count and skipped.
288
+ if (isTruthyParam(req.query.get("count"))) {
289
+ try {
290
+ const rows = await db.query(
291
+ `SELECT COUNT(*) AS n FROM ${quoteIdent(table)}${whereSql}`,
292
+ params,
293
+ );
294
+ const first = Array.isArray(rows) && rows.length > 0 ? rows[0] : undefined;
295
+ const rawCount = isRecord(first) ? first.n : 0;
296
+ const count = Number(rawCount);
297
+ return json({ count: Number.isFinite(count) ? count : 0 });
298
+ } catch (e) {
299
+ return json({ error: errorMessage(e) }, 500);
300
+ }
301
+ }
275
302
  try {
276
303
  const rows = await db.query(
277
304
  `SELECT * FROM ${quoteIdent(table)}${whereSql}${orderSql} LIMIT ${rowLimit}`,
@@ -682,6 +709,11 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
682
709
  .pc-nav-link:hover { background:var(--nano-hover); color:var(--nano-text); }
683
710
  .pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
684
711
  .pc-nav-icon { line-height:1; }
712
+ /* A nav item's live count pill (item.badge). Slightly more compact than the base
713
+ .pc-badge so it sits neatly beside a nav label; [hidden] must win over the
714
+ .pc-badge inline-flex display so hideWhenZero / a failed fetch truly hides it. */
715
+ .pc-nav-badge { min-width:1.1rem; height:1.1rem; font-size:.68rem; padding:0 .35rem; margin-left:.1rem; }
716
+ .pc-nav-badge[hidden] { display:none; }
685
717
  .pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
686
718
  /* The per-row mobile "More" toggle (revealing mobile:{priority:"hidden"} columns)
687
719
  only exists for the card view — hidden on the desktop table, shown by the