@nanobpm/urban 0.60.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 +85 -4
- package/src/runtime/browser/runtime.browser.test.ts +181 -1
- package/src/runtime/core/modules/pages.test.ts +27 -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",
|
|
@@ -2054,7 +2054,9 @@ function renderDataGrid(node) {
|
|
|
2054
2054
|
// ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
|
|
2055
2055
|
// current page) or to an external URL ({ label, href } -> a hardened new-tab
|
|
2056
2056
|
// link; only http(s) is honoured). With items:"auto" (or omitted) the nav
|
|
2057
|
-
// 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.
|
|
2058
2060
|
/** @param {any} item
|
|
2059
2061
|
* @returns {HTMLAnchorElement|null}
|
|
2060
2062
|
*/
|
|
@@ -2073,8 +2075,87 @@ function navLink(item) {
|
|
|
2073
2075
|
/** @type {Array<Node|string>} */
|
|
2074
2076
|
const kids = [];
|
|
2075
2077
|
if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
|
|
2076
|
-
|
|
2077
|
-
|
|
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);
|
|
2078
2159
|
}
|
|
2079
2160
|
/** @param {HTMLElement} list
|
|
2080
2161
|
* @param {any[]} items
|
|
@@ -2290,4 +2371,4 @@ function boot() {
|
|
|
2290
2371
|
// renderer functions / RENDERERS registry can be imported directly.
|
|
2291
2372
|
if (typeof document !== "undefined" && document.getElementById("page")) boot();
|
|
2292
2373
|
|
|
2293
|
-
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 } } }));
|
|
@@ -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
|