@nanobpm/urban 0.43.0 → 0.45.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 +53 -2
- package/dist/effect/index.d.ts +39 -0
- package/dist/effect/index.js +133 -0
- package/dist/runtime/core/modules/pages.js +148 -13
- package/package.json +5 -1
- package/src/effect/index.test.ts +185 -0
- package/src/effect/index.ts +193 -0
- package/src/runtime/core/modules/pages.test.ts +87 -2
- package/src/runtime/core/modules/pages.ts +148 -13
package/README.md
CHANGED
|
@@ -64,8 +64,8 @@ urban run # start workers and serve surfaces
|
|
|
64
64
|
## Library API
|
|
65
65
|
|
|
66
66
|
Everything the CLI does is available programmatically. Import the whole surface
|
|
67
|
-
from `@nanobpm/urban`, or the focused subpaths `@nanobpm/urban/runtime
|
|
68
|
-
`@nanobpm/urban/toolkit`.
|
|
67
|
+
from `@nanobpm/urban`, or the focused subpaths `@nanobpm/urban/runtime`,
|
|
68
|
+
`@nanobpm/urban/toolkit`, and `@nanobpm/urban/effect`.
|
|
69
69
|
|
|
70
70
|
### Runtime — run an app
|
|
71
71
|
|
|
@@ -244,6 +244,57 @@ survives restarts, model a timer **start**/**intermediate** event instead with
|
|
|
244
244
|
`w.startOn(...)` / `w.timer(...)` from [`@nanobpm/workflow`](../workflow) — the
|
|
245
245
|
engine owns those.
|
|
246
246
|
|
|
247
|
+
### Effect — typed errors & scoped resources (`@nanobpm/urban/effect`)
|
|
248
|
+
|
|
249
|
+
A tiny, **zero-dependency**, Effect-like core for the imperative seams (workers,
|
|
250
|
+
provisioning, resource lifecycles) — without pulling in the `effect` package or
|
|
251
|
+
its viral paradigm. It gives you the three ergonomics you actually reach for:
|
|
252
|
+
|
|
253
|
+
- **Typed-error `Result<A, E>` with generator do-notation.** `gen(function* … )`
|
|
254
|
+
+ `yield*` threads success values and short-circuits on the first failure,
|
|
255
|
+
automatically inferring the union of every failure type into `E` — like
|
|
256
|
+
`Effect.gen` + `yield*`.
|
|
257
|
+
- **Tagged errors + exhaustive matching.** `tag("NotFound")` builds a discriminated
|
|
258
|
+
error; `matchTags` forces you to handle **every** variant (omitting one is a
|
|
259
|
+
compile error), like `Data.TaggedError` + `catchTags`.
|
|
260
|
+
- **Scoped resources.** `scoped` + `acquireRelease` run every release on every exit
|
|
261
|
+
— success, failure, or thrown — LIFO, like `Effect.scoped`.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { gen, ok, fail, tag, matchTags, scoped, acquireRelease } from "@nanobpm/urban/effect";
|
|
265
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
266
|
+
import { tmpdir } from "node:os";
|
|
267
|
+
import { join } from "node:path";
|
|
268
|
+
|
|
269
|
+
const parse = (s: string) => (s ? ok(s.length) : fail(tag("Empty")));
|
|
270
|
+
const check = (n: number) => (n > 3 ? fail(tag("TooLong", { n })) : ok(n));
|
|
271
|
+
|
|
272
|
+
const run = (s: string) =>
|
|
273
|
+
gen(function* () {
|
|
274
|
+
const n = yield* parse(s); // E gains "Empty"
|
|
275
|
+
return yield* check(n); // E gains "TooLong"
|
|
276
|
+
}); // Result<number, {_tag:"Empty"} | {_tag:"TooLong", n:number}>
|
|
277
|
+
|
|
278
|
+
const r = run("hello");
|
|
279
|
+
if (r._tag === "Fail") {
|
|
280
|
+
matchTags(r.error, { // must handle both — omit one and it won't compile
|
|
281
|
+
Empty: () => "was empty",
|
|
282
|
+
TooLong: (e) => `too long: ${e.n}`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// `acquireRelease`'s `acquire` is synchronous, so use sync fs APIs (or `await`
|
|
287
|
+
// the acquisition yourself and register the disposer with `scope.add`).
|
|
288
|
+
await scoped(async (scope) => {
|
|
289
|
+
const dir = acquireRelease(
|
|
290
|
+
scope,
|
|
291
|
+
() => mkdtempSync(join(tmpdir(), "urban-")),
|
|
292
|
+
(d) => rmSync(d, { recursive: true, force: true }),
|
|
293
|
+
); // released on any exit
|
|
294
|
+
// …use dir…
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
247
298
|
## Related packages
|
|
248
299
|
|
|
249
300
|
- [`@nanobpm/workflow`](../workflow) — the code-first process surface (`defineFlow`).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type Result<A, E> = Ok<A> | Fail<E>;
|
|
2
|
+
export declare class Ok<A> {
|
|
3
|
+
readonly _tag: "Ok";
|
|
4
|
+
readonly value: A;
|
|
5
|
+
constructor(value: A);
|
|
6
|
+
[Symbol.iterator](): Generator<never, A, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export declare class Fail<E> {
|
|
9
|
+
readonly _tag: "Fail";
|
|
10
|
+
readonly error: E;
|
|
11
|
+
constructor(error: E);
|
|
12
|
+
[Symbol.iterator](): Generator<Fail<E>, never, unknown>;
|
|
13
|
+
}
|
|
14
|
+
export declare const ok: <A>(value: A) => Ok<A>;
|
|
15
|
+
export declare const fail: <E>(error: E) => Fail<E>;
|
|
16
|
+
export declare const isOk: <A, E>(r: Result<A, E>) => r is Ok<A>;
|
|
17
|
+
export declare const isFail: <A, E>(r: Result<A, E>) => r is Fail<E>;
|
|
18
|
+
export declare function gen<Y extends Fail<unknown>, A>(body: () => Generator<Y, A, unknown>): Result<A, Y["error"]>;
|
|
19
|
+
export type OkOf<R> = R extends Ok<infer A> ? A : never;
|
|
20
|
+
export type ErrOf<R> = R extends Fail<infer E> ? E : never;
|
|
21
|
+
export declare const map: <R extends Result<unknown, unknown>, B>(r: R, f: (a: OkOf<R>) => B) => Result<B, ErrOf<R>>;
|
|
22
|
+
export declare const mapError: <R extends Result<unknown, unknown>, E2>(r: R, f: (e: ErrOf<R>) => E2) => Result<OkOf<R>, E2>;
|
|
23
|
+
export declare const match: <R extends Result<unknown, unknown>, T>(r: R, onOk: (a: OkOf<R>) => T, onFail: (e: ErrOf<R>) => T) => T;
|
|
24
|
+
export type Tagged<Tag extends string> = {
|
|
25
|
+
readonly _tag: Tag;
|
|
26
|
+
};
|
|
27
|
+
export type TagUnion<Tag extends string, P> = Tag extends string ? Tagged<Tag> & P : never;
|
|
28
|
+
export declare function tag<Tag extends string>(t: Tag): TagUnion<Tag, {}>;
|
|
29
|
+
export declare function tag<Tag extends string, P extends object>(t: Tag, props: P & {
|
|
30
|
+
_tag?: never;
|
|
31
|
+
}): TagUnion<Tag, P>;
|
|
32
|
+
export declare const matchTags: <E extends Tagged<string>, R>(e: E, handlers: { [K in E["_tag"]]: (e: Extract<E, Tagged<K>>) => R; }) => R;
|
|
33
|
+
export declare class Scope {
|
|
34
|
+
#private;
|
|
35
|
+
add(dispose: () => void | Promise<void>): void;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export declare function acquireRelease<A>(scope: Scope, acquire: () => A, release: (a: A) => void | Promise<void>): A;
|
|
39
|
+
export declare function scoped<A>(body: (scope: Scope) => A | Promise<A>): Promise<A>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// effectlite — a tiny, ZERO-DEPENDENCY, Effect-like core for Urban glue code.
|
|
2
|
+
//
|
|
3
|
+
// Urban's published surface is declarative JSON + a client renderer, so we do
|
|
4
|
+
// NOT want the `effect` package (bundle weight + viral paradigm) in the runtime.
|
|
5
|
+
// But the imperative seams — workers, provisioning, resource lifecycles — still
|
|
6
|
+
// benefit from the three Effect ergonomics we actually reach for. effectlite
|
|
7
|
+
// gives us those, in ~100 LOC of implementation with no runtime dependencies:
|
|
8
|
+
//
|
|
9
|
+
// 1. A typed-error `Result<A, E>` whose `E` composes automatically through
|
|
10
|
+
// generator do-notation (`gen(function*(){ const x = yield* step })`),
|
|
11
|
+
// short-circuiting on the first failure exactly like `Effect.gen` + `yield*`.
|
|
12
|
+
// 2. `tag()` tagged errors (cf. `Data.TaggedError`) + exhaustive `matchTags`
|
|
13
|
+
// (cf. `catchTags`) — the compiler forces every failure mode to be handled.
|
|
14
|
+
// 3. `scoped` + `acquireRelease` (cf. `Effect.scoped`) — releases run on every
|
|
15
|
+
// exit: success, failure, and thrown.
|
|
16
|
+
//
|
|
17
|
+
// It is synchronous over `Result`; the scope is async only so disposers may
|
|
18
|
+
// await. Consume via `import { gen, ok, fail } from "@nanobpm/urban/effect"`.
|
|
19
|
+
export class Ok {
|
|
20
|
+
_tag = "Ok";
|
|
21
|
+
value;
|
|
22
|
+
constructor(value) {
|
|
23
|
+
this.value = value;
|
|
24
|
+
}
|
|
25
|
+
// Delegated by `yield*`: returns the value, yields nothing to the driver.
|
|
26
|
+
*[Symbol.iterator]() {
|
|
27
|
+
return this.value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class Fail {
|
|
31
|
+
_tag = "Fail";
|
|
32
|
+
error;
|
|
33
|
+
constructor(error) {
|
|
34
|
+
this.error = error;
|
|
35
|
+
}
|
|
36
|
+
// Delegated by `yield*`: yields itself so the driver can short-circuit; never
|
|
37
|
+
// resumes (the driver abandons the generator on the first failure).
|
|
38
|
+
*[Symbol.iterator]() {
|
|
39
|
+
yield this;
|
|
40
|
+
throw new Error("effectlite: generator resumed after a failure");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const ok = (value) => new Ok(value);
|
|
44
|
+
export const fail = (error) => new Fail(error);
|
|
45
|
+
export const isOk = (r) => r._tag === "Ok";
|
|
46
|
+
export const isFail = (r) => r._tag === "Fail";
|
|
47
|
+
// Run a generator that `yield*`s Results. Because only `Fail` yields to us, a
|
|
48
|
+
// single `next()` either lands on the first failure or on the final return.
|
|
49
|
+
// `Y` captures the UNION of every yielded `Fail<…>`; `Y["error"]` distributes
|
|
50
|
+
// that index access into the Result's error channel (a union in ⇒ a union out).
|
|
51
|
+
export function gen(body) {
|
|
52
|
+
// Typed as `Iterator` (not `Generator`) so `return` is the optional-arg
|
|
53
|
+
// variant — lets us close a short-circuited body without inventing a return value.
|
|
54
|
+
const it = body();
|
|
55
|
+
const step = it.next();
|
|
56
|
+
if (step.done)
|
|
57
|
+
return ok(step.value);
|
|
58
|
+
// Not done ⇒ a `Fail` yielded to short-circuit. Close the generator first so
|
|
59
|
+
// any `try/finally` in the body runs and captured resources aren't retained,
|
|
60
|
+
// then surface its error on the failure channel.
|
|
61
|
+
it.return?.();
|
|
62
|
+
return fail(step.value.error);
|
|
63
|
+
}
|
|
64
|
+
// Narrowing guards that carry the *extracted* channel type, so `r.value` /
|
|
65
|
+
// `r.error` land as `OkOf<R>` / `ErrOf<R>` (not `unknown`) with no assertion —
|
|
66
|
+
// keeping the combinators within the repo's no-type-assertion convention.
|
|
67
|
+
const isOkOf = (r) => r._tag === "Ok";
|
|
68
|
+
const isFailOf = (r) => r._tag === "Fail";
|
|
69
|
+
export const map = (r, f) => {
|
|
70
|
+
if (isOkOf(r))
|
|
71
|
+
return ok(f(r.value));
|
|
72
|
+
if (isFailOf(r))
|
|
73
|
+
return r;
|
|
74
|
+
throw new Error("effectlite: Result was neither Ok nor Fail");
|
|
75
|
+
};
|
|
76
|
+
export const mapError = (r, f) => {
|
|
77
|
+
if (isFailOf(r))
|
|
78
|
+
return fail(f(r.error));
|
|
79
|
+
if (isOkOf(r))
|
|
80
|
+
return r;
|
|
81
|
+
throw new Error("effectlite: Result was neither Ok nor Fail");
|
|
82
|
+
};
|
|
83
|
+
export const match = (r, onOk, onFail) => {
|
|
84
|
+
if (isOkOf(r))
|
|
85
|
+
return onOk(r.value);
|
|
86
|
+
if (isFailOf(r))
|
|
87
|
+
return onFail(r.error);
|
|
88
|
+
throw new Error("effectlite: Result was neither Ok nor Fail");
|
|
89
|
+
};
|
|
90
|
+
export function tag(t, props) {
|
|
91
|
+
return { ...props, _tag: t };
|
|
92
|
+
}
|
|
93
|
+
// Handle every tag in the union — the handlers object is keyed by `E["_tag"]`,
|
|
94
|
+
// so omitting a variant is a COMPILE error (no accidental dropped failure mode).
|
|
95
|
+
export const matchTags = (e, handlers) =>
|
|
96
|
+
// biome-ignore lint/plugin: the mapped handler type is precise for callers; internally we index it with the runtime tag, which needs this erasure to a plain record.
|
|
97
|
+
handlers[e._tag](e);
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Scoped resources
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
export class Scope {
|
|
102
|
+
#disposers = [];
|
|
103
|
+
add(dispose) {
|
|
104
|
+
this.#disposers.push(dispose);
|
|
105
|
+
}
|
|
106
|
+
async close() {
|
|
107
|
+
// LIFO, best-effort — a failing disposer never blocks the others.
|
|
108
|
+
for (const d of this.#disposers.reverse()) {
|
|
109
|
+
try {
|
|
110
|
+
await d();
|
|
111
|
+
}
|
|
112
|
+
catch { /* best effort */ }
|
|
113
|
+
}
|
|
114
|
+
this.#disposers = [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Acquire a resource and register its release with the scope in one step.
|
|
118
|
+
export function acquireRelease(scope, acquire, release) {
|
|
119
|
+
const a = acquire();
|
|
120
|
+
scope.add(() => release(a));
|
|
121
|
+
return a;
|
|
122
|
+
}
|
|
123
|
+
// Run `body` with a fresh scope; close it (releasing all resources) on every
|
|
124
|
+
// exit path — return, Result failure, or thrown.
|
|
125
|
+
export async function scoped(body) {
|
|
126
|
+
const scope = new Scope();
|
|
127
|
+
try {
|
|
128
|
+
return await body(scope);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await scope.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -518,6 +518,12 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
518
518
|
.pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
|
|
519
519
|
.pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
|
|
520
520
|
.pc-card-body[hidden] { display:none; }
|
|
521
|
+
/* Grid group headers (dataGrid groupBy): a full-width, clickable band that
|
|
522
|
+
collapses/expands its member rows; the collapsed state is persisted. */
|
|
523
|
+
.pc-group-header td { padding:0; background:var(--nano-inset); border-top:1px solid var(--nano-edge); }
|
|
524
|
+
.pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
|
|
525
|
+
.pc-group-toggle:hover { background:var(--nano-hover); }
|
|
526
|
+
.pc-group-title { font-size:.9rem; }
|
|
521
527
|
/* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
|
|
522
528
|
.pc-buttonrow { margin:1rem 0; }
|
|
523
529
|
.pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
|
|
@@ -534,6 +540,11 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
534
540
|
.pc-main-col { flex:1 1 auto; min-width:0; }
|
|
535
541
|
.pc-nav-title { font-weight:650; }
|
|
536
542
|
.pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
|
|
543
|
+
/* Opt-in sticky bar: stays pinned to the viewport top while the page scrolls.
|
|
544
|
+
The negative inline margin + matching padding lets its background span the
|
|
545
|
+
body's 2rem inline padding so scrolled content never shows beside it, and a
|
|
546
|
+
solid app-colored background hides content passing underneath. */
|
|
547
|
+
.pc-bar.pc-sticky { position:sticky; top:0; z-index:20; background:var(--nano-app); margin-inline:-2rem; padding-inline:2rem; padding-top:.6rem; }
|
|
537
548
|
.pc-bar .pc-nav-title { margin-right:.5rem; }
|
|
538
549
|
.pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
|
|
539
550
|
.pc-rail { display:flex; flex-direction:column; gap:.5rem; }
|
|
@@ -563,15 +574,34 @@ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
|
|
|
563
574
|
function safePageId(value) {
|
|
564
575
|
return typeof value === "string" && PAGE_ID.test(value);
|
|
565
576
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
577
|
+
// A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST
|
|
578
|
+
// "/" *before* decoding, so a param that itself contains encoded slashes or
|
|
579
|
+
// hashes (e.g. a plan_key "owner/repo#123" carried as "owner%2Frepo%23123")
|
|
580
|
+
// survives intact instead of the inner "%2F" being decoded into a second
|
|
581
|
+
// segment. The page segment is still restricted to the safe id charset; the
|
|
582
|
+
// param is opaque free text (bound only as a whitelisted '?' SQL value, or
|
|
583
|
+
// interpolated as DOM text — never as HTML/markup).
|
|
584
|
+
function parseRoute() {
|
|
585
|
+
const raw = (location.hash || "").replace(/^#\/?/, "");
|
|
586
|
+
const slash = raw.indexOf("/");
|
|
587
|
+
const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;
|
|
588
|
+
const paramRaw = slash >= 0 ? raw.slice(slash + 1) : "";
|
|
589
|
+
let page = HOME, param = "", pageOk = false;
|
|
590
|
+
try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }
|
|
591
|
+
// Only carry the param when the page segment resolved to a real (safe-id) page.
|
|
592
|
+
// A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT
|
|
593
|
+
// inherit x, or that stray param would silently scope every eqParam grid and
|
|
594
|
+
// {{param}} on the home page to an entity the user never selected.
|
|
595
|
+
if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : ""; } catch (e) { param = ""; } }
|
|
596
|
+
return { page, param };
|
|
573
597
|
}
|
|
598
|
+
function currentPage() { return parseRoute().page; }
|
|
574
599
|
let CURRENT = currentPage();
|
|
600
|
+
// The current route param (the "/<param>" tail, or "" when absent). Exposed to a
|
|
601
|
+
// page's datasource filters (a filter with { eqParam: true } binds its value to
|
|
602
|
+
// this) and to {{param}} text interpolation, so one page template can scope
|
|
603
|
+
// every section to a selected entity (e.g. an epic's plan_key).
|
|
604
|
+
let PARAM = parseRoute().param;
|
|
575
605
|
|
|
576
606
|
// Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
|
|
577
607
|
// listener here; navigating to another page runs every disposer before the new
|
|
@@ -740,10 +770,19 @@ function el(tag, attrs = {}, ...kids) {
|
|
|
740
770
|
return n;
|
|
741
771
|
}
|
|
742
772
|
|
|
773
|
+
// Interpolate the {{param}} token in a schema-supplied string with the current
|
|
774
|
+
// route param. The result is only ever set as DOM text (textContent), so the
|
|
775
|
+
// substituted value cannot inject markup. Other tokens are left untouched.
|
|
776
|
+
// A function replacement inserts PARAM literally; a plain string replacement would
|
|
777
|
+
// treat replacement patterns inside PARAM (an id like the dollar-one token) as
|
|
778
|
+
// special and render the wrong text.
|
|
779
|
+
function interpParam(s) {
|
|
780
|
+
return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
|
|
781
|
+
}
|
|
743
782
|
function renderText(node) {
|
|
744
783
|
const v = node.props.variant;
|
|
745
784
|
const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
|
|
746
|
-
return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
|
|
785
|
+
return el(v === "heading" ? "h1" : "p", { class: cls }, interpParam(node.props.text) || "");
|
|
747
786
|
}
|
|
748
787
|
|
|
749
788
|
// Copy text to the clipboard, resiliently. The async Clipboard API is tried
|
|
@@ -937,6 +976,20 @@ function gridCell(col, row) {
|
|
|
937
976
|
);
|
|
938
977
|
}
|
|
939
978
|
}
|
|
979
|
+
if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
|
|
980
|
+
// An in-app link to another page, scoped to this row: #/<page>/<key>. The
|
|
981
|
+
// key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
|
|
982
|
+
// param tail, which parseRoute() decodes back verbatim; the page id is
|
|
983
|
+
// validated against the safe-id charset and the key must be non-empty, so a
|
|
984
|
+
// blank/keyless cell stays plain text. Navigation is a pure hash change
|
|
985
|
+
// (no new tab, no server hit) — the SPA router re-renders in place.
|
|
986
|
+
const key = row[col.link.keyField];
|
|
987
|
+
const keyStr = key == null ? "" : String(key).trim();
|
|
988
|
+
if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
|
|
989
|
+
const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
|
|
990
|
+
return el("td", {}, el("a", { class: "pc-link", href }, text));
|
|
991
|
+
}
|
|
992
|
+
}
|
|
940
993
|
if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
|
|
941
994
|
const key = row[col.link.keyField];
|
|
942
995
|
const keyStr = key == null ? "" : String(key).trim();
|
|
@@ -1109,6 +1162,7 @@ function renderDataGrid(node) {
|
|
|
1109
1162
|
const qs = [];
|
|
1110
1163
|
for (const f of filters || []) {
|
|
1111
1164
|
if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
|
|
1165
|
+
else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
|
|
1112
1166
|
else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
|
|
1113
1167
|
}
|
|
1114
1168
|
if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
|
|
@@ -1239,7 +1293,7 @@ function renderDataGrid(node) {
|
|
|
1239
1293
|
return box;
|
|
1240
1294
|
}
|
|
1241
1295
|
|
|
1242
|
-
function renderRow(row) {
|
|
1296
|
+
function renderRow(row, sink) {
|
|
1243
1297
|
const cells = cols.map((c) => gridCell(c, row));
|
|
1244
1298
|
const key = rowKeyOf(row);
|
|
1245
1299
|
let toggle = null;
|
|
@@ -1257,6 +1311,9 @@ function renderDataGrid(node) {
|
|
|
1257
1311
|
}
|
|
1258
1312
|
const tr = el("tr", {}, ...cells);
|
|
1259
1313
|
tbody.append(tr);
|
|
1314
|
+
// When grouping, the caller collects every <tr> this row produced (the data
|
|
1315
|
+
// row plus any detail row) so a group header can hide/show them as one unit.
|
|
1316
|
+
if (sink) sink.push({ tr, isDetail: false, key });
|
|
1260
1317
|
if (detail && toggle) {
|
|
1261
1318
|
const isOpen = key != null && expanded.has(key);
|
|
1262
1319
|
// Reuse an already-built detail row for an open PR across refreshes so its
|
|
@@ -1284,6 +1341,67 @@ function renderDataGrid(node) {
|
|
|
1284
1341
|
}
|
|
1285
1342
|
});
|
|
1286
1343
|
tbody.append(dtr);
|
|
1344
|
+
if (sink) sink.push({ tr: dtr, isDetail: true, key });
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Append the fetched rows into the tbody, either flat (default) or partitioned
|
|
1349
|
+
// into collapsible groups when the grid declares 'groupBy: "<field>"'. Each
|
|
1350
|
+
// group renders a clickable header row ("<Label> <value> (<count>)") that
|
|
1351
|
+
// collapses/expands its member rows as one unit; the collapsed state is
|
|
1352
|
+
// persisted (localStorage, keyed by page + node + group value) so it survives
|
|
1353
|
+
// both the refresh poll and a full reload — the same durability the top-level
|
|
1354
|
+
// 'collapsible' node wrapper gives. Group order is numeric-aware ascending
|
|
1355
|
+
// (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric
|
|
1356
|
+
// keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.
|
|
1357
|
+
function appendRows(rows) {
|
|
1358
|
+
const groupBy = p.groupBy;
|
|
1359
|
+
// Treat a non-string groupBy as "no grouping": labelBase calls groupBy.charAt()
|
|
1360
|
+
// below, so a truthy non-string (number/object from a malformed page schema)
|
|
1361
|
+
// would throw at render time and break the whole page instead of one grid.
|
|
1362
|
+
if (!groupBy || typeof groupBy !== "string") { for (const row of rows) renderRow(row, null); return; }
|
|
1363
|
+
const order = [];
|
|
1364
|
+
const groups = new Map();
|
|
1365
|
+
for (const row of rows) {
|
|
1366
|
+
const gvRaw = row[groupBy];
|
|
1367
|
+
const gv = gvRaw == null ? "" : String(gvRaw);
|
|
1368
|
+
if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }
|
|
1369
|
+
groups.get(gv).push(row);
|
|
1370
|
+
}
|
|
1371
|
+
order.sort((a, b) => {
|
|
1372
|
+
const na = Number(a), nb = Number(b);
|
|
1373
|
+
if (a !== "" && b !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
|
|
1374
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1375
|
+
});
|
|
1376
|
+
const labelBase = p.groupLabel != null
|
|
1377
|
+
? String(p.groupLabel)
|
|
1378
|
+
: groupBy.charAt(0).toUpperCase() + groupBy.slice(1);
|
|
1379
|
+
for (const gv of order) {
|
|
1380
|
+
const groupRows = groups.get(gv);
|
|
1381
|
+
const gkey = "pc:collapsed:" + CURRENT + ":" + (node.id || p.title || "grid") + ":g:" + gv;
|
|
1382
|
+
let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);
|
|
1383
|
+
const chevron = el("span", { class: "pc-chevron-inline" }, gcollapsed ? "▸" : "▾");
|
|
1384
|
+
const label = (gv === "" ? labelBase + " —" : labelBase + " " + gv) + " (" + groupRows.length + ")";
|
|
1385
|
+
const btn = el("button", { class: "pc-group-toggle", type: "button" },
|
|
1386
|
+
chevron, el("span", { class: "pc-group-title" }, label));
|
|
1387
|
+
tbody.append(el("tr", { class: "pc-group-header" }, el("td", { colspan: span }, btn)));
|
|
1388
|
+
const members = [];
|
|
1389
|
+
for (const row of groupRows) renderRow(row, members);
|
|
1390
|
+
const applyGroup = () => {
|
|
1391
|
+
chevron.textContent = gcollapsed ? "▸" : "▾";
|
|
1392
|
+
btn.setAttribute("aria-expanded", String(!gcollapsed));
|
|
1393
|
+
for (const m of members) {
|
|
1394
|
+
m.tr.hidden = gcollapsed
|
|
1395
|
+
? true
|
|
1396
|
+
: (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
btn.addEventListener("click", () => {
|
|
1400
|
+
gcollapsed = !gcollapsed;
|
|
1401
|
+
writeCollapsed(gkey, gcollapsed);
|
|
1402
|
+
applyGroup();
|
|
1403
|
+
});
|
|
1404
|
+
applyGroup();
|
|
1287
1405
|
}
|
|
1288
1406
|
}
|
|
1289
1407
|
|
|
@@ -1299,7 +1417,15 @@ function renderDataGrid(node) {
|
|
|
1299
1417
|
|
|
1300
1418
|
async function refresh() {
|
|
1301
1419
|
try {
|
|
1302
|
-
|
|
1420
|
+
// A grid scoped to a route param (any eqParam filter) means "show the rows
|
|
1421
|
+
// for the selected entity". With no param present, PARAM is empty and emitting
|
|
1422
|
+
// a where clause of field-colon-empty would read server-side as field equals
|
|
1423
|
+
// empty string and surface empty-valued rows. That is not the selected entity's
|
|
1424
|
+
// rows (there is no selection), so short-circuit to zero rows without a request.
|
|
1425
|
+
const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
|
|
1426
|
+
const { rows } = paramScoped && PARAM === ""
|
|
1427
|
+
? { rows: [] }
|
|
1428
|
+
: await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
1303
1429
|
// Forget expansion / cached detail nodes for rows no longer present so the maps
|
|
1304
1430
|
// don't grow without bound and a stale answer can't resurface on a key reuse.
|
|
1305
1431
|
if (p.rowKey) {
|
|
@@ -1338,7 +1464,7 @@ function renderDataGrid(node) {
|
|
|
1338
1464
|
}
|
|
1339
1465
|
}
|
|
1340
1466
|
tbody.replaceChildren();
|
|
1341
|
-
|
|
1467
|
+
appendRows(rows);
|
|
1342
1468
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|
|
1343
1469
|
if (keepFocus && active.isConnected) {
|
|
1344
1470
|
active.focus();
|
|
@@ -1405,7 +1531,14 @@ function fillNav(list, items) {
|
|
|
1405
1531
|
function renderNav(node) {
|
|
1406
1532
|
const p = node.props || {};
|
|
1407
1533
|
const rail = p.variant === "rail";
|
|
1408
|
-
|
|
1534
|
+
// A bar nav can opt into staying pinned to the top of the viewport while the
|
|
1535
|
+
// page scrolls (sticky) — useful on the long, multi-section operator pages so
|
|
1536
|
+
// the page switcher is always reachable. Rails are already full-height, so the
|
|
1537
|
+
// flag only applies to the bar variant.
|
|
1538
|
+
const barSticky = !rail && p.sticky === true;
|
|
1539
|
+
const nav = el("nav", {
|
|
1540
|
+
class: (rail ? "pc-nav pc-rail" : "pc-nav pc-bar") + (barSticky ? " pc-sticky" : ""),
|
|
1541
|
+
});
|
|
1409
1542
|
if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
|
|
1410
1543
|
const list = el("div", { class: "pc-nav-items" });
|
|
1411
1544
|
nav.append(list);
|
|
@@ -1499,7 +1632,9 @@ function makeCollapsible(node, card) {
|
|
|
1499
1632
|
// tearing down the previous page's grid polls first.
|
|
1500
1633
|
async function renderPage() {
|
|
1501
1634
|
teardown();
|
|
1502
|
-
|
|
1635
|
+
const route = parseRoute();
|
|
1636
|
+
CURRENT = route.page;
|
|
1637
|
+
PARAM = route.param;
|
|
1503
1638
|
try {
|
|
1504
1639
|
const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
|
|
1505
1640
|
document.title = (doc && doc.title) || "Urban App";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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",
|
|
@@ -47,6 +47,10 @@
|
|
|
47
47
|
"types": "./dist/worker.d.ts",
|
|
48
48
|
"default": "./dist/worker.js"
|
|
49
49
|
},
|
|
50
|
+
"./effect": {
|
|
51
|
+
"types": "./dist/effect/index.d.ts",
|
|
52
|
+
"default": "./dist/effect/index.js"
|
|
53
|
+
},
|
|
50
54
|
"./source": "./src/index.ts"
|
|
51
55
|
},
|
|
52
56
|
"files": [
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
ok,
|
|
5
|
+
fail,
|
|
6
|
+
isOk,
|
|
7
|
+
isFail,
|
|
8
|
+
gen,
|
|
9
|
+
map,
|
|
10
|
+
mapError,
|
|
11
|
+
match,
|
|
12
|
+
tag,
|
|
13
|
+
matchTags,
|
|
14
|
+
scoped,
|
|
15
|
+
acquireRelease,
|
|
16
|
+
type Result,
|
|
17
|
+
type Tagged,
|
|
18
|
+
} from "./index.ts";
|
|
19
|
+
|
|
20
|
+
test("ok/fail construct and narrow", () => {
|
|
21
|
+
const a = ok(42);
|
|
22
|
+
const b = fail("boom");
|
|
23
|
+
assert.equal(isOk(a), true);
|
|
24
|
+
assert.equal(isFail(a), false);
|
|
25
|
+
assert.equal(isOk(b), false);
|
|
26
|
+
assert.equal(isFail(b), true);
|
|
27
|
+
if (isOk(a)) assert.equal(a.value, 42);
|
|
28
|
+
if (isFail(b)) assert.equal(b.error, "boom");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("gen threads values through yield* on the happy path", () => {
|
|
32
|
+
const r = gen(function* () {
|
|
33
|
+
const x = yield* ok(2);
|
|
34
|
+
const y = yield* ok(3);
|
|
35
|
+
return x + y;
|
|
36
|
+
});
|
|
37
|
+
assert.equal(isOk(r), true);
|
|
38
|
+
if (isOk(r)) assert.equal(r.value, 5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("gen short-circuits on the first failure", () => {
|
|
42
|
+
const steps: string[] = [];
|
|
43
|
+
const r = gen(function* () {
|
|
44
|
+
steps.push("a");
|
|
45
|
+
const x = yield* ok(1);
|
|
46
|
+
steps.push("b");
|
|
47
|
+
yield* fail("nope");
|
|
48
|
+
steps.push("c"); // must not run
|
|
49
|
+
return x;
|
|
50
|
+
});
|
|
51
|
+
assert.equal(isFail(r), true);
|
|
52
|
+
if (isFail(r)) assert.equal(r.error, "nope");
|
|
53
|
+
assert.deepEqual(steps, ["a", "b"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("gen unions error types from multiple failure sources", () => {
|
|
57
|
+
const parse = (s: string) => (s === "" ? fail(tag("Empty")) : ok(s.length));
|
|
58
|
+
const check = (n: number) => (n > 3 ? fail(tag("TooLong")) : ok(n));
|
|
59
|
+
const run = (s: string) =>
|
|
60
|
+
gen(function* () {
|
|
61
|
+
const n = yield* parse(s);
|
|
62
|
+
const ok2 = yield* check(n);
|
|
63
|
+
return ok2;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const good = run("ab");
|
|
67
|
+
assert.equal(isOk(good), true);
|
|
68
|
+
if (isOk(good)) assert.equal(good.value, 2);
|
|
69
|
+
|
|
70
|
+
const empty = run("");
|
|
71
|
+
assert.equal(isFail(empty), true);
|
|
72
|
+
if (isFail(empty)) assert.equal(empty.error._tag, "Empty");
|
|
73
|
+
|
|
74
|
+
const long = run("abcd");
|
|
75
|
+
assert.equal(isFail(long), true);
|
|
76
|
+
if (isFail(long)) assert.equal(long.error._tag, "TooLong");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("gen closes the generator so try/finally runs on short-circuit", () => {
|
|
80
|
+
const events: string[] = [];
|
|
81
|
+
const r = gen(function* () {
|
|
82
|
+
try {
|
|
83
|
+
events.push("enter");
|
|
84
|
+
yield* fail("boom");
|
|
85
|
+
events.push("after-fail"); // must not run
|
|
86
|
+
return 1;
|
|
87
|
+
} finally {
|
|
88
|
+
events.push("cleanup"); // must run even though we short-circuited
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
assert.equal(isFail(r), true);
|
|
92
|
+
if (isFail(r)) assert.equal(r.error, "boom");
|
|
93
|
+
// The failure short-circuits before "after-fail", but the generator is closed
|
|
94
|
+
// so its `finally` still releases (LIFO: enter → cleanup, no after-fail).
|
|
95
|
+
assert.deepEqual(events, ["enter", "cleanup"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("map/mapError/match", () => {
|
|
99
|
+
assert.equal(match(map(ok(2), (n) => n * 10), (a) => a, () => -1), 20);
|
|
100
|
+
const failed: Result<number, string> = fail("e");
|
|
101
|
+
assert.equal(match(map(failed, (n) => n * 10), (n) => String(n), (e) => e), "e");
|
|
102
|
+
assert.equal(match(mapError(fail("e"), (e) => e + "!"), () => "", (e) => e), "e!");
|
|
103
|
+
assert.equal(match(mapError(ok(7), (e: string) => e), (a) => a, () => -1), 7);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("tag builds tagged errors with props", () => {
|
|
107
|
+
const e = tag("Http", { status: 404 });
|
|
108
|
+
assert.equal(e._tag, "Http");
|
|
109
|
+
assert.equal(e.status, 404);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("tag without props carries only the tag (no phantom prop keys)", () => {
|
|
113
|
+
const e = tag("Empty");
|
|
114
|
+
assert.equal(e._tag, "Empty");
|
|
115
|
+
assert.deepEqual(Object.keys(e), ["_tag"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("tag() sets _tag last so untyped props cannot clobber the discriminant", () => {
|
|
119
|
+
// Props arriving through an untyped boundary must never override the tag —
|
|
120
|
+
// otherwise a stray `_tag` would break the discriminant `matchTags` relies on.
|
|
121
|
+
const untyped: object = JSON.parse('{"_tag":"EVIL","detail":1}');
|
|
122
|
+
const e = tag("Real", untyped);
|
|
123
|
+
assert.equal(e._tag, "Real");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("matchTags dispatches on the tag", () => {
|
|
127
|
+
type E = Tagged<"NotFound"> | (Tagged<"Denied"> & { who: string });
|
|
128
|
+
const describe = (e: E) =>
|
|
129
|
+
matchTags(e, {
|
|
130
|
+
NotFound: () => "missing",
|
|
131
|
+
Denied: (d) => `denied:${d.who}`,
|
|
132
|
+
});
|
|
133
|
+
assert.equal(describe(tag("NotFound")), "missing");
|
|
134
|
+
assert.equal(describe(tag("Denied", { who: "bob" })), "denied:bob");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("tag distributes a union tag into a discriminated union for matchTags", () => {
|
|
138
|
+
// `outcome` is a plain string union, as returned by e.g. a land/merge step.
|
|
139
|
+
const dispatch = (outcome: "merged" | "queued" | "blocked", detail: string) =>
|
|
140
|
+
matchTags(tag(outcome, { detail }), {
|
|
141
|
+
merged: () => "done",
|
|
142
|
+
queued: () => "waiting",
|
|
143
|
+
// The shared `detail` prop is present on every variant.
|
|
144
|
+
blocked: (b) => `blocked:${b.detail}`,
|
|
145
|
+
});
|
|
146
|
+
assert.equal(dispatch("merged", "x"), "done");
|
|
147
|
+
assert.equal(dispatch("queued", "x"), "waiting");
|
|
148
|
+
assert.equal(dispatch("blocked", "perms"), "blocked:perms");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("scoped releases resources LIFO on success", async () => {
|
|
152
|
+
const released: string[] = [];
|
|
153
|
+
const out = await scoped(async (scope) => {
|
|
154
|
+
acquireRelease(scope, () => "a", () => { released.push("a"); });
|
|
155
|
+
acquireRelease(scope, () => "b", () => { released.push("b"); });
|
|
156
|
+
return "done";
|
|
157
|
+
});
|
|
158
|
+
assert.equal(out, "done");
|
|
159
|
+
assert.deepEqual(released, ["b", "a"]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("scoped releases resources even when the body throws", async () => {
|
|
163
|
+
const released: string[] = [];
|
|
164
|
+
await assert.rejects(
|
|
165
|
+
scoped(async (scope) => {
|
|
166
|
+
acquireRelease(scope, () => "a", () => { released.push("a"); });
|
|
167
|
+
acquireRelease(scope, () => "b", () => { released.push("b"); });
|
|
168
|
+
throw new Error("kaboom");
|
|
169
|
+
}),
|
|
170
|
+
/kaboom/,
|
|
171
|
+
);
|
|
172
|
+
assert.deepEqual(released, ["b", "a"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("scoped best-effort: a failing disposer does not block the others", async () => {
|
|
176
|
+
const released: string[] = [];
|
|
177
|
+
await scoped(async (scope) => {
|
|
178
|
+
acquireRelease(scope, () => "a", () => { released.push("a"); });
|
|
179
|
+
acquireRelease(scope, () => "b", () => { throw new Error("dispose fail"); });
|
|
180
|
+
acquireRelease(scope, () => "c", () => { released.push("c"); });
|
|
181
|
+
return null;
|
|
182
|
+
});
|
|
183
|
+
// b's disposer threw but a and c still ran (LIFO: c, [b throws], a).
|
|
184
|
+
assert.deepEqual(released, ["c", "a"]);
|
|
185
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// effectlite — a tiny, ZERO-DEPENDENCY, Effect-like core for Urban glue code.
|
|
2
|
+
//
|
|
3
|
+
// Urban's published surface is declarative JSON + a client renderer, so we do
|
|
4
|
+
// NOT want the `effect` package (bundle weight + viral paradigm) in the runtime.
|
|
5
|
+
// But the imperative seams — workers, provisioning, resource lifecycles — still
|
|
6
|
+
// benefit from the three Effect ergonomics we actually reach for. effectlite
|
|
7
|
+
// gives us those, in ~100 LOC of implementation with no runtime dependencies:
|
|
8
|
+
//
|
|
9
|
+
// 1. A typed-error `Result<A, E>` whose `E` composes automatically through
|
|
10
|
+
// generator do-notation (`gen(function*(){ const x = yield* step })`),
|
|
11
|
+
// short-circuiting on the first failure exactly like `Effect.gen` + `yield*`.
|
|
12
|
+
// 2. `tag()` tagged errors (cf. `Data.TaggedError`) + exhaustive `matchTags`
|
|
13
|
+
// (cf. `catchTags`) — the compiler forces every failure mode to be handled.
|
|
14
|
+
// 3. `scoped` + `acquireRelease` (cf. `Effect.scoped`) — releases run on every
|
|
15
|
+
// exit: success, failure, and thrown.
|
|
16
|
+
//
|
|
17
|
+
// It is synchronous over `Result`; the scope is async only so disposers may
|
|
18
|
+
// await. Consume via `import { gen, ok, fail } from "@nanobpm/urban/effect"`.
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Result + generator do-notation
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export type Result<A, E> = Ok<A> | Fail<E>;
|
|
25
|
+
|
|
26
|
+
export class Ok<A> {
|
|
27
|
+
readonly _tag = "Ok" as const;
|
|
28
|
+
readonly value: A;
|
|
29
|
+
constructor(value: A) {
|
|
30
|
+
this.value = value;
|
|
31
|
+
}
|
|
32
|
+
// Delegated by `yield*`: returns the value, yields nothing to the driver.
|
|
33
|
+
*[Symbol.iterator](): Generator<never, A, unknown> {
|
|
34
|
+
return this.value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class Fail<E> {
|
|
39
|
+
readonly _tag = "Fail" as const;
|
|
40
|
+
readonly error: E;
|
|
41
|
+
constructor(error: E) {
|
|
42
|
+
this.error = error;
|
|
43
|
+
}
|
|
44
|
+
// Delegated by `yield*`: yields itself so the driver can short-circuit; never
|
|
45
|
+
// resumes (the driver abandons the generator on the first failure).
|
|
46
|
+
*[Symbol.iterator](): Generator<Fail<E>, never, unknown> {
|
|
47
|
+
yield this;
|
|
48
|
+
throw new Error("effectlite: generator resumed after a failure");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const ok = <A>(value: A): Ok<A> => new Ok(value);
|
|
53
|
+
export const fail = <E>(error: E): Fail<E> => new Fail(error);
|
|
54
|
+
|
|
55
|
+
export const isOk = <A, E>(r: Result<A, E>): r is Ok<A> => r._tag === "Ok";
|
|
56
|
+
export const isFail = <A, E>(r: Result<A, E>): r is Fail<E> => r._tag === "Fail";
|
|
57
|
+
|
|
58
|
+
// Run a generator that `yield*`s Results. Because only `Fail` yields to us, a
|
|
59
|
+
// single `next()` either lands on the first failure or on the final return.
|
|
60
|
+
// `Y` captures the UNION of every yielded `Fail<…>`; `Y["error"]` distributes
|
|
61
|
+
// that index access into the Result's error channel (a union in ⇒ a union out).
|
|
62
|
+
export function gen<Y extends Fail<unknown>, A>(
|
|
63
|
+
body: () => Generator<Y, A, unknown>,
|
|
64
|
+
): Result<A, Y["error"]> {
|
|
65
|
+
// Typed as `Iterator` (not `Generator`) so `return` is the optional-arg
|
|
66
|
+
// variant — lets us close a short-circuited body without inventing a return value.
|
|
67
|
+
const it: Iterator<Y, A> = body();
|
|
68
|
+
const step = it.next();
|
|
69
|
+
if (step.done) return ok(step.value);
|
|
70
|
+
// Not done ⇒ a `Fail` yielded to short-circuit. Close the generator first so
|
|
71
|
+
// any `try/finally` in the body runs and captured resources aren't retained,
|
|
72
|
+
// then surface its error on the failure channel.
|
|
73
|
+
it.return?.();
|
|
74
|
+
return fail(step.value.error);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Combinators
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
// Extract the success/error channels from a `Result` (including a value typed as
|
|
82
|
+
// the union `Result<A, E>`), so the combinators below infer `A`/`E` cleanly
|
|
83
|
+
// instead of collapsing them to `unknown` on union inputs.
|
|
84
|
+
export type OkOf<R> = R extends Ok<infer A> ? A : never;
|
|
85
|
+
export type ErrOf<R> = R extends Fail<infer E> ? E : never;
|
|
86
|
+
|
|
87
|
+
// Narrowing guards that carry the *extracted* channel type, so `r.value` /
|
|
88
|
+
// `r.error` land as `OkOf<R>` / `ErrOf<R>` (not `unknown`) with no assertion —
|
|
89
|
+
// keeping the combinators within the repo's no-type-assertion convention.
|
|
90
|
+
const isOkOf = <R extends Result<unknown, unknown>>(r: R): r is R & Ok<OkOf<R>> => r._tag === "Ok";
|
|
91
|
+
const isFailOf = <R extends Result<unknown, unknown>>(r: R): r is R & Fail<ErrOf<R>> => r._tag === "Fail";
|
|
92
|
+
|
|
93
|
+
export const map = <R extends Result<unknown, unknown>, B>(
|
|
94
|
+
r: R,
|
|
95
|
+
f: (a: OkOf<R>) => B,
|
|
96
|
+
): Result<B, ErrOf<R>> => {
|
|
97
|
+
if (isOkOf(r)) return ok(f(r.value));
|
|
98
|
+
if (isFailOf(r)) return r;
|
|
99
|
+
throw new Error("effectlite: Result was neither Ok nor Fail");
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const mapError = <R extends Result<unknown, unknown>, E2>(
|
|
103
|
+
r: R,
|
|
104
|
+
f: (e: ErrOf<R>) => E2,
|
|
105
|
+
): Result<OkOf<R>, E2> => {
|
|
106
|
+
if (isFailOf(r)) return fail(f(r.error));
|
|
107
|
+
if (isOkOf(r)) return r;
|
|
108
|
+
throw new Error("effectlite: Result was neither Ok nor Fail");
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const match = <R extends Result<unknown, unknown>, T>(
|
|
112
|
+
r: R,
|
|
113
|
+
onOk: (a: OkOf<R>) => T,
|
|
114
|
+
onFail: (e: ErrOf<R>) => T,
|
|
115
|
+
): T => {
|
|
116
|
+
if (isOkOf(r)) return onOk(r.value);
|
|
117
|
+
if (isFailOf(r)) return onFail(r.error);
|
|
118
|
+
throw new Error("effectlite: Result was neither Ok nor Fail");
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Tagged errors + exhaustive matching
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
export type Tagged<Tag extends string> = { readonly _tag: Tag };
|
|
126
|
+
|
|
127
|
+
// A distributive `Tagged<Tag> & P`: when `Tag` is a union (e.g. a
|
|
128
|
+
// `"merged" | "queued" | "blocked"` outcome), this expands to a discriminated
|
|
129
|
+
// UNION of tagged objects — `({_tag:"merged"} & P) | ({_tag:"queued"} & P) | …`
|
|
130
|
+
// — not a single object with a union-typed `_tag`. That is what lets `matchTags`
|
|
131
|
+
// `Extract` each variant and force an exhaustive handler per case.
|
|
132
|
+
export type TagUnion<Tag extends string, P> = Tag extends string ? Tagged<Tag> & P : never;
|
|
133
|
+
|
|
134
|
+
// Overloaded so the extra props type only appears when props are actually
|
|
135
|
+
// passed: `tag("X")` is exactly `Tagged<"X">` (no phantom `& P` that could read
|
|
136
|
+
// as `undefined` at runtime), while `tag("X", props)` carries them. Both return
|
|
137
|
+
// the distributive `TagUnion`, so a union `Tag` expands to a discriminated union.
|
|
138
|
+
// `props` is forbidden from carrying its own `_tag` (`{ _tag?: never }`) so it
|
|
139
|
+
// cannot shadow the discriminant, and the runtime spreads `props` *first* so the
|
|
140
|
+
// tag always wins even for props arriving through an untyped boundary.
|
|
141
|
+
export function tag<Tag extends string>(t: Tag): TagUnion<Tag, {}>;
|
|
142
|
+
export function tag<Tag extends string, P extends object>(
|
|
143
|
+
t: Tag,
|
|
144
|
+
props: P & { _tag?: never },
|
|
145
|
+
): TagUnion<Tag, P>;
|
|
146
|
+
export function tag(t: string, props?: object): Tagged<string> {
|
|
147
|
+
return { ...props, _tag: t };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Handle every tag in the union — the handlers object is keyed by `E["_tag"]`,
|
|
151
|
+
// so omitting a variant is a COMPILE error (no accidental dropped failure mode).
|
|
152
|
+
export const matchTags = <E extends Tagged<string>, R>(
|
|
153
|
+
e: E,
|
|
154
|
+
handlers: { [K in E["_tag"]]: (e: Extract<E, Tagged<K>>) => R },
|
|
155
|
+
): R =>
|
|
156
|
+
// biome-ignore lint/plugin: the mapped handler type is precise for callers; internally we index it with the runtime tag, which needs this erasure to a plain record.
|
|
157
|
+
(handlers as unknown as Record<string, (e: E) => R>)[e._tag](e);
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Scoped resources
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export class Scope {
|
|
164
|
+
#disposers: Array<() => void | Promise<void>> = [];
|
|
165
|
+
add(dispose: () => void | Promise<void>): void {
|
|
166
|
+
this.#disposers.push(dispose);
|
|
167
|
+
}
|
|
168
|
+
async close(): Promise<void> {
|
|
169
|
+
// LIFO, best-effort — a failing disposer never blocks the others.
|
|
170
|
+
for (const d of this.#disposers.reverse()) {
|
|
171
|
+
try { await d(); } catch { /* best effort */ }
|
|
172
|
+
}
|
|
173
|
+
this.#disposers = [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Acquire a resource and register its release with the scope in one step.
|
|
178
|
+
export function acquireRelease<A>(scope: Scope, acquire: () => A, release: (a: A) => void | Promise<void>): A {
|
|
179
|
+
const a = acquire();
|
|
180
|
+
scope.add(() => release(a));
|
|
181
|
+
return a;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Run `body` with a fresh scope; close it (releasing all resources) on every
|
|
185
|
+
// exit path — return, Result failure, or thrown.
|
|
186
|
+
export async function scoped<A>(body: (scope: Scope) => A | Promise<A>): Promise<A> {
|
|
187
|
+
const scope = new Scope();
|
|
188
|
+
try {
|
|
189
|
+
return await body(scope);
|
|
190
|
+
} finally {
|
|
191
|
+
await scope.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -364,6 +364,84 @@ test("renderer wires a column's processExplorer link to the console explorer", a
|
|
|
364
364
|
assert.match(js, /ev\.preventDefault\(\)/);
|
|
365
365
|
});
|
|
366
366
|
|
|
367
|
+
test("renderer binds a route param: parseRoute splits page/param, filters + text consume it (#134)", async () => {
|
|
368
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
369
|
+
const js = res.body ?? "";
|
|
370
|
+
// A route is #/<page>/<url-encoded-param>. parseRoute splits on the FIRST "/"
|
|
371
|
+
// BEFORE decoding so a param carrying encoded slashes/hashes (a plan_key like
|
|
372
|
+
// "owner/repo#123") survives; the param segment is decoded verbatim into PARAM.
|
|
373
|
+
assert.match(js, /const slash = raw\.indexOf\("\/"\)/);
|
|
374
|
+
assert.match(js, /const paramRaw = slash >= 0 \? raw\.slice\(slash \+ 1\) : ""/);
|
|
375
|
+
assert.match(js, /param = paramRaw \? decodeURIComponent\(paramRaw\) : ""/);
|
|
376
|
+
assert.match(js, /let PARAM = parseRoute\(\)\.param/);
|
|
377
|
+
// renderPage refreshes PARAM from the current route on every (re)render.
|
|
378
|
+
assert.match(js, /const route = parseRoute\(\);\s*CURRENT = route\.page;\s*PARAM = route\.param;/s);
|
|
379
|
+
// A datasource filter with { eqParam: true } binds its value to the live PARAM,
|
|
380
|
+
// so one page template scopes every section to the selected entity.
|
|
381
|
+
assert.match(js, /else if \(f\.eqParam\) qs\.push\("where=" \+ encodeURIComponent\(f\.field \+ ":" \+ PARAM\)\)/);
|
|
382
|
+
// {{param}} interpolates into text nodes as DOM text (never markup). A function
|
|
383
|
+
// replacement inserts PARAM literally, so replacement patterns inside a param id
|
|
384
|
+
// can't be reinterpreted.
|
|
385
|
+
assert.match(js, /function interpParam\(s\)/);
|
|
386
|
+
assert.match(js, /replace\(\/\\\{\\\{param\\\}\\\}\/g, \(\) => PARAM\)/);
|
|
387
|
+
assert.match(js, /interpParam\(node\.props\.text\)/);
|
|
388
|
+
// A grid whose active filter binds to the route param renders zero rows (no data
|
|
389
|
+
// request) when no param is present, rather than emitting a where clause of
|
|
390
|
+
// field-colon-empty (server reads that as field equals empty string and would
|
|
391
|
+
// surface empty-valued rows).
|
|
392
|
+
assert.match(js, /const paramScoped = \(activeFilter \|\| \[\]\)\.some\(\(f\) => f && f\.eqParam\)/);
|
|
393
|
+
assert.match(js, /paramScoped && PARAM === ""\s*\?\s*\{ rows: \[\] \}/s);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("renderer wires a column's page link to an in-app scoped hash route", async () => {
|
|
397
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
398
|
+
const js = res.body ?? "";
|
|
399
|
+
// link: { kind: "page", page, keyField } → an in-app #/<page>/<key> hash link
|
|
400
|
+
// (no new tab, no server hit). The page id is safe-id-validated, the key is
|
|
401
|
+
// trimmed + URL-encoded into the param tail, and a blank/keyless cell stays text.
|
|
402
|
+
assert.match(js, /col\.link && col\.link\.kind === "page" && col\.link\.page && col\.link\.keyField/);
|
|
403
|
+
assert.match(js, /safePageId\(col\.link\.page\)/);
|
|
404
|
+
assert.match(js, /"#\/" \+ encodeURIComponent\(col\.link\.page\) \+ "\/" \+ encodeURIComponent\(keyStr\)/);
|
|
405
|
+
// In-app: NOT a new-tab link (no target/rel on this anchor).
|
|
406
|
+
assert.match(js, /el\("a", \{ class: "pc-link", href \}, text\)/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("renderer groups a dataGrid by a field into persisted collapsible bands (waves)", async () => {
|
|
410
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
411
|
+
const js = res.body ?? "";
|
|
412
|
+
// groupBy partitions rows into collapsible groups; each header toggles its
|
|
413
|
+
// members as a unit and the collapsed state is persisted (survives poll+reload).
|
|
414
|
+
assert.match(js, /function appendRows\(rows\)/);
|
|
415
|
+
assert.match(js, /const groupBy = p\.groupBy/);
|
|
416
|
+
assert.match(js, /if \(!groupBy \|\| typeof groupBy !== "string"\) \{ for \(const row of rows\) renderRow\(row, null\); return; \}/);
|
|
417
|
+
// Numeric-aware ascending group order (waves 1,2,10 sort naturally).
|
|
418
|
+
assert.match(js, /const na = Number\(a\), nb = Number\(b\)/);
|
|
419
|
+
// Collapsed state persisted per page + node + group value via the shared helpers.
|
|
420
|
+
assert.match(js, /readCollapsed\(gkey, !!p\.groupDefaultCollapsed\)/);
|
|
421
|
+
assert.match(js, /writeCollapsed\(gkey, gcollapsed\)/);
|
|
422
|
+
assert.match(js, /"pc:collapsed:" \+ CURRENT \+ ":" \+ \(node\.id \|\| p\.title \|\| "grid"\) \+ ":g:" \+ gv/);
|
|
423
|
+
// A collapsed group hides every member row; expanded restores data rows and
|
|
424
|
+
// leaves detail rows at their own expansion state.
|
|
425
|
+
assert.match(js, /m\.tr\.hidden = gcollapsed/);
|
|
426
|
+
assert.match(js, /class: "pc-group-header"/);
|
|
427
|
+
// The group toggle exposes its expanded/collapsed state to assistive tech,
|
|
428
|
+
// mirroring makeCollapsible's header (screen readers announce the change).
|
|
429
|
+
assert.match(js, /btn\.setAttribute\("aria-expanded", String\(!gcollapsed\)\)/);
|
|
430
|
+
// refresh() renders through appendRows so grouping applies on every poll.
|
|
431
|
+
assert.match(js, /tbody\.replaceChildren\(\);\s*appendRows\(rows\);/s);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("renderer supports an opt-in sticky nav bar", async () => {
|
|
435
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
436
|
+
const js = res.body ?? "";
|
|
437
|
+
const css = (await dispatch("GET", "/")).body ?? "";
|
|
438
|
+
// A bar nav with { sticky: true } gets the pc-sticky class (rails are excluded).
|
|
439
|
+
assert.match(js, /const barSticky = !rail && p\.sticky === true/);
|
|
440
|
+
assert.match(js, /\(barSticky \? " pc-sticky" : ""\)/);
|
|
441
|
+
// The CSS pins it to the viewport top with a solid background.
|
|
442
|
+
assert.match(css, /\.pc-bar\.pc-sticky \{[^}]*position:sticky[^}]*top:0/);
|
|
443
|
+
});
|
|
444
|
+
|
|
367
445
|
test("renderer exposes an embed-gated host-navigation bridge", async () => {
|
|
368
446
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
369
447
|
const js = res.body ?? "";
|
|
@@ -489,8 +567,15 @@ test("the renderer routes between pages by hash and tears down grid polls", asyn
|
|
|
489
567
|
assert.match(js, /const PAGE_ID = \/\^\[A-Za-z0-9_-/);
|
|
490
568
|
assert.match(js, /function safePageId\(value\)/);
|
|
491
569
|
assert.match(js, /function currentPage\(\)/);
|
|
492
|
-
|
|
493
|
-
|
|
570
|
+
// The hash is parsed into a page + optional param by parseRoute(); the page
|
|
571
|
+
// segment is decoded and safe-id-validated (falling back to HOME).
|
|
572
|
+
assert.match(js, /function parseRoute\(\)/);
|
|
573
|
+
assert.match(js, /const raw = \(location\.hash \|\| ""\)\.replace/s);
|
|
574
|
+
assert.match(js, /if \(safePageId\(p\)\) \{ page = p; pageOk = true; \}/s);
|
|
575
|
+
// A stray param is only carried when the page segment resolved to a real page,
|
|
576
|
+
// so a malformed/unknown page (#/not-a-page/x) falls back to HOME without
|
|
577
|
+
// inheriting x (which would otherwise silently scope home's eqParam grids).
|
|
578
|
+
assert.match(js, /if \(pageOk\) \{ try \{ param = paramRaw \? decodeURIComponent\(paramRaw\) : ""; \}/s);
|
|
494
579
|
assert.match(js, /location\.hash/);
|
|
495
580
|
assert.match(js, /window\.addEventListener\("hashchange", renderPage\)/);
|
|
496
581
|
assert.match(js, /async function renderPage\(\)/);
|
|
@@ -569,6 +569,12 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
569
569
|
.pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
|
|
570
570
|
.pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
|
|
571
571
|
.pc-card-body[hidden] { display:none; }
|
|
572
|
+
/* Grid group headers (dataGrid groupBy): a full-width, clickable band that
|
|
573
|
+
collapses/expands its member rows; the collapsed state is persisted. */
|
|
574
|
+
.pc-group-header td { padding:0; background:var(--nano-inset); border-top:1px solid var(--nano-edge); }
|
|
575
|
+
.pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
|
|
576
|
+
.pc-group-toggle:hover { background:var(--nano-hover); }
|
|
577
|
+
.pc-group-title { font-size:.9rem; }
|
|
572
578
|
/* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
|
|
573
579
|
.pc-buttonrow { margin:1rem 0; }
|
|
574
580
|
.pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
|
|
@@ -585,6 +591,11 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
585
591
|
.pc-main-col { flex:1 1 auto; min-width:0; }
|
|
586
592
|
.pc-nav-title { font-weight:650; }
|
|
587
593
|
.pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
|
|
594
|
+
/* Opt-in sticky bar: stays pinned to the viewport top while the page scrolls.
|
|
595
|
+
The negative inline margin + matching padding lets its background span the
|
|
596
|
+
body's 2rem inline padding so scrolled content never shows beside it, and a
|
|
597
|
+
solid app-colored background hides content passing underneath. */
|
|
598
|
+
.pc-bar.pc-sticky { position:sticky; top:0; z-index:20; background:var(--nano-app); margin-inline:-2rem; padding-inline:2rem; padding-top:.6rem; }
|
|
588
599
|
.pc-bar .pc-nav-title { margin-right:.5rem; }
|
|
589
600
|
.pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
|
|
590
601
|
.pc-rail { display:flex; flex-direction:column; gap:.5rem; }
|
|
@@ -615,15 +626,34 @@ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
|
|
|
615
626
|
function safePageId(value) {
|
|
616
627
|
return typeof value === "string" && PAGE_ID.test(value);
|
|
617
628
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
629
|
+
// A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST
|
|
630
|
+
// "/" *before* decoding, so a param that itself contains encoded slashes or
|
|
631
|
+
// hashes (e.g. a plan_key "owner/repo#123" carried as "owner%2Frepo%23123")
|
|
632
|
+
// survives intact instead of the inner "%2F" being decoded into a second
|
|
633
|
+
// segment. The page segment is still restricted to the safe id charset; the
|
|
634
|
+
// param is opaque free text (bound only as a whitelisted '?' SQL value, or
|
|
635
|
+
// interpolated as DOM text — never as HTML/markup).
|
|
636
|
+
function parseRoute() {
|
|
637
|
+
const raw = (location.hash || "").replace(/^#\/?/, "");
|
|
638
|
+
const slash = raw.indexOf("/");
|
|
639
|
+
const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;
|
|
640
|
+
const paramRaw = slash >= 0 ? raw.slice(slash + 1) : "";
|
|
641
|
+
let page = HOME, param = "", pageOk = false;
|
|
642
|
+
try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }
|
|
643
|
+
// Only carry the param when the page segment resolved to a real (safe-id) page.
|
|
644
|
+
// A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT
|
|
645
|
+
// inherit x, or that stray param would silently scope every eqParam grid and
|
|
646
|
+
// {{param}} on the home page to an entity the user never selected.
|
|
647
|
+
if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : ""; } catch (e) { param = ""; } }
|
|
648
|
+
return { page, param };
|
|
625
649
|
}
|
|
650
|
+
function currentPage() { return parseRoute().page; }
|
|
626
651
|
let CURRENT = currentPage();
|
|
652
|
+
// The current route param (the "/<param>" tail, or "" when absent). Exposed to a
|
|
653
|
+
// page's datasource filters (a filter with { eqParam: true } binds its value to
|
|
654
|
+
// this) and to {{param}} text interpolation, so one page template can scope
|
|
655
|
+
// every section to a selected entity (e.g. an epic's plan_key).
|
|
656
|
+
let PARAM = parseRoute().param;
|
|
627
657
|
|
|
628
658
|
// Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
|
|
629
659
|
// listener here; navigating to another page runs every disposer before the new
|
|
@@ -792,10 +822,19 @@ function el(tag, attrs = {}, ...kids) {
|
|
|
792
822
|
return n;
|
|
793
823
|
}
|
|
794
824
|
|
|
825
|
+
// Interpolate the {{param}} token in a schema-supplied string with the current
|
|
826
|
+
// route param. The result is only ever set as DOM text (textContent), so the
|
|
827
|
+
// substituted value cannot inject markup. Other tokens are left untouched.
|
|
828
|
+
// A function replacement inserts PARAM literally; a plain string replacement would
|
|
829
|
+
// treat replacement patterns inside PARAM (an id like the dollar-one token) as
|
|
830
|
+
// special and render the wrong text.
|
|
831
|
+
function interpParam(s) {
|
|
832
|
+
return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
|
|
833
|
+
}
|
|
795
834
|
function renderText(node) {
|
|
796
835
|
const v = node.props.variant;
|
|
797
836
|
const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
|
|
798
|
-
return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
|
|
837
|
+
return el(v === "heading" ? "h1" : "p", { class: cls }, interpParam(node.props.text) || "");
|
|
799
838
|
}
|
|
800
839
|
|
|
801
840
|
// Copy text to the clipboard, resiliently. The async Clipboard API is tried
|
|
@@ -989,6 +1028,20 @@ function gridCell(col, row) {
|
|
|
989
1028
|
);
|
|
990
1029
|
}
|
|
991
1030
|
}
|
|
1031
|
+
if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
|
|
1032
|
+
// An in-app link to another page, scoped to this row: #/<page>/<key>. The
|
|
1033
|
+
// key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
|
|
1034
|
+
// param tail, which parseRoute() decodes back verbatim; the page id is
|
|
1035
|
+
// validated against the safe-id charset and the key must be non-empty, so a
|
|
1036
|
+
// blank/keyless cell stays plain text. Navigation is a pure hash change
|
|
1037
|
+
// (no new tab, no server hit) — the SPA router re-renders in place.
|
|
1038
|
+
const key = row[col.link.keyField];
|
|
1039
|
+
const keyStr = key == null ? "" : String(key).trim();
|
|
1040
|
+
if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
|
|
1041
|
+
const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
|
|
1042
|
+
return el("td", {}, el("a", { class: "pc-link", href }, text));
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
992
1045
|
if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
|
|
993
1046
|
const key = row[col.link.keyField];
|
|
994
1047
|
const keyStr = key == null ? "" : String(key).trim();
|
|
@@ -1161,6 +1214,7 @@ function renderDataGrid(node) {
|
|
|
1161
1214
|
const qs = [];
|
|
1162
1215
|
for (const f of filters || []) {
|
|
1163
1216
|
if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
|
|
1217
|
+
else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
|
|
1164
1218
|
else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
|
|
1165
1219
|
}
|
|
1166
1220
|
if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
|
|
@@ -1291,7 +1345,7 @@ function renderDataGrid(node) {
|
|
|
1291
1345
|
return box;
|
|
1292
1346
|
}
|
|
1293
1347
|
|
|
1294
|
-
function renderRow(row) {
|
|
1348
|
+
function renderRow(row, sink) {
|
|
1295
1349
|
const cells = cols.map((c) => gridCell(c, row));
|
|
1296
1350
|
const key = rowKeyOf(row);
|
|
1297
1351
|
let toggle = null;
|
|
@@ -1309,6 +1363,9 @@ function renderDataGrid(node) {
|
|
|
1309
1363
|
}
|
|
1310
1364
|
const tr = el("tr", {}, ...cells);
|
|
1311
1365
|
tbody.append(tr);
|
|
1366
|
+
// When grouping, the caller collects every <tr> this row produced (the data
|
|
1367
|
+
// row plus any detail row) so a group header can hide/show them as one unit.
|
|
1368
|
+
if (sink) sink.push({ tr, isDetail: false, key });
|
|
1312
1369
|
if (detail && toggle) {
|
|
1313
1370
|
const isOpen = key != null && expanded.has(key);
|
|
1314
1371
|
// Reuse an already-built detail row for an open PR across refreshes so its
|
|
@@ -1336,6 +1393,67 @@ function renderDataGrid(node) {
|
|
|
1336
1393
|
}
|
|
1337
1394
|
});
|
|
1338
1395
|
tbody.append(dtr);
|
|
1396
|
+
if (sink) sink.push({ tr: dtr, isDetail: true, key });
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Append the fetched rows into the tbody, either flat (default) or partitioned
|
|
1401
|
+
// into collapsible groups when the grid declares 'groupBy: "<field>"'. Each
|
|
1402
|
+
// group renders a clickable header row ("<Label> <value> (<count>)") that
|
|
1403
|
+
// collapses/expands its member rows as one unit; the collapsed state is
|
|
1404
|
+
// persisted (localStorage, keyed by page + node + group value) so it survives
|
|
1405
|
+
// both the refresh poll and a full reload — the same durability the top-level
|
|
1406
|
+
// 'collapsible' node wrapper gives. Group order is numeric-aware ascending
|
|
1407
|
+
// (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric
|
|
1408
|
+
// keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.
|
|
1409
|
+
function appendRows(rows) {
|
|
1410
|
+
const groupBy = p.groupBy;
|
|
1411
|
+
// Treat a non-string groupBy as "no grouping": labelBase calls groupBy.charAt()
|
|
1412
|
+
// below, so a truthy non-string (number/object from a malformed page schema)
|
|
1413
|
+
// would throw at render time and break the whole page instead of one grid.
|
|
1414
|
+
if (!groupBy || typeof groupBy !== "string") { for (const row of rows) renderRow(row, null); return; }
|
|
1415
|
+
const order = [];
|
|
1416
|
+
const groups = new Map();
|
|
1417
|
+
for (const row of rows) {
|
|
1418
|
+
const gvRaw = row[groupBy];
|
|
1419
|
+
const gv = gvRaw == null ? "" : String(gvRaw);
|
|
1420
|
+
if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }
|
|
1421
|
+
groups.get(gv).push(row);
|
|
1422
|
+
}
|
|
1423
|
+
order.sort((a, b) => {
|
|
1424
|
+
const na = Number(a), nb = Number(b);
|
|
1425
|
+
if (a !== "" && b !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
|
|
1426
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1427
|
+
});
|
|
1428
|
+
const labelBase = p.groupLabel != null
|
|
1429
|
+
? String(p.groupLabel)
|
|
1430
|
+
: groupBy.charAt(0).toUpperCase() + groupBy.slice(1);
|
|
1431
|
+
for (const gv of order) {
|
|
1432
|
+
const groupRows = groups.get(gv);
|
|
1433
|
+
const gkey = "pc:collapsed:" + CURRENT + ":" + (node.id || p.title || "grid") + ":g:" + gv;
|
|
1434
|
+
let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);
|
|
1435
|
+
const chevron = el("span", { class: "pc-chevron-inline" }, gcollapsed ? "▸" : "▾");
|
|
1436
|
+
const label = (gv === "" ? labelBase + " —" : labelBase + " " + gv) + " (" + groupRows.length + ")";
|
|
1437
|
+
const btn = el("button", { class: "pc-group-toggle", type: "button" },
|
|
1438
|
+
chevron, el("span", { class: "pc-group-title" }, label));
|
|
1439
|
+
tbody.append(el("tr", { class: "pc-group-header" }, el("td", { colspan: span }, btn)));
|
|
1440
|
+
const members = [];
|
|
1441
|
+
for (const row of groupRows) renderRow(row, members);
|
|
1442
|
+
const applyGroup = () => {
|
|
1443
|
+
chevron.textContent = gcollapsed ? "▸" : "▾";
|
|
1444
|
+
btn.setAttribute("aria-expanded", String(!gcollapsed));
|
|
1445
|
+
for (const m of members) {
|
|
1446
|
+
m.tr.hidden = gcollapsed
|
|
1447
|
+
? true
|
|
1448
|
+
: (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
btn.addEventListener("click", () => {
|
|
1452
|
+
gcollapsed = !gcollapsed;
|
|
1453
|
+
writeCollapsed(gkey, gcollapsed);
|
|
1454
|
+
applyGroup();
|
|
1455
|
+
});
|
|
1456
|
+
applyGroup();
|
|
1339
1457
|
}
|
|
1340
1458
|
}
|
|
1341
1459
|
|
|
@@ -1351,7 +1469,15 @@ function renderDataGrid(node) {
|
|
|
1351
1469
|
|
|
1352
1470
|
async function refresh() {
|
|
1353
1471
|
try {
|
|
1354
|
-
|
|
1472
|
+
// A grid scoped to a route param (any eqParam filter) means "show the rows
|
|
1473
|
+
// for the selected entity". With no param present, PARAM is empty and emitting
|
|
1474
|
+
// a where clause of field-colon-empty would read server-side as field equals
|
|
1475
|
+
// empty string and surface empty-valued rows. That is not the selected entity's
|
|
1476
|
+
// rows (there is no selection), so short-circuit to zero rows without a request.
|
|
1477
|
+
const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
|
|
1478
|
+
const { rows } = paramScoped && PARAM === ""
|
|
1479
|
+
? { rows: [] }
|
|
1480
|
+
: await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
1355
1481
|
// Forget expansion / cached detail nodes for rows no longer present so the maps
|
|
1356
1482
|
// don't grow without bound and a stale answer can't resurface on a key reuse.
|
|
1357
1483
|
if (p.rowKey) {
|
|
@@ -1390,7 +1516,7 @@ function renderDataGrid(node) {
|
|
|
1390
1516
|
}
|
|
1391
1517
|
}
|
|
1392
1518
|
tbody.replaceChildren();
|
|
1393
|
-
|
|
1519
|
+
appendRows(rows);
|
|
1394
1520
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|
|
1395
1521
|
if (keepFocus && active.isConnected) {
|
|
1396
1522
|
active.focus();
|
|
@@ -1457,7 +1583,14 @@ function fillNav(list, items) {
|
|
|
1457
1583
|
function renderNav(node) {
|
|
1458
1584
|
const p = node.props || {};
|
|
1459
1585
|
const rail = p.variant === "rail";
|
|
1460
|
-
|
|
1586
|
+
// A bar nav can opt into staying pinned to the top of the viewport while the
|
|
1587
|
+
// page scrolls (sticky) — useful on the long, multi-section operator pages so
|
|
1588
|
+
// the page switcher is always reachable. Rails are already full-height, so the
|
|
1589
|
+
// flag only applies to the bar variant.
|
|
1590
|
+
const barSticky = !rail && p.sticky === true;
|
|
1591
|
+
const nav = el("nav", {
|
|
1592
|
+
class: (rail ? "pc-nav pc-rail" : "pc-nav pc-bar") + (barSticky ? " pc-sticky" : ""),
|
|
1593
|
+
});
|
|
1461
1594
|
if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
|
|
1462
1595
|
const list = el("div", { class: "pc-nav-items" });
|
|
1463
1596
|
nav.append(list);
|
|
@@ -1551,7 +1684,9 @@ function makeCollapsible(node, card) {
|
|
|
1551
1684
|
// tearing down the previous page's grid polls first.
|
|
1552
1685
|
async function renderPage() {
|
|
1553
1686
|
teardown();
|
|
1554
|
-
|
|
1687
|
+
const route = parseRoute();
|
|
1688
|
+
CURRENT = route.page;
|
|
1689
|
+
PARAM = route.param;
|
|
1555
1690
|
try {
|
|
1556
1691
|
const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
|
|
1557
1692
|
document.title = (doc && doc.title) || "Urban App";
|