@nanobpm/urban 0.79.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.
@@ -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,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";
@@ -0,0 +1,122 @@
1
+ // The shared engine-form server seams (ADR 0026, #457): the /api/form resolution
2
+ // gate and the user-task completion, factored out of surfaces.ts so the taskInbox
3
+ // surface and the pages grid detail share ONE implementation. These tests pin the
4
+ // gate's contract directly (both callers depend on it identically).
5
+
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import type { EngineClient, FormSchema } from "../host.ts";
9
+ import { completeUserTaskResponse, resolveFormResponse } from "./forms.ts";
10
+
11
+ function formEngine(getForm: EngineClient["getForm"]): Pick<EngineClient, "getForm"> {
12
+ return { getForm };
13
+ }
14
+
15
+ test("resolveFormResponse 400s when neither identifier is present (empty/whitespace = absent)", async () => {
16
+ const engine = formEngine(async () => {
17
+ throw new Error("getForm must not be called when no identifier is present");
18
+ });
19
+ for (const [formKey, formId] of [
20
+ [undefined, undefined],
21
+ ["", ""],
22
+ [" ", undefined],
23
+ [undefined, "\t"],
24
+ ] as const) {
25
+ const res = await resolveFormResponse(engine, formKey, formId);
26
+ assert.equal(res.status, 400);
27
+ }
28
+ });
29
+
30
+ test("resolveFormResponse passes raw identifiers through to getForm and returns its schema", async () => {
31
+ const seen: { formKey?: string; formId?: string }[] = [];
32
+ const schema: FormSchema = { formKey: "form-1", schema: { components: [] } };
33
+ const engine = formEngine(async (input) => {
34
+ seen.push(input);
35
+ return schema;
36
+ });
37
+ const res = await resolveFormResponse(engine, "form-1", undefined);
38
+ assert.equal(res.status, 200);
39
+ assert.ok(res.body);
40
+ assert.deepEqual(JSON.parse(res.body), schema);
41
+ // A blank formKey is passed through unchanged so getForm can fall back to formId
42
+ // (that fallback is getForm's single responsibility, not the gate's).
43
+ await resolveFormResponse(engine, "", "the-form-id");
44
+ assert.deepEqual(seen, [
45
+ { formKey: "form-1", formId: undefined },
46
+ { formKey: "", formId: "the-form-id" },
47
+ ]);
48
+ });
49
+
50
+ test("resolveFormResponse 204s (no body) when getForm can't resolve a form", async () => {
51
+ const res = await resolveFormResponse(formEngine(async () => null), "missing", undefined);
52
+ assert.equal(res.status, 204);
53
+ assert.equal(res.body, "");
54
+ });
55
+
56
+ test("completeUserTaskResponse completes the task with the submitted variables", async () => {
57
+ const calls: { key: string; variables?: Record<string, unknown> }[] = [];
58
+ const engine: Pick<EngineClient, "completeUserTask"> = {
59
+ async completeUserTask(key, variables) {
60
+ calls.push({ key, variables });
61
+ },
62
+ };
63
+ const res = await completeUserTaskResponse(engine, JSON.stringify({ userTaskKey: "ut-7", variables: { a: 1 } }));
64
+ assert.equal(res.status, 200);
65
+ assert.ok(res.body);
66
+ assert.deepEqual(JSON.parse(res.body), { ok: true });
67
+ assert.deepEqual(calls, [{ key: "ut-7", variables: { a: 1 } }]);
68
+ });
69
+
70
+ test("completeUserTaskResponse 400s on a missing userTaskKey or malformed body — no completion attempted", async () => {
71
+ let called = false;
72
+ const engine: Pick<EngineClient, "completeUserTask"> = {
73
+ async completeUserTask() {
74
+ called = true;
75
+ },
76
+ };
77
+ assert.equal((await completeUserTaskResponse(engine, JSON.stringify({ variables: {} }))).status, 400);
78
+ assert.equal((await completeUserTaskResponse(engine, "{ not json")).status, 400);
79
+ assert.equal(called, false);
80
+ });
81
+
82
+ test("completeUserTaskResponse 400s on a non-object JSON body — no crash, no completion attempted", async () => {
83
+ let called = false;
84
+ const engine: Pick<EngineClient, "completeUserTask"> = {
85
+ async completeUserTask() {
86
+ called = true;
87
+ },
88
+ };
89
+ // `JSON.parse("null")` returns `null`, and a bare scalar/array parses to a
90
+ // non-record — reading `.userTaskKey` off those must 400, never throw a 500.
91
+ for (const bodyText of ["null", "123", '"a string"', "true", "[]"]) {
92
+ const res = await completeUserTaskResponse(engine, bodyText);
93
+ assert.equal(res.status, 400, `body=${bodyText} should 400`);
94
+ }
95
+ assert.equal(called, false);
96
+ });
97
+
98
+ test("completeUserTaskResponse 400s on non-object variables — no completion attempted", async () => {
99
+ let called = false;
100
+ const engine: Pick<EngineClient, "completeUserTask"> = {
101
+ async completeUserTask() {
102
+ called = true;
103
+ },
104
+ };
105
+ for (const variables of [1, "x", true, [], null]) {
106
+ const res = await completeUserTaskResponse(engine, JSON.stringify({ userTaskKey: "ut-1", variables }));
107
+ assert.equal(res.status, 400, `variables=${JSON.stringify(variables)} should 400`);
108
+ }
109
+ assert.equal(called, false);
110
+ });
111
+
112
+ test("completeUserTaskResponse allows an omitted variables (undefined)", async () => {
113
+ const calls: { key: string; variables?: Record<string, unknown> }[] = [];
114
+ const engine: Pick<EngineClient, "completeUserTask"> = {
115
+ async completeUserTask(key, variables) {
116
+ calls.push({ key, variables });
117
+ },
118
+ };
119
+ const res = await completeUserTaskResponse(engine, JSON.stringify({ userTaskKey: "ut-2" }));
120
+ assert.equal(res.status, 200);
121
+ assert.deepEqual(calls, [{ key: "ut-2", variables: undefined }]);
122
+ });
@@ -0,0 +1,67 @@
1
+ // Shared engine-form plumbing (ADR 0026, extended to the pages surface).
2
+ //
3
+ // The `taskInbox` surface pioneered rendering a user task's *engine-declared*
4
+ // form: resolve the task's `formKey`/`formId` via `EngineClient.getForm(...)`,
5
+ // render the returned form-js schema client-side, and post the entered values
6
+ // as the completion `variables`. The pages `dataGrid` engine-form detail (#457)
7
+ // needs the SAME two server seams — the `/api/form` resolution gate and the
8
+ // task-completion endpoint — so they live here ONCE and are wired by both
9
+ // `surfaces.ts` (taskInbox) and `pages.ts` (the grid detail). No fork: a single
10
+ // resolution gate, and the `host.ts` `presentFormIdentifier` presence rule stays
11
+ // the single source of truth for "was an identifier provided".
12
+
13
+ import type { EngineClient, HttpResponse } from "../host.ts";
14
+ import { presentFormIdentifier } from "../host.ts";
15
+ import { isRecord } from "../guards.ts";
16
+ import { json, noContent } from "../router.ts";
17
+
18
+ /**
19
+ * The shared `/api/form` resolution gate. Rejects only when *neither* identifier
20
+ * is present — presence follows getForm's canonical rule (`presentFormIdentifier`:
21
+ * empty/whitespace = absent) so a whitespace-only `?formKey= ` 400s here instead
22
+ * of slipping past a raw truthiness check and returning a spurious 204. The raw
23
+ * values are passed through unchanged — resolving a blank key to its `formId`
24
+ * fallback is getForm's single responsibility. A form that can't be resolved
25
+ * returns 204 so the client renders the no-form fallback rather than erroring.
26
+ */
27
+ export async function resolveFormResponse(
28
+ engine: Pick<EngineClient, "getForm">,
29
+ formKey: string | undefined,
30
+ formId: string | undefined,
31
+ ): Promise<HttpResponse> {
32
+ if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
33
+ return json({ error: "formKey or formId required" }, 400);
34
+ const form = await engine.getForm({ formKey, formId });
35
+ if (!form) return noContent();
36
+ return json(form);
37
+ }
38
+
39
+ /**
40
+ * The shared user-task completion endpoint. Parses `{ userTaskKey, variables }`
41
+ * from the request body and completes the task via `EngineClient.completeUserTask`.
42
+ * A body that isn't a JSON object (`null`, a scalar, an array) 400s instead of
43
+ * throwing a 500; a missing/blank `userTaskKey` 400s; a malformed body 400s; a
44
+ * present but non-object `variables` (number, string, array, `null`) 400s so the
45
+ * completion contract stays well-defined at this shared seam. Used by both the
46
+ * taskInbox surface and the pages grid's engine-form detail so the completion
47
+ * contract can't drift between them.
48
+ */
49
+ export async function completeUserTaskResponse(
50
+ engine: Pick<EngineClient, "completeUserTask">,
51
+ bodyText: string,
52
+ ): Promise<HttpResponse> {
53
+ let parsed: unknown;
54
+ try {
55
+ parsed = JSON.parse(bodyText || "{}");
56
+ } catch {
57
+ return json({ error: "invalid JSON body" }, 400);
58
+ }
59
+ if (!isRecord(parsed)) return json({ error: "invalid JSON body" }, 400);
60
+ const { userTaskKey, variables } = parsed;
61
+ if (typeof userTaskKey !== "string" || !userTaskKey)
62
+ return json({ error: "userTaskKey required" }, 400);
63
+ if (variables !== undefined && !isRecord(variables))
64
+ return json({ error: "variables must be a JSON object" }, 400);
65
+ await engine.completeUserTask(userTaskKey, variables);
66
+ return json({ ok: true });
67
+ }
@@ -0,0 +1,41 @@
1
+ // Drift gate for the generated browser-runtime artifacts, enforced INSIDE the test
2
+ // suite (which CI already runs) rather than in a workflow step. The browser runtime
3
+ // and the shared form-js renderer are authored as real, type-checked/linted/tested
4
+ // source (runtime.browser.js, formjs.browser.js) and emitted to committed string
5
+ // artifacts (runtime.gen.ts, formjs.gen.ts) by scripts/gen-runtime.mjs. If either
6
+ // checked-in artifact drifts from a fresh generation, the served bundle no longer
7
+ // matches its source and a stale renderer could ship — so we regenerate-and-compare
8
+ // here and fail on any drift, the same guarantee `npm run check:runtime` gives.
9
+
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { execFileSync } from "node:child_process";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ // Resolve the generator relative to THIS file (cwd varies: `npm test` runs from the
16
+ // package root, but a runner may invoke us from elsewhere).
17
+ const GEN_SCRIPT = fileURLToPath(new URL("../../../../scripts/gen-runtime.mjs", import.meta.url));
18
+
19
+ function hasStderr(e: unknown): e is { stderr?: unknown; stdout?: unknown } {
20
+ return typeof e === "object" && e !== null && ("stderr" in e || "stdout" in e);
21
+ }
22
+
23
+ test("the committed runtime + formjs artifacts are byte-identical to a fresh generation (no drift)", () => {
24
+ // `--check` regenerates both artifacts in-memory and diffs them against the
25
+ // committed files, exiting non-zero (with a diagnostic) on any drift. Spawn the real
26
+ // `node` binary explicitly: under `deno test`, `process.execPath` is the `deno`
27
+ // binary, which cannot run this Node script — mirrors the pages.test.ts node --check
28
+ // guard. Regenerate + commit locally with `npm run gen:runtime -w packages/urban`.
29
+ let ok = true;
30
+ let diagnostic = "";
31
+ try {
32
+ execFileSync("node", [GEN_SCRIPT, "--check"], { stdio: "pipe" });
33
+ } catch (err) {
34
+ ok = false;
35
+ diagnostic = hasStderr(err) ? `${String(err.stdout ?? "")}${String(err.stderr ?? "")}` : String(err);
36
+ }
37
+ assert.ok(
38
+ ok,
39
+ `a committed generated artifact is stale — run \`npm run gen:runtime -w packages/urban\` and commit the result.\n${diagnostic}`,
40
+ );
41
+ });
@@ -1950,3 +1950,102 @@ test("the nested serve declines (does not shadow) a same-path trigger route regi
1950
1950
  assert.equal(hook.status, 202);
1951
1951
  assert.deepEqual(JSON.parse(hook.body ?? "{}"), { queued: true });
1952
1952
  });
