@nanobpm/urban 0.66.2 → 0.68.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/context/pii/classifier.d.ts +55 -0
- package/dist/context/pii/classifier.js +250 -0
- package/dist/context/pii/guard.d.ts +63 -0
- package/dist/context/pii/guard.js +94 -0
- package/dist/context/pii/index.d.ts +2 -1
- package/dist/context/pii/index.js +24 -1
- package/dist/runtime/core/modules/pages.js +7 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/package.json +1 -1
- package/src/context/pii/NOTES.md +34 -0
- package/src/context/pii/classifier.test.ts +183 -0
- package/src/context/pii/classifier.ts +321 -0
- package/src/context/pii/guard.test.ts +124 -0
- package/src/context/pii/guard.ts +144 -0
- package/src/context/pii/index.ts +39 -2
- package/src/runtime/browser/runtime.browser.js +248 -10
- package/src/runtime/browser/runtime.detailform.test.ts +177 -1
- package/src/runtime/core/modules/pages.ts +7 -0
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Slice S6 CORE (pii guard-core) — the MANDATORY pre-commit PII guard.
|
|
2
|
+
//
|
|
3
|
+
// This is the enforcement seam S3 (git substrate / governance) consumes. It
|
|
4
|
+
// wraps the pure classifier (`./classifier.ts`) in a default-DENY policy: a
|
|
5
|
+
// candidate carrying PII is REJECTED (the guard throws); clean content passes
|
|
6
|
+
// through untouched.
|
|
7
|
+
//
|
|
8
|
+
// ┌────────────────────────────────────────────────────────────────────────┐
|
|
9
|
+
// │ CONTRACT — read before wiring: │
|
|
10
|
+
// │ guard.assert(candidate) → returns void when clean, THROWS │
|
|
11
|
+
// │ PiiGuardError when PII is detected. │
|
|
12
|
+
// │ guard.inspect(candidate) → non-throwing; returns the classification. │
|
|
13
|
+
// │ │
|
|
14
|
+
// │ S3 MUST register `preCommitPiiGuard` (or a `createPiiGuard()` instance) │
|
|
15
|
+
// │ as the NON-OPTIONAL, default pre-commit step on EVERY write path, so │
|
|
16
|
+
// │ PII is blocked BY CONSTRUCTION — not merely when a caller opts in. The │
|
|
17
|
+
// │ later S6 CI slice adds a build-time classification workflow + │
|
|
18
|
+
// │ erasure/immutability docs on TOP of this in-process guard; it must not │
|
|
19
|
+
// │ weaken or bypass it. │
|
|
20
|
+
// └────────────────────────────────────────────────────────────────────────┘
|
|
21
|
+
//
|
|
22
|
+
// This slice owns the classifier + guard ONLY. It has no git / network access
|
|
23
|
+
// and does NOT import `../git` (S3 imports us — the dependency points one way to
|
|
24
|
+
// keep the graph acyclic).
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
classifyPii,
|
|
28
|
+
type PiiCandidate,
|
|
29
|
+
type PiiClassification,
|
|
30
|
+
type PiiFinding,
|
|
31
|
+
} from "./classifier.ts";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Thrown by {@link PiiGuard.assert} (and the default guard) when a candidate
|
|
35
|
+
* carries PII. Carries the located {@link PiiFinding}s so callers can log /
|
|
36
|
+
* surface exactly what was rejected without re-running the classifier.
|
|
37
|
+
*/
|
|
38
|
+
export class PiiGuardError extends Error {
|
|
39
|
+
/** The located detections that caused the rejection (never empty). */
|
|
40
|
+
readonly findings: readonly PiiFinding[];
|
|
41
|
+
|
|
42
|
+
constructor(findings: readonly PiiFinding[]) {
|
|
43
|
+
const summary = findings
|
|
44
|
+
.map((f) => `${f.kind}${f.path ? ` @ ${f.path}` : ""}`)
|
|
45
|
+
.join(", ");
|
|
46
|
+
super(`PII pre-commit guard rejected the write: ${summary}`);
|
|
47
|
+
this.name = "PiiGuardError";
|
|
48
|
+
// Defensively snapshot: a caller mutating the array it passed in must never
|
|
49
|
+
// retroactively change what this error reports it rejected.
|
|
50
|
+
this.findings = Object.freeze([...findings]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A pre-commit guard. The published seam S3 registers as its default,
|
|
56
|
+
* non-bypassable write-path check.
|
|
57
|
+
*
|
|
58
|
+
* Implementations MUST be default-DENY: {@link assert} throws on any detected
|
|
59
|
+
* PII and returns normally only for clean content.
|
|
60
|
+
*/
|
|
61
|
+
export interface PiiGuard {
|
|
62
|
+
/** Stable identifier (useful when a registry holds several guards). */
|
|
63
|
+
readonly name: string;
|
|
64
|
+
/**
|
|
65
|
+
* Non-throwing classification — returns the decision so a caller can inspect
|
|
66
|
+
* findings without catching. Does not enforce policy on its own.
|
|
67
|
+
*/
|
|
68
|
+
inspect(candidate: PiiCandidate): PiiClassification;
|
|
69
|
+
/**
|
|
70
|
+
* Default-DENY enforcement. Returns `void` when the candidate is clean;
|
|
71
|
+
* THROWS {@link PiiGuardError} (carrying the findings) when PII is detected.
|
|
72
|
+
* This is the method a write path calls immediately before committing.
|
|
73
|
+
*/
|
|
74
|
+
assert(candidate: PiiCandidate): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Options for {@link createPiiGuard}. Additive and backward-compatible; the
|
|
79
|
+
* default (no options) is the mandatory, no-PII-by-construction guard.
|
|
80
|
+
*/
|
|
81
|
+
export interface PiiGuardOptions {
|
|
82
|
+
/** Override the guard's `name` (defaults to `"pre-commit-pii-guard"`). */
|
|
83
|
+
readonly name?: string;
|
|
84
|
+
/**
|
|
85
|
+
* An ADDITIONAL classifier, unioned with the built-in {@link classifyPii}.
|
|
86
|
+
* The built-in classifier ALWAYS runs; this override's findings are merged on
|
|
87
|
+
* top, so an override can only make the guard STRICTER — it can never suppress
|
|
88
|
+
* a built-in detection or otherwise disable enforcement (a guard is always
|
|
89
|
+
* default-DENY). Intended for tests and for a future stricter policy.
|
|
90
|
+
*/
|
|
91
|
+
readonly classify?: (candidate: PiiCandidate) => PiiClassification;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a default-DENY PII guard. With no options this returns the same policy
|
|
96
|
+
* as {@link preCommitPiiGuard}: it classifies with the built-in classifier and
|
|
97
|
+
* rejects any candidate that carries PII. A caller-supplied `classify` runs IN
|
|
98
|
+
* ADDITION to the built-in classifier (findings are unioned), so it can only
|
|
99
|
+
* ever make the guard stricter — never weaker.
|
|
100
|
+
*/
|
|
101
|
+
export function createPiiGuard(options: PiiGuardOptions = {}): PiiGuard {
|
|
102
|
+
const name = options.name ?? "pre-commit-pii-guard";
|
|
103
|
+
const override = options.classify;
|
|
104
|
+
// The built-in classifier ALWAYS runs; an override can only ADD findings.
|
|
105
|
+
// This makes the `classify` option unable to disable the default-DENY guard.
|
|
106
|
+
const classify = (candidate: PiiCandidate): PiiClassification => {
|
|
107
|
+
const base = classifyPii(candidate);
|
|
108
|
+
if (override === undefined) return base;
|
|
109
|
+
const extra = override(candidate);
|
|
110
|
+
const findings = [...base.findings, ...extra.findings];
|
|
111
|
+
// Derive the verdict from the merged findings — the single source of truth.
|
|
112
|
+
// A guard is default-DENY, so any `!clean` classification MUST carry the
|
|
113
|
+
// findings that justify it; a non-clean result with no findings would make
|
|
114
|
+
// `assert` throw a PiiGuardError whose summary is empty, contradicting the
|
|
115
|
+
// "findings are never empty" contract. Surface that override bug loudly
|
|
116
|
+
// rather than as a confusing, empty rejection.
|
|
117
|
+
if (findings.length === 0 && (!base.clean || !extra.clean)) {
|
|
118
|
+
throw new TypeError(
|
|
119
|
+
"PII classifier override returned a non-clean classification with no findings; " +
|
|
120
|
+
"an override may only ADD located findings, never report PII without locating it.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return findings.length === 0
|
|
124
|
+
? { clean: true, findings: [] }
|
|
125
|
+
: { clean: false, findings };
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
name,
|
|
129
|
+
inspect(candidate) {
|
|
130
|
+
return classify(candidate);
|
|
131
|
+
},
|
|
132
|
+
assert(candidate) {
|
|
133
|
+
const result = classify(candidate);
|
|
134
|
+
if (!result.clean) throw new PiiGuardError(result.findings);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The ready-to-use default guard. S3 registers THIS instance as the
|
|
141
|
+
* non-optional, default pre-commit step on every write path so that PII is
|
|
142
|
+
* blocked by construction. Do not gate it behind a caller opt-in.
|
|
143
|
+
*/
|
|
144
|
+
export const preCommitPiiGuard: PiiGuard = createPiiGuard();
|
package/src/context/pii/index.ts
CHANGED
|
@@ -1,2 +1,39 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
1
|
+
// @nanobpm/urban/context/pii — slice S6 CORE (pii guard-core).
|
|
2
|
+
//
|
|
3
|
+
// The enforcement seam of the Urban context layer. It publishes two things:
|
|
4
|
+
//
|
|
5
|
+
// 1. A PURE PII classifier (`classifyPii`) — inspects candidate record content
|
|
6
|
+
// and returns a located clean/violation decision. Reusable on any hot path
|
|
7
|
+
// and by the S6 CI slice's build-time scan.
|
|
8
|
+
// 2. A MANDATORY, default-DENY pre-commit guard (`preCommitPiiGuard` /
|
|
9
|
+
// `createPiiGuard`) built on the classifier. S3 (git substrate / governance)
|
|
10
|
+
// registers this as the NON-OPTIONAL default pre-commit step on every write
|
|
11
|
+
// path, so PII is blocked BY CONSTRUCTION — not merely when a caller opts in.
|
|
12
|
+
//
|
|
13
|
+
// Contract summary (see ./guard.ts and ./NOTES.md for detail):
|
|
14
|
+
// guard.assert(candidate) → void when clean, THROWS PiiGuardError on PII.
|
|
15
|
+
// guard.inspect(candidate) → non-throwing PiiClassification.
|
|
16
|
+
//
|
|
17
|
+
// The MVP substrate is no-PII by construction; this slice builds neither a
|
|
18
|
+
// mutable/erasure backend nor the CI workflow (that is the later S6 CI slice,
|
|
19
|
+
// which layers a build-time net + erasure/immutability docs on top of this).
|
|
20
|
+
//
|
|
21
|
+
// This slice imports the record types from `../schema` but NOT `../git`: S3
|
|
22
|
+
// imports us, so the dependency points one way and the graph stays acyclic.
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
type PiiKind,
|
|
26
|
+
type PiiFinding,
|
|
27
|
+
type PiiClassification,
|
|
28
|
+
type PiiCandidate,
|
|
29
|
+
classifyPii,
|
|
30
|
+
isPiiClean,
|
|
31
|
+
} from "./classifier.ts";
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
type PiiGuard,
|
|
35
|
+
type PiiGuardOptions,
|
|
36
|
+
PiiGuardError,
|
|
37
|
+
createPiiGuard,
|
|
38
|
+
preCommitPiiGuard,
|
|
39
|
+
} from "./guard.ts";
|
|
@@ -1550,9 +1550,17 @@ function renderProse(node) {
|
|
|
1550
1550
|
// Build a detail/escalation answer form for a single grid row. This is the ONE
|
|
1551
1551
|
// implementation of the `detail.form` contract — shared verbatim by the
|
|
1552
1552
|
// top-level grid's detail panel AND a dataGrid child-grid row (#333) so the two
|
|
1553
|
-
// can never drift. The form
|
|
1554
|
-
//
|
|
1555
|
-
// (f.
|
|
1553
|
+
// can never drift. The form is SCHEMA-DERIVED (#372): its fields come from the
|
|
1554
|
+
// element's `.form` contract, not hand-rolled markup. A form may declare either a
|
|
1555
|
+
// single free-text field (legacy: f.inputKey, rendered exactly as before) or an
|
|
1556
|
+
// array of field descriptors (f.fields) — each a `select` (enumerated
|
|
1557
|
+
// value/label options), `textarea`, or `text` widget bound to its own
|
|
1558
|
+
// f.inputKey, optionally gated by a per-field `conditional.hide` FEEL expression
|
|
1559
|
+
// measured against the form's other field values. Every visible field is
|
|
1560
|
+
// assembled into ONE `{{form}}` body ({ inputKey: value, … }); a hidden field is
|
|
1561
|
+
// omitted. There is an optional prompt echoed from the row (f.promptField), and a
|
|
1562
|
+
// route-driven submit (f.action) whose path/body interpolate {{form.*}} /
|
|
1563
|
+
// {{row.*}} tokens. It
|
|
1556
1564
|
// renders only when the row's f.showWhenField is truthy (an answerable
|
|
1557
1565
|
// escalation), returning null otherwise. On a successful submit it invokes
|
|
1558
1566
|
// onSuccess — the top-level grid re-polls the whole page (pc:refresh) so the
|
|
@@ -1580,6 +1588,181 @@ function setChevronOpen(btn, open) {
|
|
|
1580
1588
|
btn.setAttribute("aria-expanded", String(open));
|
|
1581
1589
|
}
|
|
1582
1590
|
|
|
1591
|
+
// Normalise a `detail.form` descriptor to a list of field descriptors so the
|
|
1592
|
+
// legacy single-field form (f.inputKey + free-text textarea) and the
|
|
1593
|
+
// schema-derived multi-field form (f.fields) share ONE render/submit path (no
|
|
1594
|
+
// drift). A descriptor carrying f.fields is used verbatim; otherwise the whole
|
|
1595
|
+
// form collapses to a single textarea field bound to f.inputKey — which renders
|
|
1596
|
+
// byte-for-byte as the pre-#372 form.
|
|
1597
|
+
/** @param {any} f
|
|
1598
|
+
* @returns {any[]}
|
|
1599
|
+
*/
|
|
1600
|
+
function normalizeDetailFields(f) {
|
|
1601
|
+
if (Array.isArray(f.fields) && f.fields.length > 0) return f.fields;
|
|
1602
|
+
return [{ inputKey: f.inputKey, type: "textarea", inputLabel: f.inputLabel }];
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// Normalise a select field's enumerated values (from the `.form` contract) to
|
|
1606
|
+
// {value,label} pairs. Accepts either objects ({ value, label } — label
|
|
1607
|
+
// defaults to the value) or bare scalars (value === label), so an adopter can
|
|
1608
|
+
// author the terse or the labelled form without a bespoke shape.
|
|
1609
|
+
/** @param {any} options
|
|
1610
|
+
* @returns {Array<{value: string, label: string}>}
|
|
1611
|
+
*/
|
|
1612
|
+
function normalizeDetailOptions(options) {
|
|
1613
|
+
/** @type {Array<{value: string, label: string}>} */
|
|
1614
|
+
const out = [];
|
|
1615
|
+
for (const opt of Array.isArray(options) ? options : []) {
|
|
1616
|
+
if (opt && typeof opt === "object") {
|
|
1617
|
+
const value = opt.value != null ? String(opt.value) : "";
|
|
1618
|
+
const label = opt.label != null ? String(opt.label) : value;
|
|
1619
|
+
out.push({ value, label });
|
|
1620
|
+
} else {
|
|
1621
|
+
out.push({ value: String(opt), label: String(opt) });
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
return out;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// ── conditional.hide evaluation (a tiny, SAFE FEEL subset) ──────────────────
|
|
1628
|
+
// A `.form` field can carry `conditional.hide` — a FEEL boolean (optionally with
|
|
1629
|
+
// the FEEL `=` prefix, e.g. `=resolution != "answer"`) that, when true, hides
|
|
1630
|
+
// the field from the operator AND drops it from the submitted `{{form}}` body.
|
|
1631
|
+
// We evaluate it against the form's CURRENT field values with a purpose-built
|
|
1632
|
+
// recursive-descent parser — never `eval`/`new Function` — so a schema-supplied
|
|
1633
|
+
// string can't execute arbitrary code. The grammar is deliberately small:
|
|
1634
|
+
// or := and ( "or" and )*
|
|
1635
|
+
// and := cmp ( "and" cmp )*
|
|
1636
|
+
// cmp := primary ( op primary )? op ∈ = == != < <= > >=
|
|
1637
|
+
// primary := ident | string | number | true | false | null | "(" or ")"
|
|
1638
|
+
// An identifier resolves to the same-form field's current value. Anything the
|
|
1639
|
+
// parser can't understand fails OPEN (returns false → the field stays visible),
|
|
1640
|
+
// because erroneously HIDING a field could silently drop a required input.
|
|
1641
|
+
/** @param {string} src
|
|
1642
|
+
* @returns {any[]}
|
|
1643
|
+
*/
|
|
1644
|
+
function tokenizeDetailCondition(src) {
|
|
1645
|
+
/** @type {any[]} */
|
|
1646
|
+
const toks = [];
|
|
1647
|
+
const s = String(src);
|
|
1648
|
+
let i = 0;
|
|
1649
|
+
while (i < s.length) {
|
|
1650
|
+
const ch = s[i];
|
|
1651
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { i++; continue; }
|
|
1652
|
+
if (ch === '"' || ch === "'") {
|
|
1653
|
+
const quote = ch; let j = i + 1; let str = ""; let closed = false;
|
|
1654
|
+
while (j < s.length) {
|
|
1655
|
+
if (s[j] === quote) { closed = true; break; }
|
|
1656
|
+
if (s[j] === "\\" && j + 1 < s.length) { str += s[j + 1]; j += 2; } else { str += s[j]; j++; }
|
|
1657
|
+
}
|
|
1658
|
+
// An unterminated string literal can't be part of the grammar; abandon
|
|
1659
|
+
// parsing so the whole expression fails open (field stays visible) rather
|
|
1660
|
+
// than treating a truncated FEEL string as a valid token.
|
|
1661
|
+
if (!closed) throw new Error("unterminated string in condition");
|
|
1662
|
+
toks.push({ t: "val", v: str }); i = j + 1; continue;
|
|
1663
|
+
}
|
|
1664
|
+
if (ch === "(") { toks.push({ t: "(" }); i++; continue; }
|
|
1665
|
+
if (ch === ")") { toks.push({ t: ")" }); i++; continue; }
|
|
1666
|
+
if (ch === "!" && s[i + 1] === "=") { toks.push({ t: "op", v: "!=" }); i += 2; continue; }
|
|
1667
|
+
if (ch === "=" && s[i + 1] === "=") { toks.push({ t: "op", v: "=" }); i += 2; continue; }
|
|
1668
|
+
if (ch === "<" && s[i + 1] === "=") { toks.push({ t: "op", v: "<=" }); i += 2; continue; }
|
|
1669
|
+
if (ch === ">" && s[i + 1] === "=") { toks.push({ t: "op", v: ">=" }); i += 2; continue; }
|
|
1670
|
+
if (ch === "=") { toks.push({ t: "op", v: "=" }); i++; continue; }
|
|
1671
|
+
if (ch === "<") { toks.push({ t: "op", v: "<" }); i++; continue; }
|
|
1672
|
+
if (ch === ">") { toks.push({ t: "op", v: ">" }); i++; continue; }
|
|
1673
|
+
if (/[0-9]/.test(ch) || (ch === "-" && /[0-9]/.test(s[i + 1] || ""))) {
|
|
1674
|
+
let j = i + 1;
|
|
1675
|
+
while (j < s.length && /[0-9.]/.test(s[j])) j++;
|
|
1676
|
+
toks.push({ t: "val", v: Number(s.slice(i, j)) }); i = j; continue;
|
|
1677
|
+
}
|
|
1678
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
1679
|
+
let j = i + 1;
|
|
1680
|
+
while (j < s.length && /[A-Za-z0-9_.]/.test(s[j])) j++;
|
|
1681
|
+
const word = s.slice(i, j);
|
|
1682
|
+
const lower = word.toLowerCase();
|
|
1683
|
+
if (lower === "and" || lower === "or") toks.push({ t: lower });
|
|
1684
|
+
else if (lower === "true") toks.push({ t: "val", v: true });
|
|
1685
|
+
else if (lower === "false") toks.push({ t: "val", v: false });
|
|
1686
|
+
else if (lower === "null") toks.push({ t: "val", v: null });
|
|
1687
|
+
else toks.push({ t: "id", v: word });
|
|
1688
|
+
i = j; continue;
|
|
1689
|
+
}
|
|
1690
|
+
// An unrecognised character can't be part of the grammar; abandon parsing so
|
|
1691
|
+
// the whole expression fails open (field stays visible) rather than silently
|
|
1692
|
+
// mis-parsing.
|
|
1693
|
+
throw new Error("unexpected character in condition");
|
|
1694
|
+
}
|
|
1695
|
+
return toks;
|
|
1696
|
+
}
|
|
1697
|
+
/** @param {any} a @param {any} b @returns {boolean} */
|
|
1698
|
+
function detailLooseEq(a, b) {
|
|
1699
|
+
if (a === b) return true;
|
|
1700
|
+
if (a == null || b == null) return a == null && b == null;
|
|
1701
|
+
return String(a) === String(b);
|
|
1702
|
+
}
|
|
1703
|
+
/** @param {string} op @param {any} a @param {any} b @returns {boolean} */
|
|
1704
|
+
function detailCompare(op, a, b) {
|
|
1705
|
+
if (op === "=") return detailLooseEq(a, b);
|
|
1706
|
+
if (op === "!=") return !detailLooseEq(a, b);
|
|
1707
|
+
const na = Number(a); const nb = Number(b);
|
|
1708
|
+
if (!Number.isFinite(na) || !Number.isFinite(nb)) return false;
|
|
1709
|
+
if (op === "<") return na < nb;
|
|
1710
|
+
if (op === "<=") return na <= nb;
|
|
1711
|
+
if (op === ">") return na > nb;
|
|
1712
|
+
if (op === ">=") return na >= nb;
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1715
|
+
// A bare (op-less) primary used where a boolean is expected — a FEEL flag like
|
|
1716
|
+
// `=isUrgent`. Only an explicit boolean true / the string "true" reads as true.
|
|
1717
|
+
/** @param {any} v @returns {boolean} */
|
|
1718
|
+
function detailTruthy(v) { return v === true || v === "true"; }
|
|
1719
|
+
/** @param {any} f
|
|
1720
|
+
* @param {Record<string, any>} values
|
|
1721
|
+
* @returns {boolean}
|
|
1722
|
+
*/
|
|
1723
|
+
function evalDetailCondition(f, values) {
|
|
1724
|
+
/** @param {any} p @returns {boolean} */
|
|
1725
|
+
const parseOr = (p) => {
|
|
1726
|
+
let left = parseAnd(p);
|
|
1727
|
+
while (p.i < p.toks.length && p.toks[p.i].t === "or") { p.i++; const right = parseAnd(p); left = left || right; }
|
|
1728
|
+
return left;
|
|
1729
|
+
};
|
|
1730
|
+
/** @param {any} p @returns {boolean} */
|
|
1731
|
+
function parseAnd(p) {
|
|
1732
|
+
let left = parseCmp(p);
|
|
1733
|
+
while (p.i < p.toks.length && p.toks[p.i].t === "and") { p.i++; const right = parseCmp(p); left = left && right; }
|
|
1734
|
+
return left;
|
|
1735
|
+
}
|
|
1736
|
+
/** @param {any} p @returns {boolean} */
|
|
1737
|
+
function parseCmp(p) {
|
|
1738
|
+
const left = parsePrimary(p);
|
|
1739
|
+
const tok = p.toks[p.i];
|
|
1740
|
+
if (tok && tok.t === "op") { p.i++; const right = parsePrimary(p); return detailCompare(tok.v, left, right); }
|
|
1741
|
+
return detailTruthy(left);
|
|
1742
|
+
}
|
|
1743
|
+
/** @param {any} p @returns {any} */
|
|
1744
|
+
function parsePrimary(p) {
|
|
1745
|
+
const tok = p.toks[p.i];
|
|
1746
|
+
if (!tok) throw new Error("unexpected end of condition");
|
|
1747
|
+
if (tok.t === "(") { p.i++; const r = parseOr(p); if (!p.toks[p.i] || p.toks[p.i].t !== ")") throw new Error("missing )"); p.i++; return r; }
|
|
1748
|
+
if (tok.t === "id") { p.i++; return values[tok.v]; }
|
|
1749
|
+
if (tok.t === "val") { p.i++; return tok.v; }
|
|
1750
|
+
throw new Error("unexpected token in condition");
|
|
1751
|
+
}
|
|
1752
|
+
try {
|
|
1753
|
+
let src = String(f == null ? "" : f).trim();
|
|
1754
|
+
if (src === "") return false;
|
|
1755
|
+
if (src.startsWith("=")) src = src.slice(1);
|
|
1756
|
+
const p = { toks: tokenizeDetailCondition(src), i: 0 };
|
|
1757
|
+
if (p.toks.length === 0) return false;
|
|
1758
|
+
const result = parseOr(p);
|
|
1759
|
+
if (p.i !== p.toks.length) return false;
|
|
1760
|
+
return result === true;
|
|
1761
|
+
} catch (e) {
|
|
1762
|
+
return false;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1583
1766
|
/** @param {any} f
|
|
1584
1767
|
* @param {Record<string, any>} row
|
|
1585
1768
|
* @param {() => void | Promise<void>} onSuccess
|
|
@@ -1592,19 +1775,74 @@ function buildDetailForm(f, row, onSuccess) {
|
|
|
1592
1775
|
if (f.promptField && row[f.promptField] != null) {
|
|
1593
1776
|
box.append(el("div", { class: "pc-prompt" }, String(row[f.promptField])));
|
|
1594
1777
|
}
|
|
1595
|
-
|
|
1778
|
+
// Multi-field only when the descriptor supplies f.fields; a legacy single-field
|
|
1779
|
+
// form renders exactly as before (one bare textarea, no visible <label>).
|
|
1780
|
+
const multi = Array.isArray(f.fields) && f.fields.length > 0;
|
|
1781
|
+
const fields = normalizeDetailFields(f);
|
|
1596
1782
|
// role=status + aria-live so the "Sending…"/success/error transitions are
|
|
1597
1783
|
// announced to screen readers instead of silently changing text.
|
|
1598
1784
|
const msg = el("p", { class: "pc-msg", role: "status", "aria-live": "polite" });
|
|
1599
1785
|
const btn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, f.submitLabel || "Submit");
|
|
1786
|
+
/** @type {Array<{key: string, input: any, wrap: any, hideExpr: any}>} */
|
|
1787
|
+
const controls = [];
|
|
1788
|
+
for (const fd of fields) {
|
|
1789
|
+
const key = fd.inputKey != null ? fd.inputKey : fd.key;
|
|
1790
|
+
const label = fd.inputLabel || fd.label || key;
|
|
1791
|
+
const type = fd.type === "select" ? "select" : fd.type === "text" ? "text" : "textarea";
|
|
1792
|
+
let input;
|
|
1793
|
+
if (type === "select") {
|
|
1794
|
+
input = el("select", { class: "pc-select", "aria-label": label });
|
|
1795
|
+
for (const opt of normalizeDetailOptions(fd.options)) input.append(el("option", { value: opt.value }, opt.label));
|
|
1796
|
+
if (fd.default != null) input.value = String(fd.default);
|
|
1797
|
+
} else if (type === "text") {
|
|
1798
|
+
input = el("input", { type: "text", class: "pc-input", placeholder: label, "aria-label": label });
|
|
1799
|
+
if (fd.default != null) input.value = String(fd.default);
|
|
1800
|
+
} else {
|
|
1801
|
+
input = el("textarea", { class: "pc-textarea", placeholder: label, "aria-label": label });
|
|
1802
|
+
if (fd.default != null) input.value = String(fd.default);
|
|
1803
|
+
}
|
|
1804
|
+
// A multi-field form gets a visible <label> per widget; the legacy single
|
|
1805
|
+
// free-text form keeps its bare textarea (unchanged DOM).
|
|
1806
|
+
const wrap = multi
|
|
1807
|
+
? el("div", { class: "pc-field" }, el("label", {}, label), input)
|
|
1808
|
+
: el("div", { class: "pc-field" }, input);
|
|
1809
|
+
const hideExpr = fd.conditional && typeof fd.conditional.hide === "string" ? fd.conditional.hide : null;
|
|
1810
|
+
controls.push({ key, input, wrap, hideExpr });
|
|
1811
|
+
box.append(wrap);
|
|
1812
|
+
}
|
|
1813
|
+
// Snapshot every field's current value, then hide/show each conditional field.
|
|
1814
|
+
// A field with no conditional.hide is always visible; the value map is keyed by
|
|
1815
|
+
// inputKey so a hide expression can reference a sibling field (e.g. `answer`
|
|
1816
|
+
// hidden when `resolution != "answer"`).
|
|
1817
|
+
const applyConditions = () => {
|
|
1818
|
+
/** @type {Record<string, any>} */
|
|
1819
|
+
const values = Object.create(null);
|
|
1820
|
+
for (const c of controls) values[c.key] = c.input.value;
|
|
1821
|
+
for (const c of controls) {
|
|
1822
|
+
const hidden = c.hideExpr != null && evalDetailCondition(c.hideExpr, values);
|
|
1823
|
+
c.wrap.hidden = hidden;
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1826
|
+
// Re-evaluate the conditions whenever any field changes so a select flip
|
|
1827
|
+
// reveals/hides its dependent field live.
|
|
1828
|
+
if (controls.some((c) => c.hideExpr != null)) {
|
|
1829
|
+
for (const c of controls) {
|
|
1830
|
+
c.input.addEventListener("change", applyConditions);
|
|
1831
|
+
c.input.addEventListener("input", applyConditions);
|
|
1832
|
+
}
|
|
1833
|
+
applyConditions();
|
|
1834
|
+
}
|
|
1600
1835
|
btn.addEventListener("click", async () => {
|
|
1601
1836
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
|
|
1602
1837
|
try {
|
|
1603
|
-
//
|
|
1604
|
-
// template come from f.action, e.g. { path: "/app/actions/message",
|
|
1838
|
+
// Assemble every VISIBLE field into one form body, keyed by its inputKey;
|
|
1839
|
+
// route + body template come from f.action, e.g. { path: "/app/actions/message",
|
|
1605
1840
|
// body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
|
|
1606
|
-
// Null-proto so an
|
|
1607
|
-
|
|
1841
|
+
// Null-proto so an inputKey of "__proto__"/"constructor" can't mutate a
|
|
1842
|
+
// prototype. A hidden (conditional.hide) field is omitted so a stale value
|
|
1843
|
+
// for an inapplicable choice never reaches the action.
|
|
1844
|
+
const form = Object.create(null);
|
|
1845
|
+
for (const c of controls) { if (c.wrap.hidden) continue; form[c.key] = c.input.value; }
|
|
1608
1846
|
await runRoute(f.action, { form, row });
|
|
1609
1847
|
msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
|
|
1610
1848
|
// Await onSuccess so a refresh that rejects (e.g. a child grid's re-fetch
|
|
@@ -1617,7 +1855,7 @@ function buildDetailForm(f, row, onSuccess) {
|
|
|
1617
1855
|
btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
1618
1856
|
}
|
|
1619
1857
|
});
|
|
1620
|
-
box.append(
|
|
1858
|
+
box.append(btn, msg);
|
|
1621
1859
|
return box;
|
|
1622
1860
|
}
|
|
1623
1861
|
|
|
@@ -2468,4 +2706,4 @@ function boot() {
|
|
|
2468
2706
|
// renderer functions / RENDERERS registry can be imported directly.
|
|
2469
2707
|
if (typeof document !== "undefined" && document.getElementById("page")) boot();
|
|
2470
2708
|
|
|
2471
|
-
export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, chevronToggle, setChevronOpen };
|
|
2709
|
+
export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
|