@nanobpm/urban 0.22.0 → 0.23.1

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.
@@ -361,6 +361,9 @@ table.pc-grid th { font-weight:600; opacity:.75; }
361
361
  .pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
362
362
  .pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
363
363
  .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
364
+ .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; }
365
+ .pc-chevron-inline { opacity:.6; font-size:.75rem; width:1em; }
366
+ .pc-card-body[hidden] { display:none; }
364
367
  `;
365
368
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
366
369
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
@@ -718,6 +721,16 @@ function renderDataGrid(node) {
718
721
  }
719
722
  }
720
723
 
724
+ // Is the user actively typing inside this grid? tbody.replaceChildren() in refresh()
725
+ // detaches the subtree, and detaching the node that holds document.activeElement blurs
726
+ // it — so an open detail form (where you answer an escalation) loses focus every
727
+ // refreshMs even though #103 preserves its half-typed value. Used to skip the poll.
728
+ const editingInGrid = () => {
729
+ const a = document.activeElement;
730
+ if (!a || !tbody.contains(a)) return false;
731
+ return a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.tagName === "SELECT" || a.isContentEditable;
732
+ };
733
+
721
734
  async function refresh() {
722
735
  try {
723
736
  const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
@@ -729,26 +742,125 @@ function renderDataGrid(node) {
729
742
  for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
730
743
  for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
731
744
  }
745
+ // Capture focus + caret before the DOM swap. replaceChildren() detaches (and so blurs)
746
+ // the reused detail node; re-appending it restores its value but not its focus. If the
747
+ // very same node is re-attached (an open, still-present row), put the caret back.
748
+ const active = document.activeElement;
749
+ const keepFocus = !!active && tbody.contains(active);
750
+ let selStart = null, selEnd = null, savedRange = null;
751
+ if (keepFocus) {
752
+ if (active.isContentEditable) {
753
+ // contenteditable has no selectionStart — its caret lives in the document
754
+ // selection. The reused node is re-attached, so the cloned range still points
755
+ // at live containers and can be restored after focus.
756
+ try { const s = document.getSelection(); if (s && s.rangeCount) savedRange = s.getRangeAt(0).cloneRange(); } catch (e) { /* selection unavailable */ }
757
+ } else {
758
+ try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }
759
+ }
760
+ }
732
761
  tbody.replaceChildren();
733
762
  for (const row of rows) renderRow(row);
734
763
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
764
+ if (keepFocus && active.isConnected) {
765
+ active.focus();
766
+ if (savedRange) {
767
+ try { const s = document.getSelection(); s.removeAllRanges(); s.addRange(savedRange); } catch (e) { /* selection unavailable */ }
768
+ } else if (selStart != null && typeof active.setSelectionRange === "function") {
769
+ try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }
770
+ }
771
+ }
735
772
  } catch (e) {
736
773
  tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
737
774
  }
738
775
  }
739
776
  document.addEventListener("pc:refresh", refresh);
740
- if (p.refreshMs && p.refreshMs > 0) setInterval(refresh, p.refreshMs);
777
+ // Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
778
+ // (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
779
+ // answer and the detail input keeps focus across ticks.
780
+ if (p.refreshMs && p.refreshMs > 0) setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
741
781
  refresh();
742
782
  return card;
743
783
  }
744
784
 
745
785
  const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
746
786
 
787
+ // Durable per-node UI state. localStorage is keyed by the home page id + node
788
+ // id so two grids on the same page (or the same grid across pages) don't clash,
789
+ // and the collapsed state survives a full reload / new session — not just the
790
+ // refresh poll. Every access is guarded: private-mode or storage-disabled
791
+ // browsers throw on localStorage, and a UI preference must never break render.
792
+ function readCollapsed(key, dflt) {
793
+ try {
794
+ const v = localStorage.getItem(key);
795
+ return v === null ? dflt : v === "1";
796
+ } catch (e) {
797
+ return dflt;
798
+ }
799
+ }
800
+ function writeCollapsed(key, val) {
801
+ try {
802
+ localStorage.setItem(key, val ? "1" : "0");
803
+ } catch (e) {
804
+ // Preference is best-effort; ignore storage failures.
805
+ }
806
+ }
807
+
808
+ // Wrap a rendered node so its card can be collapsed from a clickable header.
809
+ // Opt-in via the node's "collapsible" prop; "defaultCollapsed" seeds the state
810
+ // the first time (before the user has toggled it). Generic across node types —
811
+ // the body (everything the renderer produced, minus its own <h2> title, which
812
+ // becomes the header label) is hidden as one unit.
813
+ function makeCollapsible(node, card) {
814
+ const props = node.props || {};
815
+ if (!props.collapsible) return card;
816
+ // Normalize to a card container. Card renderers already return
817
+ // <section class="pc-card"> — we unwrap those in place (lift out the <h2>
818
+ // title, reparent the rest into the body). A non-card renderer (e.g. text
819
+ // returning <p>/<h1>) is nested whole inside a fresh section instead:
820
+ // appending a <button>/<div> directly into a <p> is invalid markup the
821
+ // browser silently re-parents, breaking layout and click handling.
822
+ const isCard = card.tagName === "SECTION";
823
+ const container = isCard ? card : el("section", { class: "pc-card" });
824
+ const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
825
+ const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
826
+ if (h2) h2.remove();
827
+ const body = el("div", { class: "pc-card-body" });
828
+ if (isCard) {
829
+ while (card.firstChild) body.append(card.firstChild);
830
+ } else {
831
+ body.append(card);
832
+ }
833
+ const chevron = el("span", { class: "pc-chevron-inline" }, "▾");
834
+ const header = el(
835
+ "button",
836
+ { class: "pc-collapse-header", type: "button" },
837
+ chevron,
838
+ el("span", { class: "pc-collapse-title" }, titleText),
839
+ );
840
+ const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
841
+ let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
842
+ function apply() {
843
+ body.hidden = collapsed;
844
+ chevron.textContent = collapsed ? "▸" : "▾";
845
+ header.setAttribute("aria-expanded", String(!collapsed));
846
+ }
847
+ header.addEventListener("click", () => {
848
+ collapsed = !collapsed;
849
+ writeCollapsed(storageKey, collapsed);
850
+ apply();
851
+ });
852
+ apply();
853
+ container.append(header, body);
854
+ return container;
855
+ }
856
+
747
857
  async function main() {
748
858
  try {
749
859
  const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
750
860
  if (doc.title) document.title = doc.title;
751
- root.replaceChildren(...(doc.nodes || []).map((n) => (RENDERERS[n.type] || (() => el("div")))(n)));
861
+ root.replaceChildren(
862
+ ...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
863
+ );
752
864
  } catch (e) {
753
865
  root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
754
866
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
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",
@@ -110,6 +110,26 @@ test("renderer preserves grid row-detail expansion across refreshes", async () =
110
110
  assert.match(js, /v == null \? null : String\(v\)/);
111
111
  });
112
112
 
113
+ test("renderer keeps the detail input focused across the refresh poll", async () => {
114
+ // Regression: #103 reused the open detail node so a half-typed answer survived, but
115
+ // tbody.replaceChildren() still detaches (and so blurs) that node, dropping the caret
116
+ // every refreshMs. The client now (1) skips the timer-driven poll while the user is
117
+ // editing in the grid, and (2) restores focus + selection across any refresh that runs.
118
+ const res = await dispatch("GET", "/app/runtime.js");
119
+ const js = res.body ?? "";
120
+ // (1) The automatic poll is gated on not-editing; an explicit pc:refresh still runs.
121
+ assert.match(js, /const editingInGrid = \(\)\s*=>/);
122
+ assert.match(js, /setInterval\(\s*\(\)\s*=>\s*\{\s*if\s*\(\s*!editingInGrid\(\)\s*\)\s*refresh\(\)\s*;?\s*\}\s*,\s*p\.refreshMs\s*\)/);
123
+ // (2) Focus + caret are captured before replaceChildren() and restored to the reused node.
124
+ assert.match(js, /const keepFocus = !!active && tbody\.contains\(active\)/);
125
+ assert.match(js, /active\.selectionStart/);
126
+ assert.match(js, /if \(keepFocus && active\.isConnected\)/);
127
+ assert.match(js, /active\.setSelectionRange\(selStart, selEnd\)/);
128
+ // contenteditable carets are preserved via the document selection range.
129
+ assert.match(js, /active\.isContentEditable/);
130
+ assert.match(js, /s\.addRange\(savedRange\)/);
131
+ });
132
+
113
133
  test("renderer wires a column's linkField to a new-tab anchor", async () => {
114
134
  const res = await dispatch("GET", "/app/runtime.js");
115
135
  const js = res.body ?? "";
@@ -138,6 +158,37 @@ test("the renderer honours numeric actionForm fields", async () => {
138
158
  assert.match(js, /if \(!Number\.isFinite\(num\)\) continue;/);
139
159
  });
140
160
 
161
+ test("renderer makes collapsible nodes persist their state across sessions", async () => {
162
+ // A node with `collapsible` gets a clickable header that hides/shows the card
163
+ // body, and the collapsed state is written to localStorage keyed by the home
164
+ // page id + node id so it survives a full reload / new session (not just the
165
+ // refresh poll). Storage access is guarded so private-mode browsers can't
166
+ // break render.
167
+ const res = await dispatch("GET", "/app/runtime.js");
168
+ const js = res.body ?? "";
169
+ assert.match(js, /function makeCollapsible\(node, card\)/);
170
+ assert.match(js, /if \(!props\.collapsible\) return card;/);
171
+ // Seed from defaultCollapsed the first time, then from persisted state.
172
+ assert.match(js, /readCollapsed\(storageKey, !!props\.defaultCollapsed\)/);
173
+ // Namespaced, per-node storage key.
174
+ assert.match(js, /"pc:collapsed:" \+ HOME \+ ":" \+ \(node\.id/);
175
+ // Persist on toggle.
176
+ assert.match(js, /writeCollapsed\(storageKey, collapsed\)/);
177
+ // Every storage access is wrapped so a throwing localStorage can't break the UI.
178
+ assert.match(js, /localStorage\.getItem\(key\)/);
179
+ assert.match(js, /localStorage\.setItem\(key, val \? "1" : "0"\)/);
180
+ assert.match(js, /catch \(e\) \{\s*return dflt;/);
181
+ // And the wrapper is actually applied at the dispatch layer.
182
+ assert.match(js, /makeCollapsible\(n, \(RENDERERS\[n\.type\]/);
183
+ // Non-card renderer output (e.g. text -> <p>) is nested whole inside a fresh
184
+ // <section class="pc-card"> rather than having a <button>/<div> injected into
185
+ // it — so the feature is valid markup across every node type, not just cards.
186
+ assert.match(js, /const isCard = card\.tagName === "SECTION";/);
187
+ assert.match(js, /const container = isCard \? card : el\("section", \{ class: "pc-card" \}\)/);
188
+ assert.match(js, /body\.append\(card\)/);
189
+ assert.match(js, /container\.append\(header, body\);\s*return container;/);
190
+ });
191
+
141
192
  test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
142
193
  const ok = await dispatch("GET", "/app/pages/home");
143
194
  assert.equal(ok.status, 200);
@@ -391,6 +391,9 @@ table.pc-grid th { font-weight:600; opacity:.75; }
391
391
  .pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
392
392
  .pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
393
393
  .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
394
+ .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; }
395
+ .pc-chevron-inline { opacity:.6; font-size:.75rem; width:1em; }
396
+ .pc-card-body[hidden] { display:none; }
394
397
  `;
