@nanobpm/urban 0.42.0 → 0.44.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/cancel.d.ts +8 -4
- package/dist/runtime/core/modules/cancel.js +23 -11
- package/dist/runtime/core/modules/pages.js +346 -18
- package/package.json +2 -2
- package/src/runtime/core/modules/cancel.test.ts +28 -0
- package/src/runtime/core/modules/cancel.ts +23 -11
- package/src/runtime/core/modules/pages.test.ts +191 -2
- package/src/runtime/core/modules/pages.ts +346 -18
|
@@ -19,10 +19,14 @@ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
|
|
|
19
19
|
* Outcome rules:
|
|
20
20
|
* - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
|
|
21
21
|
* 204); trust it even if the read model still lags at ACTIVE.
|
|
22
|
-
* - `cancelInstance` throws but the instance reads back
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
|
|
23
|
+
* → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
|
|
24
|
+
* row here (see below); a COMPLETED read intentionally reconciles nothing.
|
|
25
|
+
* - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
|
|
26
|
+
* (absent from the read model, which after an engine restart can transiently lack a live
|
|
27
|
+
* instance) → an honest failure: report it and do NOT touch any row (the instance may still be
|
|
28
|
+
* running). A throw is not a committed cancel, and absence from the read model is not proof of
|
|
29
|
+
* termination, so success is never inferred from it.
|
|
26
30
|
*
|
|
27
31
|
* Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
|
|
28
32
|
* own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
//
|
|
14
14
|
// This primitive keys success on the engine's response, then reconciles off the read model: a
|
|
15
15
|
// non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
|
|
16
|
-
// lags at ACTIVE), while a throw
|
|
17
|
-
//
|
|
18
|
-
// never drift from the poll reconciler's.
|
|
16
|
+
// lags at ACTIVE), while a throw that does not read back a positively-terminal state (still ACTIVE,
|
|
17
|
+
// or absent from the read model) is an honest failure. It reuses the instanceTracking bindings'
|
|
18
|
+
// `onTerminated.set` patch, so the immediate reconcile can never drift from the poll reconciler's.
|
|
19
19
|
import { errorMessage } from "../guards.js";
|
|
20
20
|
import { reconcileTerminatedKey } from "./instance-tracking.js";
|
|
21
21
|
/**
|
|
@@ -25,10 +25,14 @@ import { reconcileTerminatedKey } from "./instance-tracking.js";
|
|
|
25
25
|
* Outcome rules:
|
|
26
26
|
* - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
|
|
27
27
|
* 204); trust it even if the read model still lags at ACTIVE.
|
|
28
|
-
* - `cancelInstance` throws but the instance reads back
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
|
|
29
|
+
* → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
|
|
30
|
+
* row here (see below); a COMPLETED read intentionally reconciles nothing.
|
|
31
|
+
* - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
|
|
32
|
+
* (absent from the read model, which after an engine restart can transiently lack a live
|
|
33
|
+
* instance) → an honest failure: report it and do NOT touch any row (the instance may still be
|
|
34
|
+
* running). A throw is not a committed cancel, and absence from the read model is not proof of
|
|
35
|
+
* termination, so success is never inferred from it.
|
|
32
36
|
*
|
|
33
37
|
* Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
|
|
34
38
|
* own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
|
|
@@ -43,11 +47,19 @@ export async function cancelInstanceReconciling(api, bindings, processInstanceKe
|
|
|
43
47
|
threw = error;
|
|
44
48
|
}
|
|
45
49
|
const state = await readState(api, key, threw);
|
|
46
|
-
if (threw && state
|
|
47
|
-
// The cancel
|
|
48
|
-
// terminal
|
|
49
|
-
|
|
50
|
+
if (threw && state !== "TERMINATED" && state !== "COMPLETED") {
|
|
51
|
+
// The cancel threw, so we never got a committed 204 — and the verify read did NOT positively
|
|
52
|
+
// confirm a terminal state. Two shapes land here, both honest failures:
|
|
53
|
+
// • state === "ACTIVE": the instance is demonstrably still running.
|
|
54
|
+
// • state === "gone": the read model has no record of the key. A throw makes this
|
|
55
|
+
// ambiguous — after an engine restart the query store can transiently lack a live instance
|
|
56
|
+
// before it rehydrates, so "gone" is absence-of-evidence, NOT proof of termination.
|
|
57
|
+
// Trust neither as success: claiming a cancel that may not have happened (while the run keeps
|
|
58
|
+
// executing) is the worst possible lie for a cancel button. Report ok:false so the caller
|
|
59
|
+
// surfaces it and retries; never flip a tracked row to terminal while the instance may live.
|
|
60
|
+
api.log("error", "cancel: engine termination not confirmed, instance may still be active", {
|
|
50
61
|
processInstanceKey: key,
|
|
62
|
+
state,
|
|
51
63
|
error: errorMessage(threw),
|
|
52
64
|
});
|
|
53
65
|
return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
|
|
@@ -483,7 +483,13 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
483
483
|
.pc-msg.err { color:var(--nano-danger); }
|
|
484
484
|
.pc-msg.ok { color:var(--nano-ok); }
|
|
485
485
|
table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
|
|
486
|
-
|
|
486
|
+
/* Wrap long, space-less cell values (e.g. a JSON blob or a 40-char SHA) so one
|
|
487
|
+
cell can't force its column wide and blow the whole table past the page.
|
|
488
|
+
overflow-wrap:anywhere reduces the cell's min-content width so the auto table
|
|
489
|
+
layout can shrink it to fit; max-width caps a single column when there is
|
|
490
|
+
ample room. vertical-align:top keeps a multi-line cell aligned with its
|
|
491
|
+
single-line neighbours. */
|
|
492
|
+
table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; max-width:32rem; vertical-align:top; }
|
|
487
493
|
table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
488
494
|
.pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
|
|
489
495
|
.pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
|
|
@@ -495,6 +501,13 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
495
501
|
.pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
|
|
496
502
|
.pc-detail-label { color:var(--nano-text-muted); min-width:8rem; }
|
|
497
503
|
.pc-link { color:var(--nano-accent); }
|
|
504
|
+
.pc-badge { display:inline-flex; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; box-sizing:border-box; padding:0 .4rem; border-radius:999px; font-size:.72rem; font-weight:700; line-height:1; color:#fff; background:var(--nano-danger); }
|
|
505
|
+
.pc-badge-danger { background:var(--nano-danger); }
|
|
506
|
+
.pc-badge-warn { background:var(--nano-warn); color:#3a2a00; }
|
|
507
|
+
.pc-badge-ok { background:var(--nano-ok); }
|
|
508
|
+
.pc-badge-info { background:var(--nano-info); }
|
|
509
|
+
:root[data-appearance="light"] .pc-badge-warn { color:#fff; }
|
|
510
|
+
@media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
|
|
498
511
|
.pc-child { margin:.6rem 0; }
|
|
499
512
|
.pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
|
|
500
513
|
.pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:var(--nano-inset); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
|
|
@@ -505,12 +518,33 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
505
518
|
.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; }
|
|
506
519
|
.pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
|
|
507
520
|
.pc-card-body[hidden] { display:none; }
|
|
521
|
+
/* Grid group headers (dataGrid groupBy): a full-width, clickable band that
|
|
522
|
+
collapses/expands its member rows; the collapsed state is persisted. */
|
|
523
|
+
.pc-group-header td { padding:0; background:var(--nano-inset); border-top:1px solid var(--nano-edge); }
|
|
524
|
+
.pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
|
|
525
|
+
.pc-group-toggle:hover { background:var(--nano-hover); }
|
|
526
|
+
.pc-group-title { font-size:.9rem; }
|
|
527
|
+
/* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
|
|
528
|
+
.pc-buttonrow { margin:1rem 0; }
|
|
529
|
+
.pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
|
|
530
|
+
.pc-btn-ghost:hover { color:var(--nano-text); border-color:var(--nano-accent); }
|
|
531
|
+
.pc-modal-overlay { position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center; padding:1.5rem; background:rgba(0,0,0,.55); }
|
|
532
|
+
.pc-modal { background:var(--nano-panel); border:1px solid var(--nano-edge-strong); border-radius:.6rem; padding:1.25rem 1.35rem; max-width:44rem; width:100%; max-height:85vh; overflow:auto; box-shadow:0 12px 40px rgba(0,0,0,.45); }
|
|
533
|
+
.pc-modal-title { font-size:1.15rem; margin:0 0 .5rem; }
|
|
534
|
+
.pc-modal-desc { color:var(--nano-text-muted); margin:0 0 .75rem; font-size:.9rem; white-space:pre-wrap; }
|
|
535
|
+
.pc-modal-code { white-space:pre-wrap; word-break:break-word; background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.75rem; font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; max-height:45vh; overflow:auto; margin:0 0 .85rem; color:var(--nano-text); }
|
|
536
|
+
.pc-modal-actions { display:flex; gap:.5rem; justify-content:flex-end; }
|
|
508
537
|
/* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
|
|
509
538
|
.pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
|
|
510
539
|
.pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
|
|
511
540
|
.pc-main-col { flex:1 1 auto; min-width:0; }
|
|
512
541
|
.pc-nav-title { font-weight:650; }
|
|
513
542
|
.pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
|
|
543
|
+
/* Opt-in sticky bar: stays pinned to the viewport top while the page scrolls.
|
|
544
|
+
The negative inline margin + matching padding lets its background span the
|
|
545
|
+
body's 2rem inline padding so scrolled content never shows beside it, and a
|
|
546
|
+
solid app-colored background hides content passing underneath. */
|
|
547
|
+
.pc-bar.pc-sticky { position:sticky; top:0; z-index:20; background:var(--nano-app); margin-inline:-2rem; padding-inline:2rem; padding-top:.6rem; }
|
|
514
548
|
.pc-bar .pc-nav-title { margin-right:.5rem; }
|
|
515
549
|
.pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
|
|
516
550
|
.pc-rail { display:flex; flex-direction:column; gap:.5rem; }
|
|
@@ -540,15 +574,34 @@ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
|
|
|
540
574
|
function safePageId(value) {
|
|
541
575
|
return typeof value === "string" && PAGE_ID.test(value);
|
|
542
576
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
577
|
+
// A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST
|
|
578
|
+
// "/" *before* decoding, so a param that itself contains encoded slashes or
|
|
579
|
+
// hashes (e.g. a plan_key "owner/repo#123" carried as "owner%2Frepo%23123")
|
|
580
|
+
// survives intact instead of the inner "%2F" being decoded into a second
|
|
581
|
+
// segment. The page segment is still restricted to the safe id charset; the
|
|
582
|
+
// param is opaque free text (bound only as a whitelisted '?' SQL value, or
|
|
583
|
+
// interpolated as DOM text — never as HTML/markup).
|
|
584
|
+
function parseRoute() {
|
|
585
|
+
const raw = (location.hash || "").replace(/^#\/?/, "");
|
|
586
|
+
const slash = raw.indexOf("/");
|
|
587
|
+
const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;
|
|
588
|
+
const paramRaw = slash >= 0 ? raw.slice(slash + 1) : "";
|
|
589
|
+
let page = HOME, param = "", pageOk = false;
|
|
590
|
+
try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }
|
|
591
|
+
// Only carry the param when the page segment resolved to a real (safe-id) page.
|
|
592
|
+
// A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT
|
|
593
|
+
// inherit x, or that stray param would silently scope every eqParam grid and
|
|
594
|
+
// {{param}} on the home page to an entity the user never selected.
|
|
595
|
+
if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : ""; } catch (e) { param = ""; } }
|
|
596
|
+
return { page, param };
|
|
550
597
|
}
|
|
598
|
+
function currentPage() { return parseRoute().page; }
|
|
551
599
|
let CURRENT = currentPage();
|
|
600
|
+
// The current route param (the "/<param>" tail, or "" when absent). Exposed to a
|
|
601
|
+
// page's datasource filters (a filter with { eqParam: true } binds its value to
|
|
602
|
+
// this) and to {{param}} text interpolation, so one page template can scope
|
|
603
|
+
// every section to a selected entity (e.g. an epic's plan_key).
|
|
604
|
+
let PARAM = parseRoute().param;
|
|
552
605
|
|
|
553
606
|
// Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
|
|
554
607
|
// listener here; navigating to another page runs every disposer before the new
|
|
@@ -561,6 +614,15 @@ function teardown() {
|
|
|
561
614
|
}
|
|
562
615
|
}
|
|
563
616
|
|
|
617
|
+
// Monotonic counter for per-modal element ids. aria-labelledby needs a unique
|
|
618
|
+
// title id: a fixed id would clash if the page already contains that id, and
|
|
619
|
+
// mis-associate the label for assistive tech.
|
|
620
|
+
let modalSeq = 0;
|
|
621
|
+
// At most one modal is open at a time. A double-click (or two rapid button
|
|
622
|
+
// clicks) would otherwise stack multiple overlays + document-level keydown
|
|
623
|
+
// listeners; this guard keeps a single dialog + handler live.
|
|
624
|
+
let modalOpen = false;
|
|
625
|
+
|
|
564
626
|
// Theme bridge for the Nano console embed. The app runs in a sandboxed,
|
|
565
627
|
// same-origin iframe, so the console's --nano-* custom properties and its
|
|
566
628
|
// data-appearance never cascade in. Instead the console postMessages its
|
|
@@ -708,17 +770,166 @@ function el(tag, attrs = {}, ...kids) {
|
|
|
708
770
|
return n;
|
|
709
771
|
}
|
|
710
772
|
|
|
773
|
+
// Interpolate the {{param}} token in a schema-supplied string with the current
|
|
774
|
+
// route param. The result is only ever set as DOM text (textContent), so the
|
|
775
|
+
// substituted value cannot inject markup. Other tokens are left untouched.
|
|
776
|
+
// A function replacement inserts PARAM literally; a plain string replacement would
|
|
777
|
+
// treat replacement patterns inside PARAM (an id like the dollar-one token) as
|
|
778
|
+
// special and render the wrong text.
|
|
779
|
+
function interpParam(s) {
|
|
780
|
+
return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
|
|
781
|
+
}
|
|
711
782
|
function renderText(node) {
|
|
712
783
|
const v = node.props.variant;
|
|
713
784
|
const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
|
|
714
|
-
return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
|
|
785
|
+
return el(v === "heading" ? "h1" : "p", { class: cls }, interpParam(node.props.text) || "");
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Copy text to the clipboard, resiliently. The async Clipboard API is tried
|
|
789
|
+
// first, but it can reject inside a sandboxed console iframe (no clipboard-write
|
|
790
|
+
// permission) — so fall back to a hidden <textarea> + execCommand("copy"), which
|
|
791
|
+
// works under a user gesture with only allow-scripts. Returns whether a copy
|
|
792
|
+
// succeeded so the caller can nudge the user to ⌘/Ctrl+C when both paths fail.
|
|
793
|
+
async function copyToClipboard(text) {
|
|
794
|
+
try {
|
|
795
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
796
|
+
await navigator.clipboard.writeText(text);
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
} catch (_e) { /* fall through to the execCommand path */ }
|
|
800
|
+
const ta = document.createElement("textarea");
|
|
801
|
+
try {
|
|
802
|
+
ta.value = text;
|
|
803
|
+
ta.setAttribute("readonly", "");
|
|
804
|
+
ta.style.position = "fixed";
|
|
805
|
+
ta.style.top = "-1000px";
|
|
806
|
+
ta.style.opacity = "0";
|
|
807
|
+
document.body.append(ta);
|
|
808
|
+
ta.select();
|
|
809
|
+
return document.execCommand("copy");
|
|
810
|
+
} catch (_e) { return false; }
|
|
811
|
+
// Always remove the hidden textarea, even if select()/execCommand threw, so a
|
|
812
|
+
// failed copy never leaves a detached node accreting in <body> on retry.
|
|
813
|
+
finally { ta.remove(); }
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Rebase the sole {{appBase}} token in author-supplied copy text onto the app's
|
|
817
|
+
// absolute mount root (always ends in "/"), so a prompt like
|
|
818
|
+
// "read {{appBase}}app/api/agent" resolves to a URL an external agent can fetch —
|
|
819
|
+
// the console proxy path when embedded, the app origin when standalone. No other
|
|
820
|
+
// interpolation happens: copy text is otherwise emitted verbatim.
|
|
821
|
+
function resolveCopyText(text) {
|
|
822
|
+
return typeof text === "string" ? text.split("{{appBase}}").join(APP_BASE.toString()) : "";
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Open a lightweight modal appended to <body>. Closes on the ✕/Close button, a
|
|
826
|
+
// backdrop click, Escape, or a page switch; the keydown listener is removed on
|
|
827
|
+
// close so it never leaks, and close() is registered with teardown() so
|
|
828
|
+
// navigating away (hashchange → renderPage → teardown) tears down any open modal
|
|
829
|
+
// instead of leaving a stale overlay + document-level listener alive. When it
|
|
830
|
+
// carries copyText it renders a scrollable code block plus a Copy button (see
|
|
831
|
+
// copyToClipboard for the sandbox-safe fallback).
|
|
832
|
+
function openModal(m) {
|
|
833
|
+
// Only one modal at a time — drop a second (e.g. double-click) open so we
|
|
834
|
+
// never stack overlays or register duplicate document-level keydown handlers.
|
|
835
|
+
if (modalOpen) return;
|
|
836
|
+
modalOpen = true;
|
|
837
|
+
const overlay = el("div", { class: "pc-modal-overlay" });
|
|
838
|
+
// role="dialog" + aria-modal need an accessible name: label by the visible
|
|
839
|
+
// title when present, else fall back to a generic aria-label so screen readers
|
|
840
|
+
// don't announce an unnamed dialog. The title id is unique per instance so it
|
|
841
|
+
// can't collide with a pre-existing page id.
|
|
842
|
+
const titleId = m.title ? "pc-modal-title-" + (++modalSeq) : null;
|
|
843
|
+
const dialog = el("div", titleId
|
|
844
|
+
? { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId }
|
|
845
|
+
: { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-label": "Dialog" });
|
|
846
|
+
if (m.title) dialog.append(el("h2", { class: "pc-modal-title", id: titleId }, m.title));
|
|
847
|
+
if (m.description) dialog.append(el("p", { class: "pc-modal-desc" }, m.description));
|
|
848
|
+
const copyText = resolveCopyText(m.copyText);
|
|
849
|
+
if (copyText) dialog.append(el("pre", { class: "pc-modal-code" }, copyText));
|
|
850
|
+
const actions = el("div", { class: "pc-modal-actions" });
|
|
851
|
+
// Remember what had focus so we can restore it on close — a keyboard/screen
|
|
852
|
+
// reader user returns to where they were instead of being dumped at <body>.
|
|
853
|
+
const prevFocus = document.activeElement;
|
|
854
|
+
let closed = false;
|
|
855
|
+
function close() {
|
|
856
|
+
if (closed) return;
|
|
857
|
+
closed = true;
|
|
858
|
+
modalOpen = false;
|
|
859
|
+
document.removeEventListener("keydown", onKey);
|
|
860
|
+
overlay.remove();
|
|
861
|
+
// A manual close (button / Escape / backdrop) never pops the disposers
|
|
862
|
+
// stack, so drop our own entry here — otherwise repeated open/close cycles
|
|
863
|
+
// accrete dead close() closures until the next page navigation runs teardown.
|
|
864
|
+
const i = disposers.indexOf(close);
|
|
865
|
+
if (i !== -1) disposers.splice(i, 1);
|
|
866
|
+
if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
|
|
867
|
+
}
|
|
868
|
+
// Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
|
|
869
|
+
function focusables() {
|
|
870
|
+
return Array.prototype.slice.call(dialog.querySelectorAll(
|
|
871
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
872
|
+
)).filter((n) => !n.disabled);
|
|
873
|
+
}
|
|
874
|
+
function onKey(ev) {
|
|
875
|
+
if (ev.key === "Escape") { close(); return; }
|
|
876
|
+
if (ev.key !== "Tab") return;
|
|
877
|
+
// Trap Tab within the dialog so focus can't slip behind the overlay onto the
|
|
878
|
+
// inert page underneath — that would break aria-modal semantics.
|
|
879
|
+
const f = focusables();
|
|
880
|
+
if (!f.length) return;
|
|
881
|
+
const first = f[0];
|
|
882
|
+
const last = f[f.length - 1];
|
|
883
|
+
if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
|
|
884
|
+
else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
|
|
885
|
+
}
|
|
886
|
+
if (copyText) {
|
|
887
|
+
const label = m.copyLabel || "Copy";
|
|
888
|
+
const copyBtn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, label);
|
|
889
|
+
copyBtn.addEventListener("click", async () => {
|
|
890
|
+
const ok = await copyToClipboard(copyText);
|
|
891
|
+
copyBtn.textContent = ok ? "Copied ✓" : "Press ⌘/Ctrl+C";
|
|
892
|
+
setTimeout(() => { copyBtn.textContent = label; }, 2200);
|
|
893
|
+
});
|
|
894
|
+
actions.append(copyBtn);
|
|
895
|
+
}
|
|
896
|
+
const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
|
|
897
|
+
closeBtn.addEventListener("click", close);
|
|
898
|
+
actions.append(closeBtn);
|
|
899
|
+
dialog.append(actions);
|
|
900
|
+
overlay.append(dialog);
|
|
901
|
+
// A click on the backdrop (never the dialog itself) dismisses.
|
|
902
|
+
overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
|
|
903
|
+
document.addEventListener("keydown", onKey);
|
|
904
|
+
document.body.append(overlay);
|
|
905
|
+
// A page switch runs teardown() before the next render — dispose the modal
|
|
906
|
+
// there too so navigating away never leaves a stale overlay + keydown listener.
|
|
907
|
+
disposers.push(close);
|
|
908
|
+
closeBtn.focus();
|
|
715
909
|
}
|
|
716
910
|
|
|
717
|
-
// A
|
|
718
|
-
//
|
|
911
|
+
// A standalone button node. Clicking it opens props.modal — used e.g. to surface
|
|
912
|
+
// a copy-pasteable "point your agent here" prompt. props.variant === "ghost"
|
|
913
|
+
// renders the muted outline style. A button without a modal is inert.
|
|
914
|
+
function renderButton(node) {
|
|
915
|
+
const p = node.props || {};
|
|
916
|
+
const btn = el(
|
|
917
|
+
"button",
|
|
918
|
+
{ class: "pc-btn" + (p.variant === "ghost" ? " pc-btn-ghost" : ""), type: "button" },
|
|
919
|
+
p.label || "Open",
|
|
920
|
+
);
|
|
921
|
+
const m = p.modal && typeof p.modal === "object" ? p.modal : null;
|
|
922
|
+
if (m) btn.addEventListener("click", () => openModal(m));
|
|
923
|
+
return el("div", { class: "pc-buttonrow" }, btn);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// A grid td cell with three column-declared rendering modes, checked in order:
|
|
927
|
+
// 1. badge — a compact status indicator, gated on a non-empty field value
|
|
928
|
+
// (see the detailed note at the check below).
|
|
929
|
+
// 2. linkField — the cell text becomes a link to the URL held in that other
|
|
719
930
|
// field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
|
|
720
931
|
// smuggled through row data falls back to plain text).
|
|
721
|
-
//
|
|
932
|
+
// 3. link: { kind: "processExplorer", keyField } — a structured, engine-aware
|
|
722
933
|
// link: the cell text links to the Nano console's explorer view for the
|
|
723
934
|
// process instance whose key is held in the row's keyField. The console
|
|
724
935
|
// path is constructed HERE (never taken from row data) and the key is
|
|
@@ -734,6 +945,27 @@ function renderText(node) {
|
|
|
734
945
|
// grid and child grids.
|
|
735
946
|
function gridCell(col, row) {
|
|
736
947
|
const text = row[col.field] == null ? "" : String(row[col.field]);
|
|
948
|
+
// 1. badge — a compact status indicator. When the row's field value is
|
|
949
|
+
// non-empty (truthy after trimming) render a small circular badge (e.g. a
|
|
950
|
+
// red "1" dot flagging an incident) whose tooltip is the full field text;
|
|
951
|
+
// when empty the cell stays blank so the column is unobtrusive until it
|
|
952
|
+
// matters. tone (danger|warn|ok|info, default danger) picks the color and
|
|
953
|
+
// label the glyph inside (default "1"). Label/tone come from the app
|
|
954
|
+
// schema (trusted); the tooltip is row data set via title/textContent
|
|
955
|
+
// (DOM-escaped by the platform, so no HTML/attribute injection). The full
|
|
956
|
+
// field text is also mirrored into aria-label so assistive tech announces
|
|
957
|
+
// the incident/status text (the visible "1" glyph alone is not meaningful).
|
|
958
|
+
if (col.badge) {
|
|
959
|
+
if (text.trim() === "") return el("td", {});
|
|
960
|
+
const t = col.badge.tone;
|
|
961
|
+
const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
|
|
962
|
+
const label = col.badge.label == null ? "1" : String(col.badge.label);
|
|
963
|
+
return el(
|
|
964
|
+
"td",
|
|
965
|
+
{},
|
|
966
|
+
el("span", { class: "pc-badge pc-badge-" + tone, title: text, "aria-label": text }, label),
|
|
967
|
+
);
|
|
968
|
+
}
|
|
737
969
|
if (col.linkField) {
|
|
738
970
|
const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
|
|
739
971
|
if (text !== "" && /^https?:\/\//i.test(href)) {
|
|
@@ -744,6 +976,20 @@ function gridCell(col, row) {
|
|
|
744
976
|
);
|
|
745
977
|
}
|
|
746
978
|
}
|
|
979
|
+
if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
|
|
980
|
+
// An in-app link to another page, scoped to this row: #/<page>/<key>. The
|
|
981
|
+
// key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
|
|
982
|
+
// param tail, which parseRoute() decodes back verbatim; the page id is
|
|
983
|
+
// validated against the safe-id charset and the key must be non-empty, so a
|
|
984
|
+
// blank/keyless cell stays plain text. Navigation is a pure hash change
|
|
985
|
+
// (no new tab, no server hit) — the SPA router re-renders in place.
|
|
986
|
+
const key = row[col.link.keyField];
|
|
987
|
+
const keyStr = key == null ? "" : String(key).trim();
|
|
988
|
+
if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
|
|
989
|
+
const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
|
|
990
|
+
return el("td", {}, el("a", { class: "pc-link", href }, text));
|
|
991
|
+
}
|
|
992
|
+
}
|
|
747
993
|
if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
|
|
748
994
|
const key = row[col.link.keyField];
|
|
749
995
|
const keyStr = key == null ? "" : String(key).trim();
|
|
@@ -916,6 +1162,7 @@ function renderDataGrid(node) {
|
|
|
916
1162
|
const qs = [];
|
|
917
1163
|
for (const f of filters || []) {
|
|
918
1164
|
if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
|
|
1165
|
+
else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
|
|
919
1166
|
else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
|
|
920
1167
|
}
|
|
921
1168
|
if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
|
|
@@ -1046,7 +1293,7 @@ function renderDataGrid(node) {
|
|
|
1046
1293
|
return box;
|
|
1047
1294
|
}
|
|
1048
1295
|
|
|
1049
|
-
function renderRow(row) {
|
|
1296
|
+
function renderRow(row, sink) {
|
|
1050
1297
|
const cells = cols.map((c) => gridCell(c, row));
|
|
1051
1298
|
const key = rowKeyOf(row);
|
|
1052
1299
|
let toggle = null;
|
|
@@ -1064,6 +1311,9 @@ function renderDataGrid(node) {
|
|
|
1064
1311
|
}
|
|
1065
1312
|
const tr = el("tr", {}, ...cells);
|
|
1066
1313
|
tbody.append(tr);
|
|
1314
|
+
// When grouping, the caller collects every <tr> this row produced (the data
|
|
1315
|
+
// row plus any detail row) so a group header can hide/show them as one unit.
|
|
1316
|
+
if (sink) sink.push({ tr, isDetail: false, key });
|
|
1067
1317
|
if (detail && toggle) {
|
|
1068
1318
|
const isOpen = key != null && expanded.has(key);
|
|
1069
1319
|
// Reuse an already-built detail row for an open PR across refreshes so its
|
|
@@ -1091,6 +1341,67 @@ function renderDataGrid(node) {
|
|
|
1091
1341
|
}
|
|
1092
1342
|
});
|
|
1093
1343
|
tbody.append(dtr);
|
|
1344
|
+
if (sink) sink.push({ tr: dtr, isDetail: true, key });
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Append the fetched rows into the tbody, either flat (default) or partitioned
|
|
1349
|
+
// into collapsible groups when the grid declares 'groupBy: "<field>"'. Each
|
|
1350
|
+
// group renders a clickable header row ("<Label> <value> (<count>)") that
|
|
1351
|
+
// collapses/expands its member rows as one unit; the collapsed state is
|
|
1352
|
+
// persisted (localStorage, keyed by page + node + group value) so it survives
|
|
1353
|
+
// both the refresh poll and a full reload — the same durability the top-level
|
|
1354
|
+
// 'collapsible' node wrapper gives. Group order is numeric-aware ascending
|
|
1355
|
+
// (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric
|
|
1356
|
+
// keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.
|
|
1357
|
+
function appendRows(rows) {
|
|
1358
|
+
const groupBy = p.groupBy;
|
|
1359
|
+
// Treat a non-string groupBy as "no grouping": labelBase calls groupBy.charAt()
|
|
1360
|
+
// below, so a truthy non-string (number/object from a malformed page schema)
|
|
1361
|
+
// would throw at render time and break the whole page instead of one grid.
|
|
1362
|
+
if (!groupBy || typeof groupBy !== "string") { for (const row of rows) renderRow(row, null); return; }
|
|
1363
|
+
const order = [];
|
|
1364
|
+
const groups = new Map();
|
|
1365
|
+
for (const row of rows) {
|
|
1366
|
+
const gvRaw = row[groupBy];
|
|
1367
|
+
const gv = gvRaw == null ? "" : String(gvRaw);
|
|
1368
|
+
if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }
|
|
1369
|
+
groups.get(gv).push(row);
|
|
1370
|
+
}
|
|
1371
|
+
order.sort((a, b) => {
|
|
1372
|
+
const na = Number(a), nb = Number(b);
|
|
1373
|
+
if (a !== "" && b !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
|
|
1374
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1375
|
+
});
|
|
1376
|
+
const labelBase = p.groupLabel != null
|
|
1377
|
+
? String(p.groupLabel)
|
|
1378
|
+
: groupBy.charAt(0).toUpperCase() + groupBy.slice(1);
|
|
1379
|
+
for (const gv of order) {
|
|
1380
|
+
const groupRows = groups.get(gv);
|
|
1381
|
+
const gkey = "pc:collapsed:" + CURRENT + ":" + (node.id || p.title || "grid") + ":g:" + gv;
|
|
1382
|
+
let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);
|
|
1383
|
+
const chevron = el("span", { class: "pc-chevron-inline" }, gcollapsed ? "▸" : "▾");
|
|
1384
|
+
const label = (gv === "" ? labelBase + " —" : labelBase + " " + gv) + " (" + groupRows.length + ")";
|
|
1385
|
+
const btn = el("button", { class: "pc-group-toggle", type: "button" },
|
|
1386
|
+
chevron, el("span", { class: "pc-group-title" }, label));
|
|
1387
|
+
tbody.append(el("tr", { class: "pc-group-header" }, el("td", { colspan: span }, btn)));
|
|
1388
|
+
const members = [];
|
|
1389
|
+
for (const row of groupRows) renderRow(row, members);
|
|
1390
|
+
const applyGroup = () => {
|
|
1391
|
+
chevron.textContent = gcollapsed ? "▸" : "▾";
|
|
1392
|
+
btn.setAttribute("aria-expanded", String(!gcollapsed));
|
|
1393
|
+
for (const m of members) {
|
|
1394
|
+
m.tr.hidden = gcollapsed
|
|
1395
|
+
? true
|
|
1396
|
+
: (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
btn.addEventListener("click", () => {
|
|
1400
|
+
gcollapsed = !gcollapsed;
|
|
1401
|
+
writeCollapsed(gkey, gcollapsed);
|
|
1402
|
+
applyGroup();
|
|
1403
|
+
});
|
|
1404
|
+
applyGroup();
|
|
1094
1405
|
}
|
|
1095
1406
|
}
|
|
1096
1407
|
|
|
@@ -1106,7 +1417,15 @@ function renderDataGrid(node) {
|
|
|
1106
1417
|
|
|
1107
1418
|
async function refresh() {
|
|
1108
1419
|
try {
|
|
1109
|
-
|
|
1420
|
+
// A grid scoped to a route param (any eqParam filter) means "show the rows
|
|
1421
|
+
// for the selected entity". With no param present, PARAM is empty and emitting
|
|
1422
|
+
// a where clause of field-colon-empty would read server-side as field equals
|
|
1423
|
+
// empty string and surface empty-valued rows. That is not the selected entity's
|
|
1424
|
+
// rows (there is no selection), so short-circuit to zero rows without a request.
|
|
1425
|
+
const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
|
|
1426
|
+
const { rows } = paramScoped && PARAM === ""
|
|
1427
|
+
? { rows: [] }
|
|
1428
|
+
: await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
1110
1429
|
// Forget expansion / cached detail nodes for rows no longer present so the maps
|
|
1111
1430
|
// don't grow without bound and a stale answer can't resurface on a key reuse.
|
|
1112
1431
|
if (p.rowKey) {
|
|
@@ -1145,7 +1464,7 @@ function renderDataGrid(node) {
|
|
|
1145
1464
|
}
|
|
1146
1465
|
}
|
|
1147
1466
|
tbody.replaceChildren();
|
|
1148
|
-
|
|
1467
|
+
appendRows(rows);
|
|
1149
1468
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|
|
1150
1469
|
if (keepFocus && active.isConnected) {
|
|
1151
1470
|
active.focus();
|
|
@@ -1212,7 +1531,14 @@ function fillNav(list, items) {
|
|
|
1212
1531
|
function renderNav(node) {
|
|
1213
1532
|
const p = node.props || {};
|
|
1214
1533
|
const rail = p.variant === "rail";
|
|
1215
|
-
|
|
1534
|
+
// A bar nav can opt into staying pinned to the top of the viewport while the
|
|
1535
|
+
// page scrolls (sticky) — useful on the long, multi-section operator pages so
|
|
1536
|
+
// the page switcher is always reachable. Rails are already full-height, so the
|
|
1537
|
+
// flag only applies to the bar variant.
|
|
1538
|
+
const barSticky = !rail && p.sticky === true;
|
|
1539
|
+
const nav = el("nav", {
|
|
1540
|
+
class: (rail ? "pc-nav pc-rail" : "pc-nav pc-bar") + (barSticky ? " pc-sticky" : ""),
|
|
1541
|
+
});
|
|
1216
1542
|
if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
|
|
1217
1543
|
const list = el("div", { class: "pc-nav-items" });
|
|
1218
1544
|
nav.append(list);
|
|
@@ -1228,7 +1554,7 @@ function renderNav(node) {
|
|
|
1228
1554
|
return nav;
|
|
1229
1555
|
}
|
|
1230
1556
|
|
|
1231
|
-
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
|
|
1557
|
+
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
|
|
1232
1558
|
|
|
1233
1559
|
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
1234
1560
|
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
@@ -1306,7 +1632,9 @@ function makeCollapsible(node, card) {
|
|
|
1306
1632
|
// tearing down the previous page's grid polls first.
|
|
1307
1633
|
async function renderPage() {
|
|
1308
1634
|
teardown();
|
|
1309
|
-
|
|
1635
|
+
const route = parseRoute();
|
|
1636
|
+
CURRENT = route.page;
|
|
1637
|
+
PARAM = route.param;
|
|
1310
1638
|
try {
|
|
1311
1639
|
const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
|
|
1312
1640
|
document.title = (doc && doc.title) || "Urban App";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.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",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@nanobpm/nano-sdk": "^1.2.3",
|
|
67
67
|
"@nanobpm/workflow": "^0.8.0",
|
|
68
68
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
69
|
-
"create-urban-app": "^0.
|
|
69
|
+
"create-urban-app": "^0.14.0",
|
|
70
70
|
"yaml": "^2.9.0"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
@@ -311,3 +311,31 @@ test("a thrown cancel whose verify read also fails is reported as an honest fail
|
|
|
311
311
|
await h.close();
|
|
312
312
|
}
|
|
313
313
|
});
|
|
314
|
+
|
|
315
|
+
test("a thrown cancel whose verify read comes back empty (gone) is an unverified failure, not a false success", async () => {
|
|
316
|
+
// The engine-restart trap: the cancel POST throws (dead keep-alive socket / the brief window
|
|
317
|
+
// the gateway is down), then the verify search hits the just-restarted engine whose query store
|
|
318
|
+
// has NOT yet rehydrated the still-running instance — so the read comes back empty ("gone").
|
|
319
|
+
// A throw is not a committed 204, and absence from the read model is not proof of termination, so
|
|
320
|
+
// reporting success here is the "worst possible lie": the button says cancelled while the run
|
|
321
|
+
// keeps going. It must be an honest ok:false so the caller surfaces it and the user retries.
|
|
322
|
+
const { engine } = cancelEngine({
|
|
323
|
+
states: {}, // read model transiently empty (mid-restart), though instance "100" is still live
|
|
324
|
+
onCancel: () => {
|
|
325
|
+
throw new Error("fetch failed");
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
const h = await withHarness(engine);
|
|
329
|
+
try {
|
|
330
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
|
|
331
|
+
assert.equal(r.ok, false); // NOT a false success
|
|
332
|
+
assert.equal(r.state, "gone");
|
|
333
|
+
assert.equal(r.reconciled, 0);
|
|
334
|
+
assert.match(r.error ?? "", /fetch failed/);
|
|
335
|
+
const row = await h.table.get("100");
|
|
336
|
+
assert.equal(row?.status, "converging"); // never flip a possibly-live run to a terminal status
|
|
337
|
+
assert.ok(h.logs.some((l) => l.level === "error"));
|
|
338
|
+
} finally {
|
|
339
|
+
await h.close();
|
|
340
|
+
}
|
|
341
|
+
});
|