@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.
- 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/pages.js +36 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/core/modules/surfaces.js +18 -124
- 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/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/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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const FORMJS_JS: string;
|
|
@@ -0,0 +1,5 @@
|
|
|
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
|
+
export const FORMJS_JS = "// @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,22 @@
|
|
|
1
|
+
import type { EngineClient, HttpResponse } from "../host.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The shared `/api/form` resolution gate. Rejects only when *neither* identifier
|
|
4
|
+
* is present — presence follows getForm's canonical rule (`presentFormIdentifier`:
|
|
5
|
+
* empty/whitespace = absent) so a whitespace-only `?formKey= ` 400s here instead
|
|
6
|
+
* of slipping past a raw truthiness check and returning a spurious 204. The raw
|
|
7
|
+
* values are passed through unchanged — resolving a blank key to its `formId`
|
|
8
|
+
* fallback is getForm's single responsibility. A form that can't be resolved
|
|
9
|
+
* returns 204 so the client renders the no-form fallback rather than erroring.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveFormResponse(engine: Pick<EngineClient, "getForm">, formKey: string | undefined, formId: string | undefined): Promise<HttpResponse>;
|
|
12
|
+
/**
|
|
13
|
+
* The shared user-task completion endpoint. Parses `{ userTaskKey, variables }`
|
|
14
|
+
* from the request body and completes the task via `EngineClient.completeUserTask`.
|
|
15
|
+
* A body that isn't a JSON object (`null`, a scalar, an array) 400s instead of
|
|
16
|
+
* throwing a 500; a missing/blank `userTaskKey` 400s; a malformed body 400s; a
|
|
17
|
+
* present but non-object `variables` (number, string, array, `null`) 400s so the
|
|
18
|
+
* completion contract stays well-defined at this shared seam. Used by both the
|
|
19
|
+
* taskInbox surface and the pages grid's engine-form detail so the completion
|
|
20
|
+
* contract can't drift between them.
|
|
21
|
+
*/
|
|
22
|
+
export declare function completeUserTaskResponse(engine: Pick<EngineClient, "completeUserTask">, bodyText: string): Promise<HttpResponse>;
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
import { presentFormIdentifier } from "../host.js";
|
|
13
|
+
import { isRecord } from "../guards.js";
|
|
14
|
+
import { json, noContent } from "../router.js";
|
|
15
|
+
/**
|
|
16
|
+
* The shared `/api/form` resolution gate. Rejects only when *neither* identifier
|
|
17
|
+
* is present — presence follows getForm's canonical rule (`presentFormIdentifier`:
|
|
18
|
+
* empty/whitespace = absent) so a whitespace-only `?formKey= ` 400s here instead
|
|
19
|
+
* of slipping past a raw truthiness check and returning a spurious 204. The raw
|
|
20
|
+
* values are passed through unchanged — resolving a blank key to its `formId`
|
|
21
|
+
* fallback is getForm's single responsibility. A form that can't be resolved
|
|
22
|
+
* returns 204 so the client renders the no-form fallback rather than erroring.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveFormResponse(engine, formKey, formId) {
|
|
25
|
+
if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
|
|
26
|
+
return json({ error: "formKey or formId required" }, 400);
|
|
27
|
+
const form = await engine.getForm({ formKey, formId });
|
|
28
|
+
if (!form)
|
|
29
|
+
return noContent();
|
|
30
|
+
return json(form);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The shared user-task completion endpoint. Parses `{ userTaskKey, variables }`
|
|
34
|
+
* from the request body and completes the task via `EngineClient.completeUserTask`.
|
|
35
|
+
* A body that isn't a JSON object (`null`, a scalar, an array) 400s instead of
|
|
36
|
+
* throwing a 500; a missing/blank `userTaskKey` 400s; a malformed body 400s; a
|
|
37
|
+
* present but non-object `variables` (number, string, array, `null`) 400s so the
|
|
38
|
+
* completion contract stays well-defined at this shared seam. Used by both the
|
|
39
|
+
* taskInbox surface and the pages grid's engine-form detail so the completion
|
|
40
|
+
* contract can't drift between them.
|
|
41
|
+
*/
|
|
42
|
+
export async function completeUserTaskResponse(engine, bodyText) {
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(bodyText || "{}");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
49
|
+
}
|
|
50
|
+
if (!isRecord(parsed))
|
|
51
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
52
|
+
const { userTaskKey, variables } = parsed;
|
|
53
|
+
if (typeof userTaskKey !== "string" || !userTaskKey)
|
|
54
|
+
return json({ error: "userTaskKey required" }, 400);
|
|
55
|
+
if (variables !== undefined && !isRecord(variables))
|
|
56
|
+
return json({ error: "variables must be a JSON object" }, 400);
|
|
57
|
+
await engine.completeUserTask(userTaskKey, variables);
|
|
58
|
+
return json({ ok: true });
|
|
59
|
+
}
|
|
@@ -28,6 +28,8 @@ import { errorMessage, isRecord } from "../guards.js";
|
|
|
28
28
|
import { html, json } from "../router.js";
|
|
29
29
|
import { cancelInstanceReconciling } from "./cancel.js";
|
|
30
30
|
import { apiDocsPath } from "./api.js";
|
|
31
|
+
import { FORMJS_JS } from "./formjs.gen.js";
|
|
32
|
+
import { completeUserTaskResponse, resolveFormResponse } from "./forms.js";
|
|
31
33
|
import { quoteIdent } from "./gateway.js";
|
|
32
34
|
import { RENDERER_JS } from "./runtime.gen.js";
|
|
33
35
|
/** A JavaScript-body response (the renderer module served at the fingerprinted
|
|
@@ -489,6 +491,26 @@ export function createPagesRoutes(opts, deps) {
|
|
|
489
491
|
}
|
|
490
492
|
},
|
|
491
493
|
});
|
|
494
|
+
// ── GET /app/actions/form & POST /app/actions/complete (ADR 0026, #457) ───
|
|
495
|
+
// Engine-declared forms in a `dataGrid` detail drawer. A grid whose `detail`
|
|
496
|
+
// opts into `engineForm` resolves the selected row's `form_key` here and
|
|
497
|
+
// completes the row's user task here — the SAME two seams the `taskInbox`
|
|
498
|
+
// surface uses, reused (not forked) via `forms.ts` so the resolution gate and
|
|
499
|
+
// the completion contract can't drift between the two surfaces. This closes the
|
|
500
|
+
// form-duplication drift class: the grid renders the authoritative deployed
|
|
501
|
+
// `.form` rather than a hand-authored page-local `detail.form` copy.
|
|
502
|
+
routes.push({
|
|
503
|
+
method: "GET",
|
|
504
|
+
path: "/app/actions/form",
|
|
505
|
+
source: "surface:pages",
|
|
506
|
+
handler: (req) => resolveFormResponse(engine, req.query.get("formKey") ?? undefined, req.query.get("formId") ?? undefined),
|
|
507
|
+
});
|
|
508
|
+
routes.push({
|
|
509
|
+
method: "POST",
|
|
510
|
+
path: "/app/actions/complete",
|
|
511
|
+
source: "surface:pages",
|
|
512
|
+
handler: async (req) => completeUserTaskResponse(engine, await req.text()),
|
|
513
|
+
});
|
|
492
514
|
// ── nested (multi-appView) sidecars (#442) ──────────────────────────────
|
|
493
515
|
// An app with more than one `appView` cannot share the four FLAT sidecar names above:
|
|
494
516
|
// each view ships its own SUBDIR sidecar — e.g. `pages/cockpit.page.json` →
|
|
@@ -603,6 +625,7 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
603
625
|
</head>
|
|
604
626
|
<body>${apiDocsBadge}
|
|
605
627
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
628
|
+
<script>${FORMJS_JS}</script>
|
|
606
629
|
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
607
630
|
</body>
|
|
608
631
|
</html>`;
|
|
@@ -669,6 +692,19 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
669
692
|
.pc-field-check input { width:auto; padding:0; accent-color:var(--nano-accent); cursor:pointer; }
|
|
670
693
|
.pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
|
|
671
694
|
.pc-btn:disabled { opacity:.5; cursor:default; }
|
|
695
|
+
/* The shared engine-form renderer (NanoFormJs, ADR 0026/#457) styled to match the
|
|
696
|
+
page runtime's native detail controls. njf-* are its stable class names; both
|
|
697
|
+
this surface and the taskInbox page style the same renderer output. */
|
|
698
|
+
.njf-form { display:flex; flex-direction:column; gap:.4rem; margin-top:.5rem; }
|
|
699
|
+
.njf-heading { font-size:1rem; margin:0 0 .25rem; }
|
|
700
|
+
.njf-text { color:var(--nano-text-muted); margin:.25rem 0; }
|
|
701
|
+
.njf-field { display:flex; flex-direction:column; gap:.25rem; }
|
|
702
|
+
.njf-field label { font-size:.8rem; color:var(--nano-text-muted); }
|
|
703
|
+
.njf-field input:not([type=checkbox]):not([type=radio]), .njf-field textarea, .njf-field select { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); max-width:24rem; }
|
|
704
|
+
.njf-actions { display:flex; gap:.5rem; margin-top:.4rem; }
|
|
705
|
+
.njf-actions button { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
|
|
706
|
+
.njf-msg { font-size:.85rem; margin-top:.35rem; }
|
|
707
|
+
.njf-msg.err { color:var(--nano-danger); }
|
|
672
708
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
673
709
|
.pc-msg.err { color:var(--nano-danger); }
|
|
674
710
|
.pc-msg.ok { color:var(--nano-ok); }
|