pgkiln 0.44.0 → 0.45.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 (39) hide show
  1. package/db/migrations/102_app_computations.sql +345 -0
  2. package/db/migrations/103_shortcuts.sql +341 -0
  3. package/db/migrations/104_report_queries.sql +413 -0
  4. package/db/migrations/105_component_settings_groups.sql +367 -0
  5. package/docs/apex-feature-parity.md +10 -10
  6. package/docs/guide/02-concepts.md +24 -0
  7. package/docs/guide/03-builder.md +33 -1
  8. package/docs/guide/04-pages-and-regions.md +10 -1
  9. package/docs/guide/06-processing.md +22 -0
  10. package/docs/guide/09-reference.md +13 -0
  11. package/docs/guide/12-development.md +2 -1
  12. package/docs/guide/16-files.md +23 -0
  13. package/docs/guide/18-cli.md +4 -0
  14. package/package.json +1 -1
  15. package/public/app.css +17 -0
  16. package/public/app.js +63 -0
  17. package/src/appfiles.ts +18 -2
  18. package/src/builder/advisor.ts +7 -2
  19. package/src/builder/code-editor.ts +6 -1
  20. package/src/builder/components.ts +85 -3
  21. package/src/builder/forms.ts +1 -1
  22. package/src/builder/shared.ts +39 -6
  23. package/src/builder/subscriptions.ts +30 -4
  24. package/src/cli/replace.ts +6 -2
  25. package/src/metadata.ts +14 -1
  26. package/src/runtime/component-settings.ts +42 -0
  27. package/src/runtime/context.ts +4 -0
  28. package/src/runtime/documents.ts +29 -3
  29. package/src/runtime/engine.ts +2 -2
  30. package/src/runtime/grid.ts +22 -4
  31. package/src/runtime/items.ts +14 -11
  32. package/src/runtime/logic.ts +12 -7
  33. package/src/runtime/regions.ts +4 -3
  34. package/src/runtime/render.ts +1 -0
  35. package/src/runtime/report.ts +2 -1
  36. package/src/runtime/routes.ts +4 -2
  37. package/src/runtime/shortcuts.ts +43 -0
  38. package/src/security.ts +2 -1
  39. package/src/subscriptions.ts +67 -0
@@ -503,6 +503,28 @@ savepoint. A failing computation before the page is shown leaves the item as it
503
503
  error on the page; after a submit it stops the submit (everything is rolled back) and shows the
504
504
  error, like a failing process.
505
505
 
506
+ ### Application computations
507
+
508
+ **Shared Components → Application Computations** (APEX: Application Computations) are
509
+ computations for every page of the application, with the same types, conditions, authorization
510
+ and build option as a page's (migration 102, `meta.app_computation`). Their `point`:
511
+
512
+ | `point` | When |
513
+ |---|---|
514
+ | `after_authentication` | Once, when a user signs in (after the application processes `after_login`) |
515
+ | `before_header` | Every page shown, before the page's own `before_header` computations |
516
+ | `after_submit` | Every page submitted, before the page's own `after_submit` computations (and the validations) |
517
+
518
+ They usually set **application items**, e.g. a user's sales region once at sign-in:
519
+
520
+ ```sql
521
+ select region from sales.rep where lower(username) = lower(:APP_USER) -- AI_REGION, sql_query, after_authentication
522
+ ```
523
+
524
+ An application computation of a page item runs on that item's page only (elsewhere it is skipped,
525
+ without the *unknown item* error a page's computation gives). A failing one behaves like a page
526
+ computation at the same point.
527
+
506
528
  ## Branches
507
529
 
508
530
  After a successful submit pgkiln redirects (POST-redirect-GET). **Branches** decide where to: the
