@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.
@@ -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 { presentFormIdentifier } from "../host.ts";
12
- import { html, json, noContent, normalizeRoutePath, type Route } from "../router.ts";
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
- form label{display:block;margin:.6rem 0 .2rem;font-weight:600}
46
- form .field input,form .field textarea,form .field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
47
- form .field input[type=checkbox],form .field input[type=radio]{width:auto}
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
- renderForm(t,f.schema);
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
- const formKey = req.query.get("formKey") ?? undefined;
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