@nanobpm/urban 0.22.0 → 0.23.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.
|
@@ -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
|
|
@@ -744,11 +747,83 @@ function renderDataGrid(node) {
|
|
|
744
747
|
|
|
745
748
|
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
|
|
746
749
|
|
|
750
|
+
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
751
|
+
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
752
|
+
// and the collapsed state survives a full reload / new session — not just the
|
|
753
|
+
// refresh poll. Every access is guarded: private-mode or storage-disabled
|
|
754
|
+
// browsers throw on localStorage, and a UI preference must never break render.
|
|
755
|
+
function readCollapsed(key, dflt) {
|
|
756
|
+
try {
|
|
757
|
+
const v = localStorage.getItem(key);
|
|
758
|
+
return v === null ? dflt : v === "1";
|
|
759
|
+
} catch (e) {
|
|
760
|
+
return dflt;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function writeCollapsed(key, val) {
|
|
764
|
+
try {
|
|
765
|
+
localStorage.setItem(key, val ? "1" : "0");
|
|
766
|
+
} catch (e) {
|
|
767
|
+
// Preference is best-effort; ignore storage failures.
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Wrap a rendered node so its card can be collapsed from a clickable header.
|
|
772
|
+
// Opt-in via the node's "collapsible" prop; "defaultCollapsed" seeds the state
|
|
773
|
+
// the first time (before the user has toggled it). Generic across node types —
|
|
774
|
+
// the body (everything the renderer produced, minus its own <h2> title, which
|
|
775
|
+
// becomes the header label) is hidden as one unit.
|
|
776
|
+
function makeCollapsible(node, card) {
|
|
777
|
+
const props = node.props || {};
|
|
778
|
+
if (!props.collapsible) return card;
|
|
779
|
+
// Normalize to a card container. Card renderers already return
|
|
780
|
+
// <section class="pc-card"> — we unwrap those in place (lift out the <h2>
|
|
781
|
+
// title, reparent the rest into the body). A non-card renderer (e.g. text
|
|
782
|
+
// returning <p>/<h1>) is nested whole inside a fresh section instead:
|
|
783
|
+
// appending a <button>/<div> directly into a <p> is invalid markup the
|
|
784
|
+
// browser silently re-parents, breaking layout and click handling.
|
|
785
|
+
const isCard = card.tagName === "SECTION";
|
|
786
|
+
const container = isCard ? card : el("section", { class: "pc-card" });
|
|
787
|
+
const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
|
|
788
|
+
const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
|
|
789
|
+
if (h2) h2.remove();
|
|
790
|
+
const body = el("div", { class: "pc-card-body" });
|
|
791
|
+
if (isCard) {
|
|
792
|
+
while (card.firstChild) body.append(card.firstChild);
|
|
793
|
+
} else {
|
|
794
|
+
body.append(card);
|
|
795
|
+
}
|
|
796
|
+
const chevron = el("span", { class: "pc-chevron-inline" }, "▾");
|
|
797
|
+
const header = el(
|
|
798
|
+
"button",
|
|
799
|
+
{ class: "pc-collapse-header", type: "button" },
|
|
800
|
+
chevron,
|
|
801
|
+
el("span", { class: "pc-collapse-title" }, titleText),
|
|
802
|
+
);
|
|
803
|
+
const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
|
|
804
|
+
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
805
|
+
function apply() {
|
|
806
|
+
body.hidden = collapsed;
|
|
807
|
+
chevron.textContent = collapsed ? "▸" : "▾";
|
|
808
|
+
header.setAttribute("aria-expanded", String(!collapsed));
|
|
809
|
+
}
|
|
810
|
+
header.addEventListener("click", () => {
|
|
811
|
+
collapsed = !collapsed;
|
|
812
|
+
writeCollapsed(storageKey, collapsed);
|
|
813
|
+
apply();
|
|
814
|
+
});
|
|
815
|
+
apply();
|
|
816
|
+
container.append(header, body);
|
|
817
|
+
return container;
|
|
818
|
+
}
|
|
819
|
+
|
|
747
820
|
async function main() {
|
|
748
821
|
try {
|
|
749
822
|
const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
|
|
750
823
|
if (doc.title) document.title = doc.title;
|
|
751
|
-
root.replaceChildren(
|
|
824
|
+
root.replaceChildren(
|
|
825
|
+
...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
|
|
826
|
+
);
|
|
752
827
|
} catch (e) {
|
|
753
828
|
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
|
|
754
829
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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",
|
|
@@ -138,6 +138,37 @@ test("the renderer honours numeric actionForm fields", async () => {
|
|
|
138
138
|
assert.match(js, /if \(!Number\.isFinite\(num\)\) continue;/);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test("renderer makes collapsible nodes persist their state across sessions", async () => {
|
|
142
|
+
// A node with `collapsible` gets a clickable header that hides/shows the card
|
|
143
|
+
// body, and the collapsed state is written to localStorage keyed by the home
|
|
144
|
+
// page id + node id so it survives a full reload / new session (not just the
|
|
145
|
+
// refresh poll). Storage access is guarded so private-mode browsers can't
|
|
146
|
+
// break render.
|
|
147
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
148
|
+
const js = res.body ?? "";
|
|
149
|
+
assert.match(js, /function makeCollapsible\(node, card\)/);
|
|
150
|
+
assert.match(js, /if \(!props\.collapsible\) return card;/);
|
|
151
|
+
// Seed from defaultCollapsed the first time, then from persisted state.
|
|
152
|
+
assert.match(js, /readCollapsed\(storageKey, !!props\.defaultCollapsed\)/);
|
|
153
|
+
// Namespaced, per-node storage key.
|
|
154
|
+
assert.match(js, /"pc:collapsed:" \+ HOME \+ ":" \+ \(node\.id/);
|
|
155
|
+
// Persist on toggle.
|
|
156
|
+
assert.match(js, /writeCollapsed\(storageKey, collapsed\)/);
|
|
157
|
+
// Every storage access is wrapped so a throwing localStorage can't break the UI.
|
|
158
|
+
assert.match(js, /localStorage\.getItem\(key\)/);
|
|
159
|
+
assert.match(js, /localStorage\.setItem\(key, val \? "1" : "0"\)/);
|
|
160
|
+
assert.match(js, /catch \(e\) \{\s*return dflt;/);
|
|
161
|
+
// And the wrapper is actually applied at the dispatch layer.
|
|
162
|
+
assert.match(js, /makeCollapsible\(n, \(RENDERERS\[n\.type\]/);
|
|
163
|
+
// Non-card renderer output (e.g. text -> <p>) is nested whole inside a fresh
|
|
164
|
+
// <section class="pc-card"> rather than having a <button>/<div> injected into
|
|
165
|
+
// it — so the feature is valid markup across every node type, not just cards.
|
|
166
|
+
assert.match(js, /const isCard = card\.tagName === "SECTION";/);
|
|
167
|
+
assert.match(js, /const container = isCard \? card : el\("section", \{ class: "pc-card" \}\)/);
|
|
168
|
+
assert.match(js, /body\.append\(card\)/);
|
|
169
|
+
assert.match(js, /container\.append\(header, body\);\s*return container;/);
|
|
170
|
+
});
|
|
171
|
+
|
|
141
172
|
test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
|
|
142
173
|
const ok = await dispatch("GET", "/app/pages/home");
|
|
143
174
|
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
|
|
@@ -775,11 +778,83 @@ function renderDataGrid(node) {
|
|
|
775
778
|
|
|
776
779
|
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
|
|
777
780
|
|
|
781
|
+
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
782
|
+
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
783
|
+
// and the collapsed state survives a full reload / new session — not just the
|
|
784
|
+
// refresh poll. Every access is guarded: private-mode or storage-disabled
|
|
785
|
+
// browsers throw on localStorage, and a UI preference must never break render.
|
|
786
|
+
function readCollapsed(key, dflt) {
|
|
787
|
+
try {
|
|
788
|
+
const v = localStorage.getItem(key);
|
|
789
|
+
return v === null ? dflt : v === "1";
|
|
790
|
+
} catch (e) {
|
|
791
|
+
return dflt;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function writeCollapsed(key, val) {
|
|
795
|
+
try {
|
|
796
|
+
localStorage.setItem(key, val ? "1" : "0");
|
|
797
|
+
} catch (e) {
|
|
798
|
+
// Preference is best-effort; ignore storage failures.
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Wrap a rendered node so its card can be collapsed from a clickable header.
|
|
803
|
+
// Opt-in via the node's "collapsible" prop; "defaultCollapsed" seeds the state
|
|
804
|
+
// the first time (before the user has toggled it). Generic across node types —
|
|
805
|
+
// the body (everything the renderer produced, minus its own <h2> title, which
|
|
806
|
+
// becomes the header label) is hidden as one unit.
|
|
807
|
+
function makeCollapsible(node, card) {
|
|
808
|
+
const props = node.props || {};
|
|
809
|
+
if (!props.collapsible) return card;
|
|
810
|
+
// Normalize to a card container. Card renderers already return
|
|
811
|
+
// <section class="pc-card"> — we unwrap those in place (lift out the <h2>
|
|
812
|
+
// title, reparent the rest into the body). A non-card renderer (e.g. text
|
|
813
|
+
// returning <p>/<h1>) is nested whole inside a fresh section instead:
|
|
814
|
+
// appending a <button>/<div> directly into a <p> is invalid markup the
|
|
815
|
+
// browser silently re-parents, breaking layout and click handling.
|
|
816
|
+
const isCard = card.tagName === "SECTION";
|
|
817
|
+
const container = isCard ? card : el("section", { class: "pc-card" });
|
|
818
|
+
const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
|
|
819
|
+
const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
|
|
820
|
+
if (h2) h2.remove();
|
|
821
|
+
const body = el("div", { class: "pc-card-body" });
|
|
822
|
+
if (isCard) {
|
|
823
|
+
while (card.firstChild) body.append(card.firstChild);
|
|
824
|
+
} else {
|
|
825
|
+
body.append(card);
|
|
826
|
+
}
|
|
827
|
+
const chevron = el("span", { class: "pc-chevron-inline" }, "▾");
|
|
828
|
+
const header = el(
|
|
829
|
+
"button",
|
|
830
|
+
{ class: "pc-collapse-header", type: "button" },
|
|
831
|
+
chevron,
|
|
832
|
+
el("span", { class: "pc-collapse-title" }, titleText),
|
|
833
|
+
);
|
|
834
|
+
const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
|
|
835
|
+
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
836
|
+
function apply() {
|
|
837
|
+
body.hidden = collapsed;
|
|
838
|
+
chevron.textContent = collapsed ? "▸" : "▾";
|
|
839
|
+
header.setAttribute("aria-expanded", String(!collapsed));
|
|
840
|
+
}
|
|
841
|
+
header.addEventListener("click", () => {
|
|
842
|
+
collapsed = !collapsed;
|
|
843
|
+
writeCollapsed(storageKey, collapsed);
|
|
844
|
+
apply();
|
|
845
|
+
});
|
|
846
|
+
apply();
|
|
847
|
+
container.append(header, body);
|
|
848
|
+
return container;
|
|
849
|
+
}
|
|
850
|
+
|
|
778
851
|
async function main() {
|
|
779
852
|
try {
|
|
780
853
|
const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
|
|
781
854
|
if (doc.title) document.title = doc.title;
|
|
782
|
-
root.replaceChildren(
|
|
855
|
+
root.replaceChildren(
|
|
856
|
+
...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
|
|
857
|
+
);
|
|
783
858
|
} catch (e) {
|
|
784
859
|
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
|
|
785
860
|
}
|