@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 dtr = el("tr", { hidden: "" }, el("td", { colspan: span }));
648
- let built = false;
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 && !built) {
655
- built = true;
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.20.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 dtr = el("tr", { hidden: "" }, el("td", { colspan: span }));
683
- let built = false;
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 && !built) {
690
- built = true;
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")));