@nanobpm/urban 0.6.0 → 0.7.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.
@@ -0,0 +1,670 @@
1
+ // The Urban App page runtime as a runtime SURFACE (ADR 0042 §3, ADR 0055 phase 2).
2
+ //
3
+ // Ported from the console-generated `app-pages.ts` (`@nanobpm/app`). It turns a composed
4
+ // `page.json` (authored in the console Page Composer) into a served, data-bound screen
5
+ // with no hand-written frontend or API. Where the generated module took an injected
6
+ // `db`/`nano` and served its own Deno HTTP server, this version binds to the runtime
7
+ // seams: the datasource gateway (`app.data.open(source)`), the `EngineClient`, and the
8
+ // host's `readTextFile`, and contributes `Route`s to the runtime's shared HTTP server.
9
+ //
10
+ // Endpoints (ADR 0026 §1 action API):
11
+ // GET / (+ /app/runtime.js) → the schema-driven browser renderer
12
+ // GET /app/pages/<id> → the page's page.json
13
+ // GET /app/data/<source>/<table>[?where&order] → rows (filtered/ordered, whitelisted)
14
+ // POST /app/actions/start/<process> → engine.createInstance
15
+ // POST /app/actions/cancel → engine.cancelInstance
16
+ // POST /app/actions/message → engine.publishMessage
17
+ import { html, json } from "../router.js";
18
+ import { quoteIdent } from "./gateway.js";
19
+ /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
20
+ function javascript(body, status = 200) {
21
+ return { status, headers: { "content-type": "text/javascript; charset=utf-8" }, body };
22
+ }
23
+ /** A SQL identifier guard — a table name must be a bare identifier *and* a known table
24
+ * (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
25
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
26
+ /**
27
+ * Build the page-runtime routes over an injected datasource + engine. Pure over its deps
28
+ * so it is unit-testable without a real host/server (see pages.test.ts).
29
+ */
30
+ export function createPagesRoutes(opts, deps) {
31
+ const pagesDir = opts.pagesDir ?? "pages";
32
+ const homePage = opts.homePage ?? "home";
33
+ const rowLimitRaw = opts.rowLimit ?? 200;
34
+ const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
35
+ const sourceName = opts.sourceName ?? "app";
36
+ const { db, engine, readPage } = deps;
37
+ // The table-name whitelist is memoised: an Urban app runs its migrations at boot
38
+ // (before serving), so the schema is stable for the process lifetime, and the renderer
39
+ // refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
40
+ // path. Introspect once, lazily, and reuse. A rejected introspection is NOT cached (the
41
+ // in-flight promise is cleared on failure) so a transient error doesn't wedge every
42
+ // future request.
43
+ let tableNames = null;
44
+ const knownTables = () => (tableNames ??= db.schema().then((t) => new Set(t.map((x) => x.name)), (err) => {
45
+ tableNames = null;
46
+ throw err;
47
+ }));
48
+ // Per-table column whitelist, introspected once per table via `PRAGMA table_info`.
49
+ // Every filter/order column named in a `/app/data` query is checked against this set
50
+ // before it reaches the SQL, so an attacker-supplied `where`/`order` can never inject.
51
+ const tableColumns = new Map();
52
+ const knownColumns = (table) => {
53
+ const cached = tableColumns.get(table);
54
+ if (cached)
55
+ return cached;
56
+ const p = db
57
+ .query(`PRAGMA table_info(${quoteIdent(table)})`)
58
+ .then((rows) => new Set(rows.map((r) => String(r.name))))
59
+ .catch((err) => {
60
+ tableColumns.delete(table);
61
+ throw err;
62
+ });
63
+ tableColumns.set(table, p);
64
+ return p;
65
+ };
66
+ const routes = [];
67
+ const shell = html(rendererShell(homePage));
68
+ // ── the renderer shell + module ─────────────────────────────────────────
69
+ routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
70
+ routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
71
+ routes.push({
72
+ method: "GET",
73
+ path: "/app/runtime.js",
74
+ source: "surface:pages",
75
+ handler: () => javascript(RENDERER_JS),
76
+ });
77
+ // ── GET /app/pages/<id> ─────────────────────────────────────────────────
78
+ routes.push({
79
+ method: "GET",
80
+ path: "/app/pages/",
81
+ prefix: true,
82
+ source: "surface:pages",
83
+ handler: async (req) => {
84
+ const m = req.path.match(/^\/app\/pages\/([A-Za-z0-9_-]+)$/);
85
+ if (!m)
86
+ return json({ error: "not found" }, 404);
87
+ const id = m[1];
88
+ try {
89
+ const text = await readPage(`${pagesDir}/${id}.page.json`);
90
+ return { status: 200, headers: { "content-type": "application/json" }, body: text };
91
+ }
92
+ catch {
93
+ return json({ error: `page "${id}" not found` }, 404);
94
+ }
95
+ },
96
+ });
97
+ // ── GET /app/data/<source>/<table> ──────────────────────────────────────
98
+ routes.push({
99
+ method: "GET",
100
+ path: "/app/data/",
101
+ prefix: true,
102
+ source: "surface:pages",
103
+ handler: async (req) => {
104
+ const m = req.path.match(/^\/app\/data\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_]+)$/);
105
+ if (!m)
106
+ return json({ error: "not found" }, 404);
107
+ const source = m[1];
108
+ const table = m[2];
109
+ // v1 exposes only the injected default datasource; a request naming any other
110
+ // source is rejected rather than silently served off the default.
111
+ if (source !== sourceName)
112
+ return json({ error: `unknown datasource "${source}"` }, 404);
113
+ if (!IDENT.test(table))
114
+ return json({ error: "invalid table name" }, 400);
115
+ let tables;
116
+ try {
117
+ tables = await knownTables();
118
+ }
119
+ catch {
120
+ return json({ error: "schema introspection failed" }, 500);
121
+ }
122
+ if (!tables.has(table))
123
+ return json({ error: `unknown table "${table}"` }, 404);
124
+ // Parse ?where=col:value (equality) or ?where=col:in:v1,v2 (set membership),
125
+ // repeatable and ANDed, plus ?order=col:dir. Every column is whitelisted against
126
+ // the table's real columns before it reaches the SQL; values are always bound as
127
+ // `?` parameters (commas in an `in` list split values).
128
+ let columns;
129
+ try {
130
+ columns = await knownColumns(table);
131
+ }
132
+ catch {
133
+ return json({ error: "schema introspection failed" }, 500);
134
+ }
135
+ const params = [];
136
+ const clauses = [];
137
+ for (const raw of req.query.getAll("where")) {
138
+ const colon = raw.indexOf(":");
139
+ if (colon <= 0)
140
+ return json({ error: "invalid where clause" }, 400);
141
+ const field = raw.slice(0, colon);
142
+ const rest = raw.slice(colon + 1);
143
+ if (!columns.has(field))
144
+ return json({ error: `unknown column "${field}"` }, 400);
145
+ if (rest.startsWith("in:")) {
146
+ const values = rest.slice(3).split(",");
147
+ clauses.push(`${quoteIdent(field)} IN (${values.map(() => "?").join(", ")})`);
148
+ for (const v of values)
149
+ params.push(v);
150
+ }
151
+ else {
152
+ clauses.push(`${quoteIdent(field)} = ?`);
153
+ params.push(rest);
154
+ }
155
+ }
156
+ let orderSql = "";
157
+ const orderRaw = req.query.get("order");
158
+ if (orderRaw) {
159
+ const colon = orderRaw.indexOf(":");
160
+ const field = colon > 0 ? orderRaw.slice(0, colon) : orderRaw;
161
+ const dir = colon > 0 && orderRaw.slice(colon + 1).toLowerCase() === "desc" ? "DESC" : "ASC";
162
+ if (!columns.has(field))
163
+ return json({ error: `unknown column "${field}"` }, 400);
164
+ orderSql = ` ORDER BY ${quoteIdent(field)} ${dir}`;
165
+ }
166
+ const whereSql = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
167
+ try {
168
+ const rows = await db.query(`SELECT * FROM ${quoteIdent(table)}${whereSql}${orderSql} LIMIT ${rowLimit}`, params);
169
+ return json({ rows });
170
+ }
171
+ catch (e) {
172
+ return json({ error: String(e?.message ?? e) }, 500);
173
+ }
174
+ },
175
+ });
176
+ // ── POST /app/actions/start/<process> ───────────────────────────────────
177
+ routes.push({
178
+ method: "POST",
179
+ path: "/app/actions/start/",
180
+ prefix: true,
181
+ source: "surface:pages",
182
+ handler: async (req) => {
183
+ const m = req.path.match(/^\/app\/actions\/start\/([A-Za-z0-9_.-]+)$/);
184
+ if (!m)
185
+ return json({ error: "not found" }, 404);
186
+ const process = m[1];
187
+ let variables = {};
188
+ try {
189
+ const body = JSON.parse((await req.text()) || "{}");
190
+ if (body && typeof body === "object") {
191
+ const v = body.variables;
192
+ // Only a plain object is a valid variable map — reject arrays/scalars/null so a
193
+ // malformed body can't reach the engine as bad `variables`.
194
+ if (v && typeof v === "object" && !Array.isArray(v)) {
195
+ variables = v;
196
+ }
197
+ }
198
+ }
199
+ catch {
200
+ return json({ error: "body must be JSON" }, 400);
201
+ }
202
+ try {
203
+ const res = await engine.createInstance({ processDefinitionId: process, variables });
204
+ return json({ processInstanceKey: res.processInstanceKey ?? null });
205
+ }
206
+ catch (e) {
207
+ return json({ error: String(e?.message ?? e) }, 502);
208
+ }
209
+ },
210
+ });
211
+ // ── POST /app/actions/cancel ────────────────────────────────────────────
212
+ routes.push({
213
+ method: "POST",
214
+ path: "/app/actions/cancel",
215
+ source: "surface:pages",
216
+ handler: async (req) => {
217
+ let key;
218
+ try {
219
+ const body = JSON.parse((await req.text()) || "{}");
220
+ const k = body?.processInstanceKey;
221
+ if (typeof k === "string" || typeof k === "number")
222
+ key = k;
223
+ }
224
+ catch {
225
+ return json({ error: "body must be JSON" }, 400);
226
+ }
227
+ if (key === undefined || key === "")
228
+ return json({ error: "processInstanceKey is required" }, 400);
229
+ try {
230
+ await engine.cancelInstance({ processInstanceKey: String(key) });
231
+ return json({ ok: true });
232
+ }
233
+ catch (e) {
234
+ return json({ error: String(e?.message ?? e) }, 502);
235
+ }
236
+ },
237
+ });
238
+ // ── POST /app/actions/message ───────────────────────────────────────────
239
+ routes.push({
240
+ method: "POST",
241
+ path: "/app/actions/message",
242
+ source: "surface:pages",
243
+ handler: async (req) => {
244
+ let name = "";
245
+ let correlationKey = "";
246
+ let variables = {};
247
+ try {
248
+ const body = JSON.parse((await req.text()) || "{}");
249
+ if (typeof body?.name === "string")
250
+ name = body.name;
251
+ if (typeof body?.correlationKey === "string" || typeof body?.correlationKey === "number") {
252
+ correlationKey = String(body.correlationKey);
253
+ }
254
+ const v = body?.variables;
255
+ if (v && typeof v === "object" && !Array.isArray(v)) {
256
+ variables = v;
257
+ }
258
+ }
259
+ catch {
260
+ return json({ error: "body must be JSON" }, 400);
261
+ }
262
+ if (!name)
263
+ return json({ error: "message name is required" }, 400);
264
+ if (!correlationKey)
265
+ return json({ error: "correlationKey is required" }, 400);
266
+ try {
267
+ await engine.publishMessage({ name, correlationKey, variables });
268
+ return json({ ok: true });
269
+ }
270
+ catch (e) {
271
+ return json({ error: String(e?.message ?? e) }, 502);
272
+ }
273
+ },
274
+ });
275
+ return routes;
276
+ }
277
+ /**
278
+ * Mount the `pages` surface when enabled in the manifest. Binds the page runtime to the
279
+ * app's datasource gateway (`app.data.open(source)`), engine, and the host's file read.
280
+ * Returns no routes when the surface is disabled or absent.
281
+ */
282
+ export function mountPages(ctx, app) {
283
+ const decl = ctx.manifest.surfaces?.pages;
284
+ if (!decl?.enabled) {
285
+ return { name: "pages", routes: [], describe: () => ({ enabled: false }) };
286
+ }
287
+ const opts = {
288
+ pagesDir: typeof decl.pagesDir === "string" ? decl.pagesDir : undefined,
289
+ homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
290
+ rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
291
+ sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
292
+ };
293
+ const sourceName = opts.sourceName ?? "app";
294
+ const routes = createPagesRoutes(opts, {
295
+ db: app.data.open(sourceName),
296
+ engine: app.engine,
297
+ readPage: (p) => ctx.host.readTextFile(p),
298
+ });
299
+ ctx.host.log("info", "pages surface mounted", { source: sourceName, home: opts.homePage ?? "home" });
300
+ return {
301
+ name: "pages",
302
+ routes,
303
+ describe: () => ({ enabled: true, source: sourceName, home: opts.homePage ?? "home" }),
304
+ };
305
+ }
306
+ function escapeAttr(s) {
307
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
308
+ }
309
+ function rendererShell(homePage) {
310
+ return `<!doctype html>
311
+ <html lang="en">
312
+ <head>
313
+ <meta charset="utf-8" />
314
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
315
+ <title>Urban App</title>
316
+ <style>${RENDERER_CSS}</style>
317
+ </head>
318
+ <body>
319
+ <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
320
+ <script type="module" src="/app/runtime.js"></script>
321
+ </body>
322
+ </html>`;
323
+ }
324
+ const RENDERER_CSS = `
325
+ :root { color-scheme: light dark; --pc-edge:#d0d0d8; --pc-accent:#3b5bdb; }
326
+ * { box-sizing: border-box; }
327
+ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; }
328
+ .pc-empty { opacity:.6; }
329
+ .pc-heading { font-size:1.6rem; font-weight:650; margin:0 0 .25rem; }
330
+ .pc-sub { opacity:.7; margin:.25rem 0 1rem; }
331
+ .pc-body { margin:.5rem 0; }
332
+ .pc-card { border:1px solid var(--pc-edge); border-radius:.6rem; padding:1rem 1.15rem; margin:1rem 0; }
333
+ .pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
334
+ .pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
335
+ .pc-field label { font-size:.8rem; opacity:.75; }
336
+ .pc-field input { padding:.5rem .6rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
337
+ .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--pc-accent); color:#fff; font:inherit; cursor:pointer; }
338
+ .pc-btn:disabled { opacity:.5; cursor:default; }
339
+ .pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
340
+ .pc-msg.err { color:#c0392b; }
341
+ .pc-msg.ok { color:#2b8a3e; }
342
+ table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
343
+ table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--pc-edge); }
344
+ table.pc-grid th { font-weight:600; opacity:.75; }
345
+ .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
346
+ .pc-tab { padding:.35rem .8rem; border:1px solid var(--pc-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
347
+ .pc-tab.active { background:var(--pc-accent); color:#fff; border-color:var(--pc-accent); }
348
+ .pc-btn-sm { padding:.25rem .55rem; font-size:.8rem; margin-right:.3rem; }
349
+ .pc-chevron { background:transparent; color:inherit; border:1px solid var(--pc-edge); }
350
+ .pc-row-actions { white-space:nowrap; text-align:right; }
351
+ .pc-detail { padding:.75rem .25rem; }
352
+ .pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
353
+ .pc-detail-label { opacity:.7; min-width:8rem; }
354
+ .pc-link { color:var(--pc-accent); }
355
+ .pc-child { margin:.6rem 0; }
356
+ .pc-child-title { font-size:.8rem; font-weight:600; opacity:.7; margin-bottom:.25rem; }
357
+ .pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:rgba(120,120,160,.08); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
358
+ .pc-subform { margin-top:.75rem; padding:.6rem; border:1px dashed var(--pc-edge); border-radius:.5rem; }
359
+ .pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
360
+ .pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
361
+ .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
362
+ `;
363
+ // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
364
+ // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
365
+ // fetches the home page's page.json and renders text / actionForm / dataGrid nodes,
366
+ // wiring actionForm → /app/actions/start and dataGrid → /app/data (with a refresh).
367
+ const RENDERER_JS = String.raw `
368
+ const root = document.getElementById("page");
369
+ const HOME = root.dataset.home || "home";
370
+
371
+ async function getJSON(url, opts) {
372
+ const r = await fetch(url, opts);
373
+ const body = await r.json().catch(() => ({}));
374
+ if (!r.ok) throw new Error(body.error || ("HTTP " + r.status));
375
+ return body;
376
+ }
377
+
378
+ function el(tag, attrs = {}, ...kids) {
379
+ const n = document.createElement(tag);
380
+ for (const [k, v] of Object.entries(attrs)) {
381
+ if (k === "class") n.className = v;
382
+ else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2), v);
383
+ else if (v != null) n.setAttribute(k, v);
384
+ }
385
+ for (const kid of kids) n.append(kid);
386
+ return n;
387
+ }
388
+
389
+ function renderText(node) {
390
+ const v = node.props.variant;
391
+ const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
392
+ return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
393
+ }
394
+
395
+ function renderActionForm(node) {
396
+ const p = node.props;
397
+ const card = el("section", { class: "pc-card" });
398
+ if (p.title) card.append(el("h2", {}, p.title));
399
+ const inputs = {};
400
+ for (const f of p.fields || []) {
401
+ const input = el("input", { type: "text", placeholder: f.label || f.key });
402
+ inputs[f.key] = input;
403
+ card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key), input));
404
+ }
405
+ const msg = el("p", { class: "pc-msg" });
406
+ const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
407
+ btn.addEventListener("click", async () => {
408
+ const variables = {};
409
+ for (const [k, input] of Object.entries(inputs)) variables[k] = input.value;
410
+ btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
411
+ try {
412
+ const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
413
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
414
+ msg.className = "pc-msg ok";
415
+ msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
416
+ for (const input of Object.values(inputs)) input.value = "";
417
+ document.dispatchEvent(new CustomEvent("pc:refresh"));
418
+ } catch (e) {
419
+ msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
420
+ } finally { btn.disabled = false; }
421
+ });
422
+ card.append(btn, msg);
423
+ return card;
424
+ }
425
+
426
+ function renderDataGrid(node) {
427
+ const p = node.props;
428
+ const card = el("section", { class: "pc-card" });
429
+ if (p.title) card.append(el("h2", {}, p.title));
430
+ const cols = p.columns || [];
431
+ const tabs = p.tabs || [];
432
+ const rowActions = p.rowActions || [];
433
+ const detail = p.detail || null;
434
+ const hasExtra = rowActions.length > 0 || detail != null;
435
+ let activeFilter = p.data.filter || [];
436
+
437
+ if (tabs.length) {
438
+ const bar = el("div", { class: "pc-tabs" });
439
+ activeFilter = tabs[0].filter || [];
440
+ tabs.forEach((t, i) => {
441
+ const b = el("button", { class: "pc-tab" + (i === 0 ? " active" : "") }, t.label);
442
+ b.addEventListener("click", () => {
443
+ activeFilter = t.filter || [];
444
+ for (const c of bar.children) c.classList.remove("active");
445
+ b.classList.add("active");
446
+ refresh();
447
+ });
448
+ bar.append(b);
449
+ });
450
+ card.append(bar);
451
+ }
452
+
453
+ const headCells = cols.map((c) => el("th", {}, c.header || c.field));
454
+ if (hasExtra) headCells.push(el("th", {}, ""));
455
+ const thead = el("thead", {}, el("tr", {}, ...headCells));
456
+ const tbody = el("tbody", {});
457
+ const table = el("table", { class: "pc-grid" }, thead, tbody);
458
+ card.append(table);
459
+ const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
460
+
461
+ function dataUrl(source, tbl, filters, order) {
462
+ let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
463
+ const qs = [];
464
+ for (const f of filters || []) {
465
+ if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
466
+ else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
467
+ }
468
+ if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
469
+ return qs.length ? u + "?" + qs.join("&") : u;
470
+ }
471
+
472
+ async function fireAction(action, row) {
473
+ if (action.kind === "cancelProcess") {
474
+ return getJSON("/app/actions/cancel", { method: "POST",
475
+ headers: { "content-type": "application/json" },
476
+ body: JSON.stringify({ processInstanceKey: row[action.keyField] }) });
477
+ }
478
+ if (action.kind === "publishMessage") {
479
+ return getJSON("/app/actions/message", { method: "POST",
480
+ headers: { "content-type": "application/json" },
481
+ body: JSON.stringify({ name: action.message, correlationKey: row[action.correlationKeyField],
482
+ variables: { ...(action.variables || {}) } }) });
483
+ }
484
+ if (action.kind === "startProcess") {
485
+ return getJSON("/app/actions/start/" + encodeURIComponent(action.process), { method: "POST",
486
+ headers: { "content-type": "application/json" },
487
+ body: JSON.stringify({ variables: { ...(action.variables || {}) } }) });
488
+ }
489
+ throw new Error("unknown action");
490
+ }
491
+
492
+ function rowActionButton(row, ra) {
493
+ if (ra.showWhenField && !row[ra.showWhenField]) return null;
494
+ const b = el("button", { class: "pc-btn pc-btn-sm" }, ra.label);
495
+ b.addEventListener("click", async (ev) => {
496
+ ev.stopPropagation();
497
+ if (ra.confirm && !confirm(ra.confirm)) return;
498
+ b.disabled = true;
499
+ try {
500
+ await fireAction(ra.action, row);
501
+ document.dispatchEvent(new CustomEvent("pc:refresh"));
502
+ } catch (e) {
503
+ b.disabled = false;
504
+ alert(String(e.message || e));
505
+ }
506
+ });
507
+ return b;
508
+ }
509
+
510
+ function detailForm(row) {
511
+ const f = detail.form;
512
+ if (!f || !row[f.showWhenField]) return null;
513
+ const box = el("div", { class: "pc-subform" });
514
+ if (f.title) box.append(el("div", { class: "pc-subform-title" }, f.title));
515
+ if (f.promptField && row[f.promptField] != null) {
516
+ box.append(el("div", { class: "pc-prompt" }, String(row[f.promptField])));
517
+ }
518
+ const input = el("textarea", { class: "pc-textarea", placeholder: f.inputLabel || f.inputKey });
519
+ const msg = el("p", { class: "pc-msg" });
520
+ const btn = el("button", { class: "pc-btn pc-btn-sm" }, f.submitLabel || "Submit");
521
+ btn.addEventListener("click", async () => {
522
+ btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
523
+ try {
524
+ await getJSON("/app/actions/message", { method: "POST",
525
+ headers: { "content-type": "application/json" },
526
+ body: JSON.stringify({ name: f.action.message, correlationKey: row[f.action.correlationKeyField],
527
+ variables: { [f.inputKey]: input.value, ...(f.action.variables || {}) } }) });
528
+ msg.className = "pc-msg ok"; msg.textContent = "Sent";
529
+ document.dispatchEvent(new CustomEvent("pc:refresh"));
530
+ } catch (e) {
531
+ btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
532
+ }
533
+ });
534
+ box.append(el("div", { class: "pc-field" }, input), btn, msg);
535
+ return box;
536
+ }
537
+
538
+ async function childGrid(cg, row) {
539
+ const wrap = el("div", { class: "pc-child" });
540
+ if (cg.title) wrap.append(el("div", { class: "pc-child-title" }, cg.title));
541
+ const ccols = cg.columns || [];
542
+ const cbody = el("tbody", {});
543
+ const ctable = el("table", { class: "pc-grid" },
544
+ el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
545
+ ...(cg.lazyField ? [el("th", {}, "")] : []))), cbody);
546
+ wrap.append(ctable);
547
+ try {
548
+ const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,
549
+ [{ field: cg.childField, eq: row[cg.parentField] }], cg.orderBy));
550
+ const cspan = String((ccols.length || 1) + (cg.lazyField ? 1 : 0));
551
+ if (!rows.length) {
552
+ cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
553
+ }
554
+ for (const cr of rows) {
555
+ const cells = ccols.map((c) => el("td", {}, cr[c.field] == null ? "" : String(cr[c.field])));
556
+ if (cg.lazyField) {
557
+ const lf = cg.lazyField;
558
+ const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== "";
559
+ const cell = el("td", {});
560
+ if (has) {
561
+ const toggle = el("button", { class: "pc-btn pc-btn-sm" }, lf.label || "Show");
562
+ const pre = el("pre", { class: "pc-transcript", hidden: "" });
563
+ pre.textContent = String(cr[lf.field]);
564
+ toggle.addEventListener("click", () => {
565
+ pre.hidden = !pre.hidden;
566
+ toggle.textContent = pre.hidden ? (lf.label || "Show") : "Hide";
567
+ });
568
+ cell.append(toggle, pre);
569
+ }
570
+ cells.push(cell);
571
+ }
572
+ cbody.append(el("tr", {}, ...cells));
573
+ }
574
+ } catch (e) {
575
+ cbody.append(el("tr", {}, el("td", { colspan: cspan }, String(e.message || e))));
576
+ }
577
+ return wrap;
578
+ }
579
+
580
+ function detailPanel(row) {
581
+ const box = el("div", { class: "pc-detail" });
582
+ if (detail.linkField && row[detail.linkField]) {
583
+ const href = String(row[detail.linkField]);
584
+ // Render as a link only for http(s); anything else (e.g. a javascript: URL
585
+ // smuggled through row data) is shown as inert text. External links get
586
+ // rel="noopener noreferrer" so the opened page can't reach window.opener.
587
+ if (/^https?:\/\//i.test(href)) {
588
+ box.append(el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, href));
589
+ } else {
590
+ box.append(el("span", { class: "pc-link" }, href));
591
+ }
592
+ }
593
+ for (const df of detail.fields || []) {
594
+ box.append(el("div", { class: "pc-detail-field" },
595
+ el("span", { class: "pc-detail-label" }, df.label || df.field),
596
+ el("span", {}, row[df.field] == null ? "" : String(row[df.field]))));
597
+ }
598
+ for (const cg of detail.children || []) {
599
+ const holder = el("div", {});
600
+ box.append(holder);
601
+ childGrid(cg, row).then((w) => holder.replaceChildren(w));
602
+ }
603
+ const form = detailForm(row);
604
+ if (form) box.append(form);
605
+ return box;
606
+ }
607
+
608
+ function renderRow(row) {
609
+ const cells = cols.map((c) => el("td", {}, row[c.field] == null ? "" : String(row[c.field])));
610
+ let toggle = null;
611
+ if (hasExtra) {
612
+ const actionCell = el("td", { class: "pc-row-actions" });
613
+ if (detail) {
614
+ toggle = el("button", { class: "pc-btn pc-btn-sm pc-chevron" }, "▸");
615
+ actionCell.append(toggle);
616
+ }
617
+ for (const ra of rowActions) {
618
+ const b = rowActionButton(row, ra);
619
+ if (b) actionCell.append(b);
620
+ }
621
+ cells.push(actionCell);
622
+ }
623
+ const tr = el("tr", {}, ...cells);
624
+ tbody.append(tr);
625
+ if (detail && toggle) {
626
+ const dtr = el("tr", { hidden: "" }, el("td", { colspan: span }));
627
+ let built = false;
628
+ toggle.addEventListener("click", (ev) => {
629
+ ev.stopPropagation();
630
+ const open = dtr.hidden;
631
+ dtr.hidden = !open;
632
+ toggle.textContent = open ? "▾" : "▸";
633
+ if (open && !built) {
634
+ built = true;
635
+ dtr.firstChild.append(detailPanel(row));
636
+ }
637
+ });
638
+ tbody.append(dtr);
639
+ }
640
+ }
641
+
642
+ async function refresh() {
643
+ try {
644
+ const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
645
+ tbody.replaceChildren();
646
+ for (const row of rows) renderRow(row);
647
+ if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
648
+ } catch (e) {
649
+ tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
650
+ }
651
+ }
652
+ document.addEventListener("pc:refresh", refresh);
653
+ if (p.refreshMs && p.refreshMs > 0) setInterval(refresh, p.refreshMs);
654
+ refresh();
655
+ return card;
656
+ }
657
+
658
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
659
+
660
+ async function main() {
661
+ try {
662
+ const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
663
+ if (doc.title) document.title = doc.title;
664
+ root.replaceChildren(...(doc.nodes || []).map((n) => (RENDERERS[n.type] || (() => el("div")))(n)));
665
+ } catch (e) {
666
+ root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
667
+ }
668
+ }
669
+ main();
670
+ `;