1953
+
1954
+ // ── engine-declared forms in a dataGrid detail (ADR 0026 / #457) ────────────
1955
+ // The pages surface exposes the SAME two seams the taskInbox surface uses:
1956
+ // GET /app/actions/form?formKey=… → resolve a deployed .form's form-js schema
1957
+ // POST /app/actions/complete → complete the row's user task
1958
+ // so one grid can render the several different typed forms a heterogeneous task
1959
+ // inbox carries, instead of duplicating each into a page-local `detail.form`.
1960
+
1961
+ /** A recording engine for the form-resolution + completion seams. `resolveForm`
1962
+ * supplies the getForm result while the wrapper still records every call, so a test
1963
+ * can assert BOTH the resolved schema and the exact `{formKey, formId}` the shared
1964
+ * presence gate passed through. */
1965
+ function formEngine(
1966
+ resolveForm: (input: { formKey?: string; formId?: string }) => Awaited<ReturnType<EngineClient["getForm"]>> = () => null,
1967
+ ): {
1968
+ engine: EngineClient;
1969
+ formCalls: { formKey?: string; formId?: string }[];
1970
+ completeCalls: { userTaskKey: string; variables?: Record<string, unknown> }[];
1971
+ } {
1972
+ const formCalls: { formKey?: string; formId?: string }[] = [];
1973
+ const completeCalls: { userTaskKey: string; variables?: Record<string, unknown> }[] = [];
1974
+ const { engine } = fakeEngine();
1975
+ const withSeams: EngineClient = {
1976
+ ...engine,
1977
+ async getForm(input: { formKey?: string; formId?: string }) {
1978
+ formCalls.push(input);
1979
+ return resolveForm(input);
1980
+ },
1981
+ async completeUserTask(userTaskKey: string, variables?: Record<string, unknown>) {
1982
+ completeCalls.push({ userTaskKey, variables });
1983
+ },
1984
+ };
1985
+ return { engine: withSeams, formCalls, completeCalls };
1986
+ }
1987
+
1988
+ function formRouter(engine: EngineClient) {
1989
+ const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
1990
+ db: fakeDb(),
1991
+ engine,
1992
+ readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
1993
+ listPages: async () => ["home"],
1994
+ });
1995
+ return makeRouter(routes);
1996
+ }
1997
+
1998
+ test("GET /app/actions/form resolves a row's engine formKey to its form-js schema", async () => {
1999
+ const schema = { type: "default", components: [{ type: "textfield", key: "note" }] };
2000
+ const { engine, formCalls } = formEngine((input) => ({ formKey: input.formKey, schema }));
2001
+ const res = await formRouter(engine)(req("GET", "/app/actions/form", { query: "formKey=form-123" }));
2002
+ assert.equal(res.status, 200);
2003
+ assert.deepEqual(JSON.parse(res.body ?? "{}").schema, schema);
2004
+ assert.deepEqual(formCalls, [{ formKey: "form-123", formId: undefined }]);
2005
+ });
2006
+
2007
+ test("GET /app/actions/form 400s when neither formKey nor formId is present (shared presence gate)", async () => {
2008
+ const { engine } = formEngine();
2009
+ const res = await formRouter(engine)(req("GET", "/app/actions/form"));
2010
+ assert.equal(res.status, 400);
2011
+ const blank = await formRouter(engine)(req("GET", "/app/actions/form", { query: "formKey=%20%20" }));
2012
+ assert.equal(blank.status, 400, "a whitespace-only formKey is absent per the host.ts presence rule");
2013
+ });
2014
+
2015
+ test("GET /app/actions/form 204s when the form can't be resolved (client falls back to bare completion)", async () => {
2016
+ // getForm returns null (default) → 204, matching the taskInbox no-form fallback.
2017
+ const { engine } = formEngine();
2018
+ const res = await formRouter(engine)(req("GET", "/app/actions/form", { query: "formKey=missing" }));
2019
+ assert.equal(res.status, 204);
2020
+ assert.equal(res.body ?? "", "");
2021
+ });
2022
+
2023
+ test("POST /app/actions/complete completes the row's user task with the submitted variables", async () => {
2024
+ const { engine, completeCalls } = formEngine();
2025
+ const res = await formRouter(engine)(
2026
+ req("POST", "/app/actions/complete", { body: { userTaskKey: "ut-9", variables: { note: "ok" } } }),
2027
+ );
2028
+ assert.equal(res.status, 200);
2029
+ assert.deepEqual(JSON.parse(res.body ?? "{}"), { ok: true });
2030
+ assert.deepEqual(completeCalls, [{ userTaskKey: "ut-9", variables: { note: "ok" } }]);
2031
+ });
2032
+
2033
+ test("POST /app/actions/complete supports the no-form bare completion (empty variables)", async () => {
2034
+ const { engine, completeCalls } = formEngine();
2035
+ const res = await formRouter(engine)(req("POST", "/app/actions/complete", { body: { userTaskKey: "ut-1" } }));
2036
+ assert.equal(res.status, 200);
2037
+ assert.deepEqual(completeCalls, [{ userTaskKey: "ut-1", variables: undefined }]);
2038
+ });
2039
+
2040
+ test("POST /app/actions/complete 400s without a userTaskKey", async () => {
2041
+ const { engine, completeCalls } = formEngine();
2042
+ const res = await formRouter(engine)(req("POST", "/app/actions/complete", { body: { variables: {} } }));
2043
+ assert.equal(res.status, 400);
2044
+ assert.equal(completeCalls.length, 0, "no completion is attempted for a keyless body");
2045
+ });
2046
+
2047
+ test("the pages shell embeds the shared form-js renderer (NanoFormJs) for engine forms", async () => {
2048
+ const res = await dispatch("GET", "/");
2049
+ assert.match(res.body ?? "", /NanoFormJs/, "the shell exposes the shared renderer as a global");
2050
+ assert.match(res.body ?? "", /function renderForm/, "the shared renderer is embedded, not forked");
2051
+ });