@@ -387,6 +387,19 @@ runtime role, never exported) ([chapter 19](19-rest-data-sources.md#web-credenti
387
387
 
388
388
  **`app_process`**: `app_id`, `seq`, `name`, `point` (`after_login` / `before_page`), `code`, `authz`.
389
389
 
390
+ **`app_computation`** (102): `app_id`, `seq`, `item_name`, `point` (`after_authentication` / `before_header` /
391
+ `after_submit`), `type`, `expression`, `condition_type`, `condition_expr`, `condition_value`, `authz`, `build_option`,
392
+ `comments` ([chapter 6](06-processing.md#application-computations)).
393
+
394
+ **`shortcut`** (103): `app_id`, `name` (uppercase, unique per app), `type` (`text` / `html` / `function_body`), `value`,
395
+ `comments` ([chapter 2](02-concepts.md#shortcuts-name)).
396
+
397
+ **`report_query`** (104): `app_id`, `name` (uppercase, unique per app), `description`, `query`, `format` (`pdf` / `xlsx` /
398
+ `csv` / `json` / `xml` / `html`), `layout`, `file_name`, `title`, `authz`, `comments` ([chapter 16](16-files.md#report-queries)).
399
+
400
+ **`component_group`** (105): `app_id`, `name`, `description`, `members` (`text[]` of `kind:name`) ([chapter 3](03-builder.md#component-groups)).
401
+ `meta.app.component_settings` (105): `report_rows`, `grid_rows`, `popup_lov_rows`, `lov_max_rows`, `rating_max` ([chapter 3](03-builder.md#component-settings)).
402
+
390
403
  **`build_option`**: `app_id`, `name` (uppercase, unique per app), `status` (`include` / `exclude`),
391
404
  `description` ([chapter 6](06-processing.md#build-options)). `meta.build_option_on(app_id, ref)`
392
405
  tells whether a component with that `build_option` value is part of the application.
@@ -114,7 +114,8 @@ src/
114
114
  authz.ts authorization schemes, conditions, visibility (menu requests count as buttons)
115
115
  engine.ts form fetch, validations, processes (conditions, execution chains, queueing background chains; web requests queued by an sql process are made right after it), application processes
116
116
  processes.ts download (file or zip from a query, safe headers), workflow processes, configuration checks of chains
117
- logic.ts computations, branches (page, URL, function returning a URL, another application) and their conditions
117
+ logic.ts computations (the page's and (102) the application's), branches (page, URL, function returning a URL, another application) and their conditions
118
+ shortcuts.ts (103) "NAME" shortcuts in static content, help texts and confirm messages
118
119
  render.ts page chrome (nav, breadcrumb), dynamic action JSON, theme (the page's nonce'd <style>, light/dark and style switches)
119
120
  styles.ts base styles (BASE_STYLES: Iris, Lavender, Standard; baseStyleOf → html data-style), Theme Roller style variants: fixed lists (fonts, sizes, corners), parseStyle/appStyles checks, the request's
120
121
  style (user choice, default), themeCss() (only hex values and constants reach the CSS)
@@ -604,3 +604,26 @@ name.
604
604
  The standard PDF fonts cover Western European text; set `PDF_FONT` (and `PDF_FONT_BOLD`) for other
605
605
  scripts, as for report PDFs. APEX's Word and Excel templates and outputs have no equivalent: the
606
606
  templates here are HTML and the output is PDF.
607
+
608
+ ### Report queries
609
+
610
+ **Shared Components → Report Queries** (APEX: Report Queries, migration 104) are named queries an
611
+ application prints, without a template: the query's rows as a table.
612
+
613
+ | Property | Meaning |
614
+ |---|---|
615
+ | `name` | Pages download it with `?doc=NAME` (a document template of that name wins); a button with the action *document* names it |
616
+ | `query` | A SELECT with `:ITEM` binds (the page's session state), run as the application's role; its columns' names are the headings |
617
+ | `format` | `pdf` (with the report layout), `xlsx`, `csv`, `json`, `xml` or `html` |
618
+ | `layout` | A report layout for the PDF; empty: the default layout |
619
+ | `file_name`, `title` | The file's name without extension and the PDF's or HTML file's title (`&ITEM.` allowed); empty: the name, and the description or the name |
620
+ | `authz` | Who may download it, besides access to the page |
621
+
622
+ ```
623
+ /a/sales/3?doc=ORDERS_BY_REGION the file, with page 3's session state
624
+ ```
625
+
626
+ The files are made like a download process's [data export](06-processing.md#data-export) (Excel and
627
+ PDF hold at most 100,000 rows) and are sent as attachments that a browser never shows as a page
628
+ (`Content-Security-Policy: sandbox`). Signed out, on a public page of an application with sign-in,
629
+ `?doc=` serves only the report queries and document templates of the page's own buttons.
@@ -65,6 +65,10 @@ hr/
65
65
  app-items/ai_ename.json
66
66
  app-processes/0010-link-user-to-employee.json
67
67
  app-processes/0010-link-user-to-employee.code.sql
68
+ app-computations/0010-ai_region-after_authentication.json (by sequence, item and point)
69
+ component-groups/sales_lookups.json
70
+ report-queries/orders_by_region.json (the query in .query.sql)
71
+ shortcuts/delete_confirm_msg.json (a long value in .value.txt, .html or .sql by its type)
68
72
  lovs/jobs.json
69
73
  lovs/jobs.query.sql
70
74
  report-layouts/hr_directory.json (a logo as hr_directory.logo.png)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.44.0",
3
+ "version": "0.45.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
@@ -1056,6 +1056,20 @@ fieldset.prop-group > legend { font-size: 0.75rem; text-transform: uppercase; le
1056
1056
  .grid-table th.grid-drop-after { box-shadow: inset -3px 0 0 var(--accent); }
1057
1057
  /* copy and paste: a range of selected cells */
1058
1058
  .grid-table td.grid-cell-selected { background: color-mix(in srgb, var(--accent) 16%, var(--surface)); box-shadow: inset 0 0 0 1px var(--accent); }
1059
+ /* cell navigation mode (APEX): with JavaScript an editable cell reads as text (its formatted value, .grid-text)
1060
+ until it is clicked or Enter / F2 edits it; arrow keys move between cells (app.js) */
1061
+ .grid-text { display: none; }
1062
+ .js .grid-table td[data-col]:not(.grid-frozen) { position: relative; }
1063
+ .js .grid-table tbody td[data-col]:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
1064
+ .js .grid-table tbody td[data-col] :is(input:not([type=checkbox]), select) { border-color: transparent; background: transparent; box-shadow: none; }
1065
+ .js .grid-table tbody td[data-col] select { appearance: none; }
1066
+ .js .grid-table tbody td[data-col]:focus-within :is(input:not([type=checkbox]), select) { border-color: var(--border); background: var(--surface); appearance: auto; }
1067
+ .js .grid-table td[data-col] > .grid-text { display: flex; align-items: center; position: absolute; inset: 0; padding: 0 calc(0.85rem + 1px);
1068
+ overflow: hidden; white-space: nowrap; pointer-events: none; }
1069
+ .js .grid-table td[data-col] > .grid-text.num { justify-content: flex-end; font-variant-numeric: tabular-nums; }
1070
+ .js .grid-table td[data-col]:has(> .grid-text) :is(input, select) { color: transparent; }
1071
+ .js .grid-table td[data-col]:focus-within > .grid-text { display: none; }
1072
+ .js .grid-table td[data-col]:focus-within :is(input, select) { color: inherit; }
1059
1073
  .grid-columns-form { border-collapse: collapse; font-size: 0.85rem; }
1060
1074
  .grid-columns-form th, .grid-columns-form td { padding: 0.2rem 0.35rem; text-align: left; white-space: nowrap; }
1061
1075
  .grid-columns-form input[type=number] { width: 5.5rem; }
@@ -1216,6 +1230,9 @@ input[type=color] { width: 4rem; min-height: 2.4rem; padding: 0.15rem; }
1216
1230
  .report-reflow.grid-table .agg-row td.grid-lead { display: none; }
1217
1231
  .report-reflow.grid-table td.grid-lead:empty { display: none; }
1218
1232
  .grid-resize { display: none !important; }
1233
+ /* a card shows its fields as fields: no navigation mode */
1234
+ .js .report-reflow.grid-table td[data-col] > .grid-text { display: none; }
1235
+ .js .report-reflow.grid-table tbody td[data-col] :is(input, select) { color: inherit; border-color: var(--border); background: var(--surface); appearance: auto; }
1219
1236
  }
1220
1237
 
1221
1238
  /* two-thirds / one-third columns on desktops (builder Users page) */
package/public/app.js CHANGED
@@ -1666,6 +1666,69 @@ const pgkilnUnsaved = (() => {
1666
1666
  data[i].forEach((v, j) => cells[c0 + j] && setCell(cells[c0 + j], v));
1667
1667
  }
1668
1668
  });
1669
+
1670
+ // ------------------------------------------------------------ cell navigation and edit mode (APEX)
1671
+ // An editable cell reads as text (app.css) until it is edited: a click in it, Enter or F2 or typing
1672
+ // edits it. On a cell the arrow keys move to the next one; while editing, Escape goes back to the
1673
+ // cell and Enter to the cell below. Tab moves through the cells' controls as usual.
1674
+ const cellSel = '[data-grid] table.grid-table > tbody:not(.grid-template) > tr > td[data-col]';
1675
+ function focusCell(td, edit) {
1676
+ if (!td) return;
1677
+ const el = control(td);
1678
+ if (edit && el && !el.disabled) {
1679
+ el.focus();
1680
+ if (el.type !== 'checkbox' && el.tagName !== 'SELECT') el.select?.();
1681
+ return;
1682
+ }
1683
+ if (!td.hasAttribute('tabindex')) td.tabIndex = -1;
1684
+ td.focus();
1685
+ }
1686
+ function neighbour(td, dr, dc) {
1687
+ const rows = rowsOf(td.closest('[data-grid]'));
1688
+ const { r, c } = at(td);
1689
+ return rows[r + dr] ? shownCells(rows[r + dr])[c + dc] : undefined;
1690
+ }
1691
+ const MOVES = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
1692
+ document.addEventListener('keydown', (e) => {
1693
+ if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
1694
+ const t = e.target;
1695
+ if (t.matches?.(cellSel)) {
1696
+ const el = control(t);
1697
+ if (MOVES[e.key]) {
1698
+ e.preventDefault();
1699
+ focusCell(neighbour(t, ...MOVES[e.key]), false);
1700
+ } else if (el && (e.key === 'Enter' || e.key === 'F2')) {
1701
+ e.preventDefault();
1702
+ focusCell(t, true);
1703
+ } else if (el?.type === 'checkbox' && e.key === ' ') {
1704
+ e.preventDefault();
1705
+ el.click();
1706
+ } else if (el && e.key.length === 1) focusCell(t, true); // the key types into the control
1707
+ return;
1708
+ }
1709
+ const td = t.closest?.(cellSel);
1710
+ if (!td || !t.matches('input, select')) return;
1711
+ if (e.key === 'Escape') focusCell(td, false);
1712
+ else if (e.key === 'Enter' && !e.shiftKey) {
1713
+ e.preventDefault();
1714
+ const below = neighbour(td, 1, 0);
1715
+ focusCell(below ?? td, !!below);
1716
+ } else if (e.key === 'F2') focusCell(td, false);
1717
+ });
1718
+ // a click in a cell beside its control edits it
1719
+ document.addEventListener('click', (e) => {
1720
+ if (!e.shiftKey && e.target.matches?.(cellSel)) focusCell(e.target, true);
1721
+ });
1722
+ // an edited value shows as itself (a list's display value)
1723
+ const syncText = (e) => {
1724
+ const span = e.target.closest?.('td[data-col]')?.querySelector(':scope > .grid-text');
1725
+ if (!span) return;
1726
+ const el = e.target;
1727
+ if (el.tagName === 'SELECT') span.textContent = el.selectedIndex > 0 ? el.options[el.selectedIndex].text : '';
1728
+ else span.remove();
1729
+ };
1730
+ document.addEventListener('input', syncText);
1731
+ document.addEventListener('change', syncText);
1669
1732
  })();
1670
1733
 
1671
1734
  // Faceted search: apply on change. A range facet's own from/to: typing picks
package/src/appfiles.ts CHANGED
@@ -116,6 +116,10 @@ const CODE: Record<string, Record<string, string>> = {
116
116
  computation: { expression: 'sql', condition_expr: 'sql' },
117
117
  branch: { condition_expr: 'sql' },
118
118
  app_process: { code: 'sql' },
119
+ app_computation: { expression: 'sql', condition_expr: 'sql' },
120
+ // (103) a shortcut's value: text, HTML or PL/pgSQL (see codeExt)
121
+ shortcut: { value: 'txt' },
122
+ report_query: { query: 'sql' },
119
123
  authz_scheme: { value: 'sql' },
120
124
  lov: { query: 'sql' },
121
125
  automation: { query: 'sql', code: 'sql' },
@@ -134,7 +138,9 @@ const CODE: Record<string, Record<string, string>> = {
134
138
  };
135
139
  const isLong = (v: unknown): v is string => typeof v === 'string' && (v.includes('\n') || v.length > 60);
136
140
  const codeExt = (table: string, column: string, row: any) =>
137
- table === 'region' && column === 'source' && row.type === 'static' ? 'html' : CODE[table][column];
141
+ table === 'region' && column === 'source' && row.type === 'static' ? 'html'
142
+ : table === 'shortcut' ? (row.type === 'html' ? 'html' : row.type === 'function_body' ? 'sql' : 'txt')
143
+ : CODE[table][column];
138
144
 
139
145
  export type Style = 'json' | 'text';
140
146
 
@@ -191,6 +197,11 @@ const NAMED: [section: string, dir: string, table: string][] = [
191
197
  // (086) application settings
192
198
  ['app_settings', 'shared/app-settings', 'app_setting'],
193
199
  ['page_groups', 'shared/page-groups', 'page_group'],
200
+ // (103) shortcuts, (104) report queries
201
+ ['shortcuts', 'shared/shortcuts', 'shortcut'],
202
+ ['report_queries', 'shared/report-queries', 'report_query'],
203
+ // (105) component groups
204
+ ['component_groups', 'shared/component-groups', 'component_group'],
194
205
  ];
195
206
 
196
207
  /** Components of a page: [array in the document, directory, table, key source]. */
@@ -214,10 +225,11 @@ const SINGLE: [section: string, path: string, sort: string[]][] = [
214
225
  ];
215
226
 
216
227
  const ACTIONS_DIR = 'shared/automation-actions';
228
+ const APP_COMPUTATIONS_DIR = 'shared/app-computations';
217
229
  const STATIC_DIR = 'static';
218
230
  const STATIC_INDEX = `${STATIC_DIR}/files.json`;
219
231
 
220
- const KNOWN = new Set(['format', 'app', 'static_files', 'app_processes', 'translations', 'nav', 'list_entries', 'automation_actions', 'pages', ...NAMED.map((n) => n[0]), ...SINGLE.map((s) => s[0])]);
232
+ const KNOWN = new Set(['format', 'app', 'static_files', 'app_processes', 'app_computations', 'translations', 'nav', 'list_entries', 'automation_actions', 'pages', ...NAMED.map((n) => n[0]), ...SINGLE.map((s) => s[0])]);
221
233
 
222
234
  const byColumns = (cols: string[]) => (a: any, b: any) => {
223
235
  for (const c of cols) {
@@ -258,6 +270,9 @@ export function docToFiles(doc: Doc, style: Style = 'json'): FileMap {
258
270
 
259
271
  const procs: any[] = doc.app_processes ?? [];
260
272
  uniqueKeys(procs, (r) => r.name, 'process').forEach((k, i) => w.record('shared/app-processes', `${seqPrefix(procs[i].seq)}-${k}`, 'app_process', procs[i]));
273
+ // (102) application computations, by sequence, item and point
274
+ const comps: any[] = doc.app_computations ?? [];
275
+ uniqueKeys(comps, (r) => `${r.item_name ?? ''} ${r.point ?? ''}`, 'computation').forEach((k, i) => w.record(APP_COMPUTATIONS_DIR, `${seqPrefix(comps[i].seq)}-${k}`, 'app_computation', comps[i]));
261
276
 
262
277
  for (const [section, path, sort] of SINGLE) w.json(path, [...(doc[section] ?? [])].sort(byColumns(sort)));
263
278
 
@@ -453,6 +468,7 @@ export function filesToDoc(files: FileMap): Doc {
453
468
  });
454
469
  }
455
470
  doc.app_processes = readRecords(files, 'shared/app-processes').sort(SEQ_SORT).map(mergeCode);
471
+ doc.app_computations = readRecords(files, APP_COMPUTATIONS_DIR).sort(SEQ_SORT).map(mergeCode);
456
472
  for (const [section, path] of SINGLE) doc[section] = read(path);
457
473
  doc.translations = [...files.keys()]
458
474
  .filter((p) => /^globalization\/translations\/[^/]+\.(json|yaml)$/.test(p))
@@ -57,6 +57,7 @@ function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[]
57
57
  case 'button':
58
58
  return [{ name: 'condition', shape: 'boolean' }, { name: 'badge_query', shape: 'select' }];
59
59
  case 'computation':
60
+ case 'app_computation':
60
61
  return [
61
62
  ...(row.type === 'sql_query' ? [{ name: 'expression', shape: 'select' as const }] : []),
62
63
  ...conditionSql(row),
@@ -83,6 +84,7 @@ function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[]
83
84
  case 'automation_action':
84
85
  return [{ name: 'code', shape: 'statements' }, { name: 'condition', shape: 'boolean' }];
85
86
  case 'document_template':
87
+ case 'report_query':
86
88
  return [{ name: 'query', shape: 'select' }];
87
89
  case 'task_definition':
88
90
  return [{ name: 'action_code', shape: 'statements' }];
@@ -286,7 +288,8 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
286
288
  const buildOptions = new Set(all.filter((x) => x.kind === 'build_option').map((x) => x.row.name));
287
289
  const layouts = new Set(all.filter((x) => x.kind === 'report_layout').map((x) => x.row.name.toUpperCase()));
288
290
  const regionIds = new Set(all.filter((x) => x.kind === 'region').map((x) => x.row.id));
289
- const documents = new Set(all.filter((x) => x.kind === 'document_template').map((x) => x.row.name.toUpperCase()));
291
+ // (104) a document button names a document template or a report query
292
+ const documents = new Set(all.filter((x) => x.kind === 'document_template' || x.kind === 'report_query').map((x) => x.row.name.toUpperCase()));
290
293
  const missing = (entry: Entry | null, field: string, message: string, severity: Severity = 'error') => findings.push({ severity, entry, field, message });
291
294
 
292
295
  for (const e of entries) {
@@ -353,7 +356,7 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
353
356
  && !(await one('select 1 as ok from meta.app a join meta.page p on p.app_id = a.id where a.alias = $1 and p.page_no = $2', [row.target_app, row.target_page])))
354
357
  missing(e, 'Application (app)', `Application ${row.target_app} has no page ${row.target_page}.`);
355
358
  if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
356
- missing(e, 'Document template', row.document ? `Document template ${row.document} doesn't exist.` : 'A document button needs a document template.');
359
+ missing(e, 'Document template', row.document ? `Document template or report query ${row.document} doesn't exist.` : 'A document button needs a document template or a report query.');
357
360
  if (kind === 'rest_module') for (const problem of handlerProblems(row.handlers)) missing(e, 'Handlers (JSON)', problem);
358
361
  if (kind === 'workflow_definition') {
359
362
  const tasks = new Set(all.filter((x) => x.kind === 'task_definition').map((x) => x.row.name.toUpperCase()));
@@ -376,6 +379,8 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
376
379
  for (const problem of [...devRefs.errors, ...devRefs.warnings]) missing(e, field, problem, 'warning');
377
380
  }
378
381
  }
382
+ if (kind === 'report_query' && row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
383
+ if (kind === 'app_computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
379
384
  if (kind === 'document_template') {
380
385
  const problem = templateProblem(row.template ?? '');
381
386
  if (problem) missing(e, 'Template (HTML)', problem);
@@ -32,7 +32,7 @@ const CHECKS: Record<string, SqlShape> = {
32
32
  'region.source': 'select', 'region.condition': 'boolean', 'item.lov': 'select', 'item.readonly_condition': 'boolean',
33
33
  'button.condition': 'boolean', 'validation.expression': 'boolean', 'process.code': 'statements', 'authz_scheme.value': 'boolean',
34
34
  'lov.query': 'select', 'app_process.code': 'statements', 'automation.query': 'select', 'automation_action.code': 'statements', 'automation_action.condition': 'boolean',
35
- 'document_template.query': 'select', 'task_definition.action_code': 'statements', 'dynamic_action.code': 'statements',
35
+ 'document_template.query': 'select', 'report_query.query': 'select', 'task_definition.action_code': 'statements', 'dynamic_action.code': 'statements',
36
36
  'button.badge_query': 'select', 'list.query': 'select', 'list_entry.condition': 'boolean',
37
37
  };
38
38
  /** Fields whose check follows the component's type select, like SWITCH ("none": no check). */
@@ -41,7 +41,9 @@ const CHECK_SWITCH: Record<string, string> = {
41
41
  'process.code': 'type:sql=statements,*=none',
42
42
  'region.source': 'type:static=none,form=none,facets=none,smart_filters=none,display_selector=none,tasks=none,workflows=none,list=none,data_reporter=none,ai_assistant=none,*=select',
43
43
  'computation.expression': 'type:sql_query=select,*=none',
44
+ 'app_computation.expression': 'type:sql_query=select,*=none',
44
45
  'computation.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
46
+ 'app_computation.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
45
47
  'branch.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
46
48
  };
47
49
  /** Fields whose language follows the component's type select: "type:value=lang,…" (other values: SQL). */
@@ -51,7 +53,10 @@ const SWITCH: Record<string, string> = {
51
53
  'authz_scheme.value': 'type:role=text',
52
54
  'dynamic_action.code': 'action:set_value=sql',
53
55
  'computation.expression': 'type:static=text,item=text,function_body=plpgsql',
56
+ 'app_computation.expression': 'type:static=text,item=text,function_body=plpgsql',
57
+ 'shortcut.value': 'type:text=text,html=html,function_body=plpgsql',
54
58
  'computation.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
59
+ 'app_computation.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
55
60
  'branch.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
56
61
  };
57
62
  /** Language when no SWITCH value matches. */
@@ -20,6 +20,7 @@ import { aiProblems } from '../runtime/ai.ts';
20
20
  import { DATA_LOAD_DEF_SPEC } from './dataload.ts';
21
21
  import { processProblems } from '../runtime/processes.ts';
22
22
  import { BUTTON_OPTIONS, ITEM_OPTIONS, REGION_OPTIONS } from '../runtime/template-options.ts';
23
+ import { checkGroupMembers } from '../subscriptions.ts';
23
24
 
24
25
  export type FieldKind =
25
26
  | 'text' | 'int' | 'bool' | 'code' | 'json' | 'select' | 'upper'
@@ -68,8 +69,8 @@ export interface ComponentSpec {
68
69
  defaults?: Record<string, unknown>;
69
70
  /** checks the parsed values before saving; returns a message when they're not valid */
70
71
  validate?: (values: Record<string, any>) => string | null;
71
- /** adjusts the parsed values before they are saved (cid: the row being edited); throws when they can't be */
72
- beforeSave?: (values: Record<string, unknown>, cid: string | undefined) => Promise<void>;
72
+ /** adjusts the parsed values before they are saved (cid: the row being edited; parentId: its page or application); throws when they can't be */
73
+ beforeSave?: (values: Record<string, unknown>, cid: string | undefined, parentId: string) => Promise<void>;
73
74
  }
74
75
 
75
76
  const AUTHZ_HELP = 'Authorization scheme; prefix with ! to negate. MUST_NOT_BE_PUBLIC_USER is built in.';
@@ -169,7 +170,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
169
170
  { name: 'name', common: true, label: 'Name (request)', kind: 'upper', group: 'Identification' },
170
171
  { name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
171
172
  { name: 'action', common: true, label: 'Action', kind: 'select', options: ['submit', 'redirect', 'da', 'document', 'menu'], group: 'Behaviour', help: 'menu = a menu of links and submit requests (Menu entries) · da = "Defined by dynamic action"; document = download a document template (filled with the page\'s values as last loaded or saved)' },
172
- { name: 'document', label: 'Document template (action document)', kind: 'upper', group: 'Behaviour', help: 'The name of a document template (Shared Components → Document templates).' },
173
+ { name: 'document', label: 'Document template (action document)', kind: 'upper', group: 'Behaviour', help: 'The name of a document template or a report query (Shared Components → Document templates, Report queries).' },
173
174
  { name: 'target_page', common: true, label: 'Target / branch page', kind: 'page', group: 'Behaviour' },
174
175
  { name: 'target_items', label: 'Set items (JSON)', kind: 'json', group: 'Behaviour', help: '{"P3_ID": "&P2_ID."}' },
175
176
  { name: 'confirm', label: 'Confirm message', kind: 'text', group: 'Behaviour' },
@@ -454,6 +455,66 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
454
455
  buildOption(),
455
456
  ],
456
457
  },
458
+ // (102) application computations (APEX: Shared Components → Application Computations)
459
+ app_computation: {
460
+ table: 'meta.app_computation',
461
+ scope: 'app',
462
+ label: 'Application computation',
463
+ plural: 'Application computations',
464
+ icon: 'activity',
465
+ summary: (c) => `${c.item_name} (${c.point}, ${c.type})`,
466
+ defaults: { point: 'before_header', type: 'static' },
467
+ fields: [
468
+ { name: 'item_name', label: 'Item', kind: 'upper', group: 'Identification', help: 'An application item, e.g. AI_REGION (a page item is computed on its own page only)' },
469
+ { name: 'point', label: 'Point', kind: 'select', options: ['after_authentication', 'before_header', 'after_submit'], group: 'Identification',
470
+ help: 'after_authentication: once, when a user signs in · before_header: every page shown, before the page\'s own computations · after_submit: every page submitted, before the page\'s own computations' },
471
+ { name: 'seq', label: 'Sequence', kind: 'int', group: 'Identification' },
472
+ { name: 'type', label: 'Type', kind: 'select', options: ['static', 'item', 'sql_query', 'sql_expression', 'function_body'], group: 'Computation' },
473
+ { name: 'expression', label: 'Expression', kind: 'code', wide: true, group: 'Computation',
474
+ help: 'static: a value, &ITEM. allowed · item: an item name · sql_query: a SELECT (first column of the first row), e.g. select region from sales.rep where lower(username) = lower(:APP_USER) · sql_expression · function_body: PL/pgSQL. Runs as the application\'s role.' },
475
+ ...conditionFields('Condition'),
476
+ { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
477
+ buildOption('Security'),
478
+ { name: 'comments', label: 'Comments', kind: 'textarea', wide: true, group: 'Comments' },
479
+ ],
480
+ },
481
+ // (105) component groups (APEX: Shared Components → Component Groups)
482
+ component_group: {
483
+ table: 'meta.component_group',
484
+ scope: 'app',
485
+ label: 'Component group',
486
+ plural: 'Component groups',
487
+ icon: 'layers',
488
+ summary: (g) => `${g.name} (${(g.members ?? []).length})`,
489
+ defaults: {},
490
+ beforeSave: async (v, _cid, appId) => {
491
+ const { members, problems } = await checkGroupMembers(Number(appId), (v.members as string[] | null) ?? []);
492
+ if (problems.length) throw new Error(problems.join(' '));
493
+ v.members = members;
494
+ },
495
+ fields: [
496
+ { name: 'name', label: 'Name', kind: 'text', help: 'e.g. Sales lookups. Other applications subscribe to all of its components at once (Subscriptions), when this is a library or theme application.' },
497
+ { name: 'description', label: 'Description', kind: 'text', wide: true },
498
+ { name: 'members', label: 'Components', kind: 'list', wide: true,
499
+ help: 'Comma separated kind:name, e.g. lov:JOBS, authz_scheme:MANAGER, build_option:FEATURE_EXPORT, template_component:status_badge, list:SHORTCUTS.' },
500
+ ],
501
+ },
502
+ // (103) shortcuts (APEX: Shared Components → Shortcuts)
503
+ shortcut: {
504
+ table: 'meta.shortcut',
505
+ scope: 'app',
506
+ label: 'Shortcut',
507
+ plural: 'Shortcuts',
508
+ icon: 'link',
509
+ summary: (s) => `"${s.name}" (${s.type})`,
510
+ defaults: { type: 'text' },
511
+ fields: [
512
+ { name: 'name', label: 'Name', kind: 'upper', help: 'e.g. DELETE_CONFIRM_MSG: "DELETE_CONFIRM_MSG" (in double quotes) in a static region\'s content, an item\'s help text or a button\'s confirm message is replaced by the shortcut.' },
513
+ { name: 'type', label: 'Type', kind: 'select', options: ['text', 'html', 'function_body'], help: 'text: the value (escaped in HTML) · html: the value as HTML · function_body: PL/pgSQL that returns the text, e.g. begin return \'Signed in as \' || :APP_USER; end. Runs as the application\'s role, once per request.' },
514
+ { name: 'value', label: 'Value', kind: 'code', wide: true },
515
+ { name: 'comments', label: 'Comments', kind: 'textarea', wide: true },
516
+ ],
517
+ },
457
518
  page_group: {
458
519
  table: 'meta.page_group',
459
520
  scope: 'app',
@@ -578,6 +639,27 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
578
639
  web_credential: WEB_CREDENTIAL_SPEC,
579
640
  rest_source: REST_SOURCE_SPEC,
580
641
  data_load_def: DATA_LOAD_DEF_SPEC,
642
+ // (104) report queries (APEX: Shared Components → Report Queries)
643
+ report_query: {
644
+ table: 'meta.report_query',
645
+ scope: 'app',
646
+ label: 'Report query',
647
+ plural: 'Report queries',
648
+ icon: 'file',
649
+ summary: (q) => `${q.name} (${q.format})`,
650
+ defaults: { format: 'pdf', query: 'select empno, ename, job from sales.staff order by ename' },
651
+ fields: [
652
+ { name: 'name', label: 'Name', kind: 'upper', group: 'Identification', help: 'Pages download it with ?doc=NAME (a document template of that name wins); buttons with action "document" name it.' },
653
+ { name: 'description', label: 'Description', kind: 'text', wide: true, group: 'Identification' },
654
+ { name: 'query', label: 'Query (SQL)', kind: 'code', wide: true, group: 'Source', help: 'A SELECT with :ITEM binds (the page\'s session state), run as the application\'s role. Its columns\' names are the headings.' },
655
+ { name: 'format', label: 'Output format', kind: 'select', options: ['pdf', 'xlsx', 'csv', 'json', 'xml', 'html'], group: 'Output' },
656
+ { name: 'layout', label: 'Report layout', kind: 'upper', group: 'Output', help: 'PDF: paper, margins, font size, colours, logo and footer (Shared Components → Report layouts). Empty: the default layout.' },
657
+ { name: 'file_name', label: 'File name', kind: 'text', group: 'Output', help: 'Without extension, e.g. staff-&P3_DEPTNO. Empty: the name in lower case.' },
658
+ { name: 'title', label: 'Title', kind: 'text', group: 'Output', help: 'Of a PDF or HTML file (&ITEM. allowed). Empty: the description or the name.' },
659
+ { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
660
+ { name: 'comments', label: 'Comments', kind: 'textarea', wide: true, group: 'Comments' },
661
+ ],
662
+ },
581
663
  document_template: {
582
664
  table: 'meta.document_template',
583
665
  scope: 'app',
@@ -179,7 +179,7 @@ export async function saveComponent(kind: string, parentCol: 'page_id' | 'app_id
179
179
  const values = parseFields(spec, body);
180
180
  const problem = spec.validate?.(values);
181
181
  if (problem) throw new Error(problem);
182
- await spec.beforeSave?.(values, cid);
182
+ await spec.beforeSave?.(values, cid, parentId);
183
183
  // region fields only take regions of the same page
184
184
  if (parentCol === 'page_id')
185
185
  for (const f of spec.fields)
@@ -17,6 +17,7 @@ import { appHeader, back, BASE, csrf, developer, flash, input, region, select, s
17
17
  import { endSessions, grantAccess, roleHints, roleHintsHtml, splitRoles } from './users.ts';
18
18
  import { appOr404, componentForm, lookups, saveComponent } from './forms.ts';
19
19
  import { catalogPicker } from './rest-catalogs.ts';
20
+ import { COMPONENT_SETTINGS, componentSetting, componentSettingsFrom, type ComponentSettings } from '../runtime/component-settings.ts';
20
21
 
21
22
  // Shared Components: navigation, authorization schemes, LOVs, application
22
23
  // items and processes, report layouts, and access control.
@@ -61,12 +62,12 @@ export function sharedGroups(appId: number): { key: string; title: string; icon:
61
62
  const settings = (tab: string, label: string): ScItem => ({ label, href: `${BASE}/apps/${appId}/settings?tab=${tab}` });
62
63
  const page = (path: string, label: string): ScItem => ({ label, href: `${BASE}/apps/${appId}/${path}` });
63
64
  return [
64
- { key: 'logic', title: 'Application Logic', icon: 'code', items: [t('app_item', 'Application Items'), t('app_process', 'Application Processes'), t('app_setting', 'Application Settings'), t('build_option', 'Build Options'), page('settings?tab=definition', 'Application Definition')] },
65
+ { key: 'logic', title: 'Application Logic', icon: 'code', items: [t('app_item', 'Application Items'), t('app_process', 'Application Processes'), t('app_computation', 'Application Computations'), t('app_setting', 'Application Settings'), t('build_option', 'Build Options'), page('settings?tab=definition', 'Application Definition')] },
65
66
  { key: 'security', title: 'Security', icon: 'shield', items: [{ label: 'Access Control', href: `${BASE}/apps/${appId}/shared?view=tree`, kind: 'access' }, settings('security', 'Authentication'), t('authz_scheme', 'Authorization Schemes'), settings('security', 'Security Attributes'), settings('signin', 'Sign-in and Session Management')] },
66
- { key: 'other', title: 'Other Components', icon: 'layers', items: [t('lov', 'Lists of Values'), t('list', 'Lists'), t('list_entry', 'List Entries'), t('email_template', 'Email Templates'), t('supporting_script', 'Supporting Objects'), page('subscriptions', 'Subscriptions')] },
67
+ { key: 'other', title: 'Other Components', icon: 'layers', items: [t('lov', 'Lists of Values'), t('list', 'Lists'), t('list_entry', 'List Entries'), t('email_template', 'Email Templates'), t('shortcut', 'Shortcuts'), t('component_group', 'Component Groups'), t('supporting_script', 'Supporting Objects'), page('subscriptions', 'Subscriptions')] },
67
68
  { key: 'navigation', title: 'Navigation and Search', icon: 'compass', items: [t('nav_entry', 'Navigation Menu'), settings('ui', 'Navigation Bar List'), page('breadcrumbs', 'Breadcrumbs'), t('page_group', 'Page Groups'), t('search_config', 'Search Configurations')] },
68
- { key: 'ui', title: 'User Interface', icon: 'grid', items: [page('theme', 'Themes and Theme Roller'), settings('ui', 'User Interface Attributes'), t('template_component', 'Template Components'), page('plugins', 'Plug-ins'), settings('pwa', 'Progressive Web App')] },
69
- { key: 'files', title: 'Files and Reports', icon: 'file', items: [page('static-files', 'Static Application Files'), t('report_layout', 'Report Layouts'), t('document_template', 'Document Templates')] },
69
+ { key: 'ui', title: 'User Interface', icon: 'grid', items: [page('theme', 'Themes and Theme Roller'), settings('ui', 'User Interface Attributes'), t('template_component', 'Template Components'), page('plugins', 'Plug-ins'), page('component-settings', 'Component Settings'), settings('pwa', 'Progressive Web App')] },
70
+ { key: 'files', title: 'Files and Reports', icon: 'file', items: [page('static-files', 'Static Application Files'), t('report_layout', 'Report Layouts'), t('report_query', 'Report Queries'), t('document_template', 'Document Templates')] },
70
71
  { key: 'data', title: 'Data Sources', icon: 'database', items: [t('rest_source', 'REST Data Sources'), t('web_credential', 'Web Credentials'), t('rest_module', 'RESTful Services'), t('data_load_def', 'Data Load Definitions')] },
71
72
  { key: 'workflows', title: 'Workflows and Automations', icon: 'activity', items: [t('workflow_definition', 'Workflows'), t('task_definition', 'Task Definitions'), t('automation', 'Automations')] },
72
73
  { key: 'globalization', title: 'Globalization', icon: 'globe', items: [page('globalization', 'Translations and Text Messages'), t('dynamic_translation', 'Dynamic Translations'), settings('globalization', 'Globalization Attributes')] },
@@ -96,12 +97,44 @@ export async function sharedRoutes(app: FastifyInstance) {
96
97
  return send(reply, s, shell(s, 'Breadcrumbs', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], ['Breadcrumbs']], main));
97
98
  });
98
99
 
100
+ // (105) Component Settings (APEX): the application's defaults of its built-in component types
101
+ app.get(`${BASE}/apps/:id/component-settings`, async (req: Req, reply) => {
102
+ const s = await developer(req, reply);
103
+ if (!s) return;
104
+ const a = await appOr404(req.params.id);
105
+ if (!a) return reply.code(404).send('Not found');
106
+ const cur = (a.component_settings ?? {}) as ComponentSettings;
107
+ const groups = [...new Set(COMPONENT_SETTINGS.map((x) => x.group))];
108
+ const main = html`${appHeader(a, 'shared')}
109
+ ${region('Component Settings', html`<p class="muted u-mt0">Defaults for the application's built-in component types, used where a component sets nothing itself. Empty: pgkiln's default.</p>
110
+ <form method="post" action="${BASE}/apps/${a.id}/component-settings">${csrf(s)}
111
+ ${groups.map((g) => html`<fieldset class="u-mb1"><legend>${g}</legend>${COMPONENT_SETTINGS.filter((x) => x.group === g).map((x) =>
112
+ input(x.key, x.label, cur[x.key] ?? '', { type: 'number', placeholder: String(x.def), help: `${x.help} ${x.min}–${x.max.toLocaleString('en')}; now ${componentSetting(cur, x.key).toLocaleString('en')}.` }))}</fieldset>`)}
113
+ <div class="buttons"><button class="btn btn-hot">Save</button></div>
114
+ </form>`)}`;
115
+ return send(reply, s, shell(s, 'Component Settings', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], ['Component Settings']], main));
116
+ });
117
+
118
+ app.post(`${BASE}/apps/:id/component-settings`, async (req: Req, reply) => {
119
+ const s = await developer(req, reply);
120
+ if (!s) return;
121
+ const a = await appOr404(req.params.id);
122
+ if (!a) return reply.code(404).send('Not found');
123
+ const { settings, problems } = componentSettingsFrom(req.body ?? {});
124
+ if (problems.length) flash(s, problems.join(' '), 'error');
125
+ else {
126
+ await owner.query('update meta.app set component_settings = $2 where id = $1', [a.id, JSON.stringify(settings)]);
127
+ flash(s, 'Component settings saved.');
128
+ }
129
+ return back(reply, s, `${BASE}/apps/${a.id}/component-settings`);
130
+ });
131
+
99
132
  // ---------------------------------------------------------------- shared components
100
- const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'page_group', 'lov', 'app_item', 'app_process', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
133
+ const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'page_group', 'lov', 'app_item', 'app_process', 'app_computation', 'shortcut', 'report_query', 'component_group', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
101
134
 
102
135
  const ORDER: Record<string, string> = {
103
136
  search_config: 'seq, name', dynamic_translation: 'language, source',
104
- nav_entry: 'parent_id nulls first, seq, id', app_process: 'seq, id', list_entry: 'list_name, parent_id nulls first, seq, id', automation_action: 'automation_name, seq, name', supporting_script: 'kind, seq, name',
137
+ nav_entry: 'parent_id nulls first, seq, id', app_process: 'seq, id', app_computation: 'seq, id', list_entry: 'list_name, parent_id nulls first, seq, id', automation_action: 'automation_name, seq, name', supporting_script: 'kind, seq, name',
105
138
  };
106
139
 
107
140
  app.get(`${BASE}/apps/:id/shared`, async (req: Req, reply) => {