pgkiln 0.70.0 → 0.71.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.
Files changed (46) hide show
  1. package/db/migrations/137_create_page_types.sql +904 -0
  2. package/db/migrations/138_app_utilities.sql +153 -0
  3. package/docs/apex-feature-parity.md +6 -5
  4. package/docs/guide/03-builder.md +107 -4
  5. package/docs/guide/04-pages-and-regions.md +3 -1
  6. package/docs/guide/05-items.md +1 -1
  7. package/docs/guide/06-processing.md +18 -0
  8. package/docs/guide/09-reference.md +15 -1
  9. package/docs/guide/12-development.md +22 -1
  10. package/docs/guide/16-files.md +1 -1
  11. package/examples/hr/hr_73_configuration_options.sql +17 -0
  12. package/examples/hr/hr_74_app_utilities.sql +22 -0
  13. package/package.json +1 -1
  14. package/public/app.css +9 -0
  15. package/public/builder.css +45 -0
  16. package/src/app.ts +10 -0
  17. package/src/builder/advisor.ts +2 -2
  18. package/src/builder/app-updates.ts +3 -0
  19. package/src/builder/components.ts +3 -1
  20. package/src/builder/create-page.ts +259 -0
  21. package/src/builder/dependencies.ts +311 -0
  22. package/src/builder/features.ts +313 -0
  23. package/src/builder/home.ts +2 -0
  24. package/src/builder/i18n/de.ts +4 -0
  25. package/src/builder/i18n/en.ts +4 -0
  26. package/src/builder/i18n/es.ts +4 -0
  27. package/src/builder/i18n/fr.ts +4 -0
  28. package/src/builder/i18n/it.ts +4 -0
  29. package/src/builder/i18n/ja.ts +4 -0
  30. package/src/builder/i18n/ko.ts +4 -0
  31. package/src/builder/i18n/nl.ts +4 -0
  32. package/src/builder/i18n/pt.ts +4 -0
  33. package/src/builder/i18n/zh-cn.ts +4 -0
  34. package/src/builder/i18n/zh-tw.ts +4 -0
  35. package/src/builder/login-page.ts +17 -14
  36. package/src/builder/newapp.ts +41 -23
  37. package/src/builder/routes.ts +13 -2
  38. package/src/builder/ui-defaults.ts +161 -0
  39. package/src/builder/ui.ts +2 -1
  40. package/src/builder/upgrade.ts +154 -0
  41. package/src/builder/utilities.ts +181 -0
  42. package/src/builder/wizards.ts +211 -26
  43. package/src/cli/replace.ts +9 -1
  44. package/src/history.ts +155 -0
  45. package/src/runtime/builtin-components.ts +23 -2
  46. package/src/runtime/items.ts +4 -2
@@ -406,7 +406,7 @@ The HR sample has `EMP_XML`, for XML files like `/static/samples/employees.xml`.
406
406
  ### Data loading in an application
407
407
 
408
408
  For end users, add a page with a `file` item and a button, and a **process of type
409
- `data_load`**. The process runs **as the application's database role**, so grants, row level
409
+ `data_load`** ((sprint 137) **Create page → Data loading** makes the definition and such a page for a table). The process runs **as the application's database role**, so grants, row level
410
410
  security, triggers and the audit trail apply, just as they do for the form.
411
411
 
412
412
  | `config` key | Meaning |
@@ -0,0 +1,17 @@
1
+ -- =====================================================================
2
+ -- HR example, part 73: a configurable build option (sprint 137, AP-3;
3
+ -- migration 137; docs/guide/06-processing.md "Build options",
4
+ -- 03-builder.md "Add feature")
5
+ --
6
+ -- LEAVE_FORECAST (part 22: the Forecast region of page 22, excluded) is
7
+ -- marked configurable. In the builder, Create page → Feature →
8
+ -- Configuration options then adds page 9020 "Configuration" where HR's
9
+ -- administrators (king, demo) switch the forecast on and off while the
10
+ -- application runs, without a developer. Nothing else changes until that
11
+ -- feature is added.
12
+ -- =====================================================================
13
+
14
+ update meta.build_option o set configurable = true,
15
+ description = coalesce(o.description, 'The leave forecast on the leave planner (page 22)')
16
+ from meta.app a
17
+ where a.id = o.app_id and a.alias = 'hr' and o.name = 'LEAVE_FORECAST';
@@ -0,0 +1,22 @@
1
+ -- =====================================================================
2
+ -- HR example, part 74: an application's Utilities (sprint 138, AP-5;
3
+ -- migration 138; docs/guide/03-builder.md "Utilities")
4
+ --
5
+ -- UI defaults (App Builder → Utilities → UI defaults, or Workspace
6
+ -- utilities → UI defaults) for hr.leave_request: the page wizards and the
7
+ -- Create Application wizard label its "days" column "Working days" and
8
+ -- show it with a number format mask, and a generated form item for
9
+ -- "reason" gets a help text. Pages that exist already don't change: create
10
+ -- a form on hr.leave_request (Create Page → Form) to see them.
11
+ --
12
+ -- The rest of the Utilities page needs no example data: Database object
13
+ -- dependencies, Change history (recorded from the next change on),
14
+ -- Upgrade application and the application checksum work on the HR
15
+ -- application as it is.
16
+ -- =====================================================================
17
+
18
+ insert into meta.ui_default (schema_name, table_name, column_name, label, format_mask, help, alignment, updated_by)
19
+ values
20
+ ('hr', 'leave_request', 'days', 'Working days', '990', 'Working days the leave takes, without weekends and public holidays.', 'end', 'admin'),
21
+ ('hr', 'leave_request', 'reason', null, null, 'Optional: what the leave is for. Your manager sees it when deciding.', null, 'admin')
22
+ on conflict (schema_name, table_name, column_name) do nothing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.css CHANGED
@@ -1602,6 +1602,15 @@ img.tc-avatar { object-fit: cover; }
1602
1602
  .tc-media { display: flex; gap: 0.8rem; align-items: center; padding: 0.6rem 0; min-width: 0; overflow-wrap: anywhere; }
