@nanobpm/urban 0.75.1 → 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.
@@ -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.1",
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",
@@ -185,7 +185,7 @@ test("/api/tasks surfaces the resolved form linkage", async () => {
185
185
  let seenFilter: unknown;
186
186
  const engine: EngineClient = {
187
187
  ...fakeEngine,
188
- searchUserTasks: async (filter) => {
188
+ openUserTasks: async (filter) => {
189
189
  seenFilter = filter;
190
190
  return [
191
191
  { userTaskKey: "1", elementId: "approve", formKey: "form-123" },
@@ -195,26 +195,97 @@ test("/api/tasks surfaces the resolved form linkage", async () => {
195
195
  };
196
196
  const { tasks } = inboxRoutes(engine);
197
197
  const res = await call(tasks, { query: "processInstanceKey=pi-9" });
198
- assert.deepEqual(seenFilter, { state: "CREATED", processInstanceKey: "pi-9" });
198
+ assert.deepEqual(seenFilter, { processInstanceKey: "pi-9" });
199
199
  const parsed = JSON.parse(String(res.body));
200
200
  assert.equal(parsed[0].formKey, "form-123");
201
201
  assert.equal(parsed[1].formKey, undefined);
202
202
  });
203
203
 
204
- test("/api/tasks constrains the search to open (CREATED) tasks", async () => {
204
+ test("/api/tasks constrains the search to open tasks via openUserTasks", async () => {
205
205
  let seenFilter: unknown;
206
+ let openCalled = false;
206
207
  const engine: EngineClient = {
207
208
  ...fakeEngine,
208
- 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;
209
216
  seenFilter = filter;
210
217
  return [];
211
218
  },
212
219
  };
213
220
  const { tasks } = inboxRoutes(engine);
214
221
  await call(tasks);
215
- // No processInstanceKey supplied, but the inbox must still pin state=CREATED so that
216
- // already-answered/canceled tasks never surface as answerable (nanobpm/nano-ide#248).
217
- 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
+ );
218
289
  });
219
290
 
220
291
  test("/api/form resolves a linked form's schema", async () => {
@@ -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
  },