@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
- // Constrain the inbox to open (answerable) tasks. Without a state filter the engine
206
- // returns tasks in every state (CREATED/COMPLETED/CANCELED/...), so already-answered
207
- // or withdrawn tasks would surface as if actionable — completing one then fails with
208
- // "User task ... is not active". See nanobpm/nano-ide#248.
209
- const tasks = await app.engine.searchUserTasks({
210
- state: "CREATED",
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.75.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
- searchUserTasks: async (filter) => {
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, { state: "CREATED", processInstanceKey: "pi-9" });
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 (CREATED) tasks", async () => {
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: async (filter) => {
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, but the inbox must still pin state=CREATED so that
198
- // already-answered/canceled tasks never surface as answerable (nanobpm/nano-ide#248).
199
- assert.deepEqual(seenFilter, { state: "CREATED" });
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
- // Constrain the inbox to open (answerable) tasks. Without a state filter the engine
220
- // returns tasks in every state (CREATED/COMPLETED/CANCELED/...), so already-answered
221
- // or withdrawn tasks would surface as if actionable — completing one then fails with
222
- // "User task ... is not active". See nanobpm/nano-ide#248.
223
- const tasks = await app.engine.searchUserTasks({
224
- state: "CREATED",
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
  },