@nanobpm/urban 0.47.0 → 0.49.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/README.md +80 -21
- package/dist/runtime/adapters/deno.js +22 -4
- package/dist/runtime/adapters/node.js +34 -4
- package/dist/runtime/core/context.d.ts +0 -4
- package/dist/runtime/core/glob.d.ts +9 -0
- package/dist/runtime/core/glob.js +21 -0
- package/dist/runtime/core/host.d.ts +71 -8
- package/dist/runtime/core/host.js +13 -0
- package/dist/runtime/core/manifest.d.ts +89 -2
- package/dist/runtime/core/manifest.js +72 -0
- package/dist/runtime/core/modules/deploy.d.ts +9 -3
- package/dist/runtime/core/modules/deploy.js +69 -37
- package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
- package/dist/runtime/core/modules/instance-tracking.js +20 -5
- package/dist/runtime/core/modules/pages.js +77 -5
- package/dist/runtime/core/modules/surfaces.js +192 -18
- package/dist/runtime/core/router.d.ts +7 -0
- package/dist/runtime/core/router.js +9 -0
- package/dist/runtime/core/runtime.d.ts +0 -5
- package/dist/runtime/core/runtime.js +27 -4
- package/dist/runtime/core/validate.js +65 -1
- package/dist/runtime/engine/nanosdk.d.ts +9 -6
- package/dist/runtime/engine/nanosdk.js +65 -1
- package/dist/runtime/index.d.ts +3 -5
- package/dist/runtime/index.js +3 -4
- package/dist/runtime/run.js +0 -1
- package/dist/toolkit/derivers/shapes.d.ts +5 -0
- package/dist/toolkit/derivers/shapes.js +90 -0
- package/dist/toolkit/derivers/worker-io.d.ts +25 -0
- package/dist/toolkit/derivers/worker-io.js +104 -11
- package/dist/toolkit/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +103 -22
- package/dist/toolkit/models.d.ts +6 -4
- package/dist/toolkit/models.js +7 -4
- package/package.json +1 -1
- package/src/runtime/adapters/deno.ts +26 -6
- package/src/runtime/adapters/node.test.ts +67 -0
- package/src/runtime/adapters/node.ts +37 -4
- package/src/runtime/core/context.ts +0 -4
- package/src/runtime/core/glob.ts +24 -0
- package/src/runtime/core/host.ts +74 -4
- package/src/runtime/core/manifest.test.ts +61 -1
- package/src/runtime/core/manifest.ts +136 -2
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/deploy.test.ts +97 -161
- package/src/runtime/core/modules/deploy.ts +69 -37
- package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
- package/src/runtime/core/modules/instance-tracking.ts +19 -6
- package/src/runtime/core/modules/pages.test.ts +71 -3
- package/src/runtime/core/modules/pages.ts +77 -5
- package/src/runtime/core/modules/surfaces.test.ts +198 -5
- package/src/runtime/core/modules/surfaces.ts +191 -18
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/router.test.ts +10 -1
- package/src/runtime/core/router.ts +10 -0
- package/src/runtime/core/runtime.test.ts +67 -1
- package/src/runtime/core/runtime.ts +28 -9
- package/src/runtime/core/validate.test.ts +145 -0
- package/src/runtime/core/validate.ts +64 -1
- package/src/runtime/engine/nanosdk.test.ts +122 -0
- package/src/runtime/engine/nanosdk.ts +72 -2
- package/src/runtime/index.ts +11 -4
- package/src/runtime/run.ts +0 -1
- package/src/toolkit/derivers/shapes.test.ts +66 -0
- package/src/toolkit/derivers/shapes.ts +93 -0
- package/src/toolkit/derivers/worker-io.test.ts +131 -0
- package/src/toolkit/derivers/worker-io.ts +116 -9
- package/src/toolkit/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +81 -10
- package/src/toolkit/gen.ts +109 -22
- package/src/toolkit/models.test.ts +5 -5
- package/src/toolkit/models.ts +8 -5
- package/dist/runtime/core/modules/templates.d.ts +0 -18
- package/dist/runtime/core/modules/templates.js +0 -133
- package/src/runtime/core/modules/templates.ts +0 -153
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
// deploy — resolve the
|
|
2
|
-
//
|
|
1
|
+
// deploy — resolve the app's deployable resources and send them to the engine.
|
|
2
|
+
//
|
|
3
|
+
// Resources are found by ONE of two paths:
|
|
4
|
+
// 1. Convention (the default, ADR 0062): when the manifest declares no `models`, deployables are
|
|
5
|
+
// discovered by walking `resources/` — shallow, one level deep. `resources/` is deploy-only:
|
|
6
|
+
// everything under it (and nothing outside it) is deployed. Docs (`docs/`, `AGENTS.md`,
|
|
7
|
+
// top-level `*.md`) live outside `resources/` and are never swept in.
|
|
8
|
+
// 2. Explicit override: when the manifest declares `models` globs, those are used exactly (the
|
|
9
|
+
// convention walk is skipped) — the escape hatch for a non-convention layout.
|
|
10
|
+
//
|
|
11
|
+
// Either way the deploy dedupe key is the resource's basename (filename only), so a basename
|
|
12
|
+
// collision — two files with the same name in different directories — would silently clobber one
|
|
13
|
+
// resource with another at the engine. We detect that up front and fail loudly instead.
|
|
3
14
|
|
|
4
15
|
import type { RuntimeContext } from "../context.ts";
|
|
5
|
-
import { expandPatterns } from "../glob.ts";
|
|
6
|
-
|
|
16
|
+
import { discoverResources, expandPatterns } from "../glob.ts";
|
|
17
|
+
|
|
18
|
+
/** The convention directory: deploy-only, walked one level deep when no `models` are declared. */
|
|
19
|
+
export const RESOURCES_DIR = "resources";
|
|
7
20
|
|
|
8
21
|
function contentTypeFor(path: string): string {
|
|
9
22
|
if (path.endsWith(".bpmn")) return "text/xml";
|
|
@@ -17,47 +30,66 @@ function baseName(path: string): string {
|
|
|
17
30
|
return i >= 0 ? path.slice(i + 1) : path;
|
|
18
31
|
}
|
|
19
32
|
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
|
|
33
|
+
/** Fail loudly when two resolved files share a basename (the deploy dedupe key), naming the
|
|
34
|
+
* colliding paths so the author can rename or relocate one. */
|
|
35
|
+
function assertNoBasenameCollisions(files: string[]): void {
|
|
36
|
+
const byName = new Map<string, string[]>();
|
|
37
|
+
for (const path of files) {
|
|
38
|
+
const name = baseName(path);
|
|
39
|
+
const list = byName.get(name);
|
|
40
|
+
if (list) list.push(path);
|
|
41
|
+
else byName.set(name, [path]);
|
|
42
|
+
}
|
|
43
|
+
const collisions = [...byName.entries()].filter(([, paths]) => paths.length > 1);
|
|
44
|
+
if (collisions.length === 0) return;
|
|
45
|
+
const detail = collisions
|
|
46
|
+
.map(([name, paths]) => ` ${name}: ${paths.slice().sort().join(", ")}`)
|
|
47
|
+
.join("\n");
|
|
48
|
+
throw new Error(
|
|
49
|
+
"deploy: basename collision — resources are deployed keyed by filename, so two files with " +
|
|
50
|
+
`the same name would clobber each other. Rename or relocate one of:\n${detail}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deploy the app's processes, decisions and forms.
|
|
56
|
+
*
|
|
57
|
+
* With no manifest `models`, deployables are discovered by convention under `resources/` (shallow,
|
|
58
|
+
* one level deep). With `models` globs declared, those are used verbatim. Basename collisions are a
|
|
59
|
+
* hard error in either mode.
|
|
60
|
+
*/
|
|
23
61
|
export async function deployModels(ctx: RuntimeContext): Promise<{ deployed: number; files: string[] }> {
|
|
24
|
-
const models = ctx.manifest.models
|
|
62
|
+
const models = ctx.manifest.models;
|
|
25
63
|
const patterns = [
|
|
26
|
-
...(models
|
|
27
|
-
...(models
|
|
28
|
-
...(models
|
|
64
|
+
...(models?.processes ?? []),
|
|
65
|
+
...(models?.decisions ?? []),
|
|
66
|
+
...(models?.forms ?? []),
|
|
29
67
|
];
|
|
30
|
-
|
|
68
|
+
// Convention is keyed off the *absence* of the `models` block, not an empty pattern set: a
|
|
69
|
+
// declared `models` (even one resolving to zero files) is an explicit override that must NOT
|
|
70
|
+
// silently fall back to the `resources/` walk (ADR 0062: "no `models` block → convention;
|
|
71
|
+
// `models` present → exact override").
|
|
72
|
+
const byConvention = models === undefined;
|
|
73
|
+
const files = byConvention
|
|
74
|
+
? await discoverResources(ctx.host, ctx.root, RESOURCES_DIR)
|
|
75
|
+
: await expandPatterns(ctx.host, ctx.root, patterns);
|
|
31
76
|
if (files.length === 0) {
|
|
32
|
-
|
|
77
|
+
if (byConvention) {
|
|
78
|
+
ctx.host.log("info", "deploy: no resources found by convention", { dir: RESOURCES_DIR });
|
|
79
|
+
} else {
|
|
80
|
+
ctx.host.log("info", "deploy: no model files matched", { patterns });
|
|
81
|
+
}
|
|
33
82
|
return { deployed: 0, files: [] };
|
|
34
83
|
}
|
|
35
|
-
|
|
36
|
-
// map makes substitution a no-op, so apps that use no templates are unaffected.
|
|
37
|
-
const templates = await resolveTemplates(ctx.host, ctx.root, [models.templates, ctx.templates]);
|
|
38
|
-
const hasTemplates = Object.keys(templates).length > 0;
|
|
84
|
+
assertNoBasenameCollisions(files);
|
|
39
85
|
const resources = await Promise.all(
|
|
40
|
-
files.map(async (path) => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// XML escaper over them could deterministically corrupt a non-XML/JSON resource that a
|
|
46
|
-
// manifest pattern matched by accident, so leave those files untouched.
|
|
47
|
-
if (hasTemplates && contentType !== "application/octet-stream") {
|
|
48
|
-
const applied = applyTemplates(content, contentType, templates);
|
|
49
|
-
content = applied.content;
|
|
50
|
-
if (applied.unresolved.length > 0) {
|
|
51
|
-
ctx.host.log("warn", "deploy: unresolved template placeholders", {
|
|
52
|
-
file: path,
|
|
53
|
-
unresolved: applied.unresolved,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return { name: baseName(path), content, contentType };
|
|
58
|
-
}),
|
|
86
|
+
files.map(async (path) => ({
|
|
87
|
+
name: baseName(path),
|
|
88
|
+
content: await ctx.host.readTextFile(path),
|
|
89
|
+
contentType: contentTypeFor(path),
|
|
90
|
+
})),
|
|
59
91
|
);
|
|
60
92
|
const { deployed } = await ctx.engine.deployResources(resources);
|
|
61
|
-
ctx.host.log("info", "deploy:
|
|
93
|
+
ctx.host.log("info", "deploy: resources deployed", { deployed, files, byConvention });
|
|
62
94
|
return { deployed, files };
|
|
63
95
|
}
|
|
@@ -96,6 +96,7 @@ function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
|
|
|
96
96
|
cancelInstance: notUsed("cancelInstance"),
|
|
97
97
|
publishMessage: notUsed("publishMessage"),
|
|
98
98
|
searchUserTasks: notUsed("searchUserTasks"),
|
|
99
|
+
getForm: notUsed("getForm"),
|
|
99
100
|
completeUserTask: notUsed("completeUserTask"),
|
|
100
101
|
registerWorker: notUsed("registerWorker"),
|
|
101
102
|
close: notUsed("close"),
|
|
@@ -285,6 +286,95 @@ test("without statusField/activeStatuses, polls every row", async () => {
|
|
|
285
286
|
await h.close();
|
|
286
287
|
});
|
|
287
288
|
|
|
289
|
+
test("terminalStatuses: reconciles a TERMINATED row in an un-enumerated non-terminal status (the allow-list regression)", async () => {
|
|
290
|
+
// The row's status ("awaiting_operator") is listed in NEITHER activeStatuses nor terminalStatuses
|
|
291
|
+
// — the exact drift an allow-list misses. A fail-open terminalStatuses selector still polls it.
|
|
292
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED" });
|
|
293
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
294
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "awaiting_operator", note: "hi" });
|
|
295
|
+
});
|
|
296
|
+
const sched = fakeScheduler();
|
|
297
|
+
const binding = planBinding({ activeStatuses: undefined, terminalStatuses: ["abandoned"] });
|
|
298
|
+
const handle = mount(h, [binding], sched);
|
|
299
|
+
await sched.advance(1000);
|
|
300
|
+
assert.deepEqual(queries, [["pi1"]]); // it WAS polled despite not being in any enumerated list
|
|
301
|
+
const row = await h.table.get("pi1");
|
|
302
|
+
assert.equal(row?.status, "abandoned"); // …and reconciled
|
|
303
|
+
await handle.stop();
|
|
304
|
+
await h.close();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("terminalStatuses: a row already in a terminal status is not polled", async () => {
|
|
308
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
|
|
309
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
310
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "awaiting_operator", note: null });
|
|
311
|
+
await t.insert({ plan_key: "p2", process_key: "pi2", status: "abandoned", note: null }); // terminal
|
|
312
|
+
});
|
|
313
|
+
const sched = fakeScheduler();
|
|
314
|
+
const binding = planBinding({ activeStatuses: undefined, terminalStatuses: ["abandoned"] });
|
|
315
|
+
const handle = mount(h, [binding], sched);
|
|
316
|
+
await sched.advance(1000);
|
|
317
|
+
assert.deepEqual(queries, [["pi1"]]); // pi2 was already terminal, excluded from the poll
|
|
318
|
+
await handle.stop();
|
|
319
|
+
await h.close();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("terminalStatuses wins when a binding somehow declares both selectors (fail-open)", async () => {
|
|
323
|
+
// Validation rejects declaring both, but a hand-built binding must still behave predictably:
|
|
324
|
+
// terminalStatuses (the fail-open selector) takes precedence over activeStatuses.
|
|
325
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED" });
|
|
326
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
327
|
+
// status is NOT in activeStatuses — an allow-list would skip it — but IS non-terminal.
|
|
328
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "awaiting_operator", note: null });
|
|
329
|
+
});
|
|
330
|
+
const sched = fakeScheduler();
|
|
331
|
+
const binding = planBinding({ activeStatuses: ["planning"], terminalStatuses: ["abandoned"] });
|
|
332
|
+
const handle = mount(h, [binding], sched);
|
|
333
|
+
await sched.advance(1000);
|
|
334
|
+
assert.deepEqual(queries, [["pi1"]]); // polled via the terminalStatuses selector
|
|
335
|
+
assert.equal((await h.table.get("pi1"))?.status, "abandoned");
|
|
336
|
+
await handle.stop();
|
|
337
|
+
await h.close();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("a malformed (non-array) terminalStatuses degrades to fail-open poll-all, not a Set-of-characters filter", async () => {
|
|
341
|
+
// A hand-built/JSON binding could carry a bare string. The buggy path (`new Set("ab")`) would
|
|
342
|
+
// filter rows by whether their status is one of those *characters* — silently dropping rows.
|
|
343
|
+
// The shared `isConfiguredStatusSelector` gate makes a malformed selector fall through to the
|
|
344
|
+
// fail-open poll-all path instead. Build the invalid fixture via JSON.parse (no `as` cast).
|
|
345
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
|
|
346
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
347
|
+
// Both statuses are single characters that appear in "abandoned" — the buggy Set-of-chars
|
|
348
|
+
// filter would exclude them, polling nothing; the fail-open path polls both.
|
|
349
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "a", note: null });
|
|
350
|
+
await t.insert({ plan_key: "p2", process_key: "pi2", status: "b", note: null });
|
|
351
|
+
});
|
|
352
|
+
const malformed = JSON.parse('{"terminalStatuses":"abandoned","activeStatuses":null}');
|
|
353
|
+
const sched = fakeScheduler();
|
|
354
|
+
const binding = planBinding(malformed);
|
|
355
|
+
const handle = mount(h, [binding], sched);
|
|
356
|
+
await sched.advance(1000);
|
|
357
|
+
assert.deepEqual(queries[0]?.sort(), ["pi1", "pi2"]); // fail-open: every row polled
|
|
358
|
+
await handle.stop();
|
|
359
|
+
await h.close();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a malformed (non-array) activeStatuses degrades to fail-open poll-all instead of crashing .map", async () => {
|
|
363
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
|
|
364
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
365
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "planning", note: null });
|
|
366
|
+
await t.insert({ plan_key: "p2", process_key: "pi2", status: "dispatched", note: null });
|
|
367
|
+
});
|
|
368
|
+
const malformed = JSON.parse('{"activeStatuses":"planning","terminalStatuses":null}');
|
|
369
|
+
const sched = fakeScheduler();
|
|
370
|
+
const binding = planBinding(malformed);
|
|
371
|
+
const handle = mount(h, [binding], sched);
|
|
372
|
+
await sched.advance(1000);
|
|
373
|
+
assert.deepEqual(queries[0]?.sort(), ["pi1", "pi2"]); // no crash; fail-open poll-all
|
|
374
|
+
await handle.stop();
|
|
375
|
+
await h.close();
|
|
376
|
+
});
|
|
377
|
+
|
|
288
378
|
test("defaults pollMs when the binding omits it", async () => {
|
|
289
379
|
const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
|
|
290
380
|
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// row). The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
|
|
15
15
|
|
|
16
16
|
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
17
|
-
import type
|
|
17
|
+
import { type InstanceTracking, isConfiguredStatusSelector } from "../manifest.ts";
|
|
18
18
|
import { defaultScheduler, MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
|
|
19
19
|
|
|
20
20
|
/** Default poll interval when a binding does not set `pollMs`. */
|
|
@@ -65,13 +65,26 @@ async function reconcileOnce(
|
|
|
65
65
|
): Promise<{ scanned: number; reconciled: number }> {
|
|
66
66
|
const table = api.data.table<Row>(binding.table, binding.keyField);
|
|
67
67
|
|
|
68
|
-
// Select the rows worth polling.
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
68
|
+
// Select the rows worth polling. Three modes, in precedence order:
|
|
69
|
+
// - `terminalStatuses` (fail-open exclusion): poll every row whose `statusField` is NOT
|
|
70
|
+
// a terminal value. A newly-added non-terminal status is polled by default, so it can't
|
|
71
|
+
// silently fall out of reconciliation; only already-terminal rows are excluded (bounded).
|
|
72
|
+
// - `activeStatuses` (fail-closed allow-list): poll only rows in an enumerated active status.
|
|
73
|
+
// - neither: poll every row; a terminal instance's row is idempotently re-patched (the engine
|
|
74
|
+
// query returns only TERMINATED instances and the patch is a no-op once applied).
|
|
75
|
+
// `terminalStatuses` and `activeStatuses` are mutually exclusive per binding (validation rejects
|
|
76
|
+
// both); if both are somehow present, `terminalStatuses` wins (the fail-open selector). Each
|
|
77
|
+
// selector is gated through the shared `isConfiguredStatusSelector` predicate (a non-empty array
|
|
78
|
+
// of non-empty strings) — the same one validation uses — so a malformed value (a bare string, an
|
|
79
|
+
// empty array) degrades to the fail-open poll-all path here instead of `new Set("abandoned")`
|
|
80
|
+
// silently filtering by character, or a non-array crashing `activeStatuses.map(...)`.
|
|
72
81
|
let candidates: Row[];
|
|
73
82
|
const statusField = binding.statusField;
|
|
74
|
-
if (statusField && binding.
|
|
83
|
+
if (statusField && isConfiguredStatusSelector(binding.terminalStatuses)) {
|
|
84
|
+
const terminal = new Set(binding.terminalStatuses);
|
|
85
|
+
const all = await table.all();
|
|
86
|
+
candidates = all.filter((row) => !terminal.has(String(row[statusField])));
|
|
87
|
+
} else if (statusField && isConfiguredStatusSelector(binding.activeStatuses)) {
|
|
75
88
|
const perStatus = await Promise.all(
|
|
76
89
|
binding.activeStatuses.map((s): Promise<Row[]> => table.find({ [statusField]: s })),
|
|
77
90
|
);
|
|
@@ -40,6 +40,7 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
|
|
|
40
40
|
calls.messages.push(input);
|
|
41
41
|
},
|
|
42
42
|
async searchUserTasks() { return []; },
|
|
43
|
+
async getForm() { return null; },
|
|
43
44
|
async completeUserTask() {},
|
|
44
45
|
async searchProcessInstances() { return []; },
|
|
45
46
|
async registerWorker(jobType) { return { jobType, unsubscribe: async () => {} }; },
|
|
@@ -337,12 +338,37 @@ test("renderer wires a column's badge to a tone-classed pill shown only when pre
|
|
|
337
338
|
// (default danger), the label defaults to "1", and the full field text becomes
|
|
338
339
|
// the tooltip.
|
|
339
340
|
assert.match(js, /if\s*\(col\.badge\)/);
|
|
340
|
-
assert.match(js, /
|
|
341
|
+
assert.match(js, /rawText\.trim\(\)\s*===\s*""/);
|
|
341
342
|
assert.match(js, /t\s*===\s*"warn"\s*\|\|\s*t\s*===\s*"ok"\s*\|\|\s*t\s*===\s*"info"\s*\?\s*t\s*:\s*"danger"/);
|
|
342
343
|
assert.match(js, /col\.badge\.label\s*==\s*null\s*\?\s*"1"/);
|
|
343
344
|
assert.match(js, /class:\s*"pc-badge pc-badge-"\s*\+\s*tone/);
|
|
344
|
-
assert.match(js, /title:\s*
|
|
345
|
-
assert.match(js, /"aria-label":\s*
|
|
345
|
+
assert.match(js, /title:\s*rawText/);
|
|
346
|
+
assert.match(js, /"aria-label":\s*rawText/);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("renderer interpolates a column's per-cell template into DOM text (#214)", async () => {
|
|
350
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
351
|
+
const js = res.body ?? "";
|
|
352
|
+
// A column declaring `template: "{{a}}/{{b}}"` renders one cell by splicing the
|
|
353
|
+
// named row fields into a string, coerced to text and set only as DOM text
|
|
354
|
+
// (never markup). Guard the whole shape so the primitive can't silently
|
|
355
|
+
// regress: interpTemplate exists, replaces {{field}} tokens via a function
|
|
356
|
+
// replacement (so a "$"-bearing value can't be reinterpreted as a replacement
|
|
357
|
+
// pattern), coerces null/undefined to "" (unknown tokens render blank, no
|
|
358
|
+
// throw), and gridCell prefers a string col.template over the raw field value
|
|
359
|
+
// for the visible text while the badge stays keyed to the raw field.
|
|
360
|
+
assert.match(js, /function interpTemplate\(tpl, row\)/);
|
|
361
|
+
assert.match(js, /replace\(\/\\\{\\\{\(\[\^\{\}\]\+\)\\\}\\\}\/g,/);
|
|
362
|
+
assert.match(js, /const key = name\.trim\(\);/);
|
|
363
|
+
// Unknown tokens (no such own field) render blank and, crucially, an inherited
|
|
364
|
+
// Object.prototype key (toString, constructor, …) counts as unknown rather than
|
|
365
|
+
// resolving to prototype cruft — guard the own-property gate explicitly.
|
|
366
|
+
assert.match(js, /Object\.prototype\.hasOwnProperty\.call\(row, key\) \? row\[key\] : null/);
|
|
367
|
+
assert.match(js, /return v == null \? "" : String\(v\);/);
|
|
368
|
+
// gridCell keeps the raw single-field value separate (drives the badge gate)
|
|
369
|
+
// and lets a string template win for the rendered text.
|
|
370
|
+
assert.match(js, /const rawText = row\[col\.field\] == null \? "" : String\(row\[col\.field\]\);/);
|
|
371
|
+
assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : rawText;/);
|
|
346
372
|
});
|
|
347
373
|
|
|
348
374
|
test("renderer wires a column's processExplorer link to the console explorer", async () => {
|
|
@@ -576,6 +602,48 @@ test("renderer makes collapsible nodes persist their state across sessions", asy
|
|
|
576
602
|
assert.match(js, /container\.append\(header, body\);\s*return container;/);
|
|
577
603
|
});
|
|
578
604
|
|
|
605
|
+
test("renderer surfaces a live row-count badge on a dataGrid's collapsible header (showCount)", async () => {
|
|
606
|
+
// A dataGrid opting in via `showCount:true` renders a count pill that reflects the
|
|
607
|
+
// currently-fetched, filtered (active-tab) row set, updates on every refresh (the
|
|
608
|
+
// refreshMs poll and pc:refresh), and — because makeCollapsible re-homes it into the
|
|
609
|
+
// collapse header — stays visible while the section is collapsed. Omitting showCount
|
|
610
|
+
// is a no-op (backward-compatible).
|
|
611
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
612
|
+
const js = res.body ?? "";
|
|
613
|
+
// Opt-in badge, created only when showCount is set; class-tagged for re-homing + CSS.
|
|
614
|
+
assert.match(js, /const countBadge = p\.showCount \? el\("span", \{ class: "pc-badge pc-count-badge"/);
|
|
615
|
+
// The badge's accessible name carries the count (kept in sync on refresh) rather than a
|
|
616
|
+
// bare aria-label that would override the number screen readers need to announce.
|
|
617
|
+
assert.match(js, /"aria-label": rowCountLabel\(0\)/);
|
|
618
|
+
assert.match(js, /function rowCountLabel\(n\) \{ return n \+ \(n === 1 \? " row" : " rows"\); \}/);
|
|
619
|
+
// The <h2> (title and/or badge) renders when either the title or the badge is present.
|
|
620
|
+
assert.match(js, /if \(p\.title \|\| countBadge\) \{/);
|
|
621
|
+
// The badge is appended to the <h2>; the " " spacer is inserted only when a title is
|
|
622
|
+
// present, so a badge-only header can't render (or store) a stray leading space.
|
|
623
|
+
assert.match(js, /if \(countBadge\) \{ if \(p\.title\) h2\.append\(" "\); h2\.append\(countBadge\); \}/);
|
|
624
|
+
// Every refresh rewrites the count from the fetched, filtered row set (poll + pc:refresh),
|
|
625
|
+
// keeping both the visible text and the accessible label in sync.
|
|
626
|
+
assert.match(js, /if \(countBadge\) \{ countBadge\.textContent = String\(rows\.length\); countBadge\.setAttribute\("aria-label", rowCountLabel\(rows\.length\)\); \}/);
|
|
627
|
+
// makeCollapsible lifts the badge out of the <h2> before deriving the title (so the
|
|
628
|
+
// count can't leak into the label) and re-homes it in the header so it stays visible
|
|
629
|
+
// while collapsed (only the body is hidden).
|
|
630
|
+
assert.match(js, /const countBadge = h2 && h2\.querySelector \? h2\.querySelector\("\.pc-count-badge"\) : null;/);
|
|
631
|
+
assert.match(js, /if \(countBadge\) countBadge\.remove\(\);/);
|
|
632
|
+
assert.match(js, /if \(countBadge\) header\.append\(countBadge\);/);
|
|
633
|
+
// The derived collapse-header title is trimmed so a badge-only <h2> (no title) can't
|
|
634
|
+
// leave a lone whitespace node as the label (or leak whitespace into the storage key).
|
|
635
|
+
assert.match(js, /const titleText = props\.title \|\| \(h2 \? h2\.textContent\.trim\(\) : ""\) \|\| "Section";/);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("the shell styles the row-count pill as a neutral count distinct from status badges", async () => {
|
|
639
|
+
const res = await dispatch("GET", "/");
|
|
640
|
+
const html = res.body ?? "";
|
|
641
|
+
const rule = (html.match(/\.pc-count-badge\s*\{[^}]*\}/) ?? [""])[0];
|
|
642
|
+
assert.ok(rule, ".pc-count-badge rule must be present");
|
|
643
|
+
assert.match(rule, /background:var\(--nano-inset\)/);
|
|
644
|
+
assert.match(rule, /color:var\(--nano-text\)/);
|
|
645
|
+
});
|
|
646
|
+
|
|
579
647
|
test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
|
|
580
648
|
const ok = await dispatch("GET", "/app/pages/home");
|
|
581
649
|
assert.equal(ok.status, 200);
|
|
@@ -564,6 +564,9 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
564
564
|
.pc-badge-warn { background:var(--nano-warn); color:#3a2a00; }
|
|
565
565
|
.pc-badge-ok { background:var(--nano-ok); }
|
|
566
566
|
.pc-badge-info { background:var(--nano-info); }
|
|
567
|
+
/* Live row-count pill (dataGrid showCount): a subtle, neutral count distinct from
|
|
568
|
+
the coloured status badges, legible in the collapse header while collapsed. */
|
|
569
|
+
.pc-count-badge { color:var(--nano-text); background:var(--nano-inset); border:1px solid var(--nano-edge); font-weight:600; }
|
|
567
570
|
:root[data-appearance="light"] .pc-badge-warn { color:#fff; }
|
|
568
571
|
@media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
|
|
569
572
|
.pc-child { margin:.6rem 0; }
|
|
@@ -838,6 +841,27 @@ function el(tag, attrs = {}, ...kids) {
|
|
|
838
841
|
function interpParam(s) {
|
|
839
842
|
return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
|
|
840
843
|
}
|
|
844
|
+
|
|
845
|
+
// Interpolate {{field}} tokens in a column's per-cell template with values from
|
|
846
|
+
// the row, coercing each to a string (null/undefined → ""). Like interpParam the
|
|
847
|
+
// result is only ever set as DOM text (textContent via el()), so a substituted
|
|
848
|
+
// row value cannot inject markup or attributes. A function replacement inserts
|
|
849
|
+
// each value literally, so a "$"-bearing value can't be reinterpreted as a
|
|
850
|
+
// replacement pattern. Unknown tokens (no such field) render empty rather than
|
|
851
|
+
// throwing, matching interpParam. This is a dumb text formatter: no expressions,
|
|
852
|
+
// arithmetic, or conditionals — just field splicing. Only the row's own
|
|
853
|
+
// fields resolve (own-property gate, enumerable or not): an inherited
|
|
854
|
+
// Object.prototype key (toString, constructor, …) counts as unknown and
|
|
855
|
+
// renders "", so a token can't pick up prototype cruft instead of blank.
|
|
856
|
+
function interpTemplate(tpl, row) {
|
|
857
|
+
return typeof tpl === "string"
|
|
858
|
+
? tpl.replace(/\{\{([^{}]+)\}\}/g, (_m, name) => {
|
|
859
|
+
const key = name.trim();
|
|
860
|
+
const v = Object.prototype.hasOwnProperty.call(row, key) ? row[key] : null;
|
|
861
|
+
return v == null ? "" : String(v);
|
|
862
|
+
})
|
|
863
|
+
: tpl;
|
|
864
|
+
}
|
|
841
865
|
function renderText(node) {
|
|
842
866
|
const v = node.props.variant;
|
|
843
867
|
const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
|
|
@@ -1002,8 +1026,27 @@ function renderButton(node) {
|
|
|
1002
1026
|
// host's own explorer view in place via the nano-navigate postMessage bridge
|
|
1003
1027
|
// (a modified/middle click keeps the new-tab behavior). Shared by the top-level
|
|
1004
1028
|
// grid and child grids.
|
|
1029
|
+
//
|
|
1030
|
+
// Cell TEXT (the 'text' local below): the visible string a cell renders. By
|
|
1031
|
+
// default it is the column's single 'field' value, but an optional
|
|
1032
|
+
// 'col.template' string wins: its {{field}} tokens are spliced from the row
|
|
1033
|
+
// (interpTemplate) into DOM text. A template lets one cell combine several
|
|
1034
|
+
// fields / add surrounding text (e.g. "{{current_wave}}/{{wave_count}}")
|
|
1035
|
+
// without denormalising a display column into the datasource. The template
|
|
1036
|
+
// drives the rendered text for the plain, linkField and link
|
|
1037
|
+
// (page/processExplorer) paths — a link's href still comes from its own
|
|
1038
|
+
// keyField/linkField, never the template. The 'badge' presence gate and its
|
|
1039
|
+
// tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
|
|
1040
|
+
// the field text), so a template does not change badge behaviour. Sorting,
|
|
1041
|
+
// groupBy and orderBy also key off real fields, never the rendered template.
|
|
1005
1042
|
function gridCell(col, row) {
|
|
1006
|
-
|
|
1043
|
+
// Raw single-field value: drives the badge presence gate + tooltip and any
|
|
1044
|
+
// field-derived defaults. Kept separate from the (possibly templated) display
|
|
1045
|
+
// text so a template never re-lights or blanks a badge.
|
|
1046
|
+
const rawText = row[col.field] == null ? "" : String(row[col.field]);
|
|
1047
|
+
// Visible text: a col.template (interpolated from the row) wins over the
|
|
1048
|
+
// raw field value, else the field value is shown verbatim.
|
|
1049
|
+
const text = typeof col.template === "string" ? interpTemplate(col.template, row) : rawText;
|
|
1007
1050
|
// 1. badge — a compact status indicator. When the row's field value is
|
|
1008
1051
|
// non-empty (truthy after trimming) render a small circular badge (e.g. a
|
|
1009
1052
|
// red "1" dot flagging an incident) whose tooltip is the full field text;
|
|
@@ -1015,14 +1058,14 @@ function gridCell(col, row) {
|
|
|
1015
1058
|
// field text is also mirrored into aria-label so assistive tech announces
|
|
1016
1059
|
// the incident/status text (the visible "1" glyph alone is not meaningful).
|
|
1017
1060
|
if (col.badge) {
|
|
1018
|
-
if (
|
|
1061
|
+
if (rawText.trim() === "") return el("td", {});
|
|
1019
1062
|
const t = col.badge.tone;
|
|
1020
1063
|
const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
|
|
1021
1064
|
const label = col.badge.label == null ? "1" : String(col.badge.label);
|
|
1022
1065
|
return el(
|
|
1023
1066
|
"td",
|
|
1024
1067
|
{},
|
|
1025
|
-
el("span", { class: "pc-badge pc-badge-" + tone, title:
|
|
1068
|
+
el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
|
|
1026
1069
|
);
|
|
1027
1070
|
}
|
|
1028
1071
|
if (col.linkField) {
|
|
@@ -1226,10 +1269,25 @@ function renderActionForm(node) {
|
|
|
1226
1269
|
return card;
|
|
1227
1270
|
}
|
|
1228
1271
|
|
|
1272
|
+
// Accessible name for the row-count badge. The visible text is just a number, so an
|
|
1273
|
+
// aria-label would override it with a bare "row count" (the count — the important part —
|
|
1274
|
+
// is dropped). Instead name it "N row(s)" and keep it in sync with the text on refresh.
|
|
1275
|
+
// Single source of truth for both the initial render and every refresh below.
|
|
1276
|
+
function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
|
|
1277
|
+
|
|
1229
1278
|
function renderDataGrid(node) {
|
|
1230
1279
|
const p = node.props;
|
|
1231
1280
|
const card = el("section", { class: "pc-card" });
|
|
1232
|
-
|
|
1281
|
+
// Opt-in live row-count badge ("showCount"). Rendered inside the <h2> so a plain
|
|
1282
|
+
// (non-collapsible) grid surfaces it beside the title; makeCollapsible re-homes it
|
|
1283
|
+
// into the collapse header so it stays visible — and refresh-updated — while the
|
|
1284
|
+
// section is collapsed. refresh() below rewrites its text on every poll/pc:refresh.
|
|
1285
|
+
const countBadge = p.showCount ? el("span", { class: "pc-badge pc-count-badge", "aria-label": rowCountLabel(0) }, "0") : null;
|
|
1286
|
+
if (p.title || countBadge) {
|
|
1287
|
+
const h2 = el("h2", {}, p.title || "");
|
|
1288
|
+
if (countBadge) { if (p.title) h2.append(" "); h2.append(countBadge); }
|
|
1289
|
+
card.append(h2);
|
|
1290
|
+
}
|
|
1233
1291
|
const cols = p.columns || [];
|
|
1234
1292
|
const tabs = p.tabs || [];
|
|
1235
1293
|
const rowActions = p.rowActions || [];
|
|
@@ -1547,6 +1605,10 @@ function renderDataGrid(node) {
|
|
|
1547
1605
|
const { rows } = paramScoped && PARAM === ""
|
|
1548
1606
|
? { rows: [] }
|
|
1549
1607
|
: await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
1608
|
+
// Live row-count badge: reflect the currently-fetched, filtered (active-tab)
|
|
1609
|
+
// set — exactly what the body renders — on every refresh (poll + pc:refresh),
|
|
1610
|
+
// whether the section is expanded or collapsed.
|
|
1611
|
+
if (countBadge) { countBadge.textContent = String(rows.length); countBadge.setAttribute("aria-label", rowCountLabel(rows.length)); }
|
|
1550
1612
|
// Forget expansion / cached detail nodes for rows no longer present so the maps
|
|
1551
1613
|
// don't grow without bound and a stale answer can't resurface on a key reuse.
|
|
1552
1614
|
if (p.rowKey) {
|
|
@@ -1715,7 +1777,16 @@ function makeCollapsible(node, card) {
|
|
|
1715
1777
|
const isCard = card.tagName === "SECTION";
|
|
1716
1778
|
const container = isCard ? card : el("section", { class: "pc-card" });
|
|
1717
1779
|
const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
|
|
1718
|
-
|
|
1780
|
+
// A dataGrid with "showCount" renders a live count badge inside its <h2>. Lift it
|
|
1781
|
+
// out before deriving the title (so the count doesn't leak into the label text),
|
|
1782
|
+
// then re-home it in the header alongside the title so it stays visible — and
|
|
1783
|
+
// refresh-updated by the grid's own refresh() — while the section is collapsed.
|
|
1784
|
+
const countBadge = h2 && h2.querySelector ? h2.querySelector(".pc-count-badge") : null;
|
|
1785
|
+
if (countBadge) countBadge.remove();
|
|
1786
|
+
// Trim the derived label: after the badge is lifted out, the leftover " " whitespace
|
|
1787
|
+
// node from h2.append(" ", countBadge) would otherwise make a title-less section's
|
|
1788
|
+
// header a lone space (and leak whitespace into the storage key). Fall back to "Section".
|
|
1789
|
+
const titleText = props.title || (h2 ? h2.textContent.trim() : "") || "Section";
|
|
1719
1790
|
if (h2) h2.remove();
|
|
1720
1791
|
const body = el("div", { class: "pc-card-body" });
|
|
1721
1792
|
if (isCard) {
|
|
@@ -1730,6 +1801,7 @@ function makeCollapsible(node, card) {
|
|
|
1730
1801
|
chevron,
|
|
1731
1802
|
el("span", { class: "pc-collapse-title" }, titleText),
|
|
1732
1803
|
);
|
|
1804
|
+
if (countBadge) header.append(countBadge);
|
|
1733
1805
|
const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
|
|
1734
1806
|
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
1735
1807
|
function apply() {
|