@nanobpm/urban 0.23.0 → 0.24.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.
@@ -1,6 +1,6 @@
1
1
  import type { HostContext } from "./host.ts";
2
2
  import type { AppManifest, Worker } from "@nanobpm/nano-app-schema";
3
- export type { ActionDecl, AppManifest, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
3
+ export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
4
4
  declare module "@nanobpm/nano-app-schema" {
5
5
  interface Models {
6
6
  /** `{{name}}` template sources substituted into model resources at deploy time: globs, a bare
@@ -721,6 +721,16 @@ function renderDataGrid(node) {
721
721
  }
722
722
  }
723
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
+
724
734
  async function refresh() {
725
735
  try {
726
736
  const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
@@ -732,15 +742,42 @@ function renderDataGrid(node) {
732
742
  for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
733
743
  for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
734
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
+ }
735
761
  tbody.replaceChildren();
736
762
  for (const row of rows) renderRow(row);
737
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
+ }
738
772
  } catch (e) {
739
773
  tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
740
774
  }
741
775
  }
742
776
  document.addEventListener("pc:refresh", refresh);
743
- 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);
744
781
  refresh();
745
782
  return card;
746
783
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.23.0",
3
+ "version": "0.24.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",
@@ -62,11 +62,11 @@
62
62
  "test:deno": "deno test -A --no-check src/"
63
63
  },
64
64
  "dependencies": {
65
- "@nanobpm/nano-app-schema": "^0.1.0",
65
+ "@nanobpm/nano-app-schema": "^0.2.0",
66
66
  "@nanobpm/nano-sdk": "^1.2.3",
67
67
  "@nanobpm/workflow": "^0.8.0",
68
68
  "bpmn-auto-layout": "^2.0.0-alpha.2",
69
- "create-urban-app": "^0.5.0"
69
+ "create-urban-app": "^0.7.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/node": "^22.20.1",
@@ -14,6 +14,7 @@ import type { AppManifest, Worker } from "@nanobpm/nano-app-schema";
14
14
  export type {
15
15
  ActionDecl,
16
16
  AppManifest,
17
+ AppUi,
17
18
  ChatSurface,
18
19
  Connection,
19
20
  Data,
@@ -32,10 +33,11 @@ export type {
32
33
  Worker,
33
34
  } from "@nanobpm/nano-app-schema";
34
35
 
35
- // The published `@nanobpm/nano-app-schema` does not yet declare `models.templates`; the manifest
36
+ // The published `@nanobpm/nano-app-schema` does not declare `models.templates`; the manifest
36
37
  // validator only enforces `additionalProperties:false` at the top level (see validate.ts), so the
37
38
  // nested key is accepted at runtime. Augment `Models` here so the deploy path can read it typed
38
- // (no cast) until it is formalized in the schema package.
39
+ // (no cast) until it is formalized in the schema package. (The top-level `ui` block, by contrast,
40
+ // now ships natively in the schema as `AppUi` — re-exported above — so it needs no shim.)
39
41
  declare module "@nanobpm/nano-app-schema" {
40
42
  interface Models {
41
43
  /** `{{name}}` template sources substituted into model resources at deploy time: globs, a bare
@@ -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 ?? "";
@@ -752,6 +752,16 @@ function renderDataGrid(node) {
752
752
  }
753
753
  }
754
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
+
755
765
  async function refresh() {
756
766
  try {
757
767
  const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
@@ -763,15 +773,42 @@ function renderDataGrid(node) {
763
773
  for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
764
774
  for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
765
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
+ }
766
792
  tbody.replaceChildren();
767
793
  for (const row of rows) renderRow(row);
768
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
+ }
769
803
  } catch (e) {
770
804
  tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
771
805
  }
772
806
  }
773
807
  document.addEventListener("pc:refresh", refresh);
774
- 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);
775
812
  refresh();
776
813
  return card;
777
814
  }
@@ -37,6 +37,14 @@ test("an `actions` array is an accepted top-level key (ADR 0055 §3)", () => {
37
37
  assert.deepEqual(issues, []);
38
38
  });
39
39
 
40
+ test("a `ui` block is an accepted top-level key (Studio App View, ADR 0057 / nano-bpm #638)", () => {
41
+ const issues = collectManifestIssues({
42
+ ...valid,
43
+ ui: { enabled: true, portEnv: "PORT", path: "/", label: "Nano Workforce" },
44
+ });
45
+ assert.deepEqual(issues, []);
46
+ });
47
+
40
48
  test("bad schemaVersion and bad slug id are reported", () => {
41
49
  const issues = collectManifestIssues({ ...valid, schemaVersion: 2, id: "Not A Slug" });
42
50
  assert.ok(issues.some((i) => i.path === "schemaVersion"));