@nanobpm/urban 0.78.0 → 0.80.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/modules/canonical-projections.d.ts +13 -0
- package/dist/runtime/core/modules/canonical-projections.js +30 -0
- 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/instance-state-store.d.ts +50 -0
- package/dist/runtime/core/modules/instance-state-store.js +101 -0
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +63 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +135 -0
- package/dist/runtime/core/modules/pages.js +36 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/core/modules/surfaces.js +18 -124
- package/dist/runtime/core/modules/workers.js +33 -0
- package/dist/runtime/index.d.ts +5 -0
- package/dist/runtime/index.js +8 -1
- 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/modules/canonical-projections.test.ts +190 -0
- package/src/runtime/core/modules/canonical-projections.ts +38 -0
- 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/gen-artifacts.test.ts +41 -0
- package/src/runtime/core/modules/instance-state-store.test.ts +143 -0
- package/src/runtime/core/modules/instance-state-store.ts +135 -0
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +156 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +172 -0
- package/src/runtime/core/modules/pages.test.ts +99 -0
- package/src/runtime/core/modules/pages.ts +38 -0
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/core/modules/surfaces.ts +19 -121
- package/src/runtime/core/modules/workers.ts +33 -0
- package/src/runtime/index.ts +29 -1
|
@@ -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 };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Unit tests for buildEngineForm (#457) — the dataGrid detail's ENGINE-declared
|
|
2
|
+
// form path. Where buildDetailForm renders a page-authored `detail.form`, this
|
|
3
|
+
// resolves the row's engine `formKey` via the shared `/app/actions/form` gate and
|
|
4
|
+
// renders the AUTHORITATIVE deployed form-js schema with the shared renderer
|
|
5
|
+
// (globalThis.NanoFormJs), completing the row's user task via
|
|
6
|
+
// `/app/actions/complete`. A row with no formKey degrades to a bare completion,
|
|
7
|
+
// matching the taskInbox no-form fallback.
|
|
8
|
+
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { buildEngineForm } from "./runtime.browser.js";
|
|
12
|
+
// Importing the shared renderer registers globalThis.NanoFormJs, which
|
|
13
|
+
// buildEngineForm reads lazily when it renders a resolved schema.
|
|
14
|
+
import "./formjs.browser.js";
|
|
15
|
+
import { type FakeElement, installFakeDom } from "./fake-dom.test-utils.ts";
|
|
16
|
+
|
|
17
|
+
interface FetchCall {
|
|
18
|
+
url: string;
|
|
19
|
+
method: string;
|
|
20
|
+
// Parsed request JSON body (or undefined), typed loosely so a test can assert on
|
|
21
|
+
// arbitrary shapes without ceremony — mirrors the runtime's JSDoc `any` payloads.
|
|
22
|
+
body?: any;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Install a fake `fetch` that resolves each request from a route→response map. */
|
|
26
|
+
function installFakeFetch(
|
|
27
|
+
handler: (url: string, opts: { method?: string; body?: string }) => { status?: number; json?: unknown },
|
|
28
|
+
calls: FetchCall[],
|
|
29
|
+
): () => void {
|
|
30
|
+
const prior = Reflect.getOwnPropertyDescriptor(globalThis, "fetch");
|
|
31
|
+
const fake = (input: string | URL, opts: { method?: string; body?: string } = {}) => {
|
|
32
|
+
const url = String(input);
|
|
33
|
+
calls.push({ url, method: opts.method ?? "GET", body: opts.body ? JSON.parse(opts.body) : undefined });
|
|
34
|
+
const res = handler(url, opts);
|
|
35
|
+
const status = res.status ?? 200;
|
|
36
|
+
return Promise.resolve({
|
|
37
|
+
ok: status >= 200 && status < 300,
|
|
38
|
+
status,
|
|
39
|
+
json: () =>
|
|
40
|
+
res.json === undefined ? Promise.reject(new Error("no body")) : Promise.resolve(res.json),
|
|
41
|
+
text: () => Promise.resolve(res.json === undefined ? "" : JSON.stringify(res.json)),
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
Reflect.set(globalThis, "fetch", fake);
|
|
45
|
+
return () => {
|
|
46
|
+
if (prior) Reflect.defineProperty(globalThis, "fetch", prior);
|
|
47
|
+
else Reflect.deleteProperty(globalThis, "fetch");
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CFG = { formKeyField: "form_key", userTaskKeyField: "user_task_key" };
|
|
52
|
+
|
|
53
|
+
test("buildEngineForm renders nothing when the row has no user-task key", (t) => {
|
|
54
|
+
const created: FakeElement[] = [];
|
|
55
|
+
t.after(installFakeDom(created));
|
|
56
|
+
assert.equal(buildEngineForm(CFG, { form_key: "f1" }, () => {}), null);
|
|
57
|
+
assert.equal(buildEngineForm(null, { user_task_key: "u1" }, () => {}), null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("buildEngineForm resolves the row's formKey and completes with the entered variables", async (t) => {
|
|
61
|
+
const created: FakeElement[] = [];
|
|
62
|
+
const calls: FetchCall[] = [];
|
|
63
|
+
t.after(installFakeDom(created));
|
|
64
|
+
t.after(
|
|
65
|
+
installFakeFetch((url) => {
|
|
66
|
+
if (url.includes("/app/actions/form")) {
|
|
67
|
+
return { status: 200, json: { schema: { components: [{ type: "textfield", key: "note" }] } } };
|
|
68
|
+
}
|
|
69
|
+
return { status: 200, json: { ok: true } };
|
|
70
|
+
}, calls),
|
|
71
|
+
);
|
|
72
|
+
let refreshed = false;
|
|
73
|
+
const box = buildEngineForm(CFG, { form_key: "form-9", user_task_key: "ut-9" }, () => {
|
|
74
|
+
refreshed = true;
|
|
75
|
+
});
|
|
76
|
+
assert.ok(box);
|
|
77
|
+
// Let the form fetch + render settle.
|
|
78
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
79
|
+
const formReq = calls.find((c) => c.url.includes("/app/actions/form"));
|
|
80
|
+
assert.ok(formReq, "the row's formKey is resolved via the shared gate");
|
|
81
|
+
assert.match(formReq!.url, /formKey=form-9/);
|
|
82
|
+
// The shared renderer built the field; fill it and submit.
|
|
83
|
+
const input = created.find((n) => n.tagName === "INPUT");
|
|
84
|
+
assert.ok(input, "the deployed form-js schema was rendered by the shared renderer");
|
|
85
|
+
input!.value = "done";
|
|
86
|
+
const form = created.find((n) => n.tagName === "FORM");
|
|
87
|
+
await form!.fire("submit");
|
|
88
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
89
|
+
const completeReq = calls.find((c) => c.url.includes("/app/actions/complete"));
|
|
90
|
+
assert.ok(completeReq, "submitting completes the user task");
|
|
91
|
+
assert.equal(completeReq!.method, "POST");
|
|
92
|
+
assert.deepEqual(completeReq!.body, { userTaskKey: "ut-9", variables: { note: "done" } });
|
|
93
|
+
assert.equal(refreshed, true, "a successful completion invokes onSuccess (grid re-poll)");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("buildEngineForm surfaces a completion failure in the rendered-form path", async (t) => {
|
|
97
|
+
const created: FakeElement[] = [];
|
|
98
|
+
const calls: FetchCall[] = [];
|
|
99
|
+
t.after(installFakeDom(created));
|
|
100
|
+
t.after(
|
|
101
|
+
installFakeFetch((url) => {
|
|
102
|
+
if (url.includes("/app/actions/form")) {
|
|
103
|
+
return { status: 200, json: { schema: { components: [{ type: "textfield", key: "note" }] } } };
|
|
104
|
+
}
|
|
105
|
+
return { status: 500 }; // completion fails
|
|
106
|
+
}, calls),
|
|
107
|
+
);
|
|
108
|
+
const box = buildEngineForm(CFG, { form_key: "form-e", user_task_key: "ut-e" }, () => {});
|
|
109
|
+
assert.ok(box);
|
|
110
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
111
|
+
const form = created.find((n) => n.tagName === "FORM");
|
|
112
|
+
assert.ok(form, "the deployed schema was rendered");
|
|
113
|
+
await form!.fire("submit");
|
|
114
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
115
|
+
const msg = created.find((n) => n.className.includes("njf-msg") && n.className.includes("err"));
|
|
116
|
+
assert.ok(msg, "a failed completion is surfaced to the operator (not silent)");
|
|
117
|
+
assert.notEqual(msg!.textContent, "", "the error message element has text");
|
|
118
|
+
const submit = created.find((n) => n.tagName === "BUTTON" && n.type === "submit");
|
|
119
|
+
assert.equal(submit!.disabled, false, "the submit button is re-enabled so the operator can retry");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("buildEngineForm degrades to bare completion when the row has no formKey", async (t) => {
|
|
123
|
+
const created: FakeElement[] = [];
|
|
124
|
+
const calls: FetchCall[] = [];
|
|
125
|
+
t.after(installFakeDom(created));
|
|
126
|
+
t.after(installFakeFetch(() => ({ status: 200, json: { ok: true } }), calls));
|
|
127
|
+
const box = buildEngineForm(CFG, { user_task_key: "ut-2" }, () => {});
|
|
128
|
+
assert.ok(box);
|
|
129
|
+
// No form fetch — a bare "Complete" button is shown directly.
|
|
130
|
+
assert.equal(calls.length, 0, "no formKey ⇒ no form resolution request");
|
|
131
|
+
const button = created.find((n) => n.tagName === "BUTTON");
|
|
132
|
+
assert.ok(button);
|
|
133
|
+
assert.equal(button!.textContent, "Complete");
|
|
134
|
+
await button!.fire("click");
|
|
135
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
136
|
+
const completeReq = calls.find((c) => c.url.includes("/app/actions/complete"));
|
|
137
|
+
assert.ok(completeReq, "the bare button completes the task");
|
|
138
|
+
assert.equal(completeReq!.body.userTaskKey, "ut-2");
|
|
139
|
+
assert.deepEqual(completeReq!.body.variables, {}, "bare completion sends empty variables");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("buildEngineForm degrades to bare completion when the formKey resolves to no form (204)", async (t) => {
|
|
143
|
+
const created: FakeElement[] = [];
|
|
144
|
+
const calls: FetchCall[] = [];
|
|
145
|
+
t.after(installFakeDom(created));
|
|
146
|
+
t.after(
|
|
147
|
+
installFakeFetch((url) => {
|
|
148
|
+
if (url.includes("/app/actions/form")) return { status: 204 }; // no body
|
|
149
|
+
return { status: 200, json: { ok: true } };
|
|
150
|
+
}, calls),
|
|
151
|
+
);
|
|
152
|
+
const box = buildEngineForm(CFG, { form_key: "missing", user_task_key: "ut-3" }, () => {});
|
|
153
|
+
assert.ok(box);
|
|
154
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
155
|
+
assert.ok(calls.some((c) => c.url.includes("/app/actions/form")), "an unresolved formKey still hits the gate");
|
|
156
|
+
const button = created.find((n) => n.tagName === "BUTTON" && n.textContent === "Complete");
|
|
157
|
+
assert.ok(button, "a 204 (no form) falls back to the bare completion button");
|
|
158
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { afterEach, test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createNodeHost } from "../../adapters/node.ts";
|
|
7
|
+
import type { SqliteDb } from "../host.ts";
|
|
8
|
+
import {
|
|
9
|
+
and,
|
|
10
|
+
caseWhen,
|
|
11
|
+
col,
|
|
12
|
+
defineReadModel,
|
|
13
|
+
eq,
|
|
14
|
+
exists,
|
|
15
|
+
lit,
|
|
16
|
+
pcol,
|
|
17
|
+
projectionRegistry,
|
|
18
|
+
type ParitySample,
|
|
19
|
+
type ReadModel,
|
|
20
|
+
when,
|
|
21
|
+
assertReadModelParity,
|
|
22
|
+
} from "../read-model.ts";
|
|
23
|
+
import {
|
|
24
|
+
CANONICAL_PROJECTIONS,
|
|
25
|
+
registerCanonicalProjections,
|
|
26
|
+
} from "./canonical-projections.ts";
|
|
27
|
+
import { INSTANCE_STATE_TABLE, InstanceStateStore } from "./instance-state-store.ts";
|
|
28
|
+
import { OPEN_USER_TASKS_TABLE, OpenUserTasksStore } from "./open-user-tasks-store.ts";
|
|
29
|
+
|
|
30
|
+
// This suite registers into the process-wide `projectionRegistry` singleton; clear it after every
|
|
31
|
+
// test so registrations never leak into later tests and make the suite order-dependent.
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
projectionRegistry.clear();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// A read model that derives an instance's status edge PURELY from the two canonical projections:
|
|
37
|
+
// terminated → "abandoned" (terminated wins), else open user task → "awaiting_operator", else the
|
|
38
|
+
// instance's own stored status. This is the shape the downstream writer-source-inversion task derives.
|
|
39
|
+
function statusEdgeModel(): ReadModel {
|
|
40
|
+
return defineReadModel({
|
|
41
|
+
name: "urban_instance_status",
|
|
42
|
+
baseTable: "instances",
|
|
43
|
+
derive: {
|
|
44
|
+
status_edge: caseWhen(
|
|
45
|
+
[
|
|
46
|
+
when(
|
|
47
|
+
exists(
|
|
48
|
+
"urban_instance_state",
|
|
49
|
+
and(eq(pcol("process_instance_key"), col("process_instance_key")), eq(pcol("state"), lit("TERMINATED"))),
|
|
50
|
+
),
|
|
51
|
+
lit("abandoned"),
|
|
52
|
+
),
|
|
53
|
+
when(
|
|
54
|
+
exists("urban_open_user_tasks", eq(pcol("process_instance_key"), col("process_instance_key"))),
|
|
55
|
+
lit("awaiting_operator"),
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
col("status"),
|
|
59
|
+
),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function withDb(fn: (db: SqliteDb) => void | Promise<void>): Promise<void> {
|
|
65
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-canonical-"));
|
|
66
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
67
|
+
const db = host.openSqlite(join(dir, "test.db"));
|
|
68
|
+
try {
|
|
69
|
+
await fn(db);
|
|
70
|
+
} finally {
|
|
71
|
+
db.close();
|
|
72
|
+
await rm(dir, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test("registerCanonicalProjections maps each DSL name onto its `_urban_` physical table", () => {
|
|
77
|
+
registerCanonicalProjections();
|
|
78
|
+
assert.equal(projectionRegistry.sqlTableFor("urban_open_user_tasks"), OPEN_USER_TASKS_TABLE);
|
|
79
|
+
assert.equal(projectionRegistry.sqlTableFor("urban_instance_state"), INSTANCE_STATE_TABLE);
|
|
80
|
+
// Idempotent: a second registration is a no-op, not a conflict throw.
|
|
81
|
+
assert.doesNotThrow(() => registerCanonicalProjections());
|
|
82
|
+
assert.deepEqual(
|
|
83
|
+
CANONICAL_PROJECTIONS.map((p) => p.name).sort(),
|
|
84
|
+
["urban_instance_state", "urban_open_user_tasks"],
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a defineReadModel EXISTS over both projections materialises a VIEW that reads the sidecar tables", async () => {
|
|
89
|
+
registerCanonicalProjections();
|
|
90
|
+
await withDb((db) => {
|
|
91
|
+
// Provision the two engine-truth sidecars exactly as the runtime boot path does.
|
|
92
|
+
const openTasks = new OpenUserTasksStore(db, { clock: { now: () => 0 } });
|
|
93
|
+
const instanceState = new InstanceStateStore(db, { clock: { now: () => 0 } });
|
|
94
|
+
openTasks.ensureSchema();
|
|
95
|
+
instanceState.ensureSchema();
|
|
96
|
+
|
|
97
|
+
// The base domain table the read model derives over.
|
|
98
|
+
db.exec("CREATE TABLE instances (process_instance_key TEXT, status TEXT);");
|
|
99
|
+
for (const key of ["pi-active", "pi-human", "pi-terminated", "pi-both"]) {
|
|
100
|
+
db.run("INSERT INTO instances (process_instance_key, status) VALUES (?, ?);", [key, "running"]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Record engine truth into the projections (the sources the VIEW derives from).
|
|
104
|
+
instanceState.recordState("pi-active", "ACTIVE");
|
|
105
|
+
instanceState.recordState("pi-human", "ACTIVE", true);
|
|
106
|
+
openTasks.syncInstance("pi-human", [{ userTaskKey: "ut-1", elementId: "Approve" }]);
|
|
107
|
+
instanceState.recordState("pi-terminated", "TERMINATED");
|
|
108
|
+
// pi-both: terminated AND has an open task — terminated must win.
|
|
109
|
+
instanceState.recordState("pi-both", "TERMINATED");
|
|
110
|
+
openTasks.syncInstance("pi-both", [{ userTaskKey: "ut-2" }]);
|
|
111
|
+
|
|
112
|
+
// Materialise the framework-derived managed VIEW and read the derived edge.
|
|
113
|
+
const model = statusEdgeModel();
|
|
114
|
+
db.exec(model.viewDdl());
|
|
115
|
+
const rows = db.all<{ process_instance_key: string; status_edge: string }>(
|
|
116
|
+
"SELECT process_instance_key, status_edge FROM urban_instance_status ORDER BY process_instance_key;",
|
|
117
|
+
);
|
|
118
|
+
const byKey = Object.fromEntries(rows.map((r) => [r.process_instance_key, r.status_edge]));
|
|
119
|
+
assert.equal(byKey["pi-active"], "running", "no terminal/human edge ⇒ the base status survives");
|
|
120
|
+
assert.equal(byKey["pi-human"], "awaiting_operator", "an open user task ⇒ awaiting_operator");
|
|
121
|
+
assert.equal(byKey["pi-terminated"], "abandoned", "TERMINATED ⇒ abandoned");
|
|
122
|
+
assert.equal(byKey["pi-both"], "abandoned", "terminated wins over waiting-on-human");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the SQLite VIEW and the TS function agree over the canonical projections (parity guard)", async () => {
|
|
127
|
+
registerCanonicalProjections();
|
|
128
|
+
const model = statusEdgeModel();
|
|
129
|
+
const samples: ParitySample[] = [
|
|
130
|
+
// Terminal edge.
|
|
131
|
+
{
|
|
132
|
+
baseRow: { process_instance_key: "pi-1", status: "running" },
|
|
133
|
+
projections: { urban_instance_state: [{ process_instance_key: "pi-1", state: "TERMINATED" }] },
|
|
134
|
+
},
|
|
135
|
+
// Wait-on-human edge.
|
|
136
|
+
{
|
|
137
|
+
baseRow: { process_instance_key: "pi-2", status: "running" },
|
|
138
|
+
projections: { urban_open_user_tasks: [{ process_instance_key: "pi-2", user_task_key: "ut-1" }] },
|
|
139
|
+
},
|
|
140
|
+
// Neither edge ⇒ the base status survives.
|
|
141
|
+
{
|
|
142
|
+
baseRow: { process_instance_key: "pi-3", status: "running" },
|
|
143
|
+
projections: {},
|
|
144
|
+
},
|
|
145
|
+
// Correlation: an open task for a DIFFERENT instance must not match.
|
|
146
|
+
{
|
|
147
|
+
baseRow: { process_instance_key: "pi-4", status: "running" },
|
|
148
|
+
projections: { urban_open_user_tasks: [{ process_instance_key: "pi-x", user_task_key: "ut-2" }] },
|
|
149
|
+
},
|
|
150
|
+
// Precedence: terminated AND open task ⇒ abandoned (terminated wins).
|
|
151
|
+
{
|
|
152
|
+
baseRow: { process_instance_key: "pi-5", status: "running" },
|
|
153
|
+
projections: {
|
|
154
|
+
urban_instance_state: [{ process_instance_key: "pi-5", state: "TERMINATED" }],
|
|
155
|
+
urban_open_user_tasks: [{ process_instance_key: "pi-5", user_task_key: "ut-3" }],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
// A non-terminal recorded state ⇒ no terminal edge.
|
|
159
|
+
{
|
|
160
|
+
baseRow: { process_instance_key: "pi-6", status: "converging" },
|
|
161
|
+
projections: { urban_instance_state: [{ process_instance_key: "pi-6", state: "ACTIVE" }] },
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
await withDb((db) => {
|
|
166
|
+
// The parity guard builds its own isolated TEMP fixtures for the base + projection tables and
|
|
167
|
+
// asserts the SQL VIEW and the compiled TS function agree for every sample — no bespoke oracle.
|
|
168
|
+
assert.doesNotThrow(() => assertReadModelParity(model, db, samples));
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("the parity guard catches a divergence between the two backends", async () => {
|
|
173
|
+
registerCanonicalProjections();
|
|
174
|
+
const model = statusEdgeModel();
|
|
175
|
+
// Feed the guard's SQL side a sample, but hand a mutated TS function that disagrees, to prove the
|
|
176
|
+
// guard is actually comparing the two backends (not trivially passing).
|
|
177
|
+
const mutated: ReadModel = { ...model, fnFor: () => () => "WRONG" };
|
|
178
|
+
await withDb((db) => {
|
|
179
|
+
assert.throws(
|
|
180
|
+
() =>
|
|
181
|
+
assertReadModelParity(mutated, db, [
|
|
182
|
+
{
|
|
183
|
+
baseRow: { process_instance_key: "pi-1", status: "running" },
|
|
184
|
+
projections: { urban_instance_state: [{ process_instance_key: "pi-1", state: "TERMINATED" }] },
|
|
185
|
+
},
|
|
186
|
+
]),
|
|
187
|
+
/parity mismatch/,
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// canonical-projections — register the framework's canonical engine-truth projections under their
|
|
2
|
+
// stable DSL names (ADR 0065, proposal point #1). This is the concrete use of the projection-name
|
|
3
|
+
// seam the read-model primitive established (`projectionRegistry.register({ name, sqlTable })`): it
|
|
4
|
+
// maps each unprefixed DSL name a `defineReadModel(...)` `exists(...)` reads
|
|
5
|
+
// (`urban_open_user_tasks`, `urban_instance_state`) onto its `_urban_`-prefixed physical sidecar
|
|
6
|
+
// table, so the SQL backend's `EXISTS` compiles against the real table while the domain model / DB
|
|
7
|
+
// Manager never sees it.
|
|
8
|
+
//
|
|
9
|
+
// Registration is idempotent (the registry no-ops a matching re-register), so calling this on every
|
|
10
|
+
// worker mount — the boot path in `workers.ts` — is safe. It is decoupled from schema provisioning:
|
|
11
|
+
// a read model must be able to reference these projections by name even in a process/app that has no
|
|
12
|
+
// datasource to provision the physical tables into.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
INSTANCE_STATE_PROJECTION,
|
|
16
|
+
INSTANCE_STATE_TABLE,
|
|
17
|
+
} from "./instance-state-store.ts";
|
|
18
|
+
import {
|
|
19
|
+
OPEN_USER_TASKS_PROJECTION,
|
|
20
|
+
OPEN_USER_TASKS_TABLE,
|
|
21
|
+
} from "./open-user-tasks-store.ts";
|
|
22
|
+
import { type ProjectionRegistry, projectionRegistry } from "../read-model.ts";
|
|
23
|
+
|
|
24
|
+
/** The canonical projection name → physical `_urban_` table mappings this task owns. */
|
|
25
|
+
export const CANONICAL_PROJECTIONS: ReadonlyArray<{ readonly name: string; readonly sqlTable: string }> = [
|
|
26
|
+
{ name: OPEN_USER_TASKS_PROJECTION, sqlTable: OPEN_USER_TASKS_TABLE },
|
|
27
|
+
{ name: INSTANCE_STATE_PROJECTION, sqlTable: INSTANCE_STATE_TABLE },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register the canonical engine-truth projections in a {@link ProjectionRegistry} (defaults to the
|
|
32
|
+
* process-wide `projectionRegistry`), so a `defineReadModel(...)` authored anywhere can
|
|
33
|
+
* `exists("urban_open_user_tasks", …)` / `exists("urban_instance_state", …)` and have the SQL backend
|
|
34
|
+
* resolve them to the `_urban_`-prefixed physical tables. Idempotent.
|
|
35
|
+
*/
|
|
36
|
+
export function registerCanonicalProjections(registry: ProjectionRegistry = projectionRegistry): void {
|
|
37
|
+
for (const projection of CANONICAL_PROJECTIONS) registry.register(projection);
|
|
38
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @generated by scripts/gen-runtime.mjs — DO NOT EDIT.
|
|
2
|
+
// Source: src/runtime/browser/formjs.browser.js (the shared, type-checked/linted/tested
|
|
3
|
+
// form-js renderer). Regenerate with `npm run gen:runtime`; CI enforces this file
|
|
4
|
+
// is byte-identical to a fresh generation via `git diff --exit-code` (npm run check:runtime).
|
|
5
|
+
|
|
6
|
+
export const FORMJS_JS: string = "// @ts-check\n// ── Shared, XSS-safe form-js schema renderer (ADR 0026, extended by #457) ─────\n// This is the ONE client implementation of \"render a deployed `.form`'s form-js\n// schema and collect its field values\", shared verbatim by BOTH:\n// • the `taskInbox` surface (surfaces.ts `inboxPage`), and\n// • the pages `dataGrid` engine-form detail (runtime.browser.js).\n// It used to live inline in surfaces.ts; factoring it here means the two surfaces\n// can never fork the renderer or drift on how a form-js component maps to an input\n// and its submitted value.\n//\n// Like the rest of the browser runtime it is authored/type-checked/linted/tested\n// as real source and served as a generated string artifact (formjs.gen.ts, emitted\n// by scripts/gen-runtime.mjs). It performs NO DOM access at load — every DOM touch\n// is inside a function — so it is import-safe under Node/Deno for unit testing.\n//\n// SECURITY: every value the engine returns is placed via textContent / setAttribute\n// (never innerHTML), so nothing in the form schema can inject markup. The submitted\n// `variables` bag is null-prototype so an engine-supplied component key of\n// `__proto__`/`constructor` lands as a plain own property and can never mutate a\n// prototype (prototype-pollution class).\n\n/** Create an element, setting attributes via setAttribute and text via textContent only.\n * @param {string} tag\n * @param {Record<string, string>} [attrs]\n * @param {string} [text]\n * @returns {any}\n */\nfunction fjEl(tag, attrs, text) {\n const e = document.createElement(tag);\n if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]);\n if (text != null) e.textContent = text;\n return e;\n}\n\n/** A plain type=\"button\" with an optional click handler.\n * @param {string} label\n * @param {(() => void) | null} [onClick]\n * @returns {any}\n */\nfunction fjBtn(label, onClick) {\n const b = document.createElement(\"button\");\n b.type = \"button\";\n b.textContent = label;\n if (onClick) b.addEventListener(\"click\", onClick);\n return b;\n}\n\n/** Build one form-js component into its field element and (for keyed inputs) a reader\n * that returns the entered `{ key, value }` or null when blank. Static (keyless)\n * components render as inert text; layout-only components are skipped (null).\n * @param {any} c\n * @returns {{ field: any, read?: () => ({ key: string, value: any } | null) } | null}\n */\nfunction buildField(c) {\n const type = c && c.type;\n // Static (keyless) components: show text as a paragraph, ignore layout-only ones.\n if (type === \"text\") {\n return { field: fjEl(\"p\", { class: \"njf-text\" }, typeof c.text === \"string\" ? c.text : \"\") };\n }\n const key = c && c.key;\n if (!key) return null;\n const wrap = fjEl(\"div\", { class: \"njf-field\" });\n const label = typeof c.label === \"string\" && c.label ? c.label : key;\n if (type === \"checkbox\") {\n const input = document.createElement(\"input\");\n input.type = \"checkbox\";\n const lab = fjEl(\"label\", {});\n lab.appendChild(input);\n lab.appendChild(document.createTextNode(\" \" + label));\n wrap.appendChild(lab);\n return { field: wrap, read: () => ({ key, value: input.checked }) };\n }\n const labelEl = fjEl(\"label\", {}, label);\n wrap.appendChild(labelEl);\n let input;\n if (type === \"textarea\") {\n input = document.createElement(\"textarea\");\n } else if (type === \"select\") {\n input = document.createElement(\"select\");\n input.appendChild(fjEl(\"option\", { value: \"\" }, \"\\u2014\"));\n for (const o of Array.isArray(c.values) ? c.values : []) {\n input.appendChild(fjEl(\"option\", { value: String(o.value) }, String(o.label != null ? o.label : o.value)));\n }\n } else if (type === \"radio\") {\n const name = \"r\" + Math.random().toString(36).slice(2);\n const group = fjEl(\"div\", {});\n for (const o of Array.isArray(c.values) ? c.values : []) {\n const rlab = fjEl(\"label\", {});\n const r = document.createElement(\"input\");\n r.type = \"radio\";\n r.name = name;\n r.value = String(o.value);\n rlab.appendChild(r);\n rlab.appendChild(document.createTextNode(\" \" + String(o.label != null ? o.label : o.value)));\n group.appendChild(rlab);\n }\n wrap.appendChild(group);\n return {\n field: wrap,\n read: () => {\n const sel = group.querySelector(\"input:checked\");\n return sel ? { key, value: sel.value } : null;\n },\n };\n } else {\n input = document.createElement(\"input\");\n input.type = type === \"number\" ? \"number\" : type === \"datetime\" ? \"datetime-local\" : \"text\";\n }\n // Associate the label with the input (for/id) so screen readers announce it on focus.\n const fieldId = \"f\" + Math.random().toString(36).slice(2);\n input.id = fieldId;\n labelEl.setAttribute(\"for\", fieldId);\n wrap.appendChild(input);\n const isNumber = type === \"number\";\n return {\n field: wrap,\n read: () => {\n const raw = input.value;\n if (raw === \"\" || raw == null) return null;\n if (isNumber) {\n const n = Number(raw);\n // A non-numeric value in a number field is only reachable via tampering (the browser\n // blanks invalid type=number input). Treat it as absent rather than silently submitting\n // NaN, which JSON.stringify serializes as null — quietly changing the submitted value.\n return Number.isFinite(n) ? { key, value: n } : null;\n }\n return { key, value: raw };\n },\n };\n}\n\n/**\n * Render a form-js schema into a `<form>` element with a submit + optional cancel.\n * On submit it assembles every keyed field's value into one null-prototype\n * `variables` bag and calls `opts.onSubmit(variables)`; a rejected submit re-enables\n * the button so the operator can retry. `opts.onCancel`, when supplied, wires a\n * cancel button.\n * @param {any} schema the parsed form-js document (`{ components: [...] }`)\n * @param {{ heading?: string, submitLabel?: string, cancelLabel?: string,\n * onSubmit: (variables: Record<string, any>) => (void | Promise<void>),\n * onCancel?: () => void }} opts\n * @returns {any} the built <form> element\n */\nfunction renderForm(schema, opts) {\n const form = document.createElement(\"form\");\n form.className = \"njf-form\";\n if (opts.heading) form.appendChild(fjEl(\"h2\", { class: \"njf-heading\" }, opts.heading));\n const components = schema && Array.isArray(schema.components) ? schema.components : [];\n /** @type {Array<() => ({ key: string, value: any } | null)>} */\n const inputs = [];\n for (const c of components) {\n const built = buildField(c);\n if (!built) continue;\n form.appendChild(built.field);\n if (built.read) inputs.push(built.read);\n }\n const submit = fjBtn(opts.submitLabel || \"Submit\", null);\n submit.type = \"submit\";\n const bar = fjEl(\"div\", { class: \"njf-actions\" });\n bar.appendChild(submit);\n if (opts.onCancel) bar.appendChild(fjBtn(opts.cancelLabel || \"Cancel\", opts.onCancel));\n form.appendChild(bar);\n form.addEventListener(\"submit\", (e) => {\n e.preventDefault();\n // Null-prototype: field keys come from the engine-supplied form schema. A component\n // keyed '__proto__'/'constructor' must land as a plain own property (and round-trip\n // through JSON), never mutate a prototype — no prototype pollution in the page runtime.\n const variables = Object.create(null);\n for (const read of inputs) {\n const kv = read();\n if (kv) variables[kv.key] = kv.value;\n }\n submit.disabled = true;\n Promise.resolve()\n .then(() => opts.onSubmit(variables))\n .catch(() => {\n submit.disabled = false;\n });\n });\n return form;\n}\n\n\n// Expose the renderer as a browser global so both self-contained page bundles can\n// reach the ONE implementation without importing it: the taskInbox page embeds this\n// module inline and the pages shell does the same before loading the ES-module\n// runtime, which reads `globalThis.NanoFormJs` lazily when it renders an engine form.\n// Guarded so importing under Node/Deno for tests is a harmless no-op-ish assignment.\nif (typeof globalThis !== \"undefined\") {\n /** @type {any} */ (globalThis).NanoFormJs = { buildField, renderForm };\n}\n";
|