1603
1603
  .tc-media > .tc-stack { flex: 1; }
1604
1604
  .tc-media-badge { flex: none; }
1605
+ /* (137) ut_content_row: avatar, title and description, secondary text and badge on the right */
1606
+ .tc-content-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
1607
+ .tc-content-row { display: flex; gap: 0.8rem; align-items: center; padding: 0.6rem 0; min-width: 0; overflow-wrap: anywhere; }
1608
+ .tc-content-row + .tc-content-row { border-top: 1px solid var(--border); }
1609
+ .tc-content-row > .tc-stack { flex: 1; }
1610
+ .tc-content-row-end { display: flex; flex-direction: column; align-items: flex-end; gap: 0.25rem; flex: none; max-width: 40%; text-align: end; }
1611
+ @media (max-width: 480px) { .tc-content-row { flex-wrap: wrap; } .tc-content-row-end { flex-direction: row; align-items: center; max-width: none; margin-inline-start: auto; } }
1612
+ /* (137) a checkbox group shown as switches ({"switches": true}): one per line */
1613
+ .radio-group.switch-group { flex-direction: column; align-items: flex-start; }
1605
1614
  .tc-metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr)); gap: 0.75rem; }
1606
1615
  .tc-metric { gap: 0.25rem; }
1607
1616
  .tc-metric-label { margin: 0; color: var(--muted); font-size: 0.85rem; font-weight: 600; }
@@ -1600,3 +1600,48 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
1600
1600
 
1601
1601
  /* (sprint 136) a button inside an alert (e.g. Add vector column without pgvector) wraps on a phone instead of overflowing */
1602
1602
  .alert .btn { white-space: normal; overflow-wrap: anywhere; max-width: 100%; }
1603
+
1604
+ /* (sprint 137, AP-3) Create page: the grid of page types with small previews, the features, the type's step */
1605
+ .cp-tabs { margin-bottom: 0.9rem; flex-wrap: wrap; }
1606
+ .cp-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); gap: 0.75rem; }
1607
+ .cp-grid > li { min-width: 0; }
1608
+ .cp-type { display: flex; flex-direction: column; gap: 0.35rem; height: 100%; padding: 0.6rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
1609
+ a.cp-type:hover, a.cp-type:focus-visible { border-color: var(--accent); }
1610
+ .cp-type b { font-size: 0.95rem; }
1611
+ .cp-type small { color: var(--muted); line-height: 1.35; }
1612
+ .cp-type .tag { align-self: flex-start; }
1613
+ .cp-done { opacity: 0.6; }
1614
+ .cp-preview { display: block; border-radius: var(--radius-sm); background: var(--surface-2); padding: 4px; }
1615
+ .cp-preview-lg { max-width: 16rem; }
1616
+ .cp-svg { display: block; width: 100%; height: auto; }
1617
+ .cp-svg .pv-line { fill: var(--border-strong); }
1618
+ .cp-svg .pv-soft { fill: color-mix(in srgb, var(--accent) 28%, var(--surface)); }
1619
+ .cp-svg .pv-acc { fill: var(--accent); }
1620
+ .cp-svg .pv-bg { fill: var(--surface); }
1621
+ .cp-svg .pv-frame { fill: var(--surface); stroke: var(--border-strong); stroke-width: 1; }
1622
+ .cp-svg .pv-stroke { fill: none; stroke: var(--border-strong); stroke-width: 1.5; }
1623
+ .cp-feature-icon { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 10px; background: var(--accent-soft, var(--surface-2)); color: var(--accent); }
1624
+ .cp-step { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; margin-bottom: 0.75rem; }
1625
+ .cp-step > .cp-preview { flex: 0 1 12rem; }
1626
+ .cp-step > p { flex: 1 1 14rem; margin: 0; }
1627
+ .cp-adds { padding-inline-start: 1.2rem; overflow-wrap: anywhere; }
1628
+
1629
+ /* (sprint 138, AP-5) an application's Utilities: filters, the dashboard figures, object dependencies, icons, UI defaults */
1630
+ .util-filters { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem 0.75rem; margin-bottom: 0.75rem; }
1631
+ .util-filters .field { margin: 0; min-width: 0; flex: 1 1 9rem; }
1632
+ .util-filters .buttons { margin: 0; }
1633
+ .util-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: 0.75rem; margin: 0; }
1634
+ .util-stats div { border: 1px solid var(--border); border-radius: var(--radius-sm, 6px); padding: 0.5rem 0.75rem; }
1635
+ .util-stats dt { color: var(--muted); font-size: 0.8rem; }
1636
+ .util-stats dd { margin: 0; font-size: 1.4rem; font-weight: 650; }
1637
+ .util-sum code, .util-history td, .dep-table td { overflow-wrap: anywhere; }
1638
+ .util-sum code { word-break: break-all; }
1639
+ .tag-success { background: var(--success-soft); color: var(--success); }
1640
+ .dep-problem td:first-child, .dep-missing { color: var(--danger); }
1641
+ .dep-list { margin: 0.25rem 0 0; padding-left: 1rem; }
1642
+ .dep-list a { display: inline-flex; gap: 0.3rem; align-items: center; }
1643
+ .util-icons { display: grid; grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); gap: 0.5rem; margin: 0 0 1rem; padding: 0; list-style: none; }
1644
+ .util-icons .icon-tile { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; padding: 0.6rem 0.25rem; border: 1px solid var(--border); border-radius: var(--radius-sm, 6px); text-align: center; }
1645
+ .util-icons code { font-size: 0.75rem; overflow-wrap: anywhere; }
1646
+ .ui-defaults :is(input, select, textarea) { width: 100%; min-width: 7rem; }
1647
+ .util-upgrade + .util-upgrade { margin-top: 1rem; }
package/src/app.ts CHANGED
@@ -41,6 +41,10 @@ import { layoutRoutes } from './builder/layouts.ts';
41
41
  import { dataLoadRoutes } from './builder/dataload.ts';
