@nanobpm/urban 0.61.0 → 0.63.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/host.d.ts +15 -0
- package/dist/runtime/core/host.js +28 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +4 -0
- package/package.json +1 -1
- package/src/runtime/browser/runtime.browser.js +113 -49
- package/src/runtime/browser/runtime.detailform.test.ts +268 -0
- package/src/runtime/core/host.ts +36 -0
- package/src/runtime/core/modules/pages.test.ts +43 -16
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/index.ts +4 -0
package/dist/runtime/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
|
|
|
14
14
|
export { createLogger } from "./core/logger.ts";
|
|
15
15
|
export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
|
|
16
16
|
export { BpmnError, isBpmnError } from "./core/host.ts";
|
|
17
|
+
export { ENGINE_CLIENT_METHODS } from "./core/host.ts";
|
|
17
18
|
export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.ts";
|
|
18
19
|
export type { ConnectorsHandle } from "./core/modules/connectors.ts";
|
|
19
20
|
export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -18,6 +18,10 @@ export { createLogger } from "./core/logger.js";
|
|
|
18
18
|
// BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
|
|
19
19
|
// error routed to an error boundary (ADR 0050), instead of a retryable failure.
|
|
20
20
|
export { BpmnError, isBpmnError } from "./core/host.js";
|
|
21
|
+
// The runtime source of truth for the `EngineClient` method surface (issue #341): a test
|
|
22
|
+
// double / adapter iterates it to prove at runtime that it implements the full interface,
|
|
23
|
+
// catching the "fake lags the SDK" seam-lag class categorically.
|
|
24
|
+
export { ENGINE_CLIENT_METHODS } from "./core/host.js";
|
|
21
25
|
// Connector packs (ADR 0050, in-process port)
|
|
22
26
|
export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.js";
|
|
23
27
|
// Data layer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.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",
|
|
@@ -1514,6 +1514,80 @@ function renderProse(node) {
|
|
|
1514
1514
|
return card;
|
|
1515
1515
|
}
|
|
1516
1516
|
|
|
1517
|
+
// Build a detail/escalation answer form for a single grid row. This is the ONE
|
|
1518
|
+
// implementation of the `detail.form` contract — shared verbatim by the
|
|
1519
|
+
// top-level grid's detail panel AND a dataGrid child-grid row (#333) so the two
|
|
1520
|
+
// can never drift. The form has a single free-text field (f.inputKey), an
|
|
1521
|
+
// optional prompt echoed from the row (f.promptField), and a route-driven submit
|
|
1522
|
+
// (f.action) whose path/body interpolate {{form.*}} / {{row.*}} tokens. It
|
|
1523
|
+
// renders only when the row's f.showWhenField is truthy (an answerable
|
|
1524
|
+
// escalation), returning null otherwise. On a successful submit it invokes
|
|
1525
|
+
// onSuccess — the top-level grid re-polls the whole page (pc:refresh) so the
|
|
1526
|
+
// answered row drops out on the next tick; a child grid re-fetches just its own
|
|
1527
|
+
// rows, matching the Tasks page where the row disappears once its user-task
|
|
1528
|
+
// completes.
|
|
1529
|
+
// A disclosure chevron for expanding an inline detail row. This is the ONE place
|
|
1530
|
+
// a "▸/▾" toggle button is built, so the glyph and its `aria-expanded` state can
|
|
1531
|
+
// never drift apart (they used to be set independently, leaving assistive tech
|
|
1532
|
+
// unaware a row had opened). Every chevron gets an explicit type="button" (so it
|
|
1533
|
+
// never submits an enclosing form) and an accessible name.
|
|
1534
|
+
/** @param {string} [label] @returns {HTMLElement} */
|
|
1535
|
+
function chevronToggle(label) {
|
|
1536
|
+
return el("button", {
|
|
1537
|
+
class: "pc-btn pc-btn-sm pc-chevron",
|
|
1538
|
+
type: "button",
|
|
1539
|
+
"aria-label": label || "Toggle details",
|
|
1540
|
+
"aria-expanded": "false",
|
|
1541
|
+
}, "▸");
|
|
1542
|
+
}
|
|
1543
|
+
// Set a chevron's open/closed state, keeping the glyph and aria-expanded in lock-step.
|
|
1544
|
+
/** @param {HTMLElement} btn @param {boolean} open */
|
|
1545
|
+
function setChevronOpen(btn, open) {
|
|
1546
|
+
btn.textContent = open ? "▾" : "▸";
|
|
1547
|
+
btn.setAttribute("aria-expanded", String(open));
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/** @param {any} f
|
|
1551
|
+
* @param {Record<string, any>} row
|
|
1552
|
+
* @param {() => void | Promise<void>} onSuccess
|
|
1553
|
+
* @returns {HTMLElement|null}
|
|
1554
|
+
*/
|
|
1555
|
+
function buildDetailForm(f, row, onSuccess) {
|
|
1556
|
+
if (!f || !row[f.showWhenField]) return null;
|
|
1557
|
+
const box = el("div", { class: "pc-subform" });
|
|
1558
|
+
if (f.title) box.append(el("div", { class: "pc-subform-title" }, f.title));
|
|
1559
|
+
if (f.promptField && row[f.promptField] != null) {
|
|
1560
|
+
box.append(el("div", { class: "pc-prompt" }, String(row[f.promptField])));
|
|
1561
|
+
}
|
|
1562
|
+
const input = el("textarea", { class: "pc-textarea", placeholder: f.inputLabel || f.inputKey, "aria-label": f.inputLabel || f.inputKey });
|
|
1563
|
+
// role=status + aria-live so the "Sending…"/success/error transitions are
|
|
1564
|
+
// announced to screen readers instead of silently changing text.
|
|
1565
|
+
const msg = el("p", { class: "pc-msg", role: "status", "aria-live": "polite" });
|
|
1566
|
+
const btn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, f.submitLabel || "Submit");
|
|
1567
|
+
btn.addEventListener("click", async () => {
|
|
1568
|
+
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
|
|
1569
|
+
try {
|
|
1570
|
+
// The textarea value is the form's single field (f.inputKey); route + body
|
|
1571
|
+
// template come from f.action, e.g. { path: "/app/actions/message",
|
|
1572
|
+
// body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
|
|
1573
|
+
// Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
|
|
1574
|
+
const form = Object.create(null); form[f.inputKey] = input.value;
|
|
1575
|
+
await runRoute(f.action, { form, row });
|
|
1576
|
+
msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
|
|
1577
|
+
// Await onSuccess so a refresh that rejects (e.g. a child grid's re-fetch
|
|
1578
|
+
// failing) is surfaced here and re-enables the button below, instead of the
|
|
1579
|
+
// operator being stuck on a permanently-disabled button with a silent
|
|
1580
|
+
// failure. On success the refresh detaches/rebuilds this form, so leaving
|
|
1581
|
+
// the button disabled is moot; we only re-enable on the error path.
|
|
1582
|
+
await onSuccess();
|
|
1583
|
+
} catch (e) {
|
|
1584
|
+
btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
box.append(el("div", { class: "pc-field" }, input), btn, msg);
|
|
1588
|
+
return box;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1517
1591
|
/** @param {any} node */
|
|
1518
1592
|
function renderDataGrid(node) {
|
|
1519
1593
|
const p = node.props;
|
|
@@ -1630,42 +1704,6 @@ function renderDataGrid(node) {
|
|
|
1630
1704
|
return b;
|
|
1631
1705
|
}
|
|
1632
1706
|
|
|
1633
|
-
/** @param {Record<string, any>} row
|
|
1634
|
-
* @param {any} formCfg the panel's own form config (top-level or child grid); a
|
|
1635
|
-
* missing/undefined form means this panel has no form — it must NOT fall back to
|
|
1636
|
-
* the top-level grid's form, or a child panel would render the parent's form.
|
|
1637
|
-
* @returns {HTMLElement|null}
|
|
1638
|
-
*/
|
|
1639
|
-
function detailForm(row, formCfg) {
|
|
1640
|
-
const f = formCfg;
|
|
1641
|
-
if (!f || !row[f.showWhenField]) return null;
|
|
1642
|
-
const box = el("div", { class: "pc-subform" });
|
|
1643
|
-
if (f.title) box.append(el("div", { class: "pc-subform-title" }, f.title));
|
|
1644
|
-
if (f.promptField && row[f.promptField] != null) {
|
|
1645
|
-
box.append(el("div", { class: "pc-prompt" }, String(row[f.promptField])));
|
|
1646
|
-
}
|
|
1647
|
-
const input = el("textarea", { class: "pc-textarea", placeholder: f.inputLabel || f.inputKey });
|
|
1648
|
-
const msg = el("p", { class: "pc-msg" });
|
|
1649
|
-
const btn = el("button", { class: "pc-btn pc-btn-sm" }, f.submitLabel || "Submit");
|
|
1650
|
-
btn.addEventListener("click", async () => {
|
|
1651
|
-
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
|
|
1652
|
-
try {
|
|
1653
|
-
// The textarea value is the form's single field (f.inputKey); route + body
|
|
1654
|
-
// template come from f.action, e.g. { path: "/app/actions/message",
|
|
1655
|
-
// body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
|
|
1656
|
-
// Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
|
|
1657
|
-
const form = Object.create(null); form[f.inputKey] = input.value;
|
|
1658
|
-
await runRoute(f.action, { form, row });
|
|
1659
|
-
msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
|
|
1660
|
-
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
1661
|
-
} catch (e) {
|
|
1662
|
-
btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
1663
|
-
}
|
|
1664
|
-
});
|
|
1665
|
-
box.append(el("div", { class: "pc-field" }, input), btn, msg);
|
|
1666
|
-
return box;
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
1707
|
/** @param {any} cg
|
|
1670
1708
|
* @param {Record<string, any>} row
|
|
1671
1709
|
*/
|
|
@@ -1685,7 +1723,11 @@ function renderDataGrid(node) {
|
|
|
1685
1723
|
// parent rowKey + child rowKey, so it survives the top-level grid's refresh
|
|
1686
1724
|
// poll (which rebuilds this whole subtree) and a full reload, mirroring how
|
|
1687
1725
|
// groupBy group-collapse is persisted. Absent a `detail` block the child grid
|
|
1688
|
-
// is byte-for-byte unchanged (backward compatible).
|
|
1726
|
+
// is byte-for-byte unchanged (backward compatible). A `detail.form` (#333) is
|
|
1727
|
+
// rendered inside that panel by the shared detailPanel/buildDetailForm, and its
|
|
1728
|
+
// submit re-fetches only this child grid (see load() below) rather than the
|
|
1729
|
+
// whole page — so answering a nested escalation drops the answered row without
|
|
1730
|
+
// disturbing the parent grid.
|
|
1689
1731
|
const cdetail = cg.detail || cg.expandable || null;
|
|
1690
1732
|
const chasExpand = cdetail != null;
|
|
1691
1733
|
const cextra = (cg.lazyField ? 1 : 0) + (chasExpand ? 1 : 0);
|
|
@@ -1727,12 +1769,20 @@ function renderDataGrid(node) {
|
|
|
1727
1769
|
// the catch's error row have a valid, table-spanning colspan — if getJSON()
|
|
1728
1770
|
// throws, a lazily-assigned cspan would still be undefined here.
|
|
1729
1771
|
const cspan = String((ccols.length || 1) + cextra);
|
|
1730
|
-
|
|
1772
|
+
// Re-fetch and repaint just this child grid's rows. Wired to the child detail
|
|
1773
|
+
// panel's form onSuccess so answering a nested escalation drops the row once
|
|
1774
|
+
// its user-task completes — the child-scoped mirror of the top-level grid's
|
|
1775
|
+
// pc:refresh re-poll, without disturbing the parent grid or its other detail
|
|
1776
|
+
// rows (which a global pc:refresh would leave untouched anyway, since open
|
|
1777
|
+
// detail rows are reused across the poll).
|
|
1778
|
+
async function load() {
|
|
1731
1779
|
/** @type {{ rows: Array<Record<string, any>> }} */
|
|
1732
1780
|
const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,
|
|
1733
1781
|
[{ field: cg.childField, eq: row[cg.parentField] }], cg.orderBy));
|
|
1782
|
+
cbody.replaceChildren();
|
|
1734
1783
|
if (!rows.length) {
|
|
1735
1784
|
cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
|
|
1785
|
+
return;
|
|
1736
1786
|
}
|
|
1737
1787
|
for (const cr of rows) {
|
|
1738
1788
|
const cells = ccols.map((c, i) => gridCell(c, cr, croles[i]));
|
|
@@ -1755,26 +1805,33 @@ function renderDataGrid(node) {
|
|
|
1755
1805
|
// Per-row expander cell (trailing, after any lazyField cell): a chevron
|
|
1756
1806
|
// that reveals this row's detailPanel in a following full-width row. The
|
|
1757
1807
|
// panel is built lazily on first open (and eagerly when the row starts
|
|
1758
|
-
// expanded), matching the top-level grid.
|
|
1808
|
+
// expanded), matching the top-level grid. The panel's escalation form (if
|
|
1809
|
+
// any) is wired to load() so answering it re-fetches only this child grid.
|
|
1810
|
+
// The chevron uses the shared chevronToggle()/setChevronOpen() so its glyph
|
|
1811
|
+
// and aria-expanded stay in lock-step for assistive tech.
|
|
1759
1812
|
/** @type {any} */
|
|
1760
1813
|
let cdtr = null;
|
|
1761
1814
|
if (chasExpand) {
|
|
1762
1815
|
const crk = crowKeyOf(cr);
|
|
1763
1816
|
const cskey = ckeyBase && crk != null ? ckeyBase + crk : null;
|
|
1764
1817
|
const collapsed = cskey ? readCollapsed(cskey, cdefaultCollapsed) : cdefaultCollapsed;
|
|
1765
|
-
const ctoggle =
|
|
1818
|
+
const ctoggle = chevronToggle("Toggle row details");
|
|
1819
|
+
setChevronOpen(ctoggle, !collapsed);
|
|
1766
1820
|
cells.push(el("td", { class: "pc-row-actions" }, ctoggle));
|
|
1767
1821
|
cdtr = el("tr", { hidden: collapsed ? "" : null }, el("td", { colspan: cspan }));
|
|
1768
1822
|
let built = false;
|
|
1769
1823
|
const build = () => {
|
|
1770
|
-
|
|
1824
|
+
// Return the reload promise (don't swallow it) so buildDetailForm can
|
|
1825
|
+
// surface a failed re-fetch and re-enable its submit button instead of
|
|
1826
|
+
// leaving the operator stuck on a false "Sent".
|
|
1827
|
+
if (!built) { built = true; cdtr.firstChild.append(detailPanel(cr, cdetail, () => load())); }
|
|
1771
1828
|
};
|
|
1772
1829
|
if (!collapsed) build();
|
|
1773
1830
|
ctoggle.addEventListener("click", /** @param {MouseEvent} ev */ (ev) => {
|
|
1774
1831
|
ev.stopPropagation();
|
|
1775
1832
|
const open = cdtr.hidden;
|
|
1776
1833
|
cdtr.hidden = !open;
|
|
1777
|
-
ctoggle
|
|
1834
|
+
setChevronOpen(ctoggle, open);
|
|
1778
1835
|
if (open) build();
|
|
1779
1836
|
if (cskey) writeCollapsed(cskey, !open);
|
|
1780
1837
|
});
|
|
@@ -1788,8 +1845,11 @@ function renderDataGrid(node) {
|
|
|
1788
1845
|
cbody.append(ctr);
|
|
1789
1846
|
if (cdtr) cbody.append(cdtr);
|
|
1790
1847
|
}
|
|
1848
|
+
}
|
|
1849
|
+
try {
|
|
1850
|
+
await load();
|
|
1791
1851
|
} catch (e) {
|
|
1792
|
-
cbody.
|
|
1852
|
+
cbody.replaceChildren(el("tr", {}, el("td", { colspan: cspan }, String(e.message || e))));
|
|
1793
1853
|
}
|
|
1794
1854
|
return wrap;
|
|
1795
1855
|
}
|
|
@@ -1800,8 +1860,12 @@ function renderDataGrid(node) {
|
|
|
1800
1860
|
* and an optional escalation form.
|
|
1801
1861
|
* @param {Record<string, any>} row
|
|
1802
1862
|
* @param {any} [cfg] detail config; defaults to the top-level grid's `detail`
|
|
1863
|
+
* @param {(() => void)} [onSuccess] invoked after the panel's escalation form
|
|
1864
|
+
* submits successfully. Defaults to a whole-page `pc:refresh` re-poll (the
|
|
1865
|
+
* top-level grid's behaviour); a child grid passes a child-scoped reload so
|
|
1866
|
+
* answering a nested escalation refreshes only that child grid, not the page.
|
|
1803
1867
|
*/
|
|
1804
|
-
function detailPanel(row, cfg) {
|
|
1868
|
+
function detailPanel(row, cfg, onSuccess) {
|
|
1805
1869
|
const d = cfg || detail;
|
|
1806
1870
|
const box = el("div", { class: "pc-detail" });
|
|
1807
1871
|
if (d.linkField && row[d.linkField]) {
|
|
@@ -1825,7 +1889,7 @@ function renderDataGrid(node) {
|
|
|
1825
1889
|
box.append(holder);
|
|
1826
1890
|
childGrid(cg, row).then((w) => holder.replaceChildren(w));
|
|
1827
1891
|
}
|
|
1828
|
-
const form =
|
|
1892
|
+
const form = buildDetailForm(d.form, row, onSuccess || (() => { document.dispatchEvent(new CustomEvent("pc:refresh")); }));
|
|
1829
1893
|
if (form) box.append(form);
|
|
1830
1894
|
return box;
|
|
1831
1895
|
}
|
|
@@ -1840,7 +1904,7 @@ function renderDataGrid(node) {
|
|
|
1840
1904
|
if (hasExtra) {
|
|
1841
1905
|
const actionCell = el("td", { class: "pc-row-actions" });
|
|
1842
1906
|
if (detail) {
|
|
1843
|
-
toggle =
|
|
1907
|
+
toggle = chevronToggle("Toggle row details");
|
|
1844
1908
|
actionCell.append(toggle);
|
|
1845
1909
|
}
|
|
1846
1910
|
for (const ra of rowActions) {
|
|
@@ -1872,12 +1936,12 @@ function renderDataGrid(node) {
|
|
|
1872
1936
|
if (isOpen) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
|
|
1873
1937
|
}
|
|
1874
1938
|
const dtr = entry.dtr;
|
|
1875
|
-
toggle
|
|
1939
|
+
setChevronOpen(toggle, !dtr.hidden);
|
|
1876
1940
|
toggle.addEventListener("click", /** @param {MouseEvent} ev */ (ev) => {
|
|
1877
1941
|
ev.stopPropagation();
|
|
1878
1942
|
const open = dtr.hidden;
|
|
1879
1943
|
dtr.hidden = !open;
|
|
1880
|
-
toggle
|
|
1944
|
+
setChevronOpen(toggle, open);
|
|
1881
1945
|
if (open) {
|
|
1882
1946
|
if (key != null) expanded.add(key);
|
|
1883
1947
|
if (!entry.built) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
|
|
@@ -2371,4 +2435,4 @@ function boot() {
|
|
|
2371
2435
|
// renderer functions / RENDERERS registry can be imported directly.
|
|
2372
2436
|
if (typeof document !== "undefined" && document.getElementById("page")) boot();
|
|
2373
2437
|
|
|
2374
|
-
export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell };
|
|
2438
|
+
export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, chevronToggle, setChevronOpen };
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Unit tests for buildDetailForm (#333) — the single shared implementation of the
|
|
2
|
+
// `detail.form` answer/resume contract used by BOTH the top-level dataGrid detail
|
|
3
|
+
// panel and a child-grid per-row expansion. Because #333 lets a child-grid row
|
|
4
|
+
// host the same form, the contract must be exercised directly (render gating,
|
|
5
|
+
// prompt echo, and the interpolated route-driven submit) so the top-level ↔
|
|
6
|
+
// child-grid parity can't silently drift.
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { buildDetailForm, chevronToggle, setChevronOpen } from "./runtime.browser.js";
|
|
10
|
+
|
|
11
|
+
// ── Fake DOM with fire-able listeners ───────────────────────────────────────
|
|
12
|
+
// Richer than the render-only fake in runtime.browser.test.ts: it records event
|
|
13
|
+
// listeners (so a test can fire the submit click), exposes a settable textarea
|
|
14
|
+
// `value`, a `firstChild`, and `replaceChildren` — the surface buildDetailForm's
|
|
15
|
+
// interactive path touches. Every created node is collected on `created` so a
|
|
16
|
+
// test can grab the textarea/button without a real querySelector.
|
|
17
|
+
class FakeElement {
|
|
18
|
+
tagName: string;
|
|
19
|
+
attributes: Record<string, string> = {};
|
|
20
|
+
className = "";
|
|
21
|
+
children: Array<FakeElement | { text: string }> = [];
|
|
22
|
+
textContent = "";
|
|
23
|
+
dataset: Record<string, string> = {};
|
|
24
|
+
hidden = false;
|
|
25
|
+
disabled = false;
|
|
26
|
+
value = "";
|
|
27
|
+
style = { setProperty() {} };
|
|
28
|
+
listeners: Record<string, Array<(ev: unknown) => unknown>> = {};
|
|
29
|
+
|
|
30
|
+
constructor(tag: string) {
|
|
31
|
+
this.tagName = String(tag).toUpperCase();
|
|
32
|
+
}
|
|
33
|
+
setAttribute(k: string, v: unknown): void {
|
|
34
|
+
this.attributes[k] = String(v);
|
|
35
|
+
if (k === "class") this.className = String(v);
|
|
36
|
+
}
|
|
37
|
+
getAttribute(k: string): string | null {
|
|
38
|
+
return k in this.attributes ? this.attributes[k] : null;
|
|
39
|
+
}
|
|
40
|
+
addEventListener(type: string, fn: (ev: unknown) => unknown): void {
|
|
41
|
+
(this.listeners[type] ||= []).push(fn);
|
|
42
|
+
}
|
|
43
|
+
async fire(type: string): Promise<void> {
|
|
44
|
+
for (const fn of this.listeners[type] || []) await fn({ stopPropagation() {} });
|
|
45
|
+
}
|
|
46
|
+
append(...kids: Array<FakeElement | string>): void {
|
|
47
|
+
for (const kid of kids) {
|
|
48
|
+
if (typeof kid === "string") {
|
|
49
|
+
this.children.push({ text: kid });
|
|
50
|
+
this.textContent += kid;
|
|
51
|
+
} else {
|
|
52
|
+
this.children.push(kid);
|
|
53
|
+
this.textContent += kid.textContent || "";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
replaceChildren(...kids: Array<FakeElement | string>): void {
|
|
58
|
+
this.children = [];
|
|
59
|
+
this.textContent = "";
|
|
60
|
+
this.append(...kids);
|
|
61
|
+
}
|
|
62
|
+
get firstChild(): FakeElement | { text: string } | null {
|
|
63
|
+
return this.children[0] ?? null;
|
|
64
|
+
}
|
|
65
|
+
querySelector(): null {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function installFakeDom(created: FakeElement[]): () => void {
|
|
71
|
+
const doc = {
|
|
72
|
+
createElement: (tag: string) => {
|
|
73
|
+
const n = new FakeElement(tag);
|
|
74
|
+
created.push(n);
|
|
75
|
+
return n;
|
|
76
|
+
},
|
|
77
|
+
getElementById: () => null,
|
|
78
|
+
dispatchEvent: () => true,
|
|
79
|
+
};
|
|
80
|
+
const prior = Reflect.getOwnPropertyDescriptor(globalThis, "document");
|
|
81
|
+
Reflect.set(globalThis, "document", doc);
|
|
82
|
+
return () => {
|
|
83
|
+
if (prior) Reflect.defineProperty(globalThis, "document", prior);
|
|
84
|
+
else Reflect.deleteProperty(globalThis, "document");
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ACTION = {
|
|
89
|
+
path: "/app/api/actions/complete-user-task",
|
|
90
|
+
body: { taskId: "{{row.task_id}}", variables: "{{form}}" },
|
|
91
|
+
successLabel: "Answered ✓",
|
|
92
|
+
};
|
|
93
|
+
const FORM = {
|
|
94
|
+
showWhenField: "answerable",
|
|
95
|
+
promptField: "question",
|
|
96
|
+
inputKey: "comment",
|
|
97
|
+
inputLabel: "Your answer",
|
|
98
|
+
submitLabel: "Answer & resume",
|
|
99
|
+
action: ACTION,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
test("#333: buildDetailForm renders nothing when the row's showWhenField is falsy or missing", (t) => {
|
|
103
|
+
const created: FakeElement[] = [];
|
|
104
|
+
t.after(installFakeDom(created));
|
|
105
|
+
assert.equal(buildDetailForm(FORM, { answerable: false, question: "Why?" }, () => {}), null);
|
|
106
|
+
assert.equal(buildDetailForm(FORM, { question: "Why?" }, () => {}), null);
|
|
107
|
+
assert.equal(buildDetailForm(null, { answerable: true }, () => {}), null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("#333: buildDetailForm echoes the prompt and labels the field/submit from the form config", (t) => {
|
|
111
|
+
const created: FakeElement[] = [];
|
|
112
|
+
t.after(installFakeDom(created));
|
|
113
|
+
const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?" }, () => {});
|
|
114
|
+
assert.ok(box, "an answerable row must render a form");
|
|
115
|
+
assert.match(box!.textContent, /Ship it\?/, "the promptField value is echoed");
|
|
116
|
+
const textarea = created.find((n) => n.tagName === "TEXTAREA");
|
|
117
|
+
const button = created.find((n) => n.tagName === "BUTTON");
|
|
118
|
+
assert.equal(textarea?.getAttribute("placeholder"), "Your answer");
|
|
119
|
+
assert.equal(button?.textContent, "Answer & resume");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("#333: the answer form is screen-reader accessible — textarea has an aria-label and the status is a live region", (t) => {
|
|
123
|
+
const created: FakeElement[] = [];
|
|
124
|
+
t.after(installFakeDom(created));
|
|
125
|
+
const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?" }, () => {});
|
|
126
|
+
assert.ok(box);
|
|
127
|
+
const textarea = created.find((n) => n.tagName === "TEXTAREA");
|
|
128
|
+
// The placeholder alone is not an accessible name; an explicit aria-label makes
|
|
129
|
+
// the field usable without a visible <label>.
|
|
130
|
+
assert.equal(textarea?.getAttribute("aria-label"), "Your answer");
|
|
131
|
+
const msg = created.find((n) => n.tagName === "P" && n.className.includes("pc-msg"));
|
|
132
|
+
assert.equal(msg?.getAttribute("role"), "status", "status message is a live region role");
|
|
133
|
+
assert.equal(msg?.getAttribute("aria-live"), "polite", "status transitions are announced politely");
|
|
134
|
+
// Explicit type="button" so the submit control can never trigger an unintended
|
|
135
|
+
// submit/navigation if the form is ever nested inside a real <form>.
|
|
136
|
+
const button = created.find((n) => n.tagName === "BUTTON");
|
|
137
|
+
assert.equal(button?.getAttribute("type"), "button", "submit control is an explicit type=button");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("#333: the answer form's aria-label falls back to inputKey when inputLabel is absent", (t) => {
|
|
141
|
+
const created: FakeElement[] = [];
|
|
142
|
+
t.after(installFakeDom(created));
|
|
143
|
+
const noLabel = { ...FORM, inputLabel: undefined };
|
|
144
|
+
buildDetailForm(noLabel, { answerable: true }, () => {});
|
|
145
|
+
const textarea = created.find((n) => n.tagName === "TEXTAREA");
|
|
146
|
+
assert.equal(textarea?.getAttribute("aria-label"), "comment", "aria-label mirrors the placeholder fallback (inputKey)");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("#333: chevronToggle is an accessible disclosure button and setChevronOpen keeps the glyph and aria-expanded in lock-step", (t) => {
|
|
150
|
+
const created: FakeElement[] = [];
|
|
151
|
+
t.after(installFakeDom(created));
|
|
152
|
+
const btn = chevronToggle("Toggle answer form");
|
|
153
|
+
assert.equal(btn.getAttribute("type"), "button", "explicit type so it never submits an enclosing form");
|
|
154
|
+
assert.equal(btn.getAttribute("aria-label"), "Toggle answer form", "has an accessible name");
|
|
155
|
+
assert.equal(btn.getAttribute("aria-expanded"), "false", "starts collapsed");
|
|
156
|
+
assert.equal(btn.textContent, "▸");
|
|
157
|
+
|
|
158
|
+
setChevronOpen(btn, true);
|
|
159
|
+
assert.equal(btn.textContent, "▾", "glyph flips to open");
|
|
160
|
+
assert.equal(btn.getAttribute("aria-expanded"), "true", "aria-expanded tracks the glyph");
|
|
161
|
+
|
|
162
|
+
setChevronOpen(btn, false);
|
|
163
|
+
assert.equal(btn.textContent, "▸");
|
|
164
|
+
assert.equal(btn.getAttribute("aria-expanded"), "false");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("#333: chevronToggle falls back to a generic accessible name when none is given", (t) => {
|
|
168
|
+
const created: FakeElement[] = [];
|
|
169
|
+
t.after(installFakeDom(created));
|
|
170
|
+
const btn = chevronToggle();
|
|
171
|
+
assert.equal(btn.getAttribute("aria-label"), "Toggle details");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("#333: submitting POSTs the interpolated {{row.*}}/{{form.*}} body, shows successLabel, and fires onSuccess", async (t) => {
|
|
175
|
+
const created: FakeElement[] = [];
|
|
176
|
+
t.after(installFakeDom(created));
|
|
177
|
+
/** @type {Array<{ url: unknown; init: any }>} */
|
|
178
|
+
const calls: Array<{ url: unknown; init: { method?: string; body?: string } }> = [];
|
|
179
|
+
const priorFetch = globalThis.fetch;
|
|
180
|
+
Reflect.set(globalThis, "fetch", async (url: unknown, init: { method?: string; body?: string }) => {
|
|
181
|
+
calls.push({ url, init });
|
|
182
|
+
return { ok: true, status: 200, json: async () => ({ ok: true }) };
|
|
183
|
+
});
|
|
184
|
+
t.after(() => Reflect.set(globalThis, "fetch", priorFetch));
|
|
185
|
+
|
|
186
|
+
let refreshed = 0;
|
|
187
|
+
const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?", task_id: "T-42" }, () => {
|
|
188
|
+
refreshed++;
|
|
189
|
+
});
|
|
190
|
+
assert.ok(box);
|
|
191
|
+
const textarea = created.find((n) => n.tagName === "TEXTAREA");
|
|
192
|
+
const button = created.find((n) => n.tagName === "BUTTON");
|
|
193
|
+
assert.ok(textarea && button);
|
|
194
|
+
textarea!.value = "Looks good, proceed";
|
|
195
|
+
await button!.fire("click");
|
|
196
|
+
|
|
197
|
+
assert.equal(calls.length, 1, "exactly one route POST");
|
|
198
|
+
assert.equal(calls[0].init.method, "POST");
|
|
199
|
+
const sent = JSON.parse(String(calls[0].init.body));
|
|
200
|
+
assert.deepEqual(
|
|
201
|
+
sent,
|
|
202
|
+
{ taskId: "T-42", variables: { comment: "Looks good, proceed" } },
|
|
203
|
+
"row token → task_id, whole-form splice → { comment }",
|
|
204
|
+
);
|
|
205
|
+
assert.equal(refreshed, 1, "onSuccess (child-grid reload / page re-poll) fires once");
|
|
206
|
+
const msg = created.find((n) => n.tagName === "P" && n.className.includes("ok"));
|
|
207
|
+
assert.equal(msg?.textContent, "Answered ✓", "the action successLabel is shown");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("#333: a failed submit surfaces the error, re-enables submit, and does NOT fire onSuccess", async (t) => {
|
|
211
|
+
const created: FakeElement[] = [];
|
|
212
|
+
t.after(installFakeDom(created));
|
|
213
|
+
const priorFetch = globalThis.fetch;
|
|
214
|
+
Reflect.set(globalThis, "fetch", async () => ({ ok: false, status: 500, json: async () => ({ error: "boom" }) }));
|
|
215
|
+
t.after(() => Reflect.set(globalThis, "fetch", priorFetch));
|
|
216
|
+
|
|
217
|
+
let refreshed = 0;
|
|
218
|
+
const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?", task_id: "T-9" }, () => {
|
|
219
|
+
refreshed++;
|
|
220
|
+
});
|
|
221
|
+
assert.ok(box);
|
|
222
|
+
const button = created.find((n) => n.tagName === "BUTTON");
|
|
223
|
+
await button!.fire("click");
|
|
224
|
+
|
|
225
|
+
assert.equal(refreshed, 0, "onSuccess must not fire on failure — the row stays");
|
|
226
|
+
assert.equal(button?.disabled, false, "submit is re-enabled so the operator can retry");
|
|
227
|
+
const msg = created.find((n) => n.tagName === "P" && n.className.includes("err"));
|
|
228
|
+
assert.equal(msg?.textContent, "boom", "the server error is surfaced");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("#333: a failed onSuccess refresh is surfaced and re-enables submit — no silent failure / stuck button", async (t) => {
|
|
232
|
+
const created: FakeElement[] = [];
|
|
233
|
+
t.after(installFakeDom(created));
|
|
234
|
+
const priorFetch = globalThis.fetch;
|
|
235
|
+
Reflect.set(globalThis, "fetch", async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }));
|
|
236
|
+
t.after(() => Reflect.set(globalThis, "fetch", priorFetch));
|
|
237
|
+
|
|
238
|
+
// The submit itself succeeds, but the follow-up refresh (a child grid's
|
|
239
|
+
// re-fetch) rejects. buildDetailForm awaits onSuccess, so the reload failure
|
|
240
|
+
// must NOT be swallowed: the error is shown and the button re-enabled, instead
|
|
241
|
+
// of the operator being stuck on a permanently-disabled button under a false
|
|
242
|
+
// "Sent". Guards both suppressed advisories on runtime.browser.js:1581/1807.
|
|
243
|
+
const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?", task_id: "T-7" }, async () => {
|
|
244
|
+
throw new Error("reload failed");
|
|
245
|
+
});
|
|
246
|
+
assert.ok(box);
|
|
247
|
+
const button = created.find((n) => n.tagName === "BUTTON");
|
|
248
|
+
await button!.fire("click");
|
|
249
|
+
|
|
250
|
+
assert.equal(button?.disabled, false, "submit re-enabled when the refresh fails so the operator isn't stuck");
|
|
251
|
+
const err = created.find((n) => n.tagName === "P" && n.className.includes("err"));
|
|
252
|
+
assert.equal(err?.textContent, "reload failed", "the refresh failure is surfaced, not swallowed");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("#333: on a fully successful submit the button stays disabled — the refresh detaches/rebuilds the form", async (t) => {
|
|
256
|
+
const created: FakeElement[] = [];
|
|
257
|
+
t.after(installFakeDom(created));
|
|
258
|
+
const priorFetch = globalThis.fetch;
|
|
259
|
+
Reflect.set(globalThis, "fetch", async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }));
|
|
260
|
+
t.after(() => Reflect.set(globalThis, "fetch", priorFetch));
|
|
261
|
+
|
|
262
|
+
const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?", task_id: "T-1" }, () => {});
|
|
263
|
+
assert.ok(box);
|
|
264
|
+
const button = created.find((n) => n.tagName === "BUTTON");
|
|
265
|
+
await button!.fire("click");
|
|
266
|
+
|
|
267
|
+
assert.equal(button?.disabled, true, "left disabled on success — the refresh replaces the form, so re-enabling is moot");
|
|
268
|
+
});
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -359,3 +359,39 @@ export interface EngineClient {
|
|
|
359
359
|
/** Tear down all connections. */
|
|
360
360
|
close(): Promise<void>;
|
|
361
361
|
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The names of every method on {@link EngineClient} — the single *runtime* source of
|
|
365
|
+
* truth for the interface's surface. It is compile-time-pinned to `keyof EngineClient`
|
|
366
|
+
* in both directions (see the two assertions below): the `satisfies` rejects a stray or
|
|
367
|
+
* misspelled name, and the exhaustiveness assertion rejects an *omitted* one, so adding,
|
|
368
|
+
* renaming, or removing an `EngineClient` method that this list does not mirror fails
|
|
369
|
+
* `npm run typecheck`.
|
|
370
|
+
*
|
|
371
|
+
* A fake/adapter (e.g. `@nanobpm/urban-testkit`'s `WasmEngineClient`) can iterate this
|
|
372
|
+
* list to assert at *runtime* that it implements the full surface — catching the
|
|
373
|
+
* "the SDK grew a method the test double lags" seam-lag class (issue #341: `openUserTasks`,
|
|
374
|
+
* and `getForm` before it) that a purely structural `implements EngineClient` check no
|
|
375
|
+
* longer catches once the fake is published/compiled against an older `@nanobpm/urban`.
|
|
376
|
+
*/
|
|
377
|
+
export const ENGINE_CLIENT_METHODS = [
|
|
378
|
+
"deployResources",
|
|
379
|
+
"createInstance",
|
|
380
|
+
"cancelInstance",
|
|
381
|
+
"publishMessage",
|
|
382
|
+
"searchUserTasks",
|
|
383
|
+
"openUserTasks",
|
|
384
|
+
"getForm",
|
|
385
|
+
"completeUserTask",
|
|
386
|
+
"searchProcessInstances",
|
|
387
|
+
"registerWorker",
|
|
388
|
+
"close",
|
|
389
|
+
] as const satisfies readonly (keyof EngineClient)[];
|
|
390
|
+
|
|
391
|
+
// Compile-time exhaustiveness: every `EngineClient` method must appear in
|
|
392
|
+
// `ENGINE_CLIENT_METHODS`. If one is omitted, `Exclude<...>` is a non-`never` union of
|
|
393
|
+
// the missing keys, so this type resolves to `never` and the `true` assignment fails to
|
|
394
|
+
// compile — the twin of the `satisfies` above, together pinning the list to
|
|
395
|
+
// `keyof EngineClient` exactly (No Drift Surfaces).
|
|
396
|
+
type MissingEngineClientMethods = Exclude<keyof EngineClient, (typeof ENGINE_CLIENT_METHODS)[number]>;
|
|
397
|
+
const _engineClientMethodsAreExhaustive: [MissingEngineClientMethods] extends [never] ? true : never = true;
|