@nanobpm/urban 0.93.0 → 0.94.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.
|
@@ -2,6 +2,10 @@ import type { AppApi, RuntimeContext } from "../context.ts";
|
|
|
2
2
|
import type { EngineClient } from "../host.ts";
|
|
3
3
|
import { type Route } from "../router.ts";
|
|
4
4
|
import { type CancelInstanceResult } from "./cancel.ts";
|
|
5
|
+
/** The app-wide dataGrid layout (`surfaces.pages.gridLayout`, nano-ide#572). */
|
|
6
|
+
export type GridLayout = "auto" | "cards";
|
|
7
|
+
/** Narrow an untyped manifest value to a declared {@link GridLayout}; anything else is undefined (→ `"auto"`). */
|
|
8
|
+
export declare function parseGridLayout(value: unknown): GridLayout | undefined;
|
|
5
9
|
/** The subset of the datasource gateway the page runtime needs. */
|
|
6
10
|
export interface PagesDataSource {
|
|
7
11
|
query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
|
|
@@ -14,6 +18,12 @@ export interface PagesOptions {
|
|
|
14
18
|
pagesDir?: string;
|
|
15
19
|
/** The page served at `/`. Default `home`. */
|
|
16
20
|
homePage?: string;
|
|
21
|
+
/**
|
|
22
|
+
* How every `dataGrid` lays out (nano-ide#572). `"auto"` (default): a table, flipping to the card
|
|
23
|
+
* list below the mobile breakpoint. `"cards"`: the card list at every width — for apps whose
|
|
24
|
+
* fixed-max-width shell would otherwise clip wide grids.
|
|
25
|
+
*/
|
|
26
|
+
gridLayout?: GridLayout;
|
|
17
27
|
/** Max rows a `dataGrid` fetch returns. Default 200. */
|
|
18
28
|
rowLimit?: number;
|
|
19
29
|
/** The injected default datasource name (the alias apps bind to). Default `app`. */
|
|
@@ -33,6 +33,10 @@ import { completeUserTaskResponse, resolveFormResponse } from "./forms.js";
|
|
|
33
33
|
import { quoteIdent } from "./gateway.js";
|
|
34
34
|
import { RENDERER_JS } from "./runtime.gen.js";
|
|
35
35
|
import { isSqlIdentifier } from "../read-model.js";
|
|
36
|
+
/** Narrow an untyped manifest value to a declared {@link GridLayout}; anything else is undefined (→ `"auto"`). */
|
|
37
|
+
export function parseGridLayout(value) {
|
|
38
|
+
return value === "auto" || value === "cards" ? value : undefined;
|
|
39
|
+
}
|
|
36
40
|
/** A JavaScript-body response (the renderer module served at the fingerprinted
|
|
37
41
|
* `/app/runtime.<hash>.js`, and its `/app/runtime.js` back-compat route). */
|
|
38
42
|
function javascript(body, status = 200, extraHeaders) {
|
|
@@ -177,7 +181,7 @@ export function createPagesRoutes(opts, deps) {
|
|
|
177
181
|
return p;
|
|
178
182
|
};
|
|
179
183
|
const routes = [];
|
|
180
|
-
const shell = html(rendererShell(homePage, opts.apiDocsPath));
|
|
184
|
+
const shell = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto"));
|
|
181
185
|
// The shell must never be pinned by the browser: it carries the *current* fingerprinted
|
|
182
186
|
// runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
|
|
183
187
|
// a revalidation on every load; the shell body is tiny, and the expensive module it references
|
|
@@ -567,6 +571,9 @@ export function mountPages(ctx, app) {
|
|
|
567
571
|
const opts = {
|
|
568
572
|
pagesDir: typeof decl.pagesDir === "string" ? decl.pagesDir : undefined,
|
|
569
573
|
homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
|
|
574
|
+
// `in`-narrowed so this reads the key whether or not the pinned @nanobpm/nano-app-schema already
|
|
575
|
+
// declares it (nanobpm/nano-bpm#1301); an undeclared/invalid value falls back to "auto".
|
|
576
|
+
gridLayout: parseGridLayout("gridLayout" in decl ? decl.gridLayout : undefined),
|
|
570
577
|
rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
|
|
571
578
|
sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
|
|
572
579
|
// Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
|
|
@@ -599,7 +606,7 @@ export function mountPages(ctx, app) {
|
|
|
599
606
|
function escapeAttr(s) {
|
|
600
607
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
601
608
|
}
|
|
602
|
-
function rendererShell(homePage, apiDocsPath) {
|
|
609
|
+
function rendererShell(homePage, apiDocsPath, gridLayout) {
|
|
603
610
|
// A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
|
|
604
611
|
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
605
612
|
// tab a handle back to this window (reverse-tabnabbing).
|
|
@@ -625,7 +632,7 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
625
632
|
<title>Urban App</title>
|
|
626
633
|
<style>${RENDERER_CSS}</style>
|
|
627
634
|
</head>
|
|
628
|
-
<body>${apiDocsBadge}
|
|
635
|
+
<body data-grid-layout="${gridLayout}">${apiDocsBadge}
|
|
629
636
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
630
637
|
<script>${FORMJS_JS}</script>
|
|
631
638
|
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
@@ -633,7 +640,7 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
633
640
|
</html>`;
|
|
634
641
|
}
|
|
635
642
|
// The app's stylesheet resolves every colour through the Nano console's
|
|
636
|
-
// semantic `--nano-*` token contract (see the
|
|
643
|
+
// semantic `--nano-*` token contract (see the nanobpm/nano-bpm repo's
|
|
637
644
|
// `console/src/theme/tokens.css` — MIRROR: keep the palettes below in sync). When the app is embedded in the
|
|
638
645
|
// console it inherits nothing across the iframe boundary, so the console
|
|
639
646
|
// postMessages its resolved tokens + appearance and the runtime lays them onto
|
|
@@ -647,6 +654,73 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
647
654
|
// stacking) AND the runtime's `matchMedia` mode-switch (which only Tier-2's
|
|
648
655
|
// optional page-level `mobile` layout variant needs), so the two can never
|
|
649
656
|
// drift apart on where "narrow" begins.
|
|
657
|
+
/**
|
|
658
|
+
* The dataGrid → card-list rules (#268), in ONE place. They are emitted twice from this single source:
|
|
659
|
+
* unscoped inside the mobile `@media` block (every app, narrow viewports) and scoped to
|
|
660
|
+
* `[data-grid-layout="cards"]` at top level (apps that opt into cards at every width, nano-ide#572).
|
|
661
|
+
* `scope` is prefixed onto every selector of every rule, so the two copies can never drift.
|
|
662
|
+
*/
|
|
663
|
+
function gridCardRules(scope) {
|
|
664
|
+
const css = `
|
|
665
|
+
/* dataGrid → card list. Once cells are display:block the fixed colgroup width
|
|
666
|
+
is irrelevant, so a wide table stops overflowing the phone. table-layout is
|
|
667
|
+
reset to auto so the block cells size to content. */
|
|
668
|
+
table.pc-grid { table-layout:auto; }
|
|
669
|
+
table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
|
|
670
|
+
table.pc-grid colgroup, table.pc-grid thead { display:none; }
|
|
671
|
+
table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
|
|
672
|
+
table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
|
|
673
|
+
/* label:value — the column header (data-label) prefixes the value as a muted
|
|
674
|
+
caption on the same line. Suppressed for the title, chip, actions and any
|
|
675
|
+
colspan (group/empty/error) cells below. */
|
|
676
|
+
table.pc-grid td[data-label]::before { content:attr(data-label); color:var(--nano-text-muted); font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em; flex:0 0 auto; }
|
|
677
|
+
/* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
|
|
678
|
+
card: larger, full width, no label caption. */
|
|
679
|
+
table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
|
|
680
|
+
table.pc-grid td.pc-mcell-primary::before { content:none; }
|
|
681
|
+
/* A badge column becomes a left-aligned status chip with no caption. */
|
|
682
|
+
table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
|
|
683
|
+
table.pc-grid td.pc-mcell-chip::before { content:none; }
|
|
684
|
+
/* An empty badge cell (row value blank) still carries the chip class but has no
|
|
685
|
+
child content; without this it renders as a blank flex row (padding/gap) that
|
|
686
|
+
leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
|
|
687
|
+
table.pc-grid td.pc-mcell-chip:empty { display:none; }
|
|
688
|
+
/* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
|
|
689
|
+
row's "More" toggle opens them; on desktop they render as normal columns. */
|
|
690
|
+
table.pc-grid td.pc-mcell-hidden { display:none; }
|
|
691
|
+
table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
|
|
692
|
+
.pc-mcard-toggle { display:block; padding:.3rem 0 0; }
|
|
693
|
+
.pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
|
|
694
|
+
/* Row actions stack full-width under the card body. */
|
|
695
|
+
table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
|
|
696
|
+
table.pc-grid td.pc-row-actions::before { content:none; }
|
|
697
|
+
table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
|
|
698
|
+
/* Group-header + empty/error rows span the full card width with no caption. */
|
|
699
|
+
table.pc-grid td[colspan] { display:block; }
|
|
700
|
+
table.pc-grid td[colspan]::before { content:none; }
|
|
701
|
+
.pc-group-header td { padding:0; }
|
|
702
|
+
`;
|
|
703
|
+
if (scope === "")
|
|
704
|
+
return css.replace(/^\n/, "");
|
|
705
|
+
// The scoped copy drops the comments (they document the mobile copy) and scopes each rule line.
|
|
706
|
+
return css
|
|
707
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
708
|
+
.split("\n")
|
|
709
|
+
.filter((line) => line.trim() !== "")
|
|
710
|
+
.map((line) => {
|
|
711
|
+
const brace = line.indexOf("{");
|
|
712
|
+
if (brace < 0)
|
|
713
|
+
return line;
|
|
714
|
+
const selectors = line
|
|
715
|
+
.slice(0, brace)
|
|
716
|
+
.split(",")
|
|
717
|
+
.map((sel) => `${scope} ${sel.trim()}`)
|
|
718
|
+
.join(", ");
|
|
719
|
+
return ` ${selectors} ${line.slice(brace)}`;
|
|
720
|
+
})
|
|
721
|
+
.join("\n")
|
|
722
|
+
.concat("\n");
|
|
723
|
+
}
|
|
650
724
|
const MOBILE_MAX_WIDTH = "640px";
|
|
651
725
|
const RENDERER_CSS = `
|
|
652
726
|
:root {
|
|
@@ -896,44 +970,7 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
896
970
|
matchMedia. */
|
|
897
971
|
@media (max-width:${MOBILE_MAX_WIDTH}) {
|
|
898
972
|
body { padding:1rem; }
|
|
899
|
-
/*
|
|
900
|
-
is irrelevant, so a wide table stops overflowing the phone. table-layout is
|
|
901
|
-
reset to auto so the block cells size to content. */
|
|
902
|
-
table.pc-grid { table-layout:auto; }
|
|
903
|
-
table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
|
|
904
|
-
table.pc-grid colgroup, table.pc-grid thead { display:none; }
|
|
905
|
-
table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
|
|
906
|
-
table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
|
|
907
|
-
/* label:value — the column header (data-label) prefixes the value as a muted
|
|
908
|
-
caption on the same line. Suppressed for the title, chip, actions and any
|
|
909
|
-
colspan (group/empty/error) cells below. */
|
|
910
|
-
table.pc-grid td[data-label]::before { content:attr(data-label); color:var(--nano-text-muted); font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em; flex:0 0 auto; }
|
|
911
|
-
/* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
|
|
912
|
-
card: larger, full width, no label caption. */
|
|
913
|
-
table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
|
|
914
|
-
table.pc-grid td.pc-mcell-primary::before { content:none; }
|
|
915
|
-
/* A badge column becomes a left-aligned status chip with no caption. */
|
|
916
|
-
table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
|
|
917
|
-
table.pc-grid td.pc-mcell-chip::before { content:none; }
|
|
918
|
-
/* An empty badge cell (row value blank) still carries the chip class but has no
|
|
919
|
-
child content; without this it renders as a blank flex row (padding/gap) that
|
|
920
|
-
leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
|
|
921
|
-
table.pc-grid td.pc-mcell-chip:empty { display:none; }
|
|
922
|
-
/* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
|
|
923
|
-
row's "More" toggle opens them; on desktop they render as normal columns. */
|
|
924
|
-
table.pc-grid td.pc-mcell-hidden { display:none; }
|
|
925
|
-
table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
|
|
926
|
-
.pc-mcard-toggle { display:block; padding:.3rem 0 0; }
|
|
927
|
-
.pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
|
|
928
|
-
/* Row actions stack full-width under the card body. */
|
|
929
|
-
table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
|
|
930
|
-
table.pc-grid td.pc-row-actions::before { content:none; }
|
|
931
|
-
table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
|
|
932
|
-
/* Group-header + empty/error rows span the full card width with no caption. */
|
|
933
|
-
table.pc-grid td[colspan] { display:block; }
|
|
934
|
-
table.pc-grid td[colspan]::before { content:none; }
|
|
935
|
-
.pc-group-header td { padding:0; }
|
|
936
|
-
/* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
|
|
973
|
+
${gridCardRules("")} /* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
|
|
937
974
|
its normal top-of-flow position and scrolls sideways when the pages overflow;
|
|
938
975
|
an opt-in sticky bar stays pinned to the top via the desktop .pc-sticky rule
|
|
939
976
|
(top:0). A rail drops its fixed side column and flows inline. */
|
|
@@ -951,7 +988,8 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
951
988
|
.pc-modal-actions { flex-direction:column; }
|
|
952
989
|
.pc-modal-actions .pc-btn { width:100%; }
|
|
953
990
|
}
|
|
954
|
-
|
|
991
|
+
/* App-wide card layout (nano-ide#572): the same card rules, at every width. */
|
|
992
|
+
${gridCardRules('[data-grid-layout="cards"]')}`;
|
|
955
993
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
956
994
|
// /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
|
|
957
995
|
// fetches the home page's page.json and renders text / actionForm / dataGrid / prose
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.94.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",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
},
|
|
104
104
|
"dependencies": {
|
|
105
105
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
106
|
-
"@nanobpm/agentic": "^0.
|
|
106
|
+
"@nanobpm/agentic": "^0.14.0",
|
|
107
107
|
"@nanobpm/nano-app-schema": "^0.9.0",
|
|
108
108
|
"@nanobpm/nano-sdk": "^1.2.5",
|
|
109
109
|
"@nanobpm/workflow": "^0.14.0",
|
|
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import type { EngineClient, HttpRequest, HttpResponse } from "../host.ts";
|
|
8
8
|
import { makeRouter } from "../router.ts";
|
|
9
|
-
import { createPagesRoutes, type PagesDataSource, type PagesDeps } from "./pages.ts";
|
|
9
|
+
import { createPagesRoutes, type GridLayout, type PagesDataSource, type PagesDeps, parseGridLayout } from "./pages.ts";
|
|
10
10
|
|
|
11
11
|
function req(
|
|
12
12
|
method: string,
|
|
@@ -2072,3 +2072,54 @@ test("the pages shell embeds the shared form-js renderer (NanoFormJs) for engine
|
|
|
2072
2072
|
assert.match(res.body ?? "", /NanoFormJs/, "the shell exposes the shared renderer as a global");
|
|
2073
2073
|
assert.match(res.body ?? "", /function renderForm/, "the shared renderer is embedded, not forked");
|
|
2074
2074
|
});
|
|
2075
|
+
|
|
2076
|
+
// nano-ide#572 — an app-wide opt-in (`surfaces.pages.gridLayout: "cards"`) renders every dataGrid as
|
|
2077
|
+
// the card list at ANY viewport width, not just below the mobile breakpoint.
|
|
2078
|
+
async function shellFor(gridLayout?: GridLayout): Promise<HttpResponse> {
|
|
2079
|
+
const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app", gridLayout }, {
|
|
2080
|
+
db: fakeDb(),
|
|
2081
|
+
engine: fakeEngine().engine,
|
|
2082
|
+
readPage: async () => "{}",
|
|
2083
|
+
});
|
|
2084
|
+
return await makeRouter(routes)(req("GET", "/"));
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
test("#572: gridLayout \"cards\" stamps the shell so dataGrids render as cards at every width", async () => {
|
|
2088
|
+
const html = (await shellFor("cards")).body ?? "";
|
|
2089
|
+
assert.match(html, /<body data-grid-layout="cards">/);
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
test("#572: the default (and \"auto\") keep today's table-on-desktop layout", async () => {
|
|
2093
|
+
for (const layout of [undefined, "auto"] as const) {
|
|
2094
|
+
const html = (await shellFor(layout)).body ?? "";
|
|
2095
|
+
assert.match(html, /<body data-grid-layout="auto">/);
|
|
2096
|
+
}
|
|
2097
|
+
});
|
|
2098
|
+
|
|
2099
|
+
test("#572: the always-cards rules are DERIVED from the mobile card rules — identical, just scoped", async () => {
|
|
2100
|
+
const html = (await shellFor("cards")).body ?? "";
|
|
2101
|
+
const media = (html.match(/@media \(max-width:640px\) \{[\s\S]*?\n\}/) ?? [""])[0];
|
|
2102
|
+
const gridRules = (css: string) =>
|
|
2103
|
+
css
|
|
2104
|
+
.split("\n")
|
|
2105
|
+
.map((l) => l.trim())
|
|
2106
|
+
.filter((l) => /(pc-grid|pc-mcard-toggle|pc-group-header)[^{]*\{/.test(l) && !l.startsWith("/*"));
|
|
2107
|
+
const mobile = gridRules(media);
|
|
2108
|
+
assert.ok(mobile.length > 5, "the mobile block carries the card rules");
|
|
2109
|
+
const scoped = gridRules(html).filter((l) => l.startsWith('[data-grid-layout="cards"]'));
|
|
2110
|
+
const unscoped = scoped.map((l) => l.replace(/\[data-grid-layout="cards"\] /g, ""));
|
|
2111
|
+
assert.deepEqual(unscoped, mobile, "every card rule appears once, scoped to the cards layout, byte-identical");
|
|
2112
|
+
// Every selector in a comma list is scoped, so no rule leaks onto the "auto" layout.
|
|
2113
|
+
for (const l of scoped) {
|
|
2114
|
+
const selectors = l.slice(0, l.indexOf("{")).split(",");
|
|
2115
|
+
for (const s of selectors) assert.match(s.trim(), /^\[data-grid-layout="cards"\] /, `unscoped selector in: ${l}`);
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
test("#572: parseGridLayout accepts only the declared layouts (anything else is the \"auto\" default)", () => {
|
|
2120
|
+
assert.equal(parseGridLayout("cards"), "cards");
|
|
2121
|
+
assert.equal(parseGridLayout("auto"), "auto");
|
|
2122
|
+
assert.equal(parseGridLayout(undefined), undefined);
|
|
2123
|
+
assert.equal(parseGridLayout("grid"), undefined);
|
|
2124
|
+
assert.equal(parseGridLayout(1), undefined);
|
|
2125
|
+
});
|
|
@@ -37,6 +37,14 @@ import { quoteIdent } from "./gateway.ts";
|
|
|
37
37
|
import { RENDERER_JS } from "./runtime.gen.ts";
|
|
38
38
|
import { isSqlIdentifier } from "../read-model.ts";
|
|
39
39
|
|
|
40
|
+
/** The app-wide dataGrid layout (`surfaces.pages.gridLayout`, nano-ide#572). */
|
|
41
|
+
export type GridLayout = "auto" | "cards";
|
|
42
|
+
|
|
43
|
+
/** Narrow an untyped manifest value to a declared {@link GridLayout}; anything else is undefined (→ `"auto"`). */
|
|
44
|
+
export function parseGridLayout(value: unknown): GridLayout | undefined {
|
|
45
|
+
return value === "auto" || value === "cards" ? value : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
/** The subset of the datasource gateway the page runtime needs. */
|
|
41
49
|
export interface PagesDataSource {
|
|
42
50
|
query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
|
|
@@ -48,6 +56,12 @@ export interface PagesOptions {
|
|
|
48
56
|
pagesDir?: string;
|
|
49
57
|
/** The page served at `/`. Default `home`. */
|
|
50
58
|
homePage?: string;
|
|
59
|
+
/**
|
|
60
|
+
* How every `dataGrid` lays out (nano-ide#572). `"auto"` (default): a table, flipping to the card
|
|
61
|
+
* list below the mobile breakpoint. `"cards"`: the card list at every width — for apps whose
|
|
62
|
+
* fixed-max-width shell would otherwise clip wide grids.
|
|
63
|
+
*/
|
|
64
|
+
gridLayout?: GridLayout;
|
|
51
65
|
/** Max rows a `dataGrid` fetch returns. Default 200. */
|
|
52
66
|
rowLimit?: number;
|
|
53
67
|
/** The injected default datasource name (the alias apps bind to). Default `app`. */
|
|
@@ -248,7 +262,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
248
262
|
};
|
|
249
263
|
|
|
250
264
|
const routes: Route[] = [];
|
|
251
|
-
const shell = html(rendererShell(homePage, opts.apiDocsPath));
|
|
265
|
+
const shell = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto"));
|
|
252
266
|
// The shell must never be pinned by the browser: it carries the *current* fingerprinted
|
|
253
267
|
// runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
|
|
254
268
|
// a revalidation on every load; the shell body is tiny, and the expensive module it references
|
|
@@ -630,6 +644,9 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
|
|
|
630
644
|
const opts: PagesOptions = {
|
|
631
645
|
pagesDir: typeof decl.pagesDir === "string" ? decl.pagesDir : undefined,
|
|
632
646
|
homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
|
|
647
|
+
// `in`-narrowed so this reads the key whether or not the pinned @nanobpm/nano-app-schema already
|
|
648
|
+
// declares it (nanobpm/nano-bpm#1301); an undeclared/invalid value falls back to "auto".
|
|
649
|
+
gridLayout: parseGridLayout("gridLayout" in decl ? decl.gridLayout : undefined),
|
|
633
650
|
rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
|
|
634
651
|
sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
|
|
635
652
|
// Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
|
|
@@ -664,7 +681,7 @@ function escapeAttr(s: string): string {
|
|
|
664
681
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
665
682
|
}
|
|
666
683
|
|
|
667
|
-
function rendererShell(homePage: string, apiDocsPath
|
|
684
|
+
function rendererShell(homePage: string, apiDocsPath: string | undefined, gridLayout: GridLayout): string {
|
|
668
685
|
// A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
|
|
669
686
|
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
670
687
|
// tab a handle back to this window (reverse-tabnabbing).
|
|
@@ -691,7 +708,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
691
708
|
<title>Urban App</title>
|
|
692
709
|
<style>${RENDERER_CSS}</style>
|
|
693
710
|
</head>
|
|
694
|
-
<body>${apiDocsBadge}
|
|
711
|
+
<body data-grid-layout="${gridLayout}">${apiDocsBadge}
|
|
695
712
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
696
713
|
<script>${FORMJS_JS}</script>
|
|
697
714
|
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
@@ -700,7 +717,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
700
717
|
}
|
|
701
718
|
|
|
702
719
|
// The app's stylesheet resolves every colour through the Nano console's
|
|
703
|
-
// semantic `--nano-*` token contract (see the
|
|
720
|
+
// semantic `--nano-*` token contract (see the nanobpm/nano-bpm repo's
|
|
704
721
|
// `console/src/theme/tokens.css` — MIRROR: keep the palettes below in sync). When the app is embedded in the
|
|
705
722
|
// console it inherits nothing across the iframe boundary, so the console
|
|
706
723
|
// postMessages its resolved tokens + appearance and the runtime lays them onto
|
|
@@ -714,6 +731,72 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
714
731
|
// stacking) AND the runtime's `matchMedia` mode-switch (which only Tier-2's
|
|
715
732
|
// optional page-level `mobile` layout variant needs), so the two can never
|
|
716
733
|
// drift apart on where "narrow" begins.
|
|
734
|
+
/**
|
|
735
|
+
* The dataGrid → card-list rules (#268), in ONE place. They are emitted twice from this single source:
|
|
736
|
+
* unscoped inside the mobile `@media` block (every app, narrow viewports) and scoped to
|
|
737
|
+
* `[data-grid-layout="cards"]` at top level (apps that opt into cards at every width, nano-ide#572).
|
|
738
|
+
* `scope` is prefixed onto every selector of every rule, so the two copies can never drift.
|
|
739
|
+
*/
|
|
740
|
+
function gridCardRules(scope: string): string {
|
|
741
|
+
const css = `
|
|
742
|
+
/* dataGrid → card list. Once cells are display:block the fixed colgroup width
|
|
743
|
+
is irrelevant, so a wide table stops overflowing the phone. table-layout is
|
|
744
|
+
reset to auto so the block cells size to content. */
|
|
745
|
+
table.pc-grid { table-layout:auto; }
|
|
746
|
+
table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
|
|
747
|
+
table.pc-grid colgroup, table.pc-grid thead { display:none; }
|
|
748
|
+
table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
|
|
749
|
+
table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
|
|
750
|
+
/* label:value — the column header (data-label) prefixes the value as a muted
|
|
751
|
+
caption on the same line. Suppressed for the title, chip, actions and any
|
|
752
|
+
colspan (group/empty/error) cells below. */
|
|
753
|
+
table.pc-grid td[data-label]::before { content:attr(data-label); color:var(--nano-text-muted); font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em; flex:0 0 auto; }
|
|
754
|
+
/* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
|
|
755
|
+
card: larger, full width, no label caption. */
|
|
756
|
+
table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
|
|
757
|
+
table.pc-grid td.pc-mcell-primary::before { content:none; }
|
|
758
|
+
/* A badge column becomes a left-aligned status chip with no caption. */
|
|
759
|
+
table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
|
|
760
|
+
table.pc-grid td.pc-mcell-chip::before { content:none; }
|
|
761
|
+
/* An empty badge cell (row value blank) still carries the chip class but has no
|
|
762
|
+
child content; without this it renders as a blank flex row (padding/gap) that
|
|
763
|
+
leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
|
|
764
|
+
table.pc-grid td.pc-mcell-chip:empty { display:none; }
|
|
765
|
+
/* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
|
|
766
|
+
row's "More" toggle opens them; on desktop they render as normal columns. */
|
|
767
|
+
table.pc-grid td.pc-mcell-hidden { display:none; }
|
|
768
|
+
table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
|
|
769
|
+
.pc-mcard-toggle { display:block; padding:.3rem 0 0; }
|
|
770
|
+
.pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
|
|
771
|
+
/* Row actions stack full-width under the card body. */
|
|
772
|
+
table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
|
|
773
|
+
table.pc-grid td.pc-row-actions::before { content:none; }
|
|
774
|
+
table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
|
|
775
|
+
/* Group-header + empty/error rows span the full card width with no caption. */
|
|
776
|
+
table.pc-grid td[colspan] { display:block; }
|
|
777
|
+
table.pc-grid td[colspan]::before { content:none; }
|
|
778
|
+
.pc-group-header td { padding:0; }
|
|
779
|
+
`;
|
|
780
|
+
if (scope === "") return css.replace(/^\n/, "");
|
|
781
|
+
// The scoped copy drops the comments (they document the mobile copy) and scopes each rule line.
|
|
782
|
+
return css
|
|
783
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
784
|
+
.split("\n")
|
|
785
|
+
.filter((line) => line.trim() !== "")
|
|
786
|
+
.map((line) => {
|
|
787
|
+
const brace = line.indexOf("{");
|
|
788
|
+
if (brace < 0) return line;
|
|
789
|
+
const selectors = line
|
|
790
|
+
.slice(0, brace)
|
|
791
|
+
.split(",")
|
|
792
|
+
.map((sel) => `${scope} ${sel.trim()}`)
|
|
793
|
+
.join(", ");
|
|
794
|
+
return ` ${selectors} ${line.slice(brace)}`;
|
|
795
|
+
})
|
|
796
|
+
.join("\n")
|
|
797
|
+
.concat("\n");
|
|
798
|
+
}
|
|
799
|
+
|
|
717
800
|
const MOBILE_MAX_WIDTH = "640px";
|
|
718
801
|
|
|
719
802
|
const RENDERER_CSS = `
|
|
@@ -964,44 +1047,7 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
964
1047
|
matchMedia. */
|
|
965
1048
|
@media (max-width:${MOBILE_MAX_WIDTH}) {
|
|
966
1049
|
body { padding:1rem; }
|
|
967
|
-
/*
|
|
968
|
-
is irrelevant, so a wide table stops overflowing the phone. table-layout is
|
|
969
|
-
reset to auto so the block cells size to content. */
|
|
970
|
-
table.pc-grid { table-layout:auto; }
|
|
971
|
-
table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
|
|
972
|
-
table.pc-grid colgroup, table.pc-grid thead { display:none; }
|
|
973
|
-
table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
|
|
974
|
-
table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
|
|
975
|
-
/* label:value — the column header (data-label) prefixes the value as a muted
|
|
976
|
-
caption on the same line. Suppressed for the title, chip, actions and any
|
|
977
|
-
colspan (group/empty/error) cells below. */
|
|
978
|
-
table.pc-grid td[data-label]::before { content:attr(data-label); color:var(--nano-text-muted); font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em; flex:0 0 auto; }
|
|
979
|
-
/* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
|
|
980
|
-
card: larger, full width, no label caption. */
|
|
981
|
-
table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
|
|
982
|
-
table.pc-grid td.pc-mcell-primary::before { content:none; }
|
|
983
|
-
/* A badge column becomes a left-aligned status chip with no caption. */
|
|
984
|
-
table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
|
|
985
|
-
table.pc-grid td.pc-mcell-chip::before { content:none; }
|
|
986
|
-
/* An empty badge cell (row value blank) still carries the chip class but has no
|
|
987
|
-
child content; without this it renders as a blank flex row (padding/gap) that
|
|
988
|
-
leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
|
|
989
|
-
table.pc-grid td.pc-mcell-chip:empty { display:none; }
|
|
990
|
-
/* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
|
|
991
|
-
row's "More" toggle opens them; on desktop they render as normal columns. */
|
|
992
|
-
table.pc-grid td.pc-mcell-hidden { display:none; }
|
|
993
|
-
table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
|
|
994
|
-
.pc-mcard-toggle { display:block; padding:.3rem 0 0; }
|
|
995
|
-
.pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
|
|
996
|
-
/* Row actions stack full-width under the card body. */
|
|
997
|
-
table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
|
|
998
|
-
table.pc-grid td.pc-row-actions::before { content:none; }
|
|
999
|
-
table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
|
|
1000
|
-
/* Group-header + empty/error rows span the full card width with no caption. */
|
|
1001
|
-
table.pc-grid td[colspan] { display:block; }
|
|
1002
|
-
table.pc-grid td[colspan]::before { content:none; }
|
|
1003
|
-
.pc-group-header td { padding:0; }
|
|
1004
|
-
/* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
|
|
1050
|
+
${gridCardRules("")} /* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
|
|
1005
1051
|
its normal top-of-flow position and scrolls sideways when the pages overflow;
|
|
1006
1052
|
an opt-in sticky bar stays pinned to the top via the desktop .pc-sticky rule
|
|
1007
1053
|
(top:0). A rail drops its fixed side column and flows inline. */
|
|
@@ -1019,7 +1065,8 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
1019
1065
|
.pc-modal-actions { flex-direction:column; }
|
|
1020
1066
|
.pc-modal-actions .pc-btn { width:100%; }
|
|
1021
1067
|
}
|
|
1022
|
-
|
|
1068
|
+
/* App-wide card layout (nano-ide#572): the same card rules, at every width. */
|
|
1069
|
+
${gridCardRules('[data-grid-layout="cards"]')}`;
|
|
1023
1070
|
|
|
1024
1071
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
1025
1072
|
// /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
|
|
@@ -721,3 +721,39 @@ test("threads an injected scheduler into the instance-tracking reconciler loop",
|
|
|
721
721
|
await rm(dir, { recursive: true, force: true });
|
|
722
722
|
}
|
|
723
723
|
});
|
|
724
|
+
|
|
725
|
+
// nano-ide#572 — the app-wide dataGrid card layout is opt-in via `surfaces.pages.gridLayout` in the
|
|
726
|
+
// MANIFEST. These drive the full manifest→shell wiring (createUrbanApp → mountSurfaces → mountPages)
|
|
727
|
+
// end-to-end, so a regression in that bridge — e.g. the reflective `"gridLayout" in decl` read or the
|
|
728
|
+
// `parseGridLayout` fallback — is caught here even though the focused pages.test.ts tests inject the
|
|
729
|
+
// option directly into createPagesRoutes (which bypasses the manifest read).
|
|
730
|
+
async function shellGridLayout(pages: Record<string, unknown>): Promise<string> {
|
|
731
|
+
const dir = await makeFixture({ surfaces: { pages: { enabled: true, ...pages } } });
|
|
732
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
733
|
+
const engine = new FakeEngine();
|
|
734
|
+
const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
|
|
735
|
+
await app.start();
|
|
736
|
+
try {
|
|
737
|
+
const res = await fetch(`http://localhost:${app.httpPort!}/`);
|
|
738
|
+
assert.equal(res.status, 200);
|
|
739
|
+
const body = await res.text();
|
|
740
|
+
const m = body.match(/<body data-grid-layout="([^"]*)">/);
|
|
741
|
+
assert.ok(m, `shell body carries a data-grid-layout attribute: ${body.slice(0, 200)}`);
|
|
742
|
+
return m[1];
|
|
743
|
+
} finally {
|
|
744
|
+
await app.stop();
|
|
745
|
+
await rm(dir, { recursive: true, force: true });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
test('#572: surfaces.pages.gridLayout "cards" reaches the shell body attribute through the manifest', async () => {
|
|
750
|
+
assert.equal(await shellGridLayout({ gridLayout: "cards" }), "cards");
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test('#572: an omitted surfaces.pages.gridLayout falls back to the "auto" layout', async () => {
|
|
754
|
+
assert.equal(await shellGridLayout({}), "auto");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test('#572: an invalid surfaces.pages.gridLayout falls back to the "auto" layout', async () => {
|
|
758
|
+
assert.equal(await shellGridLayout({ gridLayout: "grid" }), "auto");
|
|
759
|
+
});
|