42
42
  import { builderRoutes } from './builder/routes.ts';
43
43
  import { appUpdateHooks } from './builder/app-updates.ts';
44
+ import { historyHooks, utilityRoutes } from './builder/utilities.ts';
45
+ import { dependencyRoutes } from './builder/dependencies.ts';
46
+ import { upgradeRoutes } from './builder/upgrade.ts';
47
+ import { uiDefaultRoutes } from './builder/ui-defaults.ts';
44
48
  import { dbUserRoutes } from './builder/db-users.ts';
45
49
  import { dataPumpRoutes } from './builder/datapump.ts';
46
50
  import { schedulingRoutes } from './builder/scheduling.ts';
@@ -165,6 +169,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
165
169
  });
166
170
  builderErrorPages(app);
167
171
  appUpdateHooks(app); // (116) who changed an application last
172
+ historyHooks(app); // (138) a snapshot before an application's first recorded change
168
173
  await app.register(cookie);
169
174
  await app.register(formbody, { bodyLimit: 5 * 1024 * 1024 });
170
175
  // file upload items; oversized files are cut off and reported, not thrown
@@ -269,6 +274,11 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
269
274
  await app.register(mailQueueRoutes);
270
275
  await app.register(networkAccessRoutes);
271
276
  await app.register(workspaceMailRoutes);
277
+ // (sprint 138, AP-5) an application's Utilities
278
+ await app.register(utilityRoutes);
279
+ await app.register(dependencyRoutes);
280
+ await app.register(upgradeRoutes);
281
+ await app.register(uiDefaultRoutes);
272
282
  app.get('/', async (_req, reply) => reply.redirect('/builder/home'));
273
283
  return app;
274
284
  }
