@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) => el("td", {}, cr[c.field] == null ? "" : String(cr[c.field])));
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) => el("td", {}, row[c.field] == null ? "" : String(row[c.field])));
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 dtr = el("tr", { hidden: "" }, el("td", { colspan: span }));
627
- 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 ? "▸" : "▾";
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 && !built) {
634
- built = true;
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.19.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) => el("td", {}, cr[c.field] == null ? "" : String(cr[c.field])));
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) => el("td", {}, row[c.field] == null ? "" : String(row[c.field])));
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 dtr = el("tr", { hidden: "" }, el("td", { colspan: span }));
662
- 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 ? "▸" : "▾";
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 && !built) {
669
- built = true;
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")));