@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
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// Surfaces contribute routes to the shared server.
|
|
9
9
|
|
|
10
10
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { html, json, normalizeRoutePath, type Route } from "../router.ts";
|
|
12
|
+
import { FORMJS_JS } from "./formjs.gen.ts";
|
|
13
|
+
import { completeUserTaskResponse, resolveFormResponse } from "./forms.ts";
|
|
13
14
|
import { mountActions } from "./actions.ts";
|
|
14
15
|
import { mountAgent } from "./agent.ts";
|
|
15
16
|
import { mountApi } from "./api.ts";
|
|
@@ -42,13 +43,15 @@ function inboxPage(): string {
|
|
|
42
43
|
ul{list-style:none;padding:0}li{margin:.4rem 0;display:flex;align-items:center;gap:.5rem}
|
|
43
44
|
code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}
|
|
44
45
|
button{font:inherit;padding:.2rem .6rem;cursor:pointer}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
.njf-field label{display:block;margin:.6rem 0 .2rem;font-weight:600}
|
|
47
|
+
.njf-field input,.njf-field textarea,.njf-field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
|
|
48
|
+
.njf-field input[type=checkbox],.njf-field input[type=radio]{width:auto}
|
|
49
|
+
.njf-actions{margin-top:.6rem}
|
|
48
50
|
#form{margin-top:1.5rem;border-top:1px solid #ddd;padding-top:1rem}
|
|
49
51
|
.muted{color:#666}</style>
|
|
50
52
|
<h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
|
|
51
53
|
<div id="form"></div>
|
|
54
|
+
<script>${FORMJS_JS}</script>
|
|
52
55
|
<script>
|
|
53
56
|
const BASE=location.pathname.replace(/[/]+$/,'');
|
|
54
57
|
const tasksEl=document.getElementById('tasks');
|
|
@@ -83,7 +86,14 @@ function openForm(t){
|
|
|
83
86
|
api('/api/form?'+q).then(f=>{
|
|
84
87
|
formEl.replaceChildren();
|
|
85
88
|
if(!f||!f.schema){renderNoForm(t);return;}
|
|
86
|
-
|
|
89
|
+
// Reuse the ONE shared form-js renderer (NanoFormJs) — no fork with the pages surface.
|
|
90
|
+
formEl.appendChild(NanoFormJs.renderForm(f.schema,{
|
|
91
|
+
heading:'Task '+(t.elementId||t.userTaskKey),
|
|
92
|
+
submitLabel:'Submit',
|
|
93
|
+
cancelLabel:'Cancel',
|
|
94
|
+
onSubmit:(variables)=>complete(t.userTaskKey,variables),
|
|
95
|
+
onCancel:loadTasks,
|
|
96
|
+
}));
|
|
87
97
|
}).catch(err=>{formEl.replaceChildren(el('p',{class:'muted'},'Failed to load form: '+err.message));});
|
|
88
98
|
}
|
|
89
99
|
|
|
@@ -95,93 +105,6 @@ function renderNoForm(t){
|
|
|
95
105
|
formEl.appendChild(bar);
|
|
96
106
|
}
|
|
97
107
|
|
|
98
|
-
function renderForm(t,schema){
|
|
99
|
-
const form=document.createElement('form');
|
|
100
|
-
form.appendChild(el('h2',{},'Task '+(t.elementId||t.userTaskKey)));
|
|
101
|
-
const components=Array.isArray(schema.components)?schema.components:[];
|
|
102
|
-
const inputs=[];
|
|
103
|
-
for(const c of components){
|
|
104
|
-
const built=buildField(c);
|
|
105
|
-
if(!built)continue;
|
|
106
|
-
form.appendChild(built.field);
|
|
107
|
-
if(built.read)inputs.push(built.read);
|
|
108
|
-
}
|
|
109
|
-
const submit=btn('Submit',null);submit.type='submit';
|
|
110
|
-
const bar=el('div',{});bar.appendChild(submit);bar.appendChild(btn('Cancel',loadTasks));
|
|
111
|
-
form.appendChild(bar);
|
|
112
|
-
form.addEventListener('submit',e=>{
|
|
113
|
-
e.preventDefault();
|
|
114
|
-
// Null-prototype: field keys come from the engine-supplied form schema. A component
|
|
115
|
-
// keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
|
|
116
|
-
// through JSON), never mutate a prototype — no prototype pollution in the page runtime.
|
|
117
|
-
const variables=Object.create(null);
|
|
118
|
-
for(const read of inputs){const kv=read();if(kv)variables[kv.key]=kv.value;}
|
|
119
|
-
submit.disabled=true;
|
|
120
|
-
complete(t.userTaskKey,variables).catch(()=>{submit.disabled=false;});
|
|
121
|
-
});
|
|
122
|
-
formEl.appendChild(form);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function buildField(c){
|
|
126
|
-
const type=c&&c.type;
|
|
127
|
-
// Static (keyless) components: show text as a paragraph, ignore layout-only ones.
|
|
128
|
-
if(type==='text'){return {field:el('p',{class:'muted'},typeof c.text==='string'?c.text:'')};}
|
|
129
|
-
const key=c&&c.key;
|
|
130
|
-
if(!key)return null;
|
|
131
|
-
const wrap=el('div',{class:'field'});
|
|
132
|
-
const label=typeof c.label==='string'&&c.label?c.label:key;
|
|
133
|
-
if(type==='checkbox'){
|
|
134
|
-
const input=document.createElement('input');input.type='checkbox';
|
|
135
|
-
const lab=el('label',{});lab.appendChild(input);lab.appendChild(document.createTextNode(' '+label));
|
|
136
|
-
wrap.appendChild(lab);
|
|
137
|
-
return {field:wrap,read:()=>({key,value:input.checked})};
|
|
138
|
-
}
|
|
139
|
-
const labelEl=el('label',{},label);
|
|
140
|
-
wrap.appendChild(labelEl);
|
|
141
|
-
let input;
|
|
142
|
-
if(type==='textarea'){input=document.createElement('textarea');}
|
|
143
|
-
else if(type==='select'){
|
|
144
|
-
input=document.createElement('select');
|
|
145
|
-
input.appendChild(el('option',{value:''},'—'));
|
|
146
|
-
for(const o of (Array.isArray(c.values)?c.values:[])){
|
|
147
|
-
input.appendChild(el('option',{value:String(o.value)},String(o.label!=null?o.label:o.value)));
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
else if(type==='radio'){
|
|
151
|
-
const name='r'+Math.random().toString(36).slice(2);
|
|
152
|
-
const group=el('div',{});
|
|
153
|
-
for(const o of (Array.isArray(c.values)?c.values:[])){
|
|
154
|
-
const rlab=el('label',{});
|
|
155
|
-
const r=document.createElement('input');r.type='radio';r.name=name;r.value=String(o.value);
|
|
156
|
-
rlab.appendChild(r);rlab.appendChild(document.createTextNode(' '+String(o.label!=null?o.label:o.value)));
|
|
157
|
-
group.appendChild(rlab);
|
|
158
|
-
}
|
|
159
|
-
wrap.appendChild(group);
|
|
160
|
-
return {field:wrap,read:()=>{const sel=group.querySelector('input:checked');return sel?{key,value:sel.value}:null;}};
|
|
161
|
-
}
|
|
162
|
-
else{
|
|
163
|
-
input=document.createElement('input');
|
|
164
|
-
input.type=(type==='number')?'number':(type==='datetime'?'datetime-local':'text');
|
|
165
|
-
}
|
|
166
|
-
// Associate the label with the input (for/id) so screen readers announce it on focus.
|
|
167
|
-
const fieldId='f'+Math.random().toString(36).slice(2);
|
|
168
|
-
input.id=fieldId;labelEl.setAttribute('for',fieldId);
|
|
169
|
-
wrap.appendChild(input);
|
|
170
|
-
const isNumber=type==='number';
|
|
171
|
-
return {field:wrap,read:()=>{
|
|
172
|
-
const raw=input.value;
|
|
173
|
-
if(raw===''||raw==null)return null;
|
|
174
|
-
if(isNumber){
|
|
175
|
-
const n=Number(raw);
|
|
176
|
-
// A non-numeric value in a number field is only reachable via tampering (the browser
|
|
177
|
-
// blanks invalid type=number input). Treat it as absent rather than silently submitting
|
|
178
|
-
// NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
|
|
179
|
-
return Number.isFinite(n)?{key,value:n}:null;
|
|
180
|
-
}
|
|
181
|
-
return {key,value:raw};
|
|
182
|
-
}};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
108
|
function complete(userTaskKey,variables){
|
|
186
109
|
return api('/api/complete',{method:'POST',headers:{'content-type':'application/json'},
|
|
187
110
|
body:JSON.stringify({userTaskKey,variables})}).then(loadTasks);
|
|
@@ -236,39 +159,14 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
|
|
|
236
159
|
method: "GET",
|
|
237
160
|
path: `${base}/api/form`,
|
|
238
161
|
source: "surface:taskInbox",
|
|
239
|
-
handler: async (req) =>
|
|
240
|
-
|
|
241
|
-
const formId = req.query.get("formId") ?? undefined;
|
|
242
|
-
// Reject only when *neither* identifier is present. Presence follows getForm's
|
|
243
|
-
// canonical rule (`presentFormIdentifier`: empty/whitespace = absent) so a
|
|
244
|
-
// whitespace-only `?formKey= ` request 400s here instead of slipping past a raw
|
|
245
|
-
// truthiness check and returning a spurious 204. The raw values are still passed
|
|
246
|
-
// through unchanged — resolving a blank key to its `formId` fallback is getForm's
|
|
247
|
-
// single responsibility.
|
|
248
|
-
if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
|
|
249
|
-
return json({ error: "formKey or formId required" }, 400);
|
|
250
|
-
const form = await app.engine.getForm({ formKey, formId });
|
|
251
|
-
// A task whose form can't be resolved returns 204: the client renders the
|
|
252
|
-
// no-form fallback rather than erroring.
|
|
253
|
-
if (!form) return noContent();
|
|
254
|
-
return json(form);
|
|
255
|
-
},
|
|
162
|
+
handler: async (req) =>
|
|
163
|
+
resolveFormResponse(app.engine, req.query.get("formKey") ?? undefined, req.query.get("formId") ?? undefined),
|
|
256
164
|
});
|
|
257
165
|
routes.push({
|
|
258
166
|
method: "POST",
|
|
259
167
|
path: `${base}/api/complete`,
|
|
260
168
|
source: "surface:taskInbox",
|
|
261
|
-
handler: async (req) =>
|
|
262
|
-
let body: { userTaskKey?: string; variables?: Record<string, unknown> };
|
|
263
|
-
try {
|
|
264
|
-
body = JSON.parse((await req.text()) || "{}");
|
|
265
|
-
} catch {
|
|
266
|
-
return json({ error: "invalid JSON body" }, 400);
|
|
267
|
-
}
|
|
268
|
-
if (!body.userTaskKey) return json({ error: "userTaskKey required" }, 400);
|
|
269
|
-
await app.engine.completeUserTask(body.userTaskKey, body.variables);
|
|
270
|
-
return json({ ok: true });
|
|
271
|
-
},
|
|
169
|
+
handler: async (req) => completeUserTaskResponse(app.engine, await req.text()),
|
|
272
170
|
});
|
|
273
171
|
}
|
|
274
172
|
|