@nanobpm/urban 0.75.0 → 0.76.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.
|
@@ -38,7 +38,7 @@ form .field input[type=checkbox],form .field input[type=radio]{width:auto}
|
|
|
38
38
|
<h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
|
|
39
39
|
<div id="form"></div>
|
|
40
40
|
<script>
|
|
41
|
-
const BASE=location.pathname.replace(
|
|
41
|
+
const BASE=location.pathname.replace(/[/]+$/,'');
|
|
42
42
|
const tasksEl=document.getElementById('tasks');
|
|
43
43
|
const formEl=document.getElementById('form');
|
|
44
44
|
|
|
@@ -47,7 +47,7 @@ function api(path,opts){return fetch(BASE+path,opts).then(r=>{if(!r.ok)return r.
|
|
|
47
47
|
function loadTasks(){
|
|
48
48
|
formEl.replaceChildren();
|
|
49
49
|
tasksEl.replaceChildren(el('li',{},'loading…'));
|
|
50
|
-
return api('/api/tasks').then(ts=>{
|
|
50
|
+
return api('/api/tasks'+location.search).then(ts=>{
|
|
51
51
|
tasksEl.replaceChildren();
|
|
52
52
|
if(!ts.length){tasksEl.appendChild(el('li',{},'No open tasks.'));return;}
|
|
53
53
|
for(const t of ts){
|
|
@@ -202,13 +202,18 @@ export function mountSurfaces(ctx, app) {
|
|
|
202
202
|
source: "surface:taskInbox",
|
|
203
203
|
handler: async (req) => {
|
|
204
204
|
const pik = req.query.get("processInstanceKey") ?? undefined;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
// "
|
|
209
|
-
|
|
210
|
-
|
|
205
|
+
const assignee = req.query.get("assignee") ?? undefined;
|
|
206
|
+
const candidateGroup = req.query.get("candidateGroup") ?? undefined;
|
|
207
|
+
// Constrain the inbox to open (answerable) tasks via openUserTasks — the accessor that
|
|
208
|
+
// pins state="CREATED" for us. A bare searchUserTasks returns tasks in every state
|
|
209
|
+
// (CREATED/COMPLETED/CANCELED/...), so already-answered or withdrawn tasks would surface
|
|
210
|
+
// as if actionable — completing one then fails with "User task ... is not active".
|
|
211
|
+
// Deriving the pin from openUserTasks (rather than re-pinning state here) keeps the
|
|
212
|
+
// definition of "open" single-sourced. See nanobpm/nano-ide#248.
|
|
213
|
+
const tasks = await app.engine.openUserTasks({
|
|
211
214
|
...(pik ? { processInstanceKey: pik } : {}),
|
|
215
|
+
...(assignee ? { assignee } : {}),
|
|
216
|
+
...(candidateGroup ? { candidateGroup } : {}),
|
|
212
217
|
});
|
|
213
218
|
return json(tasks);
|
|
214
219
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -163,11 +163,29 @@ test("inbox page renders the client-side form fetch + renderer", async () => {
|
|
|
163
163
|
assert.ok(!/const\s+variables\s*=\s*\{\}/.test(rendered), "client must not collect untrusted keys into a plain {} object");
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
+
test("inbox client script parses — no template-literal backslash collapse", async () => {
|
|
167
|
+
const { page } = inboxRoutes(fakeEngine);
|
|
168
|
+
const rendered = String((await call(page)).body);
|
|
169
|
+
// Extract every inline <script> body and assert each parses. A backslash-bearing regex
|
|
170
|
+
// written inside the page's template literal (e.g. `/\/+$/`) collapses to `//…` at emit
|
|
171
|
+
// time, turning the rest of the statement into a line comment and breaking the parse
|
|
172
|
+
// (issue #433). `new Function(body)` throws on any such SyntaxError.
|
|
173
|
+
const scripts = rendered
|
|
174
|
+
.split("<script>")
|
|
175
|
+
.slice(1)
|
|
176
|
+
.map((chunk) => chunk.split("</scr" + "ipt>")[0]);
|
|
177
|
+
assert.ok(scripts.length > 0, "inbox page has at least one inline script");
|
|
178
|
+
for (const body of scripts) {
|
|
179
|
+
assert.ok(!body.includes("//+$/"), "trailing-slash-strip regex collapsed to a line comment");
|
|
180
|
+
assert.doesNotThrow(() => new Function(body), "inline client script must parse");
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
166
184
|
test("/api/tasks surfaces the resolved form linkage", async () => {
|
|
167
185
|
let seenFilter: unknown;
|
|
168
186
|
const engine: EngineClient = {
|
|
169
187
|
...fakeEngine,
|
|
170
|
-
|
|
188
|
+
openUserTasks: async (filter) => {
|
|
171
189
|
seenFilter = filter;
|
|
172
190
|
return [
|
|
173
191
|
{ userTaskKey: "1", elementId: "approve", formKey: "form-123" },
|
|
@@ -177,26 +195,97 @@ test("/api/tasks surfaces the resolved form linkage", async () => {
|
|
|
177
195
|
};
|
|
178
196
|
const { tasks } = inboxRoutes(engine);
|
|
179
197
|
const res = await call(tasks, { query: "processInstanceKey=pi-9" });
|
|
180
|
-
assert.deepEqual(seenFilter, {
|
|
198
|
+
assert.deepEqual(seenFilter, { processInstanceKey: "pi-9" });
|
|
181
199
|
const parsed = JSON.parse(String(res.body));
|
|
182
200
|
assert.equal(parsed[0].formKey, "form-123");
|
|
183
201
|
assert.equal(parsed[1].formKey, undefined);
|
|
184
202
|
});
|
|
185
203
|
|
|
186
|
-
test("/api/tasks constrains the search to open
|
|
204
|
+
test("/api/tasks constrains the search to open tasks via openUserTasks", async () => {
|
|
187
205
|
let seenFilter: unknown;
|
|
206
|
+
let openCalled = false;
|
|
188
207
|
const engine: EngineClient = {
|
|
189
208
|
...fakeEngine,
|
|
190
|
-
searchUserTasks
|
|
209
|
+
// A regression to a bare searchUserTasks (which does not pin state="CREATED") must fail
|
|
210
|
+
// loudly rather than silently surface answered/canceled tasks (nanobpm/nano-ide#248).
|
|
211
|
+
searchUserTasks: async () => {
|
|
212
|
+
throw new Error("inbox must use openUserTasks, not a bare searchUserTasks");
|
|
213
|
+
},
|
|
214
|
+
openUserTasks: async (filter) => {
|
|
215
|
+
openCalled = true;
|
|
191
216
|
seenFilter = filter;
|
|
192
217
|
return [];
|
|
193
218
|
},
|
|
194
219
|
};
|
|
195
220
|
const { tasks } = inboxRoutes(engine);
|
|
196
221
|
await call(tasks);
|
|
197
|
-
// No processInstanceKey supplied
|
|
198
|
-
//
|
|
199
|
-
assert.
|
|
222
|
+
// No processInstanceKey supplied; the inbox routes through openUserTasks, which pins the
|
|
223
|
+
// open (CREATED) invariant for us, so the handler forwards only selectors (none here).
|
|
224
|
+
assert.equal(openCalled, true);
|
|
225
|
+
assert.deepEqual(seenFilter, {});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("/api/tasks forwards assignee and candidateGroup to the engine (nanobpm/nano-ide#438)", async () => {
|
|
229
|
+
let seenFilter: unknown;
|
|
230
|
+
const engine: EngineClient = {
|
|
231
|
+
...fakeEngine,
|
|
232
|
+
openUserTasks: async (filter) => {
|
|
233
|
+
seenFilter = filter;
|
|
234
|
+
return [];
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
const { tasks } = inboxRoutes(engine);
|
|
238
|
+
await call(tasks, { query: "assignee=demo-reviewer&candidateGroup=reviewers&processInstanceKey=pi-9" });
|
|
239
|
+
// A manifest ui.path like /tasks?assignee=… must scope the inbox to that reviewer/group on a
|
|
240
|
+
// shared engine — the handler forwards both alongside the processInstanceKey selector (the
|
|
241
|
+
// open/CREATED pin is openUserTasks' responsibility, not repeated here).
|
|
242
|
+
assert.deepEqual(seenFilter, {
|
|
243
|
+
processInstanceKey: "pi-9",
|
|
244
|
+
assignee: "demo-reviewer",
|
|
245
|
+
candidateGroup: "reviewers",
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("/api/tasks omits assignee/candidateGroup when absent (unscoped inbox unchanged)", async () => {
|
|
250
|
+
let seenFilter: unknown;
|
|
251
|
+
const engine: EngineClient = {
|
|
252
|
+
...fakeEngine,
|
|
253
|
+
openUserTasks: async (filter) => {
|
|
254
|
+
seenFilter = filter;
|
|
255
|
+
return [];
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
const { tasks } = inboxRoutes(engine);
|
|
259
|
+
await call(tasks, { query: "assignee=demo-reviewer" });
|
|
260
|
+
// Only the supplied param is forwarded; candidateGroup stays off so its key never appears.
|
|
261
|
+
assert.deepEqual(seenFilter, { assignee: "demo-reviewer" });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("/api/tasks omits assignee when only candidateGroup is present (symmetric scoping)", async () => {
|
|
265
|
+
let seenFilter: unknown;
|
|
266
|
+
const engine: EngineClient = {
|
|
267
|
+
...fakeEngine,
|
|
268
|
+
openUserTasks: async (filter) => {
|
|
269
|
+
seenFilter = filter;
|
|
270
|
+
return [];
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
const { tasks } = inboxRoutes(engine);
|
|
274
|
+
await call(tasks, { query: "candidateGroup=reviewers" });
|
|
275
|
+
// Symmetric to the assignee-only case: only the supplied param is forwarded, so assignee's
|
|
276
|
+
// key never appears when scoping the inbox to a candidate group alone.
|
|
277
|
+
assert.deepEqual(seenFilter, { candidateGroup: "reviewers" });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("inbox client forwards the surface query string to /api/tasks (nanobpm/nano-ide#438)", async () => {
|
|
281
|
+
const { page } = inboxRoutes(fakeEngine);
|
|
282
|
+
const rendered = String((await call(page)).body);
|
|
283
|
+
// A scoped surface path (e.g. /tasks?assignee=…) reaches the iframe as location.search; the
|
|
284
|
+
// client must append it so the handler sees the scope. Guard the drift closed.
|
|
285
|
+
assert.ok(
|
|
286
|
+
rendered.includes("'/api/tasks'+location.search") || rendered.includes("'/api/tasks' + location.search"),
|
|
287
|
+
"client forwards location.search onto the /api/tasks fetch",
|
|
288
|
+
);
|
|
200
289
|
});
|
|
201
290
|
|
|
202
291
|
test("/api/form resolves a linked form's schema", async () => {
|
|
@@ -50,7 +50,7 @@ form .field input[type=checkbox],form .field input[type=radio]{width:auto}
|
|
|
50
50
|
<h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
|
|
51
51
|
<div id="form"></div>
|
|
52
52
|
<script>
|
|
53
|
-
const BASE=location.pathname.replace(
|
|
53
|
+
const BASE=location.pathname.replace(/[/]+$/,'');
|
|
54
54
|
const tasksEl=document.getElementById('tasks');
|
|
55
55
|
const formEl=document.getElementById('form');
|
|
56
56
|
|
|
@@ -59,7 +59,7 @@ function api(path,opts){return fetch(BASE+path,opts).then(r=>{if(!r.ok)return r.
|
|
|
59
59
|
function loadTasks(){
|
|
60
60
|
formEl.replaceChildren();
|
|
61
61
|
tasksEl.replaceChildren(el('li',{},'loading…'));
|
|
62
|
-
return api('/api/tasks').then(ts=>{
|
|
62
|
+
return api('/api/tasks'+location.search).then(ts=>{
|
|
63
63
|
tasksEl.replaceChildren();
|
|
64
64
|
if(!ts.length){tasksEl.appendChild(el('li',{},'No open tasks.'));return;}
|
|
65
65
|
for(const t of ts){
|
|
@@ -216,13 +216,18 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
|
|
|
216
216
|
source: "surface:taskInbox",
|
|
217
217
|
handler: async (req) => {
|
|
218
218
|
const pik = req.query.get("processInstanceKey") ?? undefined;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
// "
|
|
223
|
-
|
|
224
|
-
|
|
219
|
+
const assignee = req.query.get("assignee") ?? undefined;
|
|
220
|
+
const candidateGroup = req.query.get("candidateGroup") ?? undefined;
|
|
221
|
+
// Constrain the inbox to open (answerable) tasks via openUserTasks — the accessor that
|
|
222
|
+
// pins state="CREATED" for us. A bare searchUserTasks returns tasks in every state
|
|
223
|
+
// (CREATED/COMPLETED/CANCELED/...), so already-answered or withdrawn tasks would surface
|
|
224
|
+
// as if actionable — completing one then fails with "User task ... is not active".
|
|
225
|
+
// Deriving the pin from openUserTasks (rather than re-pinning state here) keeps the
|
|
226
|
+
// definition of "open" single-sourced. See nanobpm/nano-ide#248.
|
|
227
|
+
const tasks = await app.engine.openUserTasks({
|
|
225
228
|
...(pik ? { processInstanceKey: pik } : {}),
|
|
229
|
+
...(assignee ? { assignee } : {}),
|
|
230
|
+
...(candidateGroup ? { candidateGroup } : {}),
|
|
226
231
|
});
|
|
227
232
|
return json(tasks);
|
|
228
233
|
},
|