@nanobpm/urban 0.49.1 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/core/form-contract.d.ts +58 -0
- package/dist/runtime/core/form-contract.js +108 -0
- package/dist/runtime/core/modules/pages.js +165 -33
- package/dist/runtime/engine/nanosdk.js +22 -39
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +4 -0
- package/package.json +1 -1
- package/src/runtime/core/form-contract.test.ts +105 -0
- package/src/runtime/core/form-contract.ts +121 -0
- package/src/runtime/core/modules/pages.test.ts +85 -0
- package/src/runtime/core/modules/pages.ts +165 -33
- package/src/runtime/engine/nanosdk.test.ts +21 -4
- package/src/runtime/engine/nanosdk.ts +27 -36
- package/src/runtime/index.ts +15 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildFormSchema,
|
|
5
|
+
parseFormSchema,
|
|
6
|
+
pickFormLinkage,
|
|
7
|
+
resolveFormIdentifier,
|
|
8
|
+
} from "./form-contract.ts";
|
|
9
|
+
|
|
10
|
+
test("resolveFormIdentifier prefers a present formKey over a formId", () => {
|
|
11
|
+
assert.deepEqual(resolveFormIdentifier({ formKey: "42", formId: "myForm" }), {
|
|
12
|
+
kind: "key",
|
|
13
|
+
value: "42",
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("resolveFormIdentifier falls back to formId when formKey is absent/blank/whitespace", () => {
|
|
18
|
+
assert.deepEqual(resolveFormIdentifier({ formId: "myForm" }), { kind: "id", value: "myForm" });
|
|
19
|
+
for (const formKey of ["", " "]) {
|
|
20
|
+
assert.deepEqual(
|
|
21
|
+
resolveFormIdentifier({ formKey, formId: "myForm" }),
|
|
22
|
+
{ kind: "id", value: "myForm" },
|
|
23
|
+
`formKey=${JSON.stringify(formKey)} must fall through to formId`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("resolveFormIdentifier trims the resolved value and returns null when neither is present", () => {
|
|
29
|
+
assert.deepEqual(resolveFormIdentifier({ formKey: " 42 " }), { kind: "key", value: "42" });
|
|
30
|
+
assert.equal(resolveFormIdentifier({}), null);
|
|
31
|
+
assert.equal(resolveFormIdentifier({ formKey: " ", formId: "" }), null);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("parseFormSchema parses a JSON-object string, passes an object through, and rejects the rest", () => {
|
|
35
|
+
assert.deepEqual(parseFormSchema('{"a":1}'), { a: 1 });
|
|
36
|
+
const obj = { a: 1 };
|
|
37
|
+
assert.equal(parseFormSchema(obj), obj);
|
|
38
|
+
assert.equal(parseFormSchema("not json"), null);
|
|
39
|
+
assert.equal(parseFormSchema("[1,2]"), null, "a JSON array is not a form schema object");
|
|
40
|
+
assert.equal(parseFormSchema(42), null);
|
|
41
|
+
assert.equal(parseFormSchema(null), null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("buildFormSchema applies presence guards and stringifies a numeric formKey", () => {
|
|
45
|
+
const schema = { components: [] };
|
|
46
|
+
assert.deepEqual(buildFormSchema({ schema, formKey: 42, formId: "myForm", version: 3 }), {
|
|
47
|
+
schema,
|
|
48
|
+
formKey: "42",
|
|
49
|
+
formId: "myForm",
|
|
50
|
+
version: 3,
|
|
51
|
+
});
|
|
52
|
+
// Blank/garbage parts are omitted rather than surfaced as empty values.
|
|
53
|
+
assert.deepEqual(buildFormSchema({ schema, formKey: "", formId: "", version: "3" }), { schema });
|
|
54
|
+
assert.deepEqual(buildFormSchema({ schema }), { schema });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("buildFormSchema trims padded identifiers and drops whitespace-only ones (the #252 drift bug)", () => {
|
|
58
|
+
const schema = { components: [] };
|
|
59
|
+
// Whitespace-only key/id are absent, not surfaced as " ".
|
|
60
|
+
assert.deepEqual(buildFormSchema({ schema, formKey: " ", formId: " " }), { schema });
|
|
61
|
+
// A padded key/id is trimmed to its space-free value, not surfaced with the padding.
|
|
62
|
+
assert.deepEqual(buildFormSchema({ schema, formKey: " 42 ", formId: " myForm " }), {
|
|
63
|
+
schema,
|
|
64
|
+
formKey: "42",
|
|
65
|
+
formId: "myForm",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("pickFormLinkage prefers a direct formKey and carries an externalFormReference", () => {
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
pickFormLinkage({ formKey: 42, externalFormReference: "https://x/form" }),
|
|
72
|
+
{ formKey: "42", externalFormReference: "https://x/form" },
|
|
73
|
+
);
|
|
74
|
+
// Blank linkage fields are treated as absent.
|
|
75
|
+
assert.deepEqual(pickFormLinkage({ formKey: "", externalFormReference: "" }), {});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("pickFormLinkage trims padded linkage fields and drops whitespace-only ones", () => {
|
|
79
|
+
// Whitespace-only key/id/ref are absent, not surfaced with whitespace.
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
pickFormLinkage({ formKey: " ", externalFormReference: " " }),
|
|
82
|
+
{},
|
|
83
|
+
);
|
|
84
|
+
// A padded key/ref is trimmed; a padded authored id is trimmed before the resolver sees it.
|
|
85
|
+
assert.deepEqual(
|
|
86
|
+
pickFormLinkage({ formKey: " 7 ", externalFormReference: " https://x/form " }),
|
|
87
|
+
{ formKey: "7", externalFormReference: "https://x/form" },
|
|
88
|
+
);
|
|
89
|
+
assert.deepEqual(
|
|
90
|
+
pickFormLinkage({ formId: " myForm " }, (id) => (id === "myForm" ? "form-7" : undefined)),
|
|
91
|
+
{ formKey: "form-7" },
|
|
92
|
+
"the authored formId is trimmed before the key resolver is called",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("pickFormLinkage resolves an authored formId to a deploy key only via the callback", () => {
|
|
97
|
+
const raw = { formId: "myForm" };
|
|
98
|
+
// No resolver (the gateway path): an authored id is not turned into a key.
|
|
99
|
+
assert.deepEqual(pickFormLinkage(raw), {});
|
|
100
|
+
// With a resolver (the WASM/key-addressed path): the id maps to the latest deploy key.
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
pickFormLinkage(raw, (id) => (id === "myForm" ? "form-7" : undefined)),
|
|
103
|
+
{ formKey: "form-7" },
|
|
104
|
+
);
|
|
105
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The shared, transport-agnostic normalization glue for the form + user-task parts of
|
|
2
|
+
// the {@link EngineClient} contract. Both engine adapters — the live SDK/REST adapter
|
|
3
|
+
// (`engine/nanosdk.ts`, `SdkEngineClient`) and the in-process WASM test adapter
|
|
4
|
+
// (`@nanobpm/urban-testkit`, `WasmEngineClient`) — reproduce the same API-layer
|
|
5
|
+
// conveniences (form storage + `formId`→`formKey` resolution, form-schema parsing,
|
|
6
|
+
// user-task form linkage) on top of their own transport. Historically each adapter
|
|
7
|
+
// hand-mirrored these rules (with literal `// Mirror … exactly` comments), and that
|
|
8
|
+
// copy-paste drifted — e.g. a local `presentFormIdentifier` copy that trimmed for the
|
|
9
|
+
// emptiness check but returned the *untrimmed* value. Centralizing the rules here so
|
|
10
|
+
// both adapters call the *same* code removes that drift class (issue #252).
|
|
11
|
+
|
|
12
|
+
import { presentFormIdentifier } from "./host.ts";
|
|
13
|
+
import type { FormSchema } from "./host.ts";
|
|
14
|
+
import { isRecord } from "./guards.ts";
|
|
15
|
+
|
|
16
|
+
export { presentFormIdentifier };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Presence-check a possibly-numeric identifier under the shared trim rule. The REST body may
|
|
20
|
+
* carry a `formKey` as a number, so coerce it to a string; an empty or whitespace-only string
|
|
21
|
+
* is *absent* (via {@link presentFormIdentifier}, which also trims). Any other type is absent.
|
|
22
|
+
* This is the same trimming presence rule {@link resolveFormIdentifier} applies, so a blank
|
|
23
|
+
* key can never be surfaced as an empty/whitespace value — the drift-bug class #252 targets.
|
|
24
|
+
*/
|
|
25
|
+
function presentKey(v: unknown): string | undefined {
|
|
26
|
+
if (typeof v === "number") return presentFormIdentifier(String(v));
|
|
27
|
+
if (typeof v === "string") return presentFormIdentifier(v);
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve which identifier addresses a form for {@link EngineClient.getForm}, applying the
|
|
33
|
+
* contract's shared presence + preference rule: prefer a present `formKey`, else fall back
|
|
34
|
+
* to a present `formId`; an empty/whitespace-only value is *absent* (via
|
|
35
|
+
* {@link presentFormIdentifier}), so a blank `?formKey=` falls through to a valid `formId`
|
|
36
|
+
* instead of short-circuiting to null. Returns `null` when neither identifier is present.
|
|
37
|
+
*
|
|
38
|
+
* `kind` distinguishes a resolved deploy key (`"key"`) from an authored form id (`"id"`),
|
|
39
|
+
* which a key-addressed store (the WASM adapter) must itself resolve to a deploy key; a
|
|
40
|
+
* key/id-agnostic backend (the REST gateway) can ignore `kind` and address by `value`.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveFormIdentifier(
|
|
43
|
+
input: { formKey?: string; formId?: string },
|
|
44
|
+
): { kind: "key" | "id"; value: string } | null {
|
|
45
|
+
const key = presentFormIdentifier(input.formKey);
|
|
46
|
+
if (key != null) return { kind: "key", value: key };
|
|
47
|
+
const id = presentFormIdentifier(input.formId);
|
|
48
|
+
if (id != null) return { kind: "id", value: id };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a deployed form-js schema into the object the surface renders. The engine
|
|
54
|
+
* serializes it as a JSON string, so parse it; tolerate an already-parsed object (an
|
|
55
|
+
* in-memory/embedded engine). Returns `null` for anything that is neither a JSON object
|
|
56
|
+
* string nor an object (which the caller treats as "no such form").
|
|
57
|
+
*/
|
|
58
|
+
export function parseFormSchema(raw: unknown): Record<string, unknown> | null {
|
|
59
|
+
if (isRecord(raw)) return raw;
|
|
60
|
+
if (typeof raw !== "string") return null;
|
|
61
|
+
try {
|
|
62
|
+
const parsed: unknown = JSON.parse(raw);
|
|
63
|
+
return isRecord(parsed) ? parsed : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Assemble the {@link FormSchema} result with the contract's presence guards: a blank or
|
|
71
|
+
* whitespace-only `formKey`/`formId`, or a non-numeric `version`, is omitted rather than
|
|
72
|
+
* surfaced as an empty/garbage value. Identifiers are trimmed (the shared presence rule), so
|
|
73
|
+
* a padded `formKey` never leaks whitespace; `formKey` is stringified (the REST body may carry
|
|
74
|
+
* it as a number).
|
|
75
|
+
*/
|
|
76
|
+
export function buildFormSchema(parts: {
|
|
77
|
+
schema: Record<string, unknown>;
|
|
78
|
+
formKey?: unknown;
|
|
79
|
+
formId?: unknown;
|
|
80
|
+
version?: unknown;
|
|
81
|
+
}): FormSchema {
|
|
82
|
+
const formKey = presentKey(parts.formKey);
|
|
83
|
+
const formId = typeof parts.formId === "string" ? presentFormIdentifier(parts.formId) : undefined;
|
|
84
|
+
return {
|
|
85
|
+
schema: parts.schema,
|
|
86
|
+
...(formKey != null ? { formKey } : {}),
|
|
87
|
+
...(formId != null ? { formId } : {}),
|
|
88
|
+
...(typeof parts.version === "number" ? { version: parts.version } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Select a user task's form linkage from a raw engine record for
|
|
94
|
+
* {@link EngineClient.searchUserTasks}, applying the contract's non-empty rule (a blank
|
|
95
|
+
* `formKey`/`externalFormReference` is absent). The engine resolves a
|
|
96
|
+
* `<zeebe:formDefinition formId="X" />` linkage to the latest deployed form's key at task
|
|
97
|
+
* creation, so a resolved `formKey` (not a form id) is the linkage a task reports.
|
|
98
|
+
*
|
|
99
|
+
* A key-addressed engine that does *not* itself resolve that linkage (the WASM adapter)
|
|
100
|
+
* can pass `resolveFormKeyByFormId` to map an authored `formId` on the task to the deploy
|
|
101
|
+
* key; a backend that already resolved it (the REST gateway) omits the resolver, and the
|
|
102
|
+
* authored-id path is simply not taken. A blank or whitespace-only `formKey`/`formId`/
|
|
103
|
+
* `externalFormReference` is treated as absent (the shared trimming presence rule).
|
|
104
|
+
*/
|
|
105
|
+
export function pickFormLinkage(
|
|
106
|
+
raw: Record<string, unknown>,
|
|
107
|
+
resolveFormKeyByFormId?: (formId: string) => string | undefined,
|
|
108
|
+
): { formKey?: string; externalFormReference?: string } {
|
|
109
|
+
const directKey = presentKey(raw.formKey);
|
|
110
|
+
const authoredId = typeof raw.formId === "string" ? presentFormIdentifier(raw.formId) : undefined;
|
|
111
|
+
const formKey =
|
|
112
|
+
directKey ?? (authoredId && resolveFormKeyByFormId ? resolveFormKeyByFormId(authoredId) : undefined);
|
|
113
|
+
const externalFormReference =
|
|
114
|
+
typeof raw.externalFormReference === "string"
|
|
115
|
+
? presentFormIdentifier(raw.externalFormReference)
|
|
116
|
+
: undefined;
|
|
117
|
+
return {
|
|
118
|
+
...(formKey ? { formKey } : {}),
|
|
119
|
+
...(externalFormReference ? { externalFormReference } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -371,6 +371,91 @@ test("renderer interpolates a column's per-cell template into DOM text (#214)",
|
|
|
371
371
|
assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : rawText;/);
|
|
372
372
|
});
|
|
373
373
|
|
|
374
|
+
test("renderer renders a column's subtitle as a muted second line, never an href (#257)", async () => {
|
|
375
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
376
|
+
const js = res.body ?? "";
|
|
377
|
+
// A column may render an optional muted second line beneath the primary text
|
|
378
|
+
// (the identity key under a title, GitHub/Linear style). The value comes from
|
|
379
|
+
// col.subtitleField (raw) or col.subtitleTemplate (interpolated), null/absent →
|
|
380
|
+
// "" → no extra line. Guard the shape: subtitleTemplate wins via interpTemplate,
|
|
381
|
+
// the subtitle is emitted as a .pc-cell-sub DOM-text line, and — crucially for
|
|
382
|
+
// the security posture — it is NEVER an href source (only linkField/keyField
|
|
383
|
+
// drive anchors; the subtitle stays plain text).
|
|
384
|
+
assert.match(js, /col\.subtitleField != null && row\[col\.subtitleField\] != null \? String\(row\[col\.subtitleField\]\) : ""/);
|
|
385
|
+
assert.match(js, /const subText = typeof col\.subtitleTemplate === "string" \? interpTemplate\(col\.subtitleTemplate, row\) : subRaw;/);
|
|
386
|
+
assert.match(js, /class: "pc-cell-sub"/);
|
|
387
|
+
// Absent subtitle (empty) and no truncation collapses back to the single inline
|
|
388
|
+
// node, so existing grids render exactly as before (backward compatible).
|
|
389
|
+
assert.match(js, /if \(subText === "" && !truncate\) return el\("td", \{\}, primary\);/);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("renderer truncates a column to one line with an ellipsis + full-text tooltip (#257)", async () => {
|
|
393
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
394
|
+
const js = res.body ?? "";
|
|
395
|
+
// col.truncate:true clamps the primary (and any subtitle) line to a single row
|
|
396
|
+
// with an ellipsis, carrying the full value in a native title= tooltip and
|
|
397
|
+
// aria-label so the clipped text stays recoverable on hover and to assistive
|
|
398
|
+
// tech. Guard the shape: opt-in on `=== true`, a .pc-truncate class is added to
|
|
399
|
+
// the line, and the full text is mirrored into title/aria-label.
|
|
400
|
+
assert.match(js, /const truncate = col\.truncate === true;/);
|
|
401
|
+
assert.match(js, /"pc-cell-main"\s*\+\s*\(truncate \? " pc-truncate" : ""\)/);
|
|
402
|
+
assert.match(js, /mainAttrs\.title = primaryText; mainAttrs\["aria-label"\] = primaryText;/);
|
|
403
|
+
// title/aria-label are NOT inherited by descendants in HTML, so a link cell's
|
|
404
|
+
// <a> primary would miss the full-text tooltip/accessible name the wrapper div
|
|
405
|
+
// carries. Guard that they're also mirrored onto the primary element itself when
|
|
406
|
+
// it's a node (an anchor), so link cells get the same full-text recovery as text.
|
|
407
|
+
assert.match(js, /if \(primary && typeof primary\.setAttribute === "function"\) \{/);
|
|
408
|
+
assert.match(js, /primary\.setAttribute\("title", primaryText\); primary\.setAttribute\("aria-label", primaryText\);/);
|
|
409
|
+
assert.match(js, /"pc-cell-sub"\s*\+\s*\(truncate \? " pc-truncate" : ""\)/);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("renderer emits a <colgroup> sizing columns by width/weight (#257)", async () => {
|
|
413
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
414
|
+
const js = res.body ?? "";
|
|
415
|
+
// A column may declare an explicit `width` ("40%", "22rem") or a relative
|
|
416
|
+
// integer `weight`, so table-layout:fixed stops splitting width equally. This
|
|
417
|
+
// emits a <colgroup> of <col style="width:…"> (fixed layout honours <col>
|
|
418
|
+
// widths). Guard the shape: an explicit width string wins verbatim, a positive
|
|
419
|
+
// weight is normalised across weighted columns into a percentage of the width
|
|
420
|
+
// the explicit columns leave (the remainder), unsized columns get no width
|
|
421
|
+
// (share the remainder), and no colgroup is emitted at all when no column
|
|
422
|
+
// declares sizing (so unsized grids are unchanged).
|
|
423
|
+
assert.match(js, /function buildColgroup\(cols, extraCount\)/);
|
|
424
|
+
assert.match(js, /if \(!hasSizing\) return null;/);
|
|
425
|
+
assert.match(js, /function hasExplicitWidth\(col\)/);
|
|
426
|
+
assert.match(js, /if \(hasExplicitWidth\(col\)\) return col\.width;/);
|
|
427
|
+
assert.match(js, /Math\.round\(\(col\.weight \/ weightTotal\) \* remainderPct \* 100\) \/ 100 \+ "%"/);
|
|
428
|
+
assert.match(js, /el\("col", w \? \{ style: "width:" \+ w \} : \{\}\)/);
|
|
429
|
+
// Regression (#258 review): mixing explicit % widths with weighted columns
|
|
430
|
+
// must not oversubscribe past 100% — weighted columns share only the remainder
|
|
431
|
+
// (100 - sum of explicit %), and only when every explicit width is a
|
|
432
|
+
// percentage (rem/px explicit widths fall back to a share of the full width).
|
|
433
|
+
assert.match(js, /const remainderPct = allExplicitArePct \? Math\.max\(0, 100 - explicitPctTotal\) : 100;/);
|
|
434
|
+
// Regression (#258 review): a column carrying both an explicit width and a
|
|
435
|
+
// weight uses its width verbatim and never consumes its weight, so it must be
|
|
436
|
+
// excluded from weightTotal or the truly-weighted columns' share is shrunk.
|
|
437
|
+
assert.match(js, /if \(!hasExplicitWidth\(c\) && typeof c\.weight === "number" && c\.weight > 0\) weightTotal \+= c\.weight;/);
|
|
438
|
+
assert.match(js, /function colWidthPct\(width\)/);
|
|
439
|
+
// The colgroup precedes <thead> in the table, and only when sizing is present.
|
|
440
|
+
assert.match(js, /\? el\("table", \{ class: "pc-grid" \}, colgroup, thead, tbody\)/);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("the shell styles the subtitle line muted and the truncate clamp to one line (#257)", async () => {
|
|
444
|
+
const res = await dispatch("GET", "/");
|
|
445
|
+
const html = res.body ?? "";
|
|
446
|
+
// The muted subtitle line and the one-line clamp are styled in the shell CSS
|
|
447
|
+
// (served with the shell HTML, like the other pc-grid rules). Guard both rules.
|
|
448
|
+
const subRule = (html.match(/\.pc-cell-sub\s*\{[^}]*\}/) ?? [""])[0];
|
|
449
|
+
assert.ok(subRule, ".pc-cell-sub rule must be present");
|
|
450
|
+
assert.match(subRule, /color:var\(--nano-text-muted\)/);
|
|
451
|
+
assert.match(subRule, /font-size:\.8rem/);
|
|
452
|
+
const truncRule = (html.match(/\.pc-truncate\s*\{[^}]*\}/) ?? [""])[0];
|
|
453
|
+
assert.ok(truncRule, ".pc-truncate rule must be present");
|
|
454
|
+
assert.match(truncRule, /white-space:nowrap/);
|
|
455
|
+
assert.match(truncRule, /overflow:hidden/);
|
|
456
|
+
assert.match(truncRule, /text-overflow:ellipsis/);
|
|
457
|
+
});
|
|
458
|
+
|
|
374
459
|
test("renderer wires a column's processExplorer link to the console explorer", async () => {
|
|
375
460
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
376
461
|
const js = res.body ?? "";
|
|
@@ -549,6 +549,16 @@ table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-lay
|
|
|
549
549
|
neighbours. */
|
|
550
550
|
table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; vertical-align:top; }
|
|
551
551
|
table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
552
|
+
/* A cell's optional muted second line (col.subtitleField / subtitleTemplate):
|
|
553
|
+
the identity key rendered under the title, GitHub/Linear style. Plain DOM
|
|
554
|
+
text (never a link/href source), smaller and muted so the primary line leads. */
|
|
555
|
+
.pc-cell-sub { font-size:.8rem; color:var(--nano-text-muted); margin-top:.1rem; }
|
|
556
|
+
/* One-line clamp (col.truncate): stop a bounded column wrapping and show an
|
|
557
|
+
ellipsis, with the full value carried in a title=/aria-label tooltip. Needs a
|
|
558
|
+
bounded column (per-column width/weight) to clip against; a native <col> width
|
|
559
|
+
is honoured under table-layout:fixed. Applied to the primary and, when present,
|
|
560
|
+
the subtitle line so both stay single-line. */
|
|
561
|
+
.pc-truncate { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
552
562
|
.pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
|
|
553
563
|
.pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
|
|
554
564
|
.pc-tab.active { background:var(--nano-accent); color:var(--nano-on-accent); border-color:var(--nano-accent); }
|
|
@@ -1039,6 +1049,15 @@ function renderButton(node) {
|
|
|
1039
1049
|
// tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
|
|
1040
1050
|
// the field text), so a template does not change badge behaviour. Sorting,
|
|
1041
1051
|
// groupBy and orderBy also key off real fields, never the rendered template.
|
|
1052
|
+
//
|
|
1053
|
+
// Two-line identity + one-line clamp (additive, opt-in, backward-compatible):
|
|
1054
|
+
// a column may also declare 'subtitleField'/'subtitleTemplate' to render a muted
|
|
1055
|
+
// second line under the primary text (the identity key beneath a title, GitHub/
|
|
1056
|
+
// Linear style) and 'truncate: true' to clamp each line to one row with an
|
|
1057
|
+
// ellipsis and a full-text title/aria-label tooltip (pairs with a per-column
|
|
1058
|
+
// width/weight — see buildColgroup — to give the clip a bounded column). The
|
|
1059
|
+
// subtitle is ALWAYS plain DOM text, never an href source. With none of these
|
|
1060
|
+
// set the cell is a single inline node exactly as before (see cellTd).
|
|
1042
1061
|
function gridCell(col, row) {
|
|
1043
1062
|
// Raw single-field value: drives the badge presence gate + tooltip and any
|
|
1044
1063
|
// field-derived defaults. Kept separate from the (possibly templated) display
|
|
@@ -1068,17 +1087,28 @@ function gridCell(col, row) {
|
|
|
1068
1087
|
el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
|
|
1069
1088
|
);
|
|
1070
1089
|
}
|
|
1090
|
+
// Optional second line (col.subtitleField / subtitleTemplate): a muted key
|
|
1091
|
+
// under the primary title. subtitleTemplate wins (interpolated from the row);
|
|
1092
|
+
// else the raw subtitleField value. Absent/null → "" → no extra line. It is
|
|
1093
|
+
// ALWAYS plain DOM text (see cellTd), never a link/href source — a subtitle
|
|
1094
|
+
// cannot smuggle a scheme or path, only linkField/keyField drive hrefs.
|
|
1095
|
+
const subRaw =
|
|
1096
|
+
col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : "";
|
|
1097
|
+
const subText = typeof col.subtitleTemplate === "string" ? interpTemplate(col.subtitleTemplate, row) : subRaw;
|
|
1098
|
+
const truncate = col.truncate === true;
|
|
1099
|
+
// Primary inline content: an anchor for a valid link mode (checked in the same
|
|
1100
|
+
// badge < linkField < link(page) < link(processExplorer) precedence as before),
|
|
1101
|
+
// else the plain display text. Each link mode only claims the cell while
|
|
1102
|
+
// 'primary' is still the bare text — an invalid href falls through to the next
|
|
1103
|
+
// mode and ultimately to plain text, exactly as the early-return chain did.
|
|
1104
|
+
let primary = text;
|
|
1071
1105
|
if (col.linkField) {
|
|
1072
1106
|
const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
|
|
1073
1107
|
if (text !== "" && /^https?:\/\//i.test(href)) {
|
|
1074
|
-
|
|
1075
|
-
"td",
|
|
1076
|
-
{},
|
|
1077
|
-
el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
|
|
1078
|
-
);
|
|
1108
|
+
primary = el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text);
|
|
1079
1109
|
}
|
|
1080
1110
|
}
|
|
1081
|
-
if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
|
|
1111
|
+
if (primary === text && col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
|
|
1082
1112
|
// An in-app link to another page, scoped to this row: #/<page>/<key>. The
|
|
1083
1113
|
// key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
|
|
1084
1114
|
// param tail, which parseRoute() decodes back verbatim; the page id is
|
|
@@ -1089,37 +1119,130 @@ function gridCell(col, row) {
|
|
|
1089
1119
|
const keyStr = key == null ? "" : String(key).trim();
|
|
1090
1120
|
if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
|
|
1091
1121
|
const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
|
|
1092
|
-
|
|
1122
|
+
primary = el("a", { class: "pc-link", href }, text);
|
|
1093
1123
|
}
|
|
1094
1124
|
}
|
|
1095
|
-
if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
|
|
1125
|
+
if (primary === text && col.link && col.link.kind === "processExplorer" && col.link.keyField) {
|
|
1096
1126
|
const key = row[col.link.keyField];
|
|
1097
1127
|
const keyStr = key == null ? "" : String(key).trim();
|
|
1098
1128
|
if (text !== "" && keyStr !== "") {
|
|
1099
1129
|
const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
|
|
1100
|
-
|
|
1101
|
-
"
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1130
|
+
primary = el("a", {
|
|
1131
|
+
class: "pc-link",
|
|
1132
|
+
href,
|
|
1133
|
+
target: "_blank",
|
|
1134
|
+
rel: "noopener noreferrer",
|
|
1135
|
+
// Embedded in the console a plain primary click routes the host's
|
|
1136
|
+
// explorer in place (via the nano-navigate bridge) instead of opening
|
|
1137
|
+
// a new window. A non-primary button (middle/right = new-tab/context
|
|
1138
|
+
// intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
|
|
1139
|
+
// any standalone run fall through to the native anchor above.
|
|
1140
|
+
onclick: (ev) => {
|
|
1141
|
+
if (ev.button !== 0) return;
|
|
1142
|
+
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
|
1143
|
+
if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
|
|
1144
|
+
},
|
|
1145
|
+
}, text);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return cellTd(primary, text, subText, truncate);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Compose a grid <td> from its primary inline content (a string or an anchor),
|
|
1152
|
+
// an optional muted subtitle line, and optional one-line truncation. With
|
|
1153
|
+
// NEITHER a subtitle nor truncation the cell is exactly the single inline node
|
|
1154
|
+
// as before, so existing grids render byte-for-byte unchanged (backward
|
|
1155
|
+
// compatible). When either is set the content is wrapped in a .pc-cell-main
|
|
1156
|
+
// line and, if a subtitle is present, a .pc-cell-sub line beneath it. truncate
|
|
1157
|
+
// adds .pc-truncate (nowrap + ellipsis) to each line and mirrors the full text
|
|
1158
|
+
// into title=/aria-label so the clipped value is still recoverable on hover and
|
|
1159
|
+
// to assistive tech. When the primary is an element (a link cell's <a>), title=/
|
|
1160
|
+
// aria-label= are ALSO mirrored onto it: HTML does not inherit those to
|
|
1161
|
+
// descendants, so hovering the link text or naming the anchor for assistive tech
|
|
1162
|
+
// would otherwise miss the full value the wrapper carries. The subtitle is set as
|
|
1163
|
+
// DOM text only — never an href.
|
|
1164
|
+
function cellTd(primary, primaryText, subText, truncate) {
|
|
1165
|
+
if (subText === "" && !truncate) return el("td", {}, primary);
|
|
1166
|
+
const mainAttrs = { class: "pc-cell-main" + (truncate ? " pc-truncate" : "") };
|
|
1167
|
+
if (truncate && primaryText !== "") {
|
|
1168
|
+
mainAttrs.title = primaryText; mainAttrs["aria-label"] = primaryText;
|
|
1169
|
+
if (primary && typeof primary.setAttribute === "function") {
|
|
1170
|
+
primary.setAttribute("title", primaryText); primary.setAttribute("aria-label", primaryText);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
const main = el("div", mainAttrs, primary);
|
|
1174
|
+
if (subText === "") return el("td", {}, main);
|
|
1175
|
+
const subAttrs = { class: "pc-cell-sub" + (truncate ? " pc-truncate" : "") };
|
|
1176
|
+
if (truncate) { subAttrs.title = subText; subAttrs["aria-label"] = subText; }
|
|
1177
|
+
return el("td", {}, main, el("div", subAttrs, subText));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Parse a "<number>%" width into its numeric percentage, or null if the string
|
|
1181
|
+
// is not a bare percentage (e.g. "22rem", "120px") — used to decide whether the
|
|
1182
|
+
// remainder left for weighted columns can be computed.
|
|
1183
|
+
function colWidthPct(width) {
|
|
1184
|
+
const m = /^\s*([0-9]*\.?[0-9]+)%\s*$/.exec(width);
|
|
1185
|
+
return m ? parseFloat(m[1]) : null;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// A column carries an explicit width only when it is a non-empty string. Single
|
|
1189
|
+
// source of truth so colWidthStyle/buildColgroup can't drift on what "explicit"
|
|
1190
|
+
// means (which columns win verbatim, consume the remainder, or use their weight).
|
|
1191
|
+
function hasExplicitWidth(col) {
|
|
1192
|
+
return typeof col.width === "string" && col.width !== "";
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// A column's <col> width for the grid's <colgroup>: an explicit col.width string
|
|
1196
|
+
// ("40%", "22rem") wins verbatim; else a positive numeric col.weight is
|
|
1197
|
+
// normalised across the weighted columns into a percentage share of the width
|
|
1198
|
+
// left over by explicit columns (remainderPct); else null (no width — the column
|
|
1199
|
+
// shares the remainder equally under table-layout:fixed).
|
|
1200
|
+
function colWidthStyle(col, weightTotal, remainderPct) {
|
|
1201
|
+
if (hasExplicitWidth(col)) return col.width;
|
|
1202
|
+
if (typeof col.weight === "number" && col.weight > 0 && weightTotal > 0) {
|
|
1203
|
+
return Math.round((col.weight / weightTotal) * remainderPct * 100) / 100 + "%";
|
|
1204
|
+
}
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Build a <colgroup> that sizes the grid's columns so table-layout:fixed stops
|
|
1209
|
+
// splitting width equally. Returns null when no column declares width/weight, so
|
|
1210
|
+
// an unsized grid emits no colgroup and lays out exactly as today. 'extraCount'
|
|
1211
|
+
// adds trailing unsized <col>s for the row-actions/detail column so the sized
|
|
1212
|
+
// data columns stay aligned with their headers.
|
|
1213
|
+
function buildColgroup(cols, extraCount) {
|
|
1214
|
+
const hasSizing = cols.some(
|
|
1215
|
+
(c) => hasExplicitWidth(c) || (typeof c.weight === "number" && c.weight > 0),
|
|
1216
|
+
);
|
|
1217
|
+
if (!hasSizing) return null;
|
|
1218
|
+
// Only columns that will actually use their weight (no explicit width — an
|
|
1219
|
+
// explicit width wins verbatim in colWidthStyle and never consumes weight)
|
|
1220
|
+
// contribute to weightTotal; counting a width-bearing column's weight here
|
|
1221
|
+
// would inflate the divisor and shrink the truly-weighted columns' share.
|
|
1222
|
+
let weightTotal = 0;
|
|
1223
|
+
for (const c of cols)
|
|
1224
|
+
if (!hasExplicitWidth(c) && typeof c.weight === "number" && c.weight > 0) weightTotal += c.weight;
|
|
1225
|
+
// Weighted columns share only the width the explicit columns leave behind, so
|
|
1226
|
+
// mixing e.g. width:"40%" with weighted columns can't oversubscribe past 100%.
|
|
1227
|
+
// The remainder is only computable when every explicit width is a percentage;
|
|
1228
|
+
// if any uses an absolute/relative unit (rem/px) we can't subtract it from
|
|
1229
|
+
// 100%, so weighted columns fall back to a share of the full width.
|
|
1230
|
+
let explicitPctTotal = 0;
|
|
1231
|
+
let allExplicitArePct = true;
|
|
1232
|
+
for (const c of cols) {
|
|
1233
|
+
if (hasExplicitWidth(c)) {
|
|
1234
|
+
const pct = colWidthPct(c.width);
|
|
1235
|
+
if (pct === null) allExplicitArePct = false;
|
|
1236
|
+
else explicitPctTotal += pct;
|
|
1120
1237
|
}
|
|
1121
1238
|
}
|
|
1122
|
-
|
|
1239
|
+
const remainderPct = allExplicitArePct ? Math.max(0, 100 - explicitPctTotal) : 100;
|
|
1240
|
+
const colEls = cols.map((c) => {
|
|
1241
|
+
const w = colWidthStyle(c, weightTotal, remainderPct);
|
|
1242
|
+
return el("col", w ? { style: "width:" + w } : {});
|
|
1243
|
+
});
|
|
1244
|
+
for (let i = 0; i < extraCount; i++) colEls.push(el("col", {}));
|
|
1245
|
+
return el("colgroup", {}, ...colEls);
|
|
1123
1246
|
}
|
|
1124
1247
|
|
|
1125
1248
|
function renderActionForm(node) {
|
|
@@ -1315,7 +1438,13 @@ function renderDataGrid(node) {
|
|
|
1315
1438
|
if (hasExtra) headCells.push(el("th", {}, ""));
|
|
1316
1439
|
const thead = el("thead", {}, el("tr", {}, ...headCells));
|
|
1317
1440
|
const tbody = el("tbody", {});
|
|
1318
|
-
|
|
1441
|
+
// A <colgroup> (present only when a column declares width/weight) sizes the
|
|
1442
|
+
// columns so table-layout:fixed stops splitting width equally; it must precede
|
|
1443
|
+
// <thead> in the table.
|
|
1444
|
+
const colgroup = buildColgroup(cols, hasExtra ? 1 : 0);
|
|
1445
|
+
const table = colgroup
|
|
1446
|
+
? el("table", { class: "pc-grid" }, colgroup, thead, tbody)
|
|
1447
|
+
: el("table", { class: "pc-grid" }, thead, tbody);
|
|
1319
1448
|
card.append(table);
|
|
1320
1449
|
const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
|
|
1321
1450
|
|
|
@@ -1407,9 +1536,12 @@ function renderDataGrid(node) {
|
|
|
1407
1536
|
if (cg.title) wrap.append(el("div", { class: "pc-child-title" }, cg.title));
|
|
1408
1537
|
const ccols = cg.columns || [];
|
|
1409
1538
|
const cbody = el("tbody", {});
|
|
1410
|
-
const
|
|
1411
|
-
|
|
1412
|
-
|
|
1539
|
+
const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
|
|
1540
|
+
const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
|
|
1541
|
+
...(cg.lazyField ? [el("th", {}, "")] : [])));
|
|
1542
|
+
const ctable = ccolgroup
|
|
1543
|
+
? el("table", { class: "pc-grid" }, ccolgroup, cthead, cbody)
|
|
1544
|
+
: el("table", { class: "pc-grid" }, cthead, cbody);
|
|
1413
1545
|
wrap.append(ctable);
|
|
1414
1546
|
try {
|
|
1415
1547
|
const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,
|
|
@@ -194,10 +194,11 @@ test("searchUserTasks passes zero-wait consistency and maps items", async () =>
|
|
|
194
194
|
const engine = new SdkEngineClient(client);
|
|
195
195
|
const tasks = await engine.searchUserTasks({ processInstanceKey: "pi" });
|
|
196
196
|
assert.deepEqual(consistency, { consistency: { waitUpToMs: 0 } });
|
|
197
|
-
// The
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
197
|
+
// The adapter forwards the caller's filter verbatim and no longer injects a lifecycle
|
|
198
|
+
// state (#252): the search is unfiltered by state unless the caller opts in, so the
|
|
199
|
+
// taskInbox surface — not the adapter — owns the open-only (CREATED) policy. Assert
|
|
200
|
+
// individual fields so the check is independent of key insertion order.
|
|
201
|
+
assert.equal(input?.filter?.state, undefined, "no state is injected when the caller passes none");
|
|
201
202
|
assert.equal(input?.filter?.processInstanceKey, "pi");
|
|
202
203
|
assert.deepEqual(tasks, [
|
|
203
204
|
{ userTaskKey: "7", elementId: "task_a", variables: { x: 1 } },
|
|
@@ -205,6 +206,22 @@ test("searchUserTasks passes zero-wait consistency and maps items", async () =>
|
|
|
205
206
|
]);
|
|
206
207
|
});
|
|
207
208
|
|
|
209
|
+
test("searchUserTasks forwards a caller-provided lifecycle state filter", async () => {
|
|
210
|
+
let input: { filter?: Record<string, unknown> } | undefined;
|
|
211
|
+
const client = fakeSdkClient({
|
|
212
|
+
searchUserTasks: async (i) => {
|
|
213
|
+
input = i;
|
|
214
|
+
return { items: [] };
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
const engine = new SdkEngineClient(client);
|
|
218
|
+
await engine.searchUserTasks({ state: "CREATED", processInstanceKey: "pi" });
|
|
219
|
+
// The caller's `state` reaches the engine unchanged — the adapter neither drops nor
|
|
220
|
+
// overrides it (the taskInbox surface passes `state: "CREATED"` to constrain the inbox).
|
|
221
|
+
assert.equal(input?.filter?.state, "CREATED");
|
|
222
|
+
assert.equal(input?.filter?.processInstanceKey, "pi");
|
|
223
|
+
});
|
|
224
|
+
|
|
208
225
|
test("searchUserTasks surfaces the resolved form linkage", async () => {
|
|
209
226
|
const client = fakeSdkClient({
|
|
210
227
|
searchUserTasks: async () => ({
|