@nanobpm/urban 0.67.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.67.0",
3
+ "version": "0.68.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",
@@ -1550,9 +1550,17 @@ function renderProse(node) {
1550
1550
  // Build a detail/escalation answer form for a single grid row. This is the ONE
1551
1551
  // implementation of the `detail.form` contract — shared verbatim by the
1552
1552
  // top-level grid's detail panel AND a dataGrid child-grid row (#333) so the two
1553
- // can never drift. The form has a single free-text field (f.inputKey), an
1554
- // optional prompt echoed from the row (f.promptField), and a route-driven submit
1555
- // (f.action) whose path/body interpolate {{form.*}} / {{row.*}} tokens. It
1553
+ // can never drift. The form is SCHEMA-DERIVED (#372): its fields come from the
1554
+ // element's `.form` contract, not hand-rolled markup. A form may declare either a
1555
+ // single free-text field (legacy: f.inputKey, rendered exactly as before) or an
1556
+ // array of field descriptors (f.fields) — each a `select` (enumerated
1557
+ // value/label options), `textarea`, or `text` widget bound to its own
1558
+ // f.inputKey, optionally gated by a per-field `conditional.hide` FEEL expression
1559
+ // measured against the form's other field values. Every visible field is
1560
+ // assembled into ONE `{{form}}` body ({ inputKey: value, … }); a hidden field is
1561
+ // omitted. There is an optional prompt echoed from the row (f.promptField), and a
1562
+ // route-driven submit (f.action) whose path/body interpolate {{form.*}} /
1563
+ // {{row.*}} tokens. It
1556
1564
  // renders only when the row's f.showWhenField is truthy (an answerable
1557
1565
  // escalation), returning null otherwise. On a successful submit it invokes
1558
1566
  // onSuccess — the top-level grid re-polls the whole page (pc:refresh) so the
@@ -1580,6 +1588,181 @@ function setChevronOpen(btn, open) {
1580
1588
  btn.setAttribute("aria-expanded", String(open));
1581
1589
  }
1582
1590
 
1591
+ // Normalise a `detail.form` descriptor to a list of field descriptors so the
1592
+ // legacy single-field form (f.inputKey + free-text textarea) and the
1593
+ // schema-derived multi-field form (f.fields) share ONE render/submit path (no
1594
+ // drift). A descriptor carrying f.fields is used verbatim; otherwise the whole
1595
+ // form collapses to a single textarea field bound to f.inputKey — which renders
1596
+ // byte-for-byte as the pre-#372 form.
1597
+ /** @param {any} f
1598
+ * @returns {any[]}
1599
+ */
1600
+ function normalizeDetailFields(f) {
1601
+ if (Array.isArray(f.fields) && f.fields.length > 0) return f.fields;
1602
+ return [{ inputKey: f.inputKey, type: "textarea", inputLabel: f.inputLabel }];
1603
+ }
1604
+
1605
+ // Normalise a select field's enumerated values (from the `.form` contract) to
1606
+ // {value,label} pairs. Accepts either objects ({ value, label } — label
1607
+ // defaults to the value) or bare scalars (value === label), so an adopter can
1608
+ // author the terse or the labelled form without a bespoke shape.
1609
+ /** @param {any} options
1610
+ * @returns {Array<{value: string, label: string}>}
1611
+ */
1612
+ function normalizeDetailOptions(options) {
1613
+ /** @type {Array<{value: string, label: string}>} */
1614
+ const out = [];
1615
+ for (const opt of Array.isArray(options) ? options : []) {
1616
+ if (opt && typeof opt === "object") {
1617
+ const value = opt.value != null ? String(opt.value) : "";
1618
+ const label = opt.label != null ? String(opt.label) : value;
1619
+ out.push({ value, label });
1620
+ } else {
1621
+ out.push({ value: String(opt), label: String(opt) });
1622
+ }
1623
+ }
1624
+ return out;
1625
+ }
1626
+
1627
+ // ── conditional.hide evaluation (a tiny, SAFE FEEL subset) ──────────────────
1628
+ // A `.form` field can carry `conditional.hide` — a FEEL boolean (optionally with
1629
+ // the FEEL `=` prefix, e.g. `=resolution != "answer"`) that, when true, hides
1630
+ // the field from the operator AND drops it from the submitted `{{form}}` body.
1631
+ // We evaluate it against the form's CURRENT field values with a purpose-built
1632
+ // recursive-descent parser — never `eval`/`new Function` — so a schema-supplied
1633
+ // string can't execute arbitrary code. The grammar is deliberately small:
1634
+ // or := and ( "or" and )*
1635
+ // and := cmp ( "and" cmp )*
1636
+ // cmp := primary ( op primary )? op ∈ = == != < <= > >=
1637
+ // primary := ident | string | number | true | false | null | "(" or ")"
1638
+ // An identifier resolves to the same-form field's current value. Anything the
1639
+ // parser can't understand fails OPEN (returns false → the field stays visible),
1640
+ // because erroneously HIDING a field could silently drop a required input.
1641
+ /** @param {string} src
1642
+ * @returns {any[]}
1643
+ */
1644
+ function tokenizeDetailCondition(src) {
1645
+ /** @type {any[]} */
1646
+ const toks = [];
1647
+ const s = String(src);
1648
+ let i = 0;
1649
+ while (i < s.length) {
1650
+ const ch = s[i];
1651
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { i++; continue; }
1652
+ if (ch === '"' || ch === "'") {
1653
+ const quote = ch; let j = i + 1; let str = ""; let closed = false;
1654
+ while (j < s.length) {
1655
+ if (s[j] === quote) { closed = true; break; }
1656
+ if (s[j] === "\\" && j + 1 < s.length) { str += s[j + 1]; j += 2; } else { str += s[j]; j++; }
1657
+ }
1658
+ // An unterminated string literal can't be part of the grammar; abandon
1659
+ // parsing so the whole expression fails open (field stays visible) rather
1660
+ // than treating a truncated FEEL string as a valid token.
1661
+ if (!closed) throw new Error("unterminated string in condition");
1662
+ toks.push({ t: "val", v: str }); i = j + 1; continue;
1663
+ }
1664
+ if (ch === "(") { toks.push({ t: "(" }); i++; continue; }
1665
+ if (ch === ")") { toks.push({ t: ")" }); i++; continue; }
1666
+ if (ch === "!" && s[i + 1] === "=") { toks.push({ t: "op", v: "!=" }); i += 2; continue; }
1667
+ if (ch === "=" && s[i + 1] === "=") { toks.push({ t: "op", v: "=" }); i += 2; continue; }
1668
+ if (ch === "<" && s[i + 1] === "=") { toks.push({ t: "op", v: "<=" }); i += 2; continue; }
1669
+ if (ch === ">" && s[i + 1] === "=") { toks.push({ t: "op", v: ">=" }); i += 2; continue; }
1670
+ if (ch === "=") { toks.push({ t: "op", v: "=" }); i++; continue; }
1671
+ if (ch === "<") { toks.push({ t: "op", v: "<" }); i++; continue; }
1672
+ if (ch === ">") { toks.push({ t: "op", v: ">" }); i++; continue; }
1673
+ if (/[0-9]/.test(ch) || (ch === "-" && /[0-9]/.test(s[i + 1] || ""))) {
1674
+ let j = i + 1;
1675
+ while (j < s.length && /[0-9.]/.test(s[j])) j++;
1676
+ toks.push({ t: "val", v: Number(s.slice(i, j)) }); i = j; continue;
1677
+ }
1678
+ if (/[A-Za-z_]/.test(ch)) {
1679
+ let j = i + 1;
1680
+ while (j < s.length && /[A-Za-z0-9_.]/.test(s[j])) j++;
1681
+ const word = s.slice(i, j);
1682
+ const lower = word.toLowerCase();
1683
+ if (lower === "and" || lower === "or") toks.push({ t: lower });
1684
+ else if (lower === "true") toks.push({ t: "val", v: true });
1685
+ else if (lower === "false") toks.push({ t: "val", v: false });
1686
+ else if (lower === "null") toks.push({ t: "val", v: null });
1687
+ else toks.push({ t: "id", v: word });
1688
+ i = j; continue;
1689
+ }
1690
+ // An unrecognised character can't be part of the grammar; abandon parsing so
1691
+ // the whole expression fails open (field stays visible) rather than silently
1692
+ // mis-parsing.
1693
+ throw new Error("unexpected character in condition");
1694
+ }
1695
+ return toks;
1696
+ }
1697
+ /** @param {any} a @param {any} b @returns {boolean} */
1698
+ function detailLooseEq(a, b) {
1699
+ if (a === b) return true;
1700
+ if (a == null || b == null) return a == null && b == null;
1701
+ return String(a) === String(b);
1702
+ }
1703
+ /** @param {string} op @param {any} a @param {any} b @returns {boolean} */
1704
+ function detailCompare(op, a, b) {
1705
+ if (op === "=") return detailLooseEq(a, b);
1706
+ if (op === "!=") return !detailLooseEq(a, b);
1707
+ const na = Number(a); const nb = Number(b);
1708
+ if (!Number.isFinite(na) || !Number.isFinite(nb)) return false;
1709
+ if (op === "<") return na < nb;
1710
+ if (op === "<=") return na <= nb;
1711
+ if (op === ">") return na > nb;
1712
+ if (op === ">=") return na >= nb;
1713
+ return false;
1714
+ }
1715
+ // A bare (op-less) primary used where a boolean is expected — a FEEL flag like
1716
+ // `=isUrgent`. Only an explicit boolean true / the string "true" reads as true.
1717
+ /** @param {any} v @returns {boolean} */
1718
+ function detailTruthy(v) { return v === true || v === "true"; }
1719
+ /** @param {any} f
1720
+ * @param {Record<string, any>} values
1721
+ * @returns {boolean}
1722
+ */
1723
+ function evalDetailCondition(f, values) {
1724
+ /** @param {any} p @returns {boolean} */
1725
+ const parseOr = (p) => {
1726
+ let left = parseAnd(p);
1727
+ while (p.i < p.toks.length && p.toks[p.i].t === "or") { p.i++; const right = parseAnd(p); left = left || right; }
1728
+ return left;
1729
+ };
1730
+ /** @param {any} p @returns {boolean} */
1731
+ function parseAnd(p) {
1732
+ let left = parseCmp(p);
1733
+ while (p.i < p.toks.length && p.toks[p.i].t === "and") { p.i++; const right = parseCmp(p); left = left && right; }
1734
+ return left;
1735
+ }
1736
+ /** @param {any} p @returns {boolean} */
1737
+ function parseCmp(p) {
1738
+ const left = parsePrimary(p);
1739
+ const tok = p.toks[p.i];
1740
+ if (tok && tok.t === "op") { p.i++; const right = parsePrimary(p); return detailCompare(tok.v, left, right); }
1741
+ return detailTruthy(left);
1742
+ }
1743
+ /** @param {any} p @returns {any} */
1744
+ function parsePrimary(p) {
1745
+ const tok = p.toks[p.i];
1746
+ if (!tok) throw new Error("unexpected end of condition");
1747
+ if (tok.t === "(") { p.i++; const r = parseOr(p); if (!p.toks[p.i] || p.toks[p.i].t !== ")") throw new Error("missing )"); p.i++; return r; }
1748
+ if (tok.t === "id") { p.i++; return values[tok.v]; }
1749
+ if (tok.t === "val") { p.i++; return tok.v; }
1750
+ throw new Error("unexpected token in condition");
1751
+ }
1752
+ try {
1753
+ let src = String(f == null ? "" : f).trim();
1754
+ if (src === "") return false;
1755
+ if (src.startsWith("=")) src = src.slice(1);
1756
+ const p = { toks: tokenizeDetailCondition(src), i: 0 };
1757
+ if (p.toks.length === 0) return false;
1758
+ const result = parseOr(p);
1759
+ if (p.i !== p.toks.length) return false;
1760
+ return result === true;
1761
+ } catch (e) {
1762
+ return false;
1763
+ }
1764
+ }
1765
+
1583
1766
  /** @param {any} f
1584
1767
  * @param {Record<string, any>} row
1585
1768
  * @param {() => void | Promise<void>} onSuccess
@@ -1592,19 +1775,74 @@ function buildDetailForm(f, row, onSuccess) {
1592
1775
  if (f.promptField && row[f.promptField] != null) {
1593
1776
  box.append(el("div", { class: "pc-prompt" }, String(row[f.promptField])));
1594
1777
  }
1595
- const input = el("textarea", { class: "pc-textarea", placeholder: f.inputLabel || f.inputKey, "aria-label": f.inputLabel || f.inputKey });
1778
+ // Multi-field only when the descriptor supplies f.fields; a legacy single-field
1779
+ // form renders exactly as before (one bare textarea, no visible <label>).
1780
+ const multi = Array.isArray(f.fields) && f.fields.length > 0;
1781
+ const fields = normalizeDetailFields(f);
1596
1782
  // role=status + aria-live so the "Sending…"/success/error transitions are
1597
1783
  // announced to screen readers instead of silently changing text.
1598
1784
  const msg = el("p", { class: "pc-msg", role: "status", "aria-live": "polite" });
1599
1785
  const btn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, f.submitLabel || "Submit");
1786
+ /** @type {Array<{key: string, input: any, wrap: any, hideExpr: any}>} */
1787
+ const controls = [];
1788
+ for (const fd of fields) {
1789
+ const key = fd.inputKey != null ? fd.inputKey : fd.key;
1790
+ const label = fd.inputLabel || fd.label || key;
1791
+ const type = fd.type === "select" ? "select" : fd.type === "text" ? "text" : "textarea";
1792
+ let input;
1793
+ if (type === "select") {
1794
+ input = el("select", { class: "pc-select", "aria-label": label });
1795
+ for (const opt of normalizeDetailOptions(fd.options)) input.append(el("option", { value: opt.value }, opt.label));
1796
+ if (fd.default != null) input.value = String(fd.default);
1797
+ } else if (type === "text") {
1798
+ input = el("input", { type: "text", class: "pc-input", placeholder: label, "aria-label": label });
1799
+ if (fd.default != null) input.value = String(fd.default);
1800
+ } else {
1801
+ input = el("textarea", { class: "pc-textarea", placeholder: label, "aria-label": label });
1802
+ if (fd.default != null) input.value = String(fd.default);
1803
+ }
1804
+ // A multi-field form gets a visible <label> per widget; the legacy single
1805
+ // free-text form keeps its bare textarea (unchanged DOM).
1806
+ const wrap = multi
1807
+ ? el("div", { class: "pc-field" }, el("label", {}, label), input)
1808
+ : el("div", { class: "pc-field" }, input);
1809
+ const hideExpr = fd.conditional && typeof fd.conditional.hide === "string" ? fd.conditional.hide : null;
1810
+ controls.push({ key, input, wrap, hideExpr });
1811
+ box.append(wrap);
1812
+ }
1813
+ // Snapshot every field's current value, then hide/show each conditional field.
1814
+ // A field with no conditional.hide is always visible; the value map is keyed by
1815
+ // inputKey so a hide expression can reference a sibling field (e.g. `answer`
1816
+ // hidden when `resolution != "answer"`).
1817
+ const applyConditions = () => {
1818
+ /** @type {Record<string, any>} */
1819
+ const values = Object.create(null);
1820
+ for (const c of controls) values[c.key] = c.input.value;
1821
+ for (const c of controls) {
1822
+ const hidden = c.hideExpr != null && evalDetailCondition(c.hideExpr, values);
1823
+ c.wrap.hidden = hidden;
1824
+ }
1825
+ };
1826
+ // Re-evaluate the conditions whenever any field changes so a select flip
1827
+ // reveals/hides its dependent field live.
1828
+ if (controls.some((c) => c.hideExpr != null)) {
1829
+ for (const c of controls) {
1830
+ c.input.addEventListener("change", applyConditions);
1831
+ c.input.addEventListener("input", applyConditions);
1832
+ }
1833
+ applyConditions();
1834
+ }
1600
1835
  btn.addEventListener("click", async () => {
1601
1836
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
1602
1837
  try {
1603
- // The textarea value is the form's single field (f.inputKey); route + body
1604
- // template come from f.action, e.g. { path: "/app/actions/message",
1838
+ // Assemble every VISIBLE field into one form body, keyed by its inputKey;
1839
+ // route + body template come from f.action, e.g. { path: "/app/actions/message",
1605
1840
  // body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
1606
- // Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
1607
- const form = Object.create(null); form[f.inputKey] = input.value;
1841
+ // Null-proto so an inputKey of "__proto__"/"constructor" can't mutate a
1842
+ // prototype. A hidden (conditional.hide) field is omitted so a stale value
1843
+ // for an inapplicable choice never reaches the action.
1844
+ const form = Object.create(null);
1845
+ for (const c of controls) { if (c.wrap.hidden) continue; form[c.key] = c.input.value; }
1608
1846
  await runRoute(f.action, { form, row });
1609
1847
  msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
1610
1848
  // Await onSuccess so a refresh that rejects (e.g. a child grid's re-fetch
@@ -1617,7 +1855,7 @@ function buildDetailForm(f, row, onSuccess) {
1617
1855
  btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
1618
1856
  }
1619
1857
  });
1620
- box.append(el("div", { class: "pc-field" }, input), btn, msg);
1858
+ box.append(btn, msg);
1621
1859
  return box;
1622
1860
  }
1623
1861
 
@@ -2468,4 +2706,4 @@ function boot() {
2468
2706
  // renderer functions / RENDERERS registry can be imported directly.
2469
2707
  if (typeof document !== "undefined" && document.getElementById("page")) boot();
2470
2708
 
2471
- export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, chevronToggle, setChevronOpen };
2709
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
@@ -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; }