@nanobpm/urban 0.66.2 → 0.68.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.
@@ -6,7 +6,7 @@
6
6
  // child-grid parity can't silently drift.
7
7
  import { test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
- import { buildDetailForm, chevronToggle, setChevronOpen } from "./runtime.browser.js";
9
+ import { buildDetailForm, chevronToggle, setChevronOpen, evalDetailCondition, normalizeDetailOptions } from "./runtime.browser.js";
10
10
 
11
11
  // ── Fake DOM with fire-able listeners ───────────────────────────────────────
12
12
  // Richer than the render-only fake in runtime.browser.test.ts: it records event
@@ -266,3 +266,179 @@ test("#333: on a fully successful submit the button stays disabled — the refre
266
266
 
267
267
  assert.equal(button?.disabled, true, "left disabled on success — the refresh replaces the form, so re-enabling is moot");
268
268
  });
269
+
270
+ // ── #372: schema-derived multi-field / choice detail.form ───────────────────
271
+ // The single-textarea form (above) is now the degenerate case of a form whose
272
+ // fields are DERIVED from the element's `.form` contract: a form may declare an
273
+ // array of `select`/`textarea`/`text` fields, each bound to its own inputKey and
274
+ // optionally gated by a `conditional.hide` FEEL expression. These tests exercise
275
+ // the multi-field render, the choice widget, the single assembled {{form}} body,
276
+ // and the conditional gate — the surface nwf's five human-decision kinds need.
277
+
278
+ const MULTI_ACTION = {
279
+ path: "/app/api/actions/complete-user-task",
280
+ body: { taskId: "{{row.task_id}}", variables: "{{form}}" },
281
+ successLabel: "Resolved ✓",
282
+ };
283
+ // A feature-escalation form: a required `resolution` choice plus a free-text
284
+ // `answer` that only applies when the operator chose "answer".
285
+ const ESCALATION_FORM = {
286
+ showWhenField: "answerable",
287
+ promptField: "question",
288
+ submitLabel: "Resolve",
289
+ action: MULTI_ACTION,
290
+ fields: [
291
+ {
292
+ inputKey: "resolution",
293
+ type: "select",
294
+ label: "Resolution",
295
+ options: [
296
+ { value: "answer", label: "Answer the question" },
297
+ { value: "abandon", label: "Abandon the feature" },
298
+ ],
299
+ },
300
+ {
301
+ inputKey: "answer",
302
+ type: "textarea",
303
+ label: "Your answer",
304
+ conditional: { hide: '=resolution != "answer"' },
305
+ },
306
+ ],
307
+ };
308
+
309
+ function fakeFetch(t: { after: (fn: () => void) => void }, calls: Array<{ url: unknown; init: { method?: string; body?: string } }>) {
310
+ const prior = globalThis.fetch;
311
+ Reflect.set(globalThis, "fetch", async (url: unknown, init: { method?: string; body?: string }) => {
312
+ calls.push({ url, init });
313
+ return { ok: true, status: 200, json: async () => ({ ok: true }) };
314
+ });
315
+ t.after(() => Reflect.set(globalThis, "fetch", prior));
316
+ }
317
+
318
+ test("#372: a multi-field form renders a labelled <select> (with option labels) and a <textarea>", (t) => {
319
+ const created: FakeElement[] = [];
320
+ t.after(installFakeDom(created));
321
+ const box = buildDetailForm(ESCALATION_FORM, { answerable: true, question: "Ship it?" }, () => {});
322
+ assert.ok(box);
323
+ const select = created.find((n) => n.tagName === "SELECT");
324
+ const textarea = created.find((n) => n.tagName === "TEXTAREA");
325
+ assert.ok(select, "the choice field renders a native <select>");
326
+ assert.ok(textarea, "the free-text field renders a <textarea>");
327
+ const options = created.filter((n) => n.tagName === "OPTION");
328
+ assert.equal(options.length, 2, "one <option> per enumerated value");
329
+ assert.equal(options[0].getAttribute("value"), "answer");
330
+ assert.equal(options[0].textContent, "Answer the question", "the option's label (not the value) is shown");
331
+ assert.equal(select?.getAttribute("aria-label"), "Resolution", "the choice field is screen-reader labelled");
332
+ // A multi-field form gives each widget a visible <label> so the fields are
333
+ // distinguishable (the legacy single-textarea form stays label-less).
334
+ const labels = created.filter((n) => n.tagName === "LABEL");
335
+ assert.ok(labels.some((l) => l.textContent === "Resolution"), "the select carries a visible label");
336
+ });
337
+
338
+ test("#372: submit assembles every visible field into ONE {{form}} body keyed by inputKey", async (t) => {
339
+ const created: FakeElement[] = [];
340
+ t.after(installFakeDom(created));
341
+ const calls: Array<{ url: unknown; init: { method?: string; body?: string } }> = [];
342
+ fakeFetch(t, calls);
343
+ const box = buildDetailForm(ESCALATION_FORM, { answerable: true, question: "Ship it?", task_id: "T-5" }, () => {});
344
+ assert.ok(box);
345
+ const select = created.find((n) => n.tagName === "SELECT")!;
346
+ const textarea = created.find((n) => n.tagName === "TEXTAREA")!;
347
+ const button = created.find((n) => n.tagName === "BUTTON")!;
348
+ select.value = "answer";
349
+ await select.fire("change");
350
+ textarea.value = "Yes — proceed";
351
+ await button.fire("click");
352
+ assert.equal(calls.length, 1, "exactly one route POST");
353
+ const sent = JSON.parse(String(calls[0].init.body));
354
+ assert.deepEqual(
355
+ sent,
356
+ { taskId: "T-5", variables: { resolution: "answer", answer: "Yes — proceed" } },
357
+ "both fields splice into a single {{form}} keyed by their inputKeys",
358
+ );
359
+ });
360
+
361
+ test("#372: a field hidden by conditional.hide is dropped from the submitted body", async (t) => {
362
+ const created: FakeElement[] = [];
363
+ t.after(installFakeDom(created));
364
+ const calls: Array<{ url: unknown; init: { method?: string; body?: string } }> = [];
365
+ fakeFetch(t, calls);
366
+ const box = buildDetailForm(ESCALATION_FORM, { answerable: true, question: "Ship it?", task_id: "T-6" }, () => {});
367
+ assert.ok(box);
368
+ const select = created.find((n) => n.tagName === "SELECT")!;
369
+ const textarea = created.find((n) => n.tagName === "TEXTAREA")!;
370
+ const wrapOf = (input: FakeElement) => created.find((n) => n.tagName === "DIV" && n.className === "pc-field" && n.children.includes(input))!;
371
+ const button = created.find((n) => n.tagName === "BUTTON")!;
372
+ // Choosing "abandon" makes `resolution != "answer"` true, so the answer field
373
+ // is hidden — and must not smuggle a stale value through.
374
+ select.value = "abandon";
375
+ await select.fire("change");
376
+ assert.equal(wrapOf(textarea).hidden, true, "the answer field is hidden when the choice isn't 'answer'");
377
+ textarea.value = "leftover text that should be ignored";
378
+ await button.fire("click");
379
+ const sent = JSON.parse(String(calls[0].init.body));
380
+ assert.deepEqual(
381
+ sent,
382
+ { taskId: "T-6", variables: { resolution: "abandon" } },
383
+ "the hidden field is omitted from {{form}}",
384
+ );
385
+ });
386
+
387
+ test("#372: conditional.hide re-evaluates live — flipping the choice reveals the dependent field", async (t) => {
388
+ const created: FakeElement[] = [];
389
+ t.after(installFakeDom(created));
390
+ const box = buildDetailForm(ESCALATION_FORM, { answerable: true, question: "Ship it?" }, () => {});
391
+ assert.ok(box);
392
+ const select = created.find((n) => n.tagName === "SELECT")!;
393
+ const textarea = created.find((n) => n.tagName === "TEXTAREA")!;
394
+ const wrapOf = (input: FakeElement) => created.find((n) => n.tagName === "DIV" && n.className === "pc-field" && n.children.includes(input))!;
395
+ // Initial state: the select defaults to its first option ("answer" is empty in
396
+ // the fake until set), so an unset select value ("") != "answer" → hidden.
397
+ select.value = "abandon";
398
+ await select.fire("change");
399
+ assert.equal(wrapOf(textarea).hidden, true, "hidden while 'abandon' is chosen");
400
+ select.value = "answer";
401
+ await select.fire("change");
402
+ assert.equal(wrapOf(textarea).hidden, false, "revealed once 'answer' is chosen");
403
+ });
404
+
405
+ test("#372: normalizeDetailOptions accepts {value,label} objects and bare scalars", () => {
406
+ assert.deepEqual(normalizeDetailOptions([{ value: "a", label: "Alpha" }, { value: "b" }]), [
407
+ { value: "a", label: "Alpha" },
408
+ { value: "b", label: "b" },
409
+ ]);
410
+ assert.deepEqual(normalizeDetailOptions(["x", 2]), [
411
+ { value: "x", label: "x" },
412
+ { value: "2", label: "2" },
413
+ ]);
414
+ assert.deepEqual(normalizeDetailOptions(undefined), []);
415
+ });
416
+
417
+ test("#372: evalDetailCondition evaluates the FEEL subset used by .form conditional.hide", () => {
418
+ // Equality / inequality against a string literal (the feature-escalation case).
419
+ assert.equal(evalDetailCondition('=resolution != "answer"', { resolution: "abandon" }), true);
420
+ assert.equal(evalDetailCondition('=resolution != "answer"', { resolution: "answer" }), false);
421
+ assert.equal(evalDetailCondition('resolution = "answer"', { resolution: "answer" }), true);
422
+ // A missing field is not equal to a concrete literal.
423
+ assert.equal(evalDetailCondition('directive = "revise"', {}), false);
424
+ assert.equal(evalDetailCondition('directive != "revise"', {}), true);
425
+ // and / or combination and numeric comparison.
426
+ assert.equal(evalDetailCondition('action = "proceed" or action = "merge"', { action: "merge" }), true);
427
+ assert.equal(evalDetailCondition('count > 3 and flag = "on"', { count: "5", flag: "on" }), true);
428
+ assert.equal(evalDetailCondition('count > 3 and flag = "on"', { count: "2", flag: "on" }), false);
429
+ // Boolean flag (bare primary) and null literal.
430
+ assert.equal(evalDetailCondition("=urgent", { urgent: true }), true);
431
+ assert.equal(evalDetailCondition("=urgent", { urgent: false }), false);
432
+ });
433
+
434
+ test("#372: evalDetailCondition fails OPEN (false → field stays visible) on any unparseable input", () => {
435
+ // A garbage / partial expression must never hide (and thus drop) a field.
436
+ assert.equal(evalDetailCondition("=resolution !!! answer", { resolution: "x" }), false);
437
+ assert.equal(evalDetailCondition("=(unbalanced", { a: 1 }), false);
438
+ // An unterminated string literal must fail open too, not read the truncated
439
+ // remainder as a valid token.
440
+ assert.equal(evalDetailCondition('=resolution != "answer', { resolution: "abandon" }), false);
441
+ assert.equal(evalDetailCondition("=resolution = 'answer", { resolution: "answer" }), false);
442
+ assert.equal(evalDetailCondition("", { a: 1 }), false);
443
+ assert.equal(evalDetailCondition(null, { a: 1 }), false);
444
+ });
@@ -646,6 +646,13 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
646
646
  .pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
647
647
  .pc-prompt { font-size:.85rem; color:var(--nano-text-muted); margin-bottom:.4rem; white-space:pre-wrap; }
648
648
  .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
649
+ /* Schema-derived multi-field detail.form widgets (#372): a short free-text
650
+ <input> and an enumerated-choice <select>, styled to match .pc-textarea so a
651
+ select/text/textarea form reads as one coherent set. A field hidden by its
652
+ conditional.hide expression carries the plain [hidden] attribute (display:none
653
+ by default), so no extra rule is needed to remove it from the layout. */
654
+ .pc-input, .pc-select { width:100%; padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
655
+ .pc-subform .pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
649
656
  .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
650
657
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
651
658
  .pc-card-body[hidden] { display:none; }