@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/dist/runtime/core/modules/pages.js +30 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/package.json +1 -1
- package/src/runtime/browser/runtime.browser.js +177 -18
- package/src/runtime/browser/runtime.browser.test.ts +181 -1
- package/src/runtime/core/modules/pages.test.ts +105 -0
- package/src/runtime/core/modules/pages.ts +32 -0
- 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.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 =
|
|
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
|
}
|
|
@@ -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
|
-
|
|
1999
|
-
|
|
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
|