395
398
 
396
399
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
@@ -749,6 +752,16 @@ function renderDataGrid(node) {
749
752
  }
750
753
  }
751
754
 
755
+ // Is the user actively typing inside this grid? tbody.replaceChildren() in refresh()
756
+ // detaches the subtree, and detaching the node that holds document.activeElement blurs
757
+ // it — so an open detail form (where you answer an escalation) loses focus every
758
+ // refreshMs even though #103 preserves its half-typed value. Used to skip the poll.
759
+ const editingInGrid = () => {
760
+ const a = document.activeElement;
761
+ if (!a || !tbody.contains(a)) return false;
762
+ return a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.tagName === "SELECT" || a.isContentEditable;
763
+ };
764
+
752
765
  async function refresh() {
753
766
  try {
754
767
  const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
@@ -760,26 +773,125 @@ function renderDataGrid(node) {
760
773
  for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
761
774
  for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
762
775
  }
776
+ // Capture focus + caret before the DOM swap. replaceChildren() detaches (and so blurs)
777
+ // the reused detail node; re-appending it restores its value but not its focus. If the
778
+ // very same node is re-attached (an open, still-present row), put the caret back.
779
+ const active = document.activeElement;
780
+ const keepFocus = !!active && tbody.contains(active);
781
+ let selStart = null, selEnd = null, savedRange = null;
782
+ if (keepFocus) {
783
+ if (active.isContentEditable) {
784
+ // contenteditable has no selectionStart — its caret lives in the document
785
+ // selection. The reused node is re-attached, so the cloned range still points
786
+ // at live containers and can be restored after focus.
787
+ try { const s = document.getSelection(); if (s && s.rangeCount) savedRange = s.getRangeAt(0).cloneRange(); } catch (e) { /* selection unavailable */ }
788
+ } else {
789
+ try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }
790
+ }
791
+ }
763
792
  tbody.replaceChildren();
764
793
  for (const row of rows) renderRow(row);
765
794
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
795
+ if (keepFocus && active.isConnected) {
796
+ active.focus();
797
+ if (savedRange) {
798
+ try { const s = document.getSelection(); s.removeAllRanges(); s.addRange(savedRange); } catch (e) { /* selection unavailable */ }
799
+ } else if (selStart != null && typeof active.setSelectionRange === "function") {
800
+ try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }
801
+ }
802
+ }
766
803
  } catch (e) {
767
804
  tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
768
805
  }
