@nanobpm/urban 0.20.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.
|
@@ -479,6 +479,23 @@ function renderDataGrid(node) {
|
|
|
479
479
|
card.append(table);
|
|
480
480
|
const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
|
|
481
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
|
+
|
|
482
499
|
function dataUrl(source, tbl, filters, order) {
|
|
483
500
|
let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
|
|
484
501
|
const qs = [];
|
|
@@ -628,6 +645,7 @@ function renderDataGrid(node) {
|
|
|
628
645
|
|
|
629
646
|
function renderRow(row) {
|
|
630
647
|
const cells = cols.map((c) => gridCell(c, row));
|
|
648
|
+
const key = rowKeyOf(row);
|
|
631
649
|
let toggle = null;
|
|
632
650
|
if (hasExtra) {
|
|
633
651
|
const actionCell = el("td", { class: "pc-row-actions" });
|
|
@@ -644,16 +662,29 @@ function renderDataGrid(node) {
|
|
|
644
662
|
const tr = el("tr", {}, ...cells);
|
|
645
663
|
tbody.append(tr);
|
|
646
664
|
if (detail && toggle) {
|
|
647
|
-
const
|
|
648
|
-
|
|
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 ? "▸" : "▾";
|
|
649
678
|
toggle.addEventListener("click", (ev) => {
|
|
650
679
|
ev.stopPropagation();
|
|
651
680
|
const open = dtr.hidden;
|
|
652
681
|
dtr.hidden = !open;
|
|
653
682
|
toggle.textContent = open ? "▾" : "▸";
|
|
654
|
-
if (open
|
|
655
|
-
|
|
656
|
-
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);
|
|
657
688
|
}
|
|
658
689
|
});
|
|
659
690
|
tbody.append(dtr);
|
|
@@ -663,6 +694,14 @@ function renderDataGrid(node) {
|
|
|
663
694
|
async function refresh() {
|
|
664
695
|
try {
|
|
665
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
|
+
}
|
|
666
705
|
tbody.replaceChildren();
|
|
667
706
|
for (const row of rows) renderRow(row);
|
|
668
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,22 @@ 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
|
+
|
|
92
108
|
test("renderer wires a column's linkField to a new-tab anchor", async () => {
|
|
93
109
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
94
110
|
const js = res.body ?? "";
|
|
@@ -514,6 +514,23 @@ function renderDataGrid(node) {
|
|
|
514
514
|
card.append(table);
|
|
515
515
|
const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
|
|
516
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
|
+
|
|
517
534
|
function dataUrl(source, tbl, filters, order) {
|
|
518
535
|
let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
|
|
519
536
|
const qs = [];
|
|
@@ -663,6 +680,7 @@ function renderDataGrid(node) {
|
|
|
663
680
|
|
|
664
681
|
function renderRow(row) {
|
|
665
682
|
const cells = cols.map((c) => gridCell(c, row));
|
|
683
|
+
const key = rowKeyOf(row);
|
|
666
684
|
let toggle = null;
|
|
667
685
|
if (hasExtra) {
|
|
668
686
|
const actionCell = el("td", { class: "pc-row-actions" });
|
|
@@ -679,16 +697,29 @@ function renderDataGrid(node) {
|
|
|
679
697
|
const tr = el("tr", {}, ...cells);
|
|
680
698
|
tbody.append(tr);
|
|
681
699
|
if (detail && toggle) {
|
|
682
|
-
const
|
|
683
|
-
|
|
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 ? "▸" : "▾";
|
|
684
713
|
toggle.addEventListener("click", (ev) => {
|
|
685
714
|
ev.stopPropagation();
|
|
686
715
|
const open = dtr.hidden;
|
|
687
716
|
dtr.hidden = !open;
|
|
688
717
|
toggle.textContent = open ? "▾" : "▸";
|
|
689
|
-
if (open
|
|
690
|
-
|
|
691
|
-
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);
|
|
692
723
|
}
|
|
693
724
|
});
|
|
694
725
|
tbody.append(dtr);
|
|
@@ -698,6 +729,14 @@ function renderDataGrid(node) {
|
|
|
698
729
|
async function refresh() {
|
|
699
730
|
try {
|
|
700
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
|
+
}
|
|
701
740
|
tbody.replaceChildren();
|
|
702
741
|
for (const row of rows) renderRow(row);
|
|
703
742
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|