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.
- package/db/migrations/102_app_computations.sql +345 -0
- package/db/migrations/103_shortcuts.sql +341 -0
- package/db/migrations/104_report_queries.sql +413 -0
- package/db/migrations/105_component_settings_groups.sql +367 -0
- package/docs/apex-feature-parity.md +10 -10
- package/docs/guide/02-concepts.md +24 -0
- package/docs/guide/03-builder.md +33 -1
- package/docs/guide/04-pages-and-regions.md +10 -1
- package/docs/guide/06-processing.md +22 -0
- package/docs/guide/09-reference.md +13 -0
- package/docs/guide/12-development.md +2 -1
- package/docs/guide/16-files.md +23 -0
- package/docs/guide/18-cli.md +4 -0
- package/package.json +1 -1
- package/public/app.css +17 -0
- package/public/app.js +63 -0
- package/src/appfiles.ts +18 -2
- package/src/builder/advisor.ts +7 -2
- package/src/builder/code-editor.ts +6 -1
- package/src/builder/components.ts +85 -3
- package/src/builder/forms.ts +1 -1
- package/src/builder/shared.ts +39 -6
- package/src/builder/subscriptions.ts +30 -4
- package/src/cli/replace.ts +6 -2
- package/src/metadata.ts +14 -1
- package/src/runtime/component-settings.ts +42 -0
- package/src/runtime/context.ts +4 -0
- package/src/runtime/documents.ts +29 -3
- package/src/runtime/engine.ts +2 -2
- package/src/runtime/grid.ts +22 -4
- package/src/runtime/items.ts +14 -11
- package/src/runtime/logic.ts +12 -7
- package/src/runtime/regions.ts +4 -3
- package/src/runtime/render.ts +1 -0
- package/src/runtime/report.ts +2 -1
- package/src/runtime/routes.ts +4 -2
- package/src/runtime/shortcuts.ts +43 -0
- package/src/security.ts +2 -1
- 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)
|
package/docs/guide/16-files.md
CHANGED
|
@@ -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.
|
package/docs/guide/18-cli.md
CHANGED
|
@@ -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
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'
|
|
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))
|
package/src/builder/advisor.ts
CHANGED
|
@@ -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
|
-
|
|
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',
|
package/src/builder/forms.ts
CHANGED
|
@@ -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)
|
package/src/builder/shared.ts
CHANGED
|
@@ -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) => {
|