769
806
  }
770
807
  document.addEventListener("pc:refresh", refresh);
771
- if (p.refreshMs && p.refreshMs > 0) setInterval(refresh, p.refreshMs);
808
+ // Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
809
+ // (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
810
+ // answer and the detail input keeps focus across ticks.
811
+ if (p.refreshMs && p.refreshMs > 0) setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
772
812
  refresh();
773
813
  return card;
774
814
  }
775
815
 
776
816
  const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
777
817
 
818
+ // Durable per-node UI state. localStorage is keyed by the home page id + node
819
+ // id so two grids on the same page (or the same grid across pages) don't clash,
820
+ // and the collapsed state survives a full reload / new session — not just the
821
+ // refresh poll. Every access is guarded: private-mode or storage-disabled
822
+ // browsers throw on localStorage, and a UI preference must never break render.
823
+ function readCollapsed(key, dflt) {
824
+ try {
825
+ const v = localStorage.getItem(key);
826
+ return v === null ? dflt : v === "1";
827
+ } catch (e) {
828
+ return dflt;
829
+ }
830
+ }
831
+ function writeCollapsed(key, val) {
832
+ try {
833
+ localStorage.setItem(key, val ? "1" : "0");
834
+ } catch (e) {
835
+ // Preference is best-effort; ignore storage failures.
836
+ }
837
+ }
838
+
839
+ // Wrap a rendered node so its card can be collapsed from a clickable header.
840
+ // Opt-in via the node's "collapsible" prop; "defaultCollapsed" seeds the state
841
+ // the first time (before the user has toggled it). Generic across node types —
842
+ // the body (everything the renderer produced, minus its own <h2> title, which
843
+ // becomes the header label) is hidden as one unit.
844
+ function makeCollapsible(node, card) {
845
+ const props = node.props || {};
846
+ if (!props.collapsible) return card;
847
+ // Normalize to a card container. Card renderers already return
848
+ // <section class="pc-card"> — we unwrap those in place (lift out the <h2>
849
+ // title, reparent the rest into the body). A non-card renderer (e.g. text
850
+ // returning <p>/<h1>) is nested whole inside a fresh section instead:
851
+ // appending a <button>/<div> directly into a <p> is invalid markup the
852
+ // browser silently re-parents, breaking layout and click handling.
853
+ const isCard = card.tagName === "SECTION";
854
+ const container = isCard ? card : el("section", { class: "pc-card" });
855
+ const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
856
+ const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
857
+ if (h2) h2.remove();
858
+ const body = el("div", { class: "pc-card-body" });
859
+ if (isCard) {
860
+ while (card.firstChild) body.append(card.firstChild);
861
+ } else {
862
+ body.append(card);
863
+ }
864
+ const chevron = el("span", { class: "pc-chevron-inline" }, "▾");
865
+ const header = el(
866
+ "button",
867
+ { class: "pc-collapse-header", type: "button" },
868
+ chevron,
869
+ el("span", { class: "pc-collapse-title" }, titleText),
870
+ );
871
+ const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
872
+ let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
873
+ function apply() {
874
+ body.hidden = collapsed;
875
+ chevron.textContent = collapsed ? "▸" : "▾";
876
+ header.setAttribute("aria-expanded", String(!collapsed));
877
+ }
878
+ header.addEventListener("click", () => {
879
+ collapsed = !collapsed;
880
+ writeCollapsed(storageKey, collapsed);
881
+ apply();
882
+ });
883
+ apply();
884
+ container.append(header, body);
885
+ return container;
886
+ }
887
+
778
888
  async function main() {
779
889
  try {
780
890
  const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
781
891
  if (doc.title) document.title = doc.title;
782
- root.replaceChildren(...(doc.nodes || []).map((n) => (RENDERERS[n.type] || (() => el("div")))(n)));
892
+ root.replaceChildren(
893
+ ...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
894
+ );
783
895
  } catch (e) {
784
896
  root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
785
897
  }