@nanobpm/urban 0.19.0 → 0.21.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.
|
@@ -392,6 +392,27 @@ function renderText(node) {
|
|
|
392
392
|
return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
// A grid td cell. When the column declares linkField, the cell text (the
|
|
396
|
+
// column's own value) becomes a link to the URL held in that other field,
|
|
397
|
+
// opened in a new tab. Only http(s) hrefs are linked — anything else (e.g. a
|
|
398
|
+
// javascript: URL smuggled through row data) falls back to plain text — and
|
|
399
|
+
// external links get rel=noopener noreferrer so the opened page can't reach
|
|
400
|
+
// window.opener. Shared by the top-level grid and child grids.
|
|
401
|
+
function gridCell(col, row) {
|
|
402
|
+
const text = row[col.field] == null ? "" : String(row[col.field]);
|
|
403
|
+
if (col.linkField) {
|
|
404
|
+
const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
|
|
405
|
+
if (text !== "" && /^https?:\/\//i.test(href)) {
|
|
406
|
+
return el(
|
|
407
|
+
"td",
|
|
408
|
+
{},
|
|
409
|
+
el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return el("td", {}, text);
|
|
414
|
+
}
|
|
415
|
+
|
|
395
416
|
function renderActionForm(node) {
|
|
396
417
|
const p = node.props;
|
|
397
418
|
const card = el("section", { class: "pc-card" });
|
|
@@ -458,6 +479,23 @@ function renderDataGrid(node) {
|
|
|
458
479
|
card.append(table);
|
|
459
480
|
const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
|
|
460
481
|
|
|
482
|
+
// Expansion state has to outlive the poll: refresh() rebuilds the whole tbody
|
|
483
|
+
// every refreshMs, so without this an open detail row (where you answer an
|
|
484
|
+
// escalation) would collapse on the next tick. We remember which rowKeys are
|
|
485
|
+
// open, and reuse the already-built detail <tr> for them across refreshes so a
|
|
486
|
+
// half-typed answer survives too. Keyed by p.rowKey; grids without one keep the
|
|
487
|
+
// old (collapse-on-refresh) behavior.
|
|
488
|
+
const expanded = new Set();
|
|
489
|
+
const detailNodes = new Map();
|
|
490
|
+
// A row only participates in expansion/detail-node caching if it has a real
|
|
491
|
+
// (non-null) rowKey. Coercing a missing key through String() would yield the
|
|
492
|
+
// literal "undefined"/"null" and collide unrelated rows, so treat it as keyless.
|
|
493
|
+
const rowKeyOf = (row) => {
|
|
494
|
+
if (!p.rowKey) return null;
|
|
495
|
+
const v = row[p.rowKey];
|
|
496
|
+
return v == null ? null : String(v);
|
|
497
|
+
};
|
|
498
|
+
|
|
461
499
|
function dataUrl(source, tbl, filters, order) {
|
|
462
500
|
let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
|
|
463
501
|
const qs = [];
|
|
@@ -552,7 +590,7 @@ function renderDataGrid(node) {
|
|
|
552
590
|
cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
|
|
553
591
|
}
|
|
554
592
|
for (const cr of rows) {
|
|
555
|
-
const cells = ccols.map((c) =>
|
|
593
|
+
const cells = ccols.map((c) => gridCell(c, cr));
|
|
556
594
|
if (cg.lazyField) {
|
|
557
595
|
const lf = cg.lazyField;
|
|
558
596
|
const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== "";
|
|
@@ -606,7 +644,8 @@ function renderDataGrid(node) {
|
|
|
606
644
|
}
|
|
607
645
|
|
|
608
646
|
function renderRow(row) {
|
|
609
|
-
const cells = cols.map((c) =>
|
|
647
|
+
const cells = cols.map((c) => gridCell(c, row));
|
|
648
|
+
const key = rowKeyOf(row);
|
|
610
649
|
let toggle = null;
|
|
611
650
|
if (hasExtra) {
|
|
612
651
|
const actionCell = el("td", { class: "pc-row-actions" });
|
|
@@ -623,16 +662,29 @@ function renderDataGrid(node) {
|
|
|
623
662
|
const tr = el("tr", {}, ...cells);
|
|
624
663
|
tbody.append(tr);
|
|
625
664
|
if (detail && toggle) {
|
|
626
|
-
const
|
|
627
|
-
|
|
665
|
+
const isOpen = key != null && expanded.has(key);
|
|
666
|
+
// Reuse an already-built detail row for an open PR across refreshes so its
|
|
667
|
+
// expanded state — and any half-typed escalation answer in the form — survives
|
|
668
|
+
// the poll. A closed (or keyless) row always gets a fresh, lazily-built panel.
|
|
669
|
+
let entry = key != null ? detailNodes.get(key) : null;
|
|
670
|
+
if (!(isOpen && entry && entry.built)) {
|
|
671
|
+
const dtr = el("tr", { hidden: isOpen ? null : "" }, el("td", { colspan: span }));
|
|
672
|
+
entry = { dtr, built: false };
|
|
673
|
+
if (key != null) detailNodes.set(key, entry);
|
|
674
|
+
if (isOpen) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
|
|
675
|
+
}
|
|
676
|
+
const dtr = entry.dtr;
|
|
677
|
+
toggle.textContent = dtr.hidden ? "▸" : "▾";
|
|
628
678
|
toggle.addEventListener("click", (ev) => {
|
|
629
679
|
ev.stopPropagation();
|
|
630
680
|
const open = dtr.hidden;
|
|
631
681
|
dtr.hidden = !open;
|
|
632
682
|
toggle.textContent = open ? "▾" : "▸";
|
|
633
|
-
if (open
|
|
634
|
-
|
|
635
|
-
dtr.firstChild.append(detailPanel(row));
|
|
683
|
+
if (open) {
|
|
684
|
+
if (key != null) expanded.add(key);
|
|
685
|
+
if (!entry.built) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
|
|
686
|
+
} else if (key != null) {
|
|
687
|
+
expanded.delete(key);
|
|
636
688
|
}
|
|
637
689
|
});
|
|
638
690
|
tbody.append(dtr);
|
|
@@ -642,6 +694,14 @@ function renderDataGrid(node) {
|
|
|
642
694
|
async function refresh() {
|
|
643
695
|
try {
|
|
644
696
|
const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
697
|
+
// Forget expansion / cached detail nodes for rows no longer present so the maps
|
|
698
|
+
// don't grow without bound and a stale answer can't resurface on a key reuse.
|
|
699
|
+
if (p.rowKey) {
|
|
700
|
+
const live = new Set();
|
|
701
|
+
for (const r of rows) { const k = rowKeyOf(r); if (k != null) live.add(k); }
|
|
702
|
+
for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
|
|
703
|
+
for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
|
|
704
|
+
}
|
|
645
705
|
tbody.replaceChildren();
|
|
646
706
|
for (const row of rows) renderRow(row);
|
|
647
707
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|
|
@@ -89,6 +89,35 @@ test("GET /app/runtime.js serves the renderer module", async () => {
|
|
|
89
89
|
assert.match(res.body ?? "", /pc:refresh/);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
test("renderer preserves grid row-detail expansion across refreshes", async () => {
|
|
93
|
+
// Regression: the 5s poll rebuilds the whole tbody, which used to collapse an open
|
|
94
|
+
// detail row (e.g. the escalation-answer form). The client now tracks open rowKeys
|
|
95
|
+
// and reuses their built detail node so expansion (and a half-typed answer) survives.
|
|
96
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
97
|
+
const js = res.body ?? "";
|
|
98
|
+
assert.match(js, /const expanded = new Set\(\)/);
|
|
99
|
+
assert.match(js, /const detailNodes = new Map\(\)/);
|
|
100
|
+
assert.match(js, /expanded\.add\(key\)/);
|
|
101
|
+
assert.match(js, /expanded\.delete\(key\)/);
|
|
102
|
+
// A missing/null rowKey must be treated as keyless (null) rather than coerced to
|
|
103
|
+
// the string "undefined"/"null", which would collide unrelated rows in the caches.
|
|
104
|
+
assert.match(js, /rowKeyOf/);
|
|
105
|
+
assert.match(js, /v == null \? null : String\(v\)/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("renderer wires a column's linkField to a new-tab anchor", async () => {
|
|
109
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
110
|
+
const js = res.body ?? "";
|
|
111
|
+
// A column declaring `linkField` renders its text as a link to that field's
|
|
112
|
+
// URL, opened safely in a new tab. Guard the whole shape so the feature can't
|
|
113
|
+
// silently regress: the field is read, http(s) is required, and the anchor
|
|
114
|
+
// opens in a new tab with a hardened rel.
|
|
115
|
+
assert.match(js, /col\.linkField/);
|
|
116
|
+
assert.match(js, /\^https\?:/);
|
|
117
|
+
assert.match(js, /target: "_blank"/);
|
|
118
|
+
assert.match(js, /rel: "noopener noreferrer"/);
|
|
119
|
+
});
|
|
120
|
+
|
|
92
121
|
test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
|
|
93
122
|
const ok = await dispatch("GET", "/app/pages/home");
|
|
94
123
|
assert.equal(ok.status, 200);
|
|
@@ -427,6 +427,27 @@ function renderText(node) {
|
|
|
427
427
|
return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
// A grid td cell. When the column declares linkField, the cell text (the
|
|
431
|
+
// column's own value) becomes a link to the URL held in that other field,
|
|
432
|
+
// opened in a new tab. Only http(s) hrefs are linked — anything else (e.g. a
|
|
433
|
+
// javascript: URL smuggled through row data) falls back to plain text — and
|
|
434
|
+
// external links get rel=noopener noreferrer so the opened page can't reach
|
|
435
|
+
// window.opener. Shared by the top-level grid and child grids.
|
|
436
|
+
function gridCell(col, row) {
|
|
437
|
+
const text = row[col.field] == null ? "" : String(row[col.field]);
|
|
438
|
+
if (col.linkField) {
|
|
439
|
+
const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
|
|
440
|
+
if (text !== "" && /^https?:\/\//i.test(href)) {
|
|
441
|
+
return el(
|
|
442
|
+
"td",
|
|
443
|
+
{},
|
|
444
|
+
el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return el("td", {}, text);
|
|
449
|
+
}
|
|
450
|
+
|
|
430
451
|
function renderActionForm(node) {
|
|
431
452
|
const p = node.props;
|
|
432
453
|
const card = el("section", { class: "pc-card" });
|
|
@@ -493,6 +514,23 @@ function renderDataGrid(node) {
|
|
|
493
514
|
card.append(table);
|
|
494
515
|
const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
|
|
495
516
|
|
|
517
|
+
// Expansion state has to outlive the poll: refresh() rebuilds the whole tbody
|
|
518
|
+
// every refreshMs, so without this an open detail row (where you answer an
|
|
519
|
+
// escalation) would collapse on the next tick. We remember which rowKeys are
|
|
520
|
+
// open, and reuse the already-built detail <tr> for them across refreshes so a
|
|
521
|
+
// half-typed answer survives too. Keyed by p.rowKey; grids without one keep the
|
|
522
|
+
// old (collapse-on-refresh) behavior.
|
|
523
|
+
const expanded = new Set();
|
|
524
|
+
const detailNodes = new Map();
|
|
525
|
+
// A row only participates in expansion/detail-node caching if it has a real
|
|
526
|
+
// (non-null) rowKey. Coercing a missing key through String() would yield the
|
|
527
|
+
// literal "undefined"/"null" and collide unrelated rows, so treat it as keyless.
|
|
528
|
+
const rowKeyOf = (row) => {
|
|
529
|
+
if (!p.rowKey) return null;
|
|
530
|
+
const v = row[p.rowKey];
|
|
531
|
+
return v == null ? null : String(v);
|
|
532
|
+
};
|
|
533
|
+
|
|
496
534
|
function dataUrl(source, tbl, filters, order) {
|
|
497
535
|
let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
|
|
498
536
|
const qs = [];
|
|
@@ -587,7 +625,7 @@ function renderDataGrid(node) {
|
|
|
587
625
|
cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
|
|
588
626
|
}
|
|
589
627
|
for (const cr of rows) {
|
|
590
|
-
const cells = ccols.map((c) =>
|
|
628
|
+
const cells = ccols.map((c) => gridCell(c, cr));
|
|
591
629
|
if (cg.lazyField) {
|
|
592
630
|
const lf = cg.lazyField;
|
|
593
631
|
const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== "";
|
|
@@ -641,7 +679,8 @@ function renderDataGrid(node) {
|
|
|
641
679
|
}
|
|
642
680
|
|
|
643
681
|
function renderRow(row) {
|
|
644
|
-
const cells = cols.map((c) =>
|
|
682
|
+
const cells = cols.map((c) => gridCell(c, row));
|
|
683
|
+
const key = rowKeyOf(row);
|
|
645
684
|
let toggle = null;
|
|
646
685
|
if (hasExtra) {
|
|
647
686
|
const actionCell = el("td", { class: "pc-row-actions" });
|
|
@@ -658,16 +697,29 @@ function renderDataGrid(node) {
|
|
|
658
697
|
const tr = el("tr", {}, ...cells);
|
|
659
698
|
tbody.append(tr);
|
|
660
699
|
if (detail && toggle) {
|
|
661
|
-
const
|
|
662
|
-
|
|
700
|
+
const isOpen = key != null && expanded.has(key);
|
|
701
|
+
// Reuse an already-built detail row for an open PR across refreshes so its
|
|
702
|
+
// expanded state — and any half-typed escalation answer in the form — survives
|
|
703
|
+
// the poll. A closed (or keyless) row always gets a fresh, lazily-built panel.
|
|
704
|
+
let entry = key != null ? detailNodes.get(key) : null;
|
|
705
|
+
if (!(isOpen && entry && entry.built)) {
|
|
706
|
+
const dtr = el("tr", { hidden: isOpen ? null : "" }, el("td", { colspan: span }));
|
|
707
|
+
entry = { dtr, built: false };
|
|
708
|
+
if (key != null) detailNodes.set(key, entry);
|
|
709
|
+
if (isOpen) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
|
|
710
|
+
}
|
|
711
|
+
const dtr = entry.dtr;
|
|
712
|
+
toggle.textContent = dtr.hidden ? "▸" : "▾";
|
|
663
713
|
toggle.addEventListener("click", (ev) => {
|
|
664
714
|
ev.stopPropagation();
|
|
665
715
|
const open = dtr.hidden;
|
|
666
716
|
dtr.hidden = !open;
|
|
667
717
|
toggle.textContent = open ? "▾" : "▸";
|
|
668
|
-
if (open
|
|
669
|
-
|
|
670
|
-
dtr.firstChild.append(detailPanel(row));
|
|
718
|
+
if (open) {
|
|
719
|
+
if (key != null) expanded.add(key);
|
|
720
|
+
if (!entry.built) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
|
|
721
|
+
} else if (key != null) {
|
|
722
|
+
expanded.delete(key);
|
|
671
723
|
}
|
|
672
724
|
});
|
|
673
725
|
tbody.append(dtr);
|
|
@@ -677,6 +729,14 @@ function renderDataGrid(node) {
|
|
|
677
729
|
async function refresh() {
|
|
678
730
|
try {
|
|
679
731
|
const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
732
|
+
// Forget expansion / cached detail nodes for rows no longer present so the maps
|
|
733
|
+
// don't grow without bound and a stale answer can't resurface on a key reuse.
|
|
734
|
+
if (p.rowKey) {
|
|
735
|
+
const live = new Set();
|
|
736
|
+
for (const r of rows) { const k = rowKeyOf(r); if (k != null) live.add(k); }
|
|
737
|
+
for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
|
|
738
|
+
for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
|
|
739
|
+
}
|
|
680
740
|
tbody.replaceChildren();
|
|
681
741
|
for (const row of rows) renderRow(row);
|
|
682
742
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|