@@ -44,7 +44,7 @@ export interface Finding {
44
44
  export type SqlShape = 'select' | 'boolean' | 'statements' | 'regex';
45
45
 
46
46
  /** Which fields hold SQL, and of what shape, per component kind (null: decided by the row). */
47
- function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[] {
47
+ export function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[] {
48
48
  switch (kind) {
49
49
  case 'region':
50
50
  return [
@@ -105,7 +105,7 @@ function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[]
105
105
  const conditionSql = (row: any): { name: string; shape: SqlShape }[] =>
106
106
  row.condition_type === 'sql' ? [{ name: 'condition_expr', shape: 'boolean' }] : row.condition_type === 'exists' || row.condition_type === 'not_exists' ? [{ name: 'condition_expr', shape: 'select' }] : [];
107
107
 
108
- const EXPLAINABLE = /^\s*(\(|select|with|values|table|insert|update|delete|merge)\b/i;
108
+ export const EXPLAINABLE = /^\s*(\(|select|with|values|table|insert|update|delete|merge)\b/i;
109
109
 
110
110
  /**
111
111
  * The problem with one piece of SQL, or null. Runs inside the caller's
@@ -1,5 +1,6 @@
1
1
  import type { FastifyInstance, FastifyRequest } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
+ import { recordHistory } from '../history.ts';
3
4
  import type { Session } from '../session.ts';
4
5
  import { BASE } from './ui.ts';
5
6
 
@@ -50,6 +51,8 @@ export function appUpdateHooks(app: FastifyInstance) {
50
51
  appId = Number(m[1]);
51
52
  }
52
53
  await recordChange(appId, n.s.username).catch((err) => req.log.warn({ err }, 'app_change not recorded'));
54
+ // (sprint 138) what changed, per component (src/history.ts)
55
+ await recordHistory(appId, n.s.username).catch((err) => req.log.warn({ err }, 'change history not recorded'));
53
56
  return payload;
54
57
  });
55
58
  }
@@ -545,11 +545,13 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
545
545
  plural: 'Build options',
546
546
  icon: 'settings',
547
547
  summary: (o) => `${o.name} (${o.status})`,
548
- defaults: { status: 'include' },
548
+ defaults: { status: 'include', configurable: false },
549
549
  fields: [
550
550
  { name: 'name', label: 'Name', kind: 'upper', help: 'e.g. FEATURE_EXPORT; components name it in their Build option property.' },
551
551
  { name: 'status', label: 'Status', kind: 'select', options: ['include', 'exclude'], help: 'exclude: every component with this option is left out of the running application (and those with "Not" are included).' },
552
552
  { name: 'description', label: 'Description', kind: 'text', wide: true },
553
+ // (137) the Configuration Options feature
554
+ { name: 'configurable', label: 'Configurable', kind: 'bool', help: 'The application\'s administrators may switch it on the Configuration page (Create page → Feature → Configuration options).' },
553
555
  ],
554
556
  },
555
557
  rest_module: {
@@ -0,0 +1,259 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { featureStates } from './features.ts';
6
+ import { appOr404 } from './forms.ts';
7
+ import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
8
+ import { NO_TABLE, PAGE_KINDS, wizardTables } from './wizards.ts';
9
+
10
+ // (sprint 137, AP-3) Create Page like APEX's: a grid of page types with small previews (tab Component), the
11
+ // features (tab Feature, features.ts) and Create page as copy (tab Copy), at /builder/apps/:id/create-page.
12
+ // A type that needs a table asks for one first (?type=…), then goes to the options step (wizards.ts); the
13
+ // others go to the options step at once. Blank page and Create pages with AI stay as they were. Plain links and
14
+ // forms: no JavaScript needed. The previews are inline SVG drawn with classes (public/builder.css), no styles.
15
+
16
+ /** The grid's order (APEX's names), each with the step it starts with. */
17
+ const GRID: { key: string; label: string; help: string; table: boolean }[] = [
18
+ { key: 'blank', label: 'Blank page', help: 'An empty page to build in the Page Designer.', table: false },
19
+ ...['calendar', 'cards', 'chart', 'comments', 'content_row', 'dashboard', 'data_load', 'facets', 'form', 'grid', 'report', 'map',
20
+ 'master_detail', 'media_list', 'report_form', 'search', 'smart_filters', 'timeline', 'tree', 'tasks', 'wizard', 'workflows'].map((k) => {
21
+ const d = PAGE_KINDS.find((x) => x[0] === k)!;
22
+ return { key: k, label: d[1], help: d[2], table: !NO_TABLE.has(k) };
23
+ }),
24
+ ];
25
+
26
+ // ---------------------------------------------------------------- previews (80 × 50, inline SVG, classes only)
27
+ const rect = (x: number, y: number, w: number, h: number, cls = 'pv-line', rx = 1.5) => `<rect class="${cls}" x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}"/>`;
28
+ const circle = (cx: number, cy: number, r: number, cls = 'pv-soft') => `<circle class="${cls}" cx="${cx}" cy="${cy}" r="${r}"/>`;
29
+ const rows = (y0: number, n: number, x = 6, w = 68, gap = 7) => Array.from({ length: n }, (_, i) => rect(x, y0 + i * gap, w, 3)).join('');
30
+ const table = `${rect(6, 6, 68, 6, 'pv-soft')}${rows(16, 5)}`;
31
+ const listRows = (n: number, avatar: boolean, right = false) => Array.from({ length: n }, (_, i) => {
32
+ const y = 7 + i * 13;
33
+ return `${avatar ? circle(11, y + 4, 4.5) : ''}${rect(avatar ? 20 : 6, y, 30, 3.5, 'pv-acc')}${rect(avatar ? 20 : 6, y + 5.5, 40, 2.5)}${right ? rect(62, y + 1, 12, 5, 'pv-soft', 2.5) : ''}`;
34
+ }).join('');
35
+ const PREVIEWS: Record<string, string> = {
36
+ blank: rect(6, 6, 68, 38, 'pv-frame', 3),
37
+ calendar: `${rect(6, 6, 68, 6, 'pv-soft')}${Array.from({ length: 15 }, (_, i) => rect(6 + (i % 5) * 14, 15 + Math.floor(i / 5) * 10, 12, 8, i === 7 ? 'pv-acc' : 'pv-line')).join('')}`,
38
+ cards: Array.from({ length: 4 }, (_, i) => `${rect(6 + (i % 2) * 35, 6 + Math.floor(i / 2) * 20, 33, 18, 'pv-frame', 2)}${rect(9 + (i % 2) * 35, 9 + Math.floor(i / 2) * 20, 18, 3, 'pv-acc')}${rect(9 + (i % 2) * 35, 15 + Math.floor(i / 2) * 20, 24, 2.5)}`).join(''),
39
+ chart: [14, 26, 20, 32, 10].map((h, i) => rect(10 + i * 13, 44 - h, 9, h, i === 3 ? 'pv-acc' : 'pv-soft', 1)).join('') + rect(6, 44, 68, 1.5),
40
+ comments: listRows(3, true),
41
+ content_row: listRows(3, true, true),
42
+ dashboard: Array.from({ length: 4 }, (_, i) => {
43
+ const x = 6 + (i % 2) * 35;
44
+ const y = 6 + Math.floor(i / 2) * 20;
45
+ return `${rect(x, y, 33, 18, 'pv-frame', 2)}${[6, 10, 7, 12].map((h, j) => rect(x + 4 + j * 7, y + 15 - h, 5, h, j === i ? 'pv-acc' : 'pv-soft', 0.8)).join('')}`;
46
+ }).join(''),
47
+ data_load: `${rect(18, 26, 44, 18, 'pv-frame', 2)}${rect(37, 10, 6, 18, 'pv-acc', 1)}<path class="pv-acc" d="M30 14 L40 4 L50 14 Z"/>${rect(24, 32, 32, 2.5)}${rect(24, 37, 24, 2.5)}`,
48
+ facets: `${rect(6, 6, 18, 38, 'pv-soft', 2)}${[10, 16, 22, 28].map((y) => rect(9, y, 12, 2.5)).join('')}${rect(28, 6, 46, 6, 'pv-soft')}${rows(16, 5, 28, 46)}`,
49
+ form: [8, 20, 32].map((y) => `${rect(8, y, 16, 3)}${rect(28, y - 2, 44, 7, 'pv-frame', 1.5)}`).join('') + rect(56, 42, 16, 5, 'pv-acc', 2),
50
+ grid: `${table}${[25, 45].map((x) => rect(x, 13, 0.8, 34, 'pv-line', 0)).join('')}${rect(25.8, 22, 19.2, 6, 'pv-frame', 0)}`,
51
+ report: `${rect(6, 3, 30, 5, 'pv-frame', 1.5)}${rect(6, 11, 68, 6, 'pv-soft')}${rows(21, 4)}`,
52
+ map: `${rect(6, 6, 68, 38, 'pv-soft', 3)}<path class="pv-stroke" d="M6 30 C 24 20, 40 40, 74 18"/>${circle(48, 20, 5, 'pv-acc')}${circle(48, 20, 2, 'pv-bg')}`,
53
+ master_detail: `${rect(6, 4, 68, 5, 'pv-soft')}${rows(12, 2)}${rect(6, 12, 68, 4, 'pv-acc')}${rect(6, 28, 68, 5, 'pv-soft')}${rows(36, 2)}`,
54
+ media_list: Array.from({ length: 3 }, (_, i) => `${rect(6, 6 + i * 13, 9, 9, 'pv-soft', 2)}${rect(19, 7 + i * 13, 30, 3.5, 'pv-acc')}${rect(19, 12.5 + i * 13, 40, 2.5)}${rect(62, 8 + i * 13, 12, 5, 'pv-soft', 2.5)}`).join(''),
55
+ report_form: `${rect(6, 6, 42, 6, 'pv-soft')}${rows(16, 4, 6, 42)}${rect(52, 10, 22, 32, 'pv-frame', 2)}${[15, 22, 29].map((y) => rect(55, y, 16, 3)).join('')}${rect(59, 35, 12, 4, 'pv-acc', 1.5)}`,
56
+ search: `${rect(6, 6, 68, 8, 'pv-frame', 4)}${circle(12, 10, 2.2, 'pv-acc')}${[18, 30].map((y) => `${rect(6, y, 26, 3.5, 'pv-acc')}${rect(6, y + 5, 60, 2.5)}`).join('')}`,
57
+ smart_filters: `${rect(6, 3, 68, 7, 'pv-frame', 3.5)}${rect(8, 12, 16, 4, 'pv-soft', 2)}${rect(26, 12, 14, 4, 'pv-soft', 2)}${rect(6, 19, 68, 5, 'pv-soft')}${rows(27, 3)}`,
58
+ timeline: `${rect(13, 4, 1.5, 42, 'pv-line', 0)}${[8, 22, 36].map((y, i) => `${circle(13.75, y, 3, i === 0 ? 'pv-acc' : 'pv-soft')}${rect(21, y - 3, 28, 3, 'pv-acc')}${rect(21, y + 2, 46, 2.5)}`).join('')}`,
59
+ tree: `${rect(6, 5, 24, 4, 'pv-acc')}<path class="pv-stroke" d="M10 9 V38 M10 18 H18 M10 28 H18 M10 38 H18 M22 32 V38 M22 38 H28"/>${rect(20, 16, 22, 3.5)}${rect(20, 26, 26, 3.5)}${rect(30, 36, 20, 3.5)}`,
60
+ tasks: [8, 20, 32].map((y, i) => `${rect(8, y, 7, 7, i === 0 ? 'pv-acc' : 'pv-frame', 1.5)}${rect(19, y, 34, 3)}${rect(19, y + 4.5, 22, 2.5)}${rect(60, y + 1, 14, 5, 'pv-soft', 2.5)}`).join(''),
61
+ wizard: `<path class="pv-stroke" d="M16 9 H64"/>${[16, 40, 64].map((x, i) => circle(x, 9, 4, i === 0 ? 'pv-acc' : 'pv-soft')).join('')}${[20, 30].map((y) => `${rect(10, y, 14, 3)}${rect(28, y - 2, 42, 7, 'pv-frame', 1.5)}`).join('')}${rect(54, 41, 16, 5, 'pv-acc', 2)}`,
62
+ workflows: `${rect(4, 19, 18, 12, 'pv-acc', 2)}${rect(31, 8, 18, 12, 'pv-soft', 2)}${rect(31, 30, 18, 12, 'pv-soft', 2)}${rect(58, 19, 18, 12, 'pv-frame', 2)}<path class="pv-stroke" d="M22 25 L31 14 M22 25 L31 36 M49 14 L58 25 M49 36 L58 25"/>`,
63
+ ai: `<path class="pv-acc" d="M40 6 L44 20 L58 25 L44 30 L40 44 L36 30 L22 25 L36 20 Z"/>${circle(62, 12, 3, 'pv-soft')}${circle(18, 38, 2.5, 'pv-soft')}`,
64
+ };
65
+ /** A page type's small picture, decorative (the label says what it is). */
66
+ export const preview = (key: string): Raw =>
67
+ raw(`<svg class="cp-svg" viewBox="0 0 80 50" aria-hidden="true" focusable="false">${PREVIEWS[key] ?? PREVIEWS.blank}</svg>`);
68
+
69
+ const TABS: [string, string][] = [['component', 'Component'], ['feature', 'Feature'], ['copy', 'Copy']];
70
+
71
+ export async function createPageRoutes(app: FastifyInstance) {
72
+ app.get(`${BASE}/apps/:id/create-page`, async (req: Req, reply) => {
73
+ const s = await developer(req, reply);
74
+ if (!s) return;
75
+ const a = await appOr404(req.params.id);
76
+ if (!a) return reply.code(404).send('Not found');
77
+ const q = req.query as Record<string, string | undefined>;
78
+ const tab = TABS.some(([t]) => t === q.tab) ? q.tab! : 'component';
79
+ const type = GRID.find((g) => g.key === q.type);
80
+ const crumbs: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', type ? `${BASE}/apps/${a.id}/create-page` : undefined]];
81
+ const nextPage = async () => {
82
+ const used = new Set((await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((r) => r.page_no as number));
83
+ let n = Math.max(0, ...[...used].filter((x) => x < 9000)) + 1;
84
+ while (used.has(n)) n++;
85
+ return n;
86
+ };
87
+
88
+ // a page type that needs a table: choose it first (APEX: the data source step)
89
+ if (type) {
90
+ let body: Raw;
91
+ if (type.key === 'blank') {
92
+ body = html`<form method="post" action="${BASE}/apps/${a.id}/pages">${csrf(s)}
93
+ <div class="form-grid">
94
+ ${input('page_no', 'Page number', await nextPage(), { type: 'number', required: true })}
95
+ ${input('name', 'Name', '', { required: true })}
96
+ ${select('mode', 'Page mode', 'normal', [['normal', 'Normal page'], ['modal', 'Modal dialog']])}
97
+ ${input('parent_page', 'Breadcrumb parent', a.home_page ?? '', { type: 'number' })}
98
+ </div>
99
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">Back</a><button class="btn btn-hot">Create page</button></div>
100
+ </form>`;
101
+ } else {
102
+ const tables = await wizardTables(a.db_role);
103
+ body = html`<form method="get" action="${BASE}/apps/${a.id}/wizard">
104
+ <input type="hidden" name="kind" value="${type.key}">
105
+ <div class="form-grid">
106
+ ${select('table', 'Table or view', '', tables.map((t): [string, string] => [t.t, t.access ? t.t : `${t.t} (no access for ${a.db_role})`]),
107
+ 'The next step proposes the columns, keys and foreign keys from the database catalog.')}
108
+ </div>
109
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">Back</a><button class="btn btn-hot">Next</button></div>
110
+ </form>`;
111
+ }
112
+ const main = html`${appHeader(a, 'pages', s)}
113
+ ${region(type.label, html`<div class="cp-step"><span class="cp-preview cp-preview-lg">${preview(type.key)}</span><p class="muted">${type.help}</p></div>${body}`)}`;
114
+ return send(reply, s, shell(s, `Create page · ${a.name}`, [...crumbs, [type.label]], main));
115
+ }
116
+
117
+ const tabs = html`<nav class="gal-tabs segmented cp-tabs" aria-label="Create page">${TABS.map(([t, l]) =>
118
+ html`<a href="${BASE}/apps/${a.id}/create-page?tab=${t}"${t === tab ? raw(' aria-current="page"') : ''}>${l}</a>`)}</nav>`;
119
+ let body: Raw;
120
+ if (tab === 'component') {
121
+ const href = (g: (typeof GRID)[number]) =>
122
+ g.key !== 'blank' && !g.table ? `${BASE}/apps/${a.id}/wizard?kind=${g.key}` : `${BASE}/apps/${a.id}/create-page?type=${g.key}`;
123
+ body = html`<p class="muted u-mt0">Choose a page type. Types on a table ask for it next; every type proposes its settings and opens the new page in the Page Designer.</p>
124
+ <ul class="cp-grid">${GRID.map((g) => html`<li><a class="cp-type" href="${href(g)}"><span class="cp-preview">${preview(g.key)}</span><b>${g.label}</b><small>${g.help}</small></a></li>`)}
125
+ <li><a class="cp-type" href="${BASE}/apps/${a.id}/ai-pages"><span class="cp-preview">${preview('ai')}</span><b>Create pages with AI</b><small>Describe the pages in your own words; check the proposals before they are made.</small></a></li></ul>`;
126
+ } else if (tab === 'feature') {
127
+ const states = await featureStates(a.id);
128
+ body = html`<p class="muted u-mt0">Features add pages and settings to the application. Each lists what it adds before it creates anything; an installed feature is greyed out.</p>
129
+ <ul class="cp-grid">${states.map(({ f, installed, blocked }) => html`<li><a class="cp-type${installed || blocked ? ' cp-done' : ''}" href="${BASE}/apps/${a.id}/features/${f.key}">
130
+ <span class="cp-feature-icon">${icon(f.icon)}</span><b>${f.label}</b><small>${f.help}</small>
131
+ ${installed ? html`<span class="tag">Installed</span>` : blocked ? html`<span class="tag">Not available</span>` : ''}</a></li>`)}</ul>`;
132
+ } else {
133
+ body = await copyStep1(a, s.username!);
134
+ }
135
+ const main = html`${appHeader(a, 'pages', s)}${region('Create page', html`${tabs}${body}`)}`;
136
+ return send(reply, s, shell(s, `Create page · ${a.name}`, crumbs, main));
137
+ });
138
+
139
+ // ---------------------------------------------------------------- Create page as copy
140
+ // step 2 (?from=): the page to copy; step 3 (?from=&page=): number, name and the shared components; POST: copy
141
+ app.get(`${BASE}/apps/:id/copy-page`, async (req: Req, reply) => {
142
+ const s = await developer(req, reply);
143
+ if (!s) return;
144
+ const a = await appOr404(req.params.id);
145
+ if (!a) return reply.code(404).send('Not found');
146
+ const q = req.query as Record<string, string | undefined>;
147
+ const from = await sourceApp(a.id, q.from);
148
+ if (!from) {
149
+ flash(s, 'Choose an application of this workspace.', 'error');
150
+ return back(reply, s, `${BASE}/apps/${a.id}/create-page?tab=copy`);
151
+ }
152
+ const pages = (await owner.query<{ id: number; page_no: number; name: string }>('select id, page_no, name from meta.page where app_id = $1 order by page_no', [from.id])).rows;
153
+ const page = pages.find((p) => String(p.page_no) === q.page);
154
+ const crumbs: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page?tab=copy`], ['Copy']];
155
+ let body: Raw;
156
+ if (!page) {
157
+ body = html`<form method="get" action="${BASE}/apps/${a.id}/copy-page"><input type="hidden" name="from" value="${from.id}">
158
+ <div class="form-grid">${select('page', `Page of ${from.name}`, '', pages.map((p): [string, string] => [String(p.page_no), `${p.page_no} ${p.name}`]))}</div>
159
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=copy">Back</a><button class="btn btn-hot">Next</button></div></form>`;
160
+ } else {
161
+ const refs = (await owner.query<{ kind: string; name: string; in_target: boolean }>('select * from meta.page_shared_refs($1, $2)', [page.id, a.id])).rows;
162
+ const used = new Set((await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((r) => r.page_no as number));
163
+ let no = used.has(page.page_no) ? Math.max(0, ...[...used].filter((x) => x < 9000)) + 1 : page.page_no;
164
+ while (used.has(no)) no++;
165
+ const same = from.id === a.id;
166
+ const options = async (kind: string) => (await owner.query<{ n: string }>(SHARED_NAMES[kind], [a.id])).rows.map((r) => r.n);
167
+ const mapRows = await Promise.all(refs.map(async (r) => {
168
+ const names = await options(r.kind);
169
+ const opts: [string, string][] = [...(same || r.in_target ? [] : [['', 'Copy it into this application'] as [string, string]]), ...names.map((n): [string, string] => [n, `Use ${n}`])];
170
+ return html`<tr><td>${KIND_LABELS[r.kind] ?? r.kind}</td><td><code>${r.name}</code></td><td>
171
+ <select name="map:${r.kind}:${r.name}" aria-label="${KIND_LABELS[r.kind] ?? r.kind} ${r.name}">${opts.map(([v, l]) => html`<option value="${v}"${v === (r.in_target || same ? r.name : '') ? raw(' selected') : ''}>${l}</option>`)}</select></td></tr>`;
172
+ }));
173
+ body = html`<p class="muted u-mt0">Page ${page.page_no} <b>${page.name}</b> of ${from.name}: its regions, items, buttons, dynamic actions, validations, processes, computations and branches. Items named P${page.page_no}_… are renamed to the new number, also where the page's code refers to them.</p>
174
+ <form method="post" action="${BASE}/apps/${a.id}/copy-page">${csrf(s)}
175
+ <input type="hidden" name="from" value="${from.id}"><input type="hidden" name="page" value="${page.page_no}">
176
+ <div class="form-grid">
177
+ ${input('new_page', 'New page number', no, { type: 'number', required: true })}
178
+ ${input('name', 'Name', page.name, { required: true })}
179
+ </div>
180
+ ${refs.length
181
+ ? html`<h3>Shared components</h3><p class="muted small">The page refers to these by name. ${same ? 'In the same application they stay as they are, or point to another of the same kind.' : 'Use one of this application\'s, or copy it from the other application.'}</p>
182
+ <div class="table-wrap"><table class="report"><thead><tr><th>Kind</th><th>Name</th><th>In this application</th></tr></thead><tbody>${mapRows}</tbody></table></div>`
183
+ : html`<p class="muted">The page uses no shared components.</p>`}
184
+ ${same ? '' : html`<p class="muted small">Its SQL runs as this application's database role <code>${a.db_role ?? '(none)'}</code>: grant it what ${from.name}'s role may read and change.</p>`}
185
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/copy-page?from=${from.id}">Back</a><button class="btn btn-hot">Copy page</button></div>
186
+ </form>`;
187
+ }
188
+ const main = html`${appHeader(a, 'pages', s)}${region('Create page as copy', body)}`;
189
+ return send(reply, s, shell(s, `Copy page · ${a.name}`, crumbs, main));
190
+ });
191
+
192
+ app.post(`${BASE}/apps/:id/copy-page`, async (req: Req, reply) => {
193
+ const s = await developer(req, reply);
194
+ if (!s) return;
195
+ const a = await appOr404(req.params.id);
196
+ if (!a) return reply.code(404).send('Not found');
197
+ const b = (req.body ?? {}) as Record<string, unknown>;
198
+ const from = await sourceApp(a.id, String(b.from ?? ''));
199
+ const retry = `${BASE}/apps/${a.id}/copy-page?from=${encodeURIComponent(String(b.from ?? ''))}&page=${encodeURIComponent(String(b.page ?? ''))}`;
200
+ if (!from) {
201
+ flash(s, 'Choose an application of this workspace.', 'error');
202
+ return back(reply, s, `${BASE}/apps/${a.id}/create-page?tab=copy`);
203
+ }
204
+ try {
205
+ const page = /^\d{1,6}$/.test(String(b.page ?? '')) ? Number(b.page) : NaN;
206
+ const no = /^\d{1,6}$/.test(String(b.new_page ?? '').trim()) ? Number(String(b.new_page).trim()) : NaN;
207
+ if (!(no >= 1)) throw new Error('Enter a page number.');
208
+ const name = String(b.name ?? '').trim().slice(0, 200);
209
+ if (!name) throw new Error('Enter a name.');
210
+ // map:<kind>:<NAME> → a name of the target's (empty: copy); only known kinds pass
211
+ const map: Record<string, string> = {};
212
+ for (const [k, v] of Object.entries(b)) {
213
+ const m = /^map:([a-z_]+):(.{1,100})$/.exec(k);
214
+ if (m && Object.hasOwn(SHARED_NAMES, m[1]) && typeof v === 'string') map[`${m[1]}:${m[2]}`] = v.slice(0, 100);
215
+ }
216
+ const id = await owner.tx(async (c) => (await c.query('select meta.copy_page($1, $2, $3, $4, $5, $6::jsonb) as id', [from.id, page, a.id, no, name, JSON.stringify(map)])).rows[0].id as number);
217
+ flash(s, `Page ${no} "${name}" created as a copy of page ${page}${from.id === a.id ? '' : ` of ${from.name}`}.`);
218
+ return back(reply, s, `${BASE}/pages/${id}`);
219
+ } catch (e) {
220
+ flash(s, (e as Error).message, 'error');
221
+ return back(reply, s, retry);
222
+ }
223
+ });
224
+ }
225
+
226
+ const KIND_LABELS: Record<string, string> = {
227
+ authz_scheme: 'Authorization scheme', build_option: 'Build option', lov: 'List of values', list: 'List',
228
+ template_component: 'Template component', search_config: 'Search configuration', data_load_def: 'Data load definition',
229
+ };
230
+ /** The target application's names of each kind (the choices of the mapping). */
231
+ const SHARED_NAMES: Record<string, string> = {
232
+ authz_scheme: 'select name as n from meta.authz_scheme where app_id = $1 order by 1',
233
+ build_option: 'select name as n from meta.build_option where app_id = $1 order by 1',
234
+ lov: 'select name as n from meta.lov where app_id = $1 order by 1',
235
+ list: 'select name as n from meta.list where app_id = $1 order by 1',
236
+ template_component: 'select static_id as n from meta.template_component where app_id = $1 order by 1',
237
+ search_config: 'select name as n from meta.search_config where app_id = $1 order by 1',
238
+ data_load_def: 'select name as n from meta.data_load_def where app_id = $1 order by 1',
239
+ };
240
+
241
+ /** An application of the same workspace as the target (the target itself too), or null. */
242
+ async function sourceApp(targetId: number, from: string | undefined) {
243
+ if (!/^\d{1,9}$/.test(String(from ?? ''))) return null;
244
+ return (await owner.one<{ id: number; name: string; alias: string }>(
245
+ 'select id, name, alias from meta.app where id = $1 and meta.app_workspace(id) = meta.app_workspace($2)', [from, targetId],
246
+ )) ?? null;
247
+ }
248
+
249
+ /** Copy, step 1: the application to copy from (this one first). */
250
+ async function copyStep1(a: { id: number; name: string }, _user: string): Promise<Raw> {
251
+ const apps = (await owner.query<{ id: number; name: string; alias: string }>(
252
+ 'select id, name, alias from meta.app where meta.app_workspace(id) = meta.app_workspace($1) order by id <> $1, lower(name)', [a.id],
253
+ )).rows;
254
+ return html`<p class="muted u-mt0">Copy a page of this application or another application of the workspace, under a new number and name. The shared components it uses (lists of values, authorization schemes, lists…) are mapped to this application's or copied.</p>
255
+ <form method="get" action="${BASE}/apps/${a.id}/copy-page">
256
+ <div class="form-grid">${select('from', 'Copy from application', String(a.id), apps.map((x): [string, string] => [String(x.id), x.id === a.id ? `${x.name} (this application)` : `${x.name} (${x.alias})`]))}</div>
257
+ <div class="buttons"><button class="btn btn-hot">Next</button></div>
258
+ </form>`;
259
+ }