@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/dist/runtime/core/modules/pages.js +7 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/package.json +1 -1
- package/src/runtime/browser/runtime.browser.js +248 -10
- package/src/runtime/browser/runtime.detailform.test.ts +177 -1
- package/src/runtime/core/modules/pages.ts +7 -0
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "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
|
|
1554
|
-
//
|
|
1555
|
-
// (f.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
1607
|
-
|
|
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(
|
|
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; }
|