@nanobpm/urban 0.79.0 → 0.81.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/manifest.d.ts +44 -11
- package/dist/runtime/core/modules/cancel.d.ts +13 -8
- package/dist/runtime/core/modules/cancel.js +40 -21
- package/dist/runtime/core/modules/datasource.js +7 -4
- package/dist/runtime/core/modules/formjs.gen.d.ts +1 -0
- package/dist/runtime/core/modules/formjs.gen.js +5 -0
- package/dist/runtime/core/modules/forms.d.ts +22 -0
- package/dist/runtime/core/modules/forms.js +59 -0
- package/dist/runtime/core/modules/gateway.js +5 -4
- package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
- package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
- package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
- package/dist/runtime/core/modules/instance-tracking.js +451 -123
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
- package/dist/runtime/core/modules/pages.js +42 -4
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/core/modules/surfaces.js +18 -124
- package/dist/runtime/core/read-model.d.ts +28 -0
- package/dist/runtime/core/read-model.js +44 -4
- package/dist/runtime/core/validate.js +192 -0
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +5 -0
- package/package.json +1 -1
- package/src/runtime/browser/fake-dom.test-utils.ts +114 -0
- package/src/runtime/browser/formjs.browser.js +192 -0
- package/src/runtime/browser/formjs.browser.test.ts +136 -0
- package/src/runtime/browser/runtime.browser.js +101 -1
- package/src/runtime/browser/runtime.engineform.test.ts +158 -0
- package/src/runtime/core/manifest.ts +44 -11
- package/src/runtime/core/modules/cancel.test.ts +112 -38
- package/src/runtime/core/modules/cancel.ts +42 -22
- package/src/runtime/core/modules/datasource.ts +7 -4
- package/src/runtime/core/modules/formjs.gen.ts +6 -0
- package/src/runtime/core/modules/forms.test.ts +122 -0
- package/src/runtime/core/modules/forms.ts +67 -0
- package/src/runtime/core/modules/gateway.ts +7 -4
- package/src/runtime/core/modules/gen-artifacts.test.ts +41 -0
- package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
- package/src/runtime/core/modules/instance-tracking.ts +469 -126
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
- package/src/runtime/core/modules/pages.test.ts +99 -0
- package/src/runtime/core/modules/pages.ts +44 -5
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/core/modules/surfaces.ts +19 -121
- package/src/runtime/core/read-model.test.ts +69 -0
- package/src/runtime/core/read-model.ts +43 -4
- package/src/runtime/core/validate.test.ts +341 -0
- package/src/runtime/core/validate.ts +188 -0
- package/src/runtime/index.ts +15 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// A small fake DOM for unit-testing the browser runtime's element-building code
|
|
2
|
+
// (the shared form-js renderer and the dataGrid engine-form path) under Node,
|
|
3
|
+
// without a real browser. Records every created node so a test can grab an input
|
|
4
|
+
// or button and fire its listeners. Kept intentionally minimal — it supports only
|
|
5
|
+
// the DOM surface those functions touch.
|
|
6
|
+
|
|
7
|
+
export class FakeElement {
|
|
8
|
+
tagName: string;
|
|
9
|
+
attributes: Record<string, string> = {};
|
|
10
|
+
className = "";
|
|
11
|
+
children: Array<FakeElement | { text: string }> = [];
|
|
12
|
+
textContent = "";
|
|
13
|
+
hidden = false;
|
|
14
|
+
disabled = false;
|
|
15
|
+
value = "";
|
|
16
|
+
checked = false;
|
|
17
|
+
type = "";
|
|
18
|
+
name = "";
|
|
19
|
+
id = "";
|
|
20
|
+
listeners: Record<string, Array<(ev: unknown) => unknown>> = {};
|
|
21
|
+
|
|
22
|
+
constructor(tag: string) {
|
|
23
|
+
this.tagName = String(tag).toUpperCase();
|
|
24
|
+
}
|
|
25
|
+
setAttribute(k: string, v: unknown): void {
|
|
26
|
+
this.attributes[k] = String(v);
|
|
27
|
+
if (k === "class") this.className = String(v);
|
|
28
|
+
}
|
|
29
|
+
getAttribute(k: string): string | null {
|
|
30
|
+
return k in this.attributes ? this.attributes[k] : null;
|
|
31
|
+
}
|
|
32
|
+
addEventListener(type: string, fn: (ev: unknown) => unknown): void {
|
|
33
|
+
(this.listeners[type] ||= []).push(fn);
|
|
34
|
+
}
|
|
35
|
+
async fire(type: string, ev: unknown = { preventDefault() {}, stopPropagation() {} }): Promise<void> {
|
|
36
|
+
for (const fn of this.listeners[type] || []) await fn(ev);
|
|
37
|
+
}
|
|
38
|
+
append(...kids: Array<FakeElement | { text: string } | string>): void {
|
|
39
|
+
for (const kid of kids) {
|
|
40
|
+
if (typeof kid === "string") {
|
|
41
|
+
this.children.push({ text: kid });
|
|
42
|
+
this.textContent += kid;
|
|
43
|
+
} else if ("text" in kid) {
|
|
44
|
+
this.children.push(kid);
|
|
45
|
+
this.textContent += kid.text;
|
|
46
|
+
} else {
|
|
47
|
+
this.children.push(kid);
|
|
48
|
+
this.textContent += kid.textContent || "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
appendChild(kid: FakeElement | { text: string }): void {
|
|
53
|
+
this.append(kid);
|
|
54
|
+
}
|
|
55
|
+
replaceChildren(...kids: Array<FakeElement | { text: string } | string>): void {
|
|
56
|
+
this.children = [];
|
|
57
|
+
this.textContent = "";
|
|
58
|
+
this.append(...kids);
|
|
59
|
+
}
|
|
60
|
+
get firstChild(): FakeElement | { text: string } | null {
|
|
61
|
+
return this.children[0] ?? null;
|
|
62
|
+
}
|
|
63
|
+
querySelector(selector: string): FakeElement | null {
|
|
64
|
+
const walk = (nodes: Array<FakeElement | { text: string }>): FakeElement | null => {
|
|
65
|
+
for (const kid of nodes) {
|
|
66
|
+
if (kid instanceof FakeElement) {
|
|
67
|
+
if (fakeSelectorMatches(kid, selector)) return kid;
|
|
68
|
+
const found = walk(kid.children);
|
|
69
|
+
if (found) return found;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
return walk(this.children);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Minimal CSS-selector matcher for the DOM surface the browser runtime touches
|
|
79
|
+
// (currently the radio group's `input:checked` lookup). Supports an optional tag
|
|
80
|
+
// name plus `:checked`; any other pseudo/selector deliberately fails to match so
|
|
81
|
+
// unsupported queries surface rather than silently returning a wrong node.
|
|
82
|
+
function fakeSelectorMatches(node: FakeElement, selector: string): boolean {
|
|
83
|
+
const parts = selector.trim().split(":");
|
|
84
|
+
const tag = parts[0];
|
|
85
|
+
if (tag && node.tagName !== tag.toUpperCase()) return false;
|
|
86
|
+
for (const pseudo of parts.slice(1)) {
|
|
87
|
+
if (pseudo === "checked") {
|
|
88
|
+
if (!node.checked) return false;
|
|
89
|
+
} else {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Install a fake `document` on globalThis; returns a restore function. */
|
|
97
|
+
export function installFakeDom(created: FakeElement[]): () => void {
|
|
98
|
+
const doc = {
|
|
99
|
+
createElement: (tag: string) => {
|
|
100
|
+
const n = new FakeElement(tag);
|
|
101
|
+
created.push(n);
|
|
102
|
+
return n;
|
|
103
|
+
},
|
|
104
|
+
createTextNode: (text: string) => ({ text: String(text) }),
|
|
105
|
+
getElementById: () => null,
|
|
106
|
+
dispatchEvent: () => true,
|
|
107
|
+
};
|
|
108
|
+
const prior = Reflect.getOwnPropertyDescriptor(globalThis, "document");
|
|
109
|
+
Reflect.set(globalThis, "document", doc);
|
|
110
|
+
return () => {
|
|
111
|
+
if (prior) Reflect.defineProperty(globalThis, "document", prior);
|
|
112
|
+
else Reflect.deleteProperty(globalThis, "document");
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// ── Shared, XSS-safe form-js schema renderer (ADR 0026, extended by #457) ─────
|
|
3
|
+
// This is the ONE client implementation of "render a deployed `.form`'s form-js
|
|
4
|
+
// schema and collect its field values", shared verbatim by BOTH:
|
|
5
|
+
// • the `taskInbox` surface (surfaces.ts `inboxPage`), and
|
|
6
|
+
// • the pages `dataGrid` engine-form detail (runtime.browser.js).
|
|
7
|
+
// It used to live inline in surfaces.ts; factoring it here means the two surfaces
|
|
8
|
+
// can never fork the renderer or drift on how a form-js component maps to an input
|
|
9
|
+
// and its submitted value.
|
|
10
|
+
//
|
|
11
|
+
// Like the rest of the browser runtime it is authored/type-checked/linted/tested
|
|
12
|
+
// as real source and served as a generated string artifact (formjs.gen.ts, emitted
|
|
13
|
+
// by scripts/gen-runtime.mjs). It performs NO DOM access at load — every DOM touch
|
|
14
|
+
// is inside a function — so it is import-safe under Node/Deno for unit testing.
|
|
15
|
+
//
|
|
16
|
+
// SECURITY: every value the engine returns is placed via textContent / setAttribute
|
|
17
|
+
// (never innerHTML), so nothing in the form schema can inject markup. The submitted
|
|
18
|
+
// `variables` bag is null-prototype so an engine-supplied component key of
|
|
19
|
+
// `__proto__`/`constructor` lands as a plain own property and can never mutate a
|
|
20
|
+
// prototype (prototype-pollution class).
|
|
21
|
+
|
|
22
|
+
/** Create an element, setting attributes via setAttribute and text via textContent only.
|
|
23
|
+
* @param {string} tag
|
|
24
|
+
* @param {Record<string, string>} [attrs]
|
|
25
|
+
* @param {string} [text]
|
|
26
|
+
* @returns {any}
|
|
27
|
+
*/
|
|
28
|
+
function fjEl(tag, attrs, text) {
|
|
29
|
+
const e = document.createElement(tag);
|
|
30
|
+
if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]);
|
|
31
|
+
if (text != null) e.textContent = text;
|
|
32
|
+
return e;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A plain type="button" with an optional click handler.
|
|
36
|
+
* @param {string} label
|
|
37
|
+
* @param {(() => void) | null} [onClick]
|
|
38
|
+
* @returns {any}
|
|
39
|
+
*/
|
|
40
|
+
function fjBtn(label, onClick) {
|
|
41
|
+
const b = document.createElement("button");
|
|
42
|
+
b.type = "button";
|
|
43
|
+
b.textContent = label;
|
|
44
|
+
if (onClick) b.addEventListener("click", onClick);
|
|
45
|
+
return b;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build one form-js component into its field element and (for keyed inputs) a reader
|
|
49
|
+
* that returns the entered `{ key, value }` or null when blank. Static (keyless)
|
|
50
|
+
* components render as inert text; layout-only components are skipped (null).
|
|
51
|
+
* @param {any} c
|
|
52
|
+
* @returns {{ field: any, read?: () => ({ key: string, value: any } | null) } | null}
|
|
53
|
+
*/
|
|
54
|
+
function buildField(c) {
|
|
55
|
+
const type = c && c.type;
|
|
56
|
+
// Static (keyless) components: show text as a paragraph, ignore layout-only ones.
|
|
57
|
+
if (type === "text") {
|
|
58
|
+
return { field: fjEl("p", { class: "njf-text" }, typeof c.text === "string" ? c.text : "") };
|
|
59
|
+
}
|
|
60
|
+
const key = c && c.key;
|
|
61
|
+
if (!key) return null;
|
|
62
|
+
const wrap = fjEl("div", { class: "njf-field" });
|
|
63
|
+
const label = typeof c.label === "string" && c.label ? c.label : key;
|
|
64
|
+
if (type === "checkbox") {
|
|
65
|
+
const input = document.createElement("input");
|
|
66
|
+
input.type = "checkbox";
|
|
67
|
+
const lab = fjEl("label", {});
|
|
68
|
+
lab.appendChild(input);
|
|
69
|
+
lab.appendChild(document.createTextNode(" " + label));
|
|
70
|
+
wrap.appendChild(lab);
|
|
71
|
+
return { field: wrap, read: () => ({ key, value: input.checked }) };
|
|
72
|
+
}
|
|
73
|
+
const labelEl = fjEl("label", {}, label);
|
|
74
|
+
wrap.appendChild(labelEl);
|
|
75
|
+
let input;
|
|
76
|
+
if (type === "textarea") {
|
|
77
|
+
input = document.createElement("textarea");
|
|
78
|
+
} else if (type === "select") {
|
|
79
|
+
input = document.createElement("select");
|
|
80
|
+
input.appendChild(fjEl("option", { value: "" }, "\u2014"));
|
|
81
|
+
for (const o of Array.isArray(c.values) ? c.values : []) {
|
|
82
|
+
input.appendChild(fjEl("option", { value: String(o.value) }, String(o.label != null ? o.label : o.value)));
|
|
83
|
+
}
|
|
84
|
+
} else if (type === "radio") {
|
|
85
|
+
const name = "r" + Math.random().toString(36).slice(2);
|
|
86
|
+
const group = fjEl("div", {});
|
|
87
|
+
for (const o of Array.isArray(c.values) ? c.values : []) {
|
|
88
|
+
const rlab = fjEl("label", {});
|
|
89
|
+
const r = document.createElement("input");
|
|
90
|
+
r.type = "radio";
|
|
91
|
+
r.name = name;
|
|
92
|
+
r.value = String(o.value);
|
|
93
|
+
rlab.appendChild(r);
|
|
94
|
+
rlab.appendChild(document.createTextNode(" " + String(o.label != null ? o.label : o.value)));
|
|
95
|
+
group.appendChild(rlab);
|
|
96
|
+
}
|
|
97
|
+
wrap.appendChild(group);
|
|
98
|
+
return {
|
|
99
|
+
field: wrap,
|
|
100
|
+
read: () => {
|
|
101
|
+
const sel = group.querySelector("input:checked");
|
|
102
|
+
return sel ? { key, value: sel.value } : null;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
} else {
|
|
106
|
+
input = document.createElement("input");
|
|
107
|
+
input.type = type === "number" ? "number" : type === "datetime" ? "datetime-local" : "text";
|
|
108
|
+
}
|
|
109
|
+
// Associate the label with the input (for/id) so screen readers announce it on focus.
|
|
110
|
+
const fieldId = "f" + Math.random().toString(36).slice(2);
|
|
111
|
+
input.id = fieldId;
|
|
112
|
+
labelEl.setAttribute("for", fieldId);
|
|
113
|
+
wrap.appendChild(input);
|
|
114
|
+
const isNumber = type === "number";
|
|
115
|
+
return {
|
|
116
|
+
field: wrap,
|
|
117
|
+
read: () => {
|
|
118
|
+
const raw = input.value;
|
|
119
|
+
if (raw === "" || raw == null) return null;
|
|
120
|
+
if (isNumber) {
|
|
121
|
+
const n = Number(raw);
|
|
122
|
+
// A non-numeric value in a number field is only reachable via tampering (the browser
|
|
123
|
+
// blanks invalid type=number input). Treat it as absent rather than silently submitting
|
|
124
|
+
// NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
|
|
125
|
+
return Number.isFinite(n) ? { key, value: n } : null;
|
|
126
|
+
}
|
|
127
|
+
return { key, value: raw };
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Render a form-js schema into a `<form>` element with a submit + optional cancel.
|
|
134
|
+
* On submit it assembles every keyed field's value into one null-prototype
|
|
135
|
+
* `variables` bag and calls `opts.onSubmit(variables)`; a rejected submit re-enables
|
|
136
|
+
* the button so the operator can retry. `opts.onCancel`, when supplied, wires a
|
|
137
|
+
* cancel button.
|
|
138
|
+
* @param {any} schema the parsed form-js document (`{ components: [...] }`)
|
|
139
|
+
* @param {{ heading?: string, submitLabel?: string, cancelLabel?: string,
|
|
140
|
+
* onSubmit: (variables: Record<string, any>) => (void | Promise<void>),
|
|
141
|
+
* onCancel?: () => void }} opts
|
|
142
|
+
* @returns {any} the built <form> element
|
|
143
|
+
*/
|
|
144
|
+
function renderForm(schema, opts) {
|
|
145
|
+
const form = document.createElement("form");
|
|
146
|
+
form.className = "njf-form";
|
|
147
|
+
if (opts.heading) form.appendChild(fjEl("h2", { class: "njf-heading" }, opts.heading));
|
|
148
|
+
const components = schema && Array.isArray(schema.components) ? schema.components : [];
|
|
149
|
+
/** @type {Array<() => ({ key: string, value: any } | null)>} */
|
|
150
|
+
const inputs = [];
|
|
151
|
+
for (const c of components) {
|
|
152
|
+
const built = buildField(c);
|
|
153
|
+
if (!built) continue;
|
|
154
|
+
form.appendChild(built.field);
|
|
155
|
+
if (built.read) inputs.push(built.read);
|
|
156
|
+
}
|
|
157
|
+
const submit = fjBtn(opts.submitLabel || "Submit", null);
|
|
158
|
+
submit.type = "submit";
|
|
159
|
+
const bar = fjEl("div", { class: "njf-actions" });
|
|
160
|
+
bar.appendChild(submit);
|
|
161
|
+
if (opts.onCancel) bar.appendChild(fjBtn(opts.cancelLabel || "Cancel", opts.onCancel));
|
|
162
|
+
form.appendChild(bar);
|
|
163
|
+
form.addEventListener("submit", (e) => {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
// Null-prototype: field keys come from the engine-supplied form schema. A component
|
|
166
|
+
// keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
|
|
167
|
+
// through JSON), never mutate a prototype — no prototype pollution in the page runtime.
|
|
168
|
+
const variables = Object.create(null);
|
|
169
|
+
for (const read of inputs) {
|
|
170
|
+
const kv = read();
|
|
171
|
+
if (kv) variables[kv.key] = kv.value;
|
|
172
|
+
}
|
|
173
|
+
submit.disabled = true;
|
|
174
|
+
Promise.resolve()
|
|
175
|
+
.then(() => opts.onSubmit(variables))
|
|
176
|
+
.catch(() => {
|
|
177
|
+
submit.disabled = false;
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
return form;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export { buildField, renderForm };
|
|
184
|
+
|
|
185
|
+
// Expose the renderer as a browser global so both self-contained page bundles can
|
|
186
|
+
// reach the ONE implementation without importing it: the taskInbox page embeds this
|
|
187
|
+
// module inline and the pages shell does the same before loading the ES-module
|
|
188
|
+
// runtime, which reads `globalThis.NanoFormJs` lazily when it renders an engine form.
|
|
189
|
+
// Guarded so importing under Node/Deno for tests is a harmless no-op-ish assignment.
|
|
190
|
+
if (typeof globalThis !== "undefined") {
|
|
191
|
+
/** @type {any} */ (globalThis).NanoFormJs = { buildField, renderForm };
|
|
192
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Unit tests for the shared form-js renderer (formjs.browser.js, ADR 0026/#457):
|
|
2
|
+
// the ONE client implementation of "render a deployed .form's form-js schema and
|
|
3
|
+
// collect its field values", shared by BOTH the taskInbox surface and the pages
|
|
4
|
+
// dataGrid engine-form detail. Importing the module also registers it as the
|
|
5
|
+
// `globalThis.NanoFormJs` global that the pages runtime consumes.
|
|
6
|
+
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { buildField, renderForm } from "./formjs.browser.js";
|
|
10
|
+
import { type FakeElement, installFakeDom } from "./fake-dom.test-utils.ts";
|
|
11
|
+
|
|
12
|
+
test("NanoFormJs is registered as a browser global on import", () => {
|
|
13
|
+
const g = Reflect.get(globalThis, "NanoFormJs");
|
|
14
|
+
assert.ok(g, "importing the module exposes globalThis.NanoFormJs");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("buildField skips keyless layout components and renders static text", (t) => {
|
|
18
|
+
const created: FakeElement[] = [];
|
|
19
|
+
t.after(installFakeDom(created));
|
|
20
|
+
assert.equal(buildField({ type: "spacer" }), null, "a keyless, non-text component is skipped");
|
|
21
|
+
const textOnly = buildField({ type: "text", text: "Read me" });
|
|
22
|
+
assert.ok(textOnly && !textOnly.read, "a static text component has no reader");
|
|
23
|
+
assert.equal(textOnly!.field.textContent, "Read me");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("buildField reads a textfield's entered value, and omits a blank one", (t) => {
|
|
27
|
+
const created: FakeElement[] = [];
|
|
28
|
+
t.after(installFakeDom(created));
|
|
29
|
+
const built = buildField({ type: "textfield", key: "note", label: "Note" });
|
|
30
|
+
assert.ok(built?.read);
|
|
31
|
+
const input = created.find((n) => n.tagName === "INPUT");
|
|
32
|
+
assert.ok(input);
|
|
33
|
+
input!.value = "hello";
|
|
34
|
+
assert.deepEqual(built!.read!(), { key: "note", value: "hello" });
|
|
35
|
+
input!.value = "";
|
|
36
|
+
assert.equal(built!.read!(), null, "a blank field is omitted from the submitted variables");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("buildField coerces a number field and drops a non-finite value", (t) => {
|
|
40
|
+
const created: FakeElement[] = [];
|
|
41
|
+
t.after(installFakeDom(created));
|
|
42
|
+
const built = buildField({ type: "number", key: "qty" });
|
|
43
|
+
const input = created.find((n) => n.tagName === "INPUT");
|
|
44
|
+
input!.value = "42";
|
|
45
|
+
assert.deepEqual(built!.read!(), { key: "qty", value: 42 });
|
|
46
|
+
input!.value = "not-a-number";
|
|
47
|
+
assert.equal(built!.read!(), null, "a tampered non-numeric number field is treated as absent, never NaN→null");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("buildField maps a checkbox to a boolean value", (t) => {
|
|
51
|
+
const created: FakeElement[] = [];
|
|
52
|
+
t.after(installFakeDom(created));
|
|
53
|
+
const built = buildField({ type: "checkbox", key: "agree", label: "Agree" });
|
|
54
|
+
const input = created.find((n) => n.tagName === "INPUT");
|
|
55
|
+
input!.checked = true;
|
|
56
|
+
assert.deepEqual(built!.read!(), { key: "agree", value: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("buildField reads the checked radio option (and omits an unselected group)", (t) => {
|
|
60
|
+
const created: FakeElement[] = [];
|
|
61
|
+
t.after(installFakeDom(created));
|
|
62
|
+
const built = buildField({
|
|
63
|
+
type: "radio",
|
|
64
|
+
key: "size",
|
|
65
|
+
label: "Size",
|
|
66
|
+
values: [
|
|
67
|
+
{ value: "s", label: "Small" },
|
|
68
|
+
{ value: "l", label: "Large" },
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
assert.ok(built?.read);
|
|
72
|
+
assert.equal(built!.read!(), null, "an unselected radio group is omitted from the submitted variables");
|
|
73
|
+
const radios = created.filter((n) => n.tagName === "INPUT" && n.type === "radio");
|
|
74
|
+
assert.equal(radios.length, 2);
|
|
75
|
+
radios[1].checked = true;
|
|
76
|
+
assert.deepEqual(built!.read!(), { key: "size", value: "l" }, "read() finds the checked option via querySelector('input:checked')");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("renderForm submits a null-prototype variables bag (prototype-pollution safe)", async (t) => {
|
|
80
|
+
const created: FakeElement[] = [];
|
|
81
|
+
t.after(installFakeDom(created));
|
|
82
|
+
const schema = {
|
|
83
|
+
components: [
|
|
84
|
+
{ type: "textfield", key: "note" },
|
|
85
|
+
{ type: "textfield", key: "__proto__" },
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
let submitted: Record<string, unknown> | undefined;
|
|
89
|
+
const form = renderForm(schema, {
|
|
90
|
+
heading: "Task A",
|
|
91
|
+
onSubmit: (variables) => {
|
|
92
|
+
submitted = variables;
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
const inputs = created.filter((n) => n.tagName === "INPUT");
|
|
96
|
+
inputs[0].value = "hi";
|
|
97
|
+
inputs[1].value = "evil";
|
|
98
|
+
await form.fire("submit");
|
|
99
|
+
assert.ok(submitted);
|
|
100
|
+
assert.equal(Object.getPrototypeOf(submitted!), null, "the variables bag has a null prototype");
|
|
101
|
+
assert.equal(submitted!.note, "hi");
|
|
102
|
+
assert.equal(Object.prototype.hasOwnProperty.call(submitted!, "__proto__"), true, "an untrusted key lands as an own property");
|
|
103
|
+
assert.equal(Object.getPrototypeOf({}), Object.prototype, "no prototype was mutated");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("renderForm re-enables the submit button when onSubmit rejects (retryable)", async (t) => {
|
|
107
|
+
const created: FakeElement[] = [];
|
|
108
|
+
t.after(installFakeDom(created));
|
|
109
|
+
const form = renderForm(
|
|
110
|
+
{ components: [{ type: "textfield", key: "x" }] },
|
|
111
|
+
{
|
|
112
|
+
onSubmit: () => Promise.reject(new Error("boom")),
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
const submit = created.find((n) => n.tagName === "BUTTON" && n.type === "submit");
|
|
116
|
+
assert.ok(submit);
|
|
117
|
+
await form.fire("submit");
|
|
118
|
+
// The rejected onSubmit settles a microtask later; flush it.
|
|
119
|
+
await Promise.resolve();
|
|
120
|
+
await Promise.resolve();
|
|
121
|
+
assert.equal(submit!.disabled, false, "a failed submit re-enables the button so the operator can retry");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("renderForm wires a cancel button only when onCancel is supplied", async (t) => {
|
|
125
|
+
const created: FakeElement[] = [];
|
|
126
|
+
t.after(installFakeDom(created));
|
|
127
|
+
renderForm({ components: [] }, { onSubmit: () => {} });
|
|
128
|
+
assert.equal(created.filter((n) => n.tagName === "BUTTON").length, 1, "no cancel button without onCancel");
|
|
129
|
+
created.length = 0;
|
|
130
|
+
let cancelled = false;
|
|
131
|
+
renderForm({ components: [] }, { onSubmit: () => {}, onCancel: () => (cancelled = true) });
|
|
132
|
+
const buttons = created.filter((n) => n.tagName === "BUTTON");
|
|
133
|
+
assert.equal(buttons.length, 2, "a cancel button is added when onCancel is supplied");
|
|
134
|
+
await buttons[1].fire("click");
|
|
135
|
+
assert.equal(cancelled, true, "clicking cancel invokes onCancel");
|
|
136
|
+
});
|
|
@@ -1859,6 +1859,101 @@ function buildDetailForm(f, row, onSuccess) {
|
|
|
1859
1859
|
return box;
|
|
1860
1860
|
}
|
|
1861
1861
|
|
|
1862
|
+
// Build a dataGrid detail's ENGINE-declared form (ADR 0026, #457). Where
|
|
1863
|
+
// buildDetailForm renders a page-authored `detail.form` (fields duplicated from a
|
|
1864
|
+
// deployed `.form`), this resolves the row's engine `formKey` and renders the
|
|
1865
|
+
// AUTHORITATIVE deployed form-js schema — so one grid can render the several
|
|
1866
|
+
// different typed forms carried by a heterogeneous task inbox, and no page-local
|
|
1867
|
+
// copy can drift from the deployed form. It reuses the ONE shared form-js renderer
|
|
1868
|
+
// (globalThis.NanoFormJs, embedded by the shell) and the shared `/app/actions/form`
|
|
1869
|
+
// resolution gate + `/app/actions/complete` completion — the same seams the
|
|
1870
|
+
// taskInbox surface uses, no fork.
|
|
1871
|
+
//
|
|
1872
|
+
// `cfg.formKeyField` (default "form_key") names the row column carrying the engine
|
|
1873
|
+
// formKey; `cfg.userTaskKeyField` (default "user_task_key") names the column
|
|
1874
|
+
// carrying the user-task key to complete. A row with no formKey degrades to a bare
|
|
1875
|
+
// "Complete" button (empty variables), matching taskInbox's no-form fallback.
|
|
1876
|
+
/** @param {any} cfg
|
|
1877
|
+
* @param {Record<string, any>} row
|
|
1878
|
+
* @param {() => void | Promise<void>} onSuccess
|
|
1879
|
+
* @returns {HTMLElement|null}
|
|
1880
|
+
*/
|
|
1881
|
+
function buildEngineForm(cfg, row, onSuccess) {
|
|
1882
|
+
if (!cfg) return null;
|
|
1883
|
+
const userTaskKey = row[cfg.userTaskKeyField || "user_task_key"];
|
|
1884
|
+
// Without a user-task key there is nothing to complete — render nothing rather
|
|
1885
|
+
// than a dead button.
|
|
1886
|
+
if (userTaskKey == null || userTaskKey === "") return null;
|
|
1887
|
+
const formKey = row[cfg.formKeyField || "form_key"];
|
|
1888
|
+
const box = el("div", { class: "pc-subform" });
|
|
1889
|
+
const msg = el("p", { class: "njf-msg", role: "status", "aria-live": "polite" });
|
|
1890
|
+
/** @param {Record<string, any>} variables */
|
|
1891
|
+
const complete = (variables) =>
|
|
1892
|
+
getJSON("/app/actions/complete", {
|
|
1893
|
+
method: "POST",
|
|
1894
|
+
headers: { "content-type": "application/json" },
|
|
1895
|
+
body: JSON.stringify({ userTaskKey, variables }),
|
|
1896
|
+
}).then(() => onSuccess());
|
|
1897
|
+
// No renderable form → the bare completion path (parity with taskInbox).
|
|
1898
|
+
const renderBare = () => {
|
|
1899
|
+
const bar = el("div", { class: "njf-actions" });
|
|
1900
|
+
const b = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, "Complete");
|
|
1901
|
+
b.addEventListener("click", async () => {
|
|
1902
|
+
b.disabled = true;
|
|
1903
|
+
msg.className = "njf-msg";
|
|
1904
|
+
msg.textContent = "Sending\u2026";
|
|
1905
|
+
try {
|
|
1906
|
+
await complete(Object.create(null));
|
|
1907
|
+
} catch (e) {
|
|
1908
|
+
b.disabled = false;
|
|
1909
|
+
msg.className = "njf-msg err";
|
|
1910
|
+
msg.textContent = String(e.message || e);
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
bar.append(b);
|
|
1914
|
+
box.append(bar, msg);
|
|
1915
|
+
};
|
|
1916
|
+
if (formKey == null || formKey === "") {
|
|
1917
|
+
renderBare();
|
|
1918
|
+
return box;
|
|
1919
|
+
}
|
|
1920
|
+
box.append(el("p", { class: "njf-text" }, "loading form\u2026"));
|
|
1921
|
+
getJSON("/app/actions/form?formKey=" + encodeURIComponent(String(formKey)))
|
|
1922
|
+
.then((f) => {
|
|
1923
|
+
box.replaceChildren();
|
|
1924
|
+
const renderer = /** @type {any} */ (globalThis).NanoFormJs;
|
|
1925
|
+
if (!f || !f.schema || !renderer) {
|
|
1926
|
+
renderBare();
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
box.append(
|
|
1930
|
+
renderer.renderForm(f.schema, {
|
|
1931
|
+
submitLabel: "Complete",
|
|
1932
|
+
/** @param {Record<string, any>} variables */
|
|
1933
|
+
onSubmit: (variables) => {
|
|
1934
|
+
msg.className = "njf-msg";
|
|
1935
|
+
msg.textContent = "Sending\u2026";
|
|
1936
|
+
// Surface completion failures to the operator (parity with the bare-completion
|
|
1937
|
+
// path). Rethrow so renderForm's catch still re-enables the submit button.
|
|
1938
|
+
return complete(variables).catch((e) => {
|
|
1939
|
+
msg.className = "njf-msg err";
|
|
1940
|
+
msg.textContent = String(e.message || e);
|
|
1941
|
+
throw e;
|
|
1942
|
+
});
|
|
1943
|
+
},
|
|
1944
|
+
}),
|
|
1945
|
+
msg,
|
|
1946
|
+
);
|
|
1947
|
+
})
|
|
1948
|
+
.catch((e) => {
|
|
1949
|
+
box.replaceChildren();
|
|
1950
|
+
msg.className = "njf-msg err";
|
|
1951
|
+
msg.textContent = "Failed to load form: " + String(e.message || e);
|
|
1952
|
+
box.append(msg);
|
|
1953
|
+
});
|
|
1954
|
+
return box;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1862
1957
|
/** @param {any} node */
|
|
1863
1958
|
function renderDataGrid(node) {
|
|
1864
1959
|
const p = node.props;
|
|
@@ -2162,6 +2257,11 @@ function renderDataGrid(node) {
|
|
|
2162
2257
|
}
|
|
2163
2258
|
const form = buildDetailForm(d.form, row, onSuccess || (() => { document.dispatchEvent(new CustomEvent("pc:refresh")); }));
|
|
2164
2259
|
if (form) box.append(form);
|
|
2260
|
+
// An engine-declared form (ADR 0026/#457) sits ALONGSIDE the page-authored
|
|
2261
|
+
// `detail.form`, not replacing it: a grid may render the deployed form-js schema
|
|
2262
|
+
// for the row's user task instead of (or in addition to) a hand-authored copy.
|
|
2263
|
+
const engineForm = buildEngineForm(d.engineForm, row, onSuccess || (() => { document.dispatchEvent(new CustomEvent("pc:refresh")); }));
|
|
2264
|
+
if (engineForm) box.append(engineForm);
|
|
2165
2265
|
return box;
|
|
2166
2266
|
}
|
|
2167
2267
|
|
|
@@ -2849,4 +2949,4 @@ function boot() {
|
|
|
2849
2949
|
// renderer functions / RENDERERS registry can be imported directly.
|
|
2850
2950
|
if (typeof document !== "undefined" && document.getElementById("page")) boot();
|
|
2851
2951
|
|
|
2852
|
-
export { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
|
|
2952
|
+
export { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, buildEngineForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
|