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
@@ -4,8 +4,8 @@ import { html, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { clientIp, logActivity, type Session } from '../session.ts';
6
6
  import {
7
- APP_TYPE_LABELS, inSync, isKind, KINDS, offers, publish, refresh, subscribe, subscribersOf, subscriptionOf, subscriptionsOf,
8
- SubscriptionError, unsubscribe, type AppType, type Kind,
7
+ APP_TYPE_LABELS, groupsOffered, inSync, isKind, KINDS, offers, publish, refresh, subscribe, subscribersOf, subscriptionOf, subscriptionsOf,
8
+ SubscriptionError, subscribeGroup, unsubscribe, type AppType, type Kind,
9
9
  } from '../subscriptions.ts';
10
10
  import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
11
11
 
@@ -59,7 +59,7 @@ export async function subscriptionRoutes(app: FastifyInstance) {
59
59
  if (!s) return;
60
60
  const a = await appRow(req.params.id);
61
61
  if (!a) return reply.code(404).send('Not found');
62
- const [subs, subscribers, offered] = await Promise.all([subscriptionsOf(a.id), subscribersOf(a.id), offers(a.id)]);
62
+ const [subs, subscribers, offered, groupList] = await Promise.all([subscriptionsOf(a.id), subscribersOf(a.id), offers(a.id), groupsOffered(a.id)]);
63
63
  const sync = await Promise.all(subs.map((x) => inSync(x)));
64
64
  const subSync = await Promise.all(subscribers.map((x) => inSync(x)));
65
65
  const hidden = (kind: string, name: string) => html`<input type="hidden" name="kind" value="${kind}"><input type="hidden" name="name" value="${name}">`;
@@ -93,7 +93,17 @@ export async function subscriptionRoutes(app: FastifyInstance) {
93
93
  </select>
94
94
  <small class="help">The component is copied now. A component of this application with the same name is replaced.</small></div>
95
95
  <div class="buttons"><button class="btn btn-hot">Subscribe</button></div></form>`
96
- : html`<p class="muted">No theme or library applications offer components. Set an application's type under its Settings.</p>`}`)}
96
+ : html`<p class="muted">No theme or library applications offer components. Set an application's type under its Settings.</p>`}
97
+ ${groupList.length
98
+ ? html`<h3>Subscribe to a component group</h3>
99
+ <form method="post" action="${BASE}/apps/${a.id}/subscriptions/group">${csrf(s)}
100
+ <div class="field"><label class="label" for="f_group">Component group</label>
101
+ <select id="f_group" name="group" required><option value="">- choose -</option>
102
+ ${groupList.map((g) => html`<option value="${g.master_id}|${g.name}">${g.master_name}: ${g.name} (${g.members.length})</option>`)}
103
+ </select>
104
+ <small class="help">Every component of the group is copied now and subscribed (Shared Components → Component Groups of a theme or library application).</small></div>
105
+ <div class="buttons"><button class="btn btn-hot">Subscribe to the group</button></div></form>`
106
+ : ''}`)}
97
107
  ${a.app_type === 'theme' || a.app_type === 'library' || subscribers.length
98
108
  ? region('Subscribers', subscribers.length
99
109
  ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Component</th><th>Application</th><th>Refreshed</th><th>State</th></tr></thead><tbody>
@@ -138,6 +148,22 @@ export async function subscriptionRoutes(app: FastifyInstance) {
138
148
  });
139
149
  });
140
150
 
151
+ // (105) all the components of a master's component group
152
+ app.post(`${BASE}/apps/:id/subscriptions/group`, async (req: Req, reply) => {
153
+ const s = await developer(req, reply);
154
+ if (!s) return;
155
+ const a = await appRow(req.params.id);
156
+ if (!a) return reply.code(404).send('Not found');
157
+ const m = /^(\d{1,9})\|(.{1,60})$/s.exec(String(req.body?.group ?? ''));
158
+ return handle(req, reply, s, a.id, async () => {
159
+ if (!m) throw new SubscriptionError('Choose a component group.');
160
+ if (!(await groupsOffered(a.id)).some((g) => g.master_id === Number(m[1]) && g.name === m[2])) throw new SubscriptionError('Choose a component group.');
161
+ const { done, skipped } = await subscribeGroup(a.id, Number(m[1]), m[2], s.username!);
162
+ await logActivity({ appId: a.id, username: s.username, event: 'subscription', ip: clientIp(req), detail: `subscribed component group ${m[2]} from application ${m[1]}: ${done.join(', ')}` });
163
+ return `Subscribed to ${m[2]}: ${done.length} component${done.length === 1 ? '' : 's'} copied${skipped.length ? `; skipped ${skipped.join(', ')}` : ''}.`;
164
+ });
165
+ });
166
+
141
167
  app.post(`${BASE}/apps/:id/subscriptions/refresh`, async (req: Req, reply) => {
142
168
  const s = await developer(req, reply);
143
169
  if (!s) return;
@@ -26,8 +26,12 @@ export const REPLACED = [
26
26
  'search_config', 'email_template',
27
27
  // (086) application settings (installed values kept, see below), dynamic translations
28
28
  'app_setting', 'dynamic_translation',
29
- // (089) page groups
30
- 'page_group',
29
+ // (089) page groups, (102) application computations
30
+ 'page_group', 'app_computation',
31
+ // (103) shortcuts, (104) report queries
32
+ 'shortcut', 'report_query',
33
+ // (105) component groups
34
+ 'component_group',
31
35
  ];
32
36
  /** Tables of an application that belong to the installation: kept. */
33
37
  export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'saved_report', 'persistent_login', 'task', 'workflow', 'process_job',
package/src/metadata.ts CHANGED
@@ -106,6 +106,12 @@ export interface App {
106
106
  authz_schemes: AuthzScheme[];
107
107
  app_items: string[];
108
108
  app_processes: AppProcess[];
109
+ /** (102) application computations: run for every page at their point, before the page's own */
110
+ app_computations: AppComputation[];
111
+ /** (105) defaults of the built-in component types (runtime/component-settings.ts) */
112
+ component_settings: import('./runtime/component-settings.ts').ComponentSettings;
113
+ /** (103) shortcuts: "NAME" in static content, help texts and confirm messages (runtime/shortcuts.ts) */
114
+ shortcuts?: { name: string; type: 'text' | 'html' | 'function_body'; value: string }[];
109
115
  lovs: { name: string; query: string; rest_source?: string | null }[];
110
116
  theme: {
111
117
  accent?: string; header?: string; nav?: 'side' | 'top'; mode?: 'auto' | 'light' | 'dark'; user_choice?: boolean;
@@ -238,6 +244,11 @@ export interface Computation extends Condition {
238
244
  authz: string | null;
239
245
  }
240
246
 
247
+ /** (102) An application computation: like a page's, for every page of the application. */
248
+ export interface AppComputation extends Omit<Computation, 'point'> {
249
+ point: 'after_authentication' | 'before_header' | 'after_submit';
250
+ }
251
+
241
252
  export interface Branch extends Condition {
242
253
  id: number;
243
254
  seq: number;
@@ -349,7 +360,7 @@ const agg = (table: string, fk: string, parent: string, appId: string) =>
349
360
  export async function loadApp(alias: string) {
350
361
  return runtime.one<App>(
351
362
  `select a.id, a.alias, a.name, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
352
- a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds,
363
+ a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds, a.component_settings,
353
364
  a.language, a.languages, a.language_from, a.date_format, a.timestamp_format, a.time_zone, a.time_zone_auto, a.currency,
354
365
  coalesce((select jsonb_agg(jsonb_build_object('name', l.name, 'query', l.query, 'rest_source', l.rest_source)) from meta.lov l where l.app_id = a.id), '[]') as lovs,
355
366
  coalesce((select jsonb_agg(jsonb_build_object('page_no', p.page_no, 'name', p.name, 'title', p.title,
@@ -361,6 +372,8 @@ export async function loadApp(alias: string) {
361
372
  from meta.authz_scheme s where s.app_id = a.id), '[]') as authz_schemes,
362
373
  coalesce((select jsonb_agg(i.name) from meta.app_item i where i.app_id = a.id), '[]') as app_items,
363
374
  ${agg('meta.app_process', 'app_id', 'a', 'a.id')} as app_processes,
375
+ ${agg('meta.app_computation', 'app_id', 'a', 'a.id')} as app_computations,
376
+ coalesce((select jsonb_agg(jsonb_build_object('name', s.name, 'type', s.type, 'value', s.value)) from meta.shortcut s where s.app_id = a.id), '[]') as shortcuts,
364
377
  ${includes('a.static_includes', 'a.id')} as static_includes,
365
378
  coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' - 'install_sql') from meta.plugin x where x.app_id = a.id), '[]') as plugins,
366
379
  coalesce((select jsonb_agg(jsonb_build_object('name', f.name, 'v', floor(extract(epoch from f.updated_at))::bigint))
@@ -0,0 +1,42 @@
1
+ // Component settings (migration 105; APEX: Shared Components → Component
2
+ // Settings): an application's defaults for its built-in component types, used
3
+ // where a component sets nothing itself. Every value is bounded here, so a
4
+ // hand-edited or imported setting can't make a page read without limits.
5
+
6
+ export interface ComponentSettings {
7
+ report_rows?: number;
8
+ grid_rows?: number;
9
+ popup_lov_rows?: number;
10
+ lov_max_rows?: number;
11
+ rating_max?: number;
12
+ }
13
+ export type ComponentSetting = keyof ComponentSettings;
14
+
15
+ export const COMPONENT_SETTINGS: { key: ComponentSetting; label: string; group: string; min: number; max: number; def: number; help: string }[] = [
16
+ { key: 'report_rows', label: 'Rows per page', group: 'Reports', min: 1, max: 1000, def: 15, help: 'Of a classic or interactive report without its own page size (users can still choose).' },
17
+ { key: 'grid_rows', label: 'Rows per page', group: 'Interactive grids', min: 1, max: 200, def: 25, help: 'Of an interactive grid without its own page size.' },
18
+ { key: 'popup_lov_rows', label: 'Rows per page', group: 'Popup lists of values', min: 1, max: 100, def: 25, help: 'In a popup list of values\' dialog, for items without their own page size.' },
19
+ { key: 'lov_max_rows', label: 'Maximum rows', group: 'Lists of values', min: 1, max: 50_000, def: 5000, help: 'Rows a select list, radio group, checkbox group or popup list reads, for items without their own maximum.' },
20
+ { key: 'rating_max', label: 'Stars', group: 'Star ratings', min: 3, max: 10, def: 5, help: 'Of a star rating item without its own maximum.' },
21
+ ];
22
+
23
+ /** The value of a setting: the application's (bounded), else the built-in default. */
24
+ export function componentSetting(settings: ComponentSettings | null | undefined, key: ComponentSetting): number {
25
+ const spec = COMPONENT_SETTINGS.find((s) => s.key === key)!;
26
+ const n = Math.floor(Number(settings?.[key]));
27
+ return Number.isFinite(n) && n >= spec.min && n <= spec.max ? n : spec.def;
28
+ }
29
+
30
+ /** The settings from the builder's form: whole numbers within bounds; empty leaves a setting at its default. */
31
+ export function componentSettingsFrom(form: Record<string, unknown>): { settings: ComponentSettings; problems: string[] } {
32
+ const settings: ComponentSettings = {};
33
+ const problems: string[] = [];
34
+ for (const s of COMPONENT_SETTINGS) {
35
+ const raw = String(form[s.key] ?? '').trim();
36
+ if (raw === '') continue;
37
+ const n = Number(raw);
38
+ if (!Number.isInteger(n) || n < s.min || n > s.max) problems.push(`${s.group}, ${s.label}: a whole number from ${s.min} to ${s.max.toLocaleString('en')}.`);
39
+ else settings[s.key] = n;
40
+ }
41
+ return { settings, problems };
42
+ }
@@ -66,6 +66,10 @@ export interface PageContext {
66
66
  cacheRefresh?: boolean;
67
67
  /** the lazy region the request renders (GET …/region/:id), not as a placeholder */
68
68
  loadNow?: number;
69
+ /** (103) the values of the function-body shortcuts this request used */
70
+ shortcutValues?: Map<string, string>;
71
+ /** the whole page is rendered (renderPage), not one region on its own */
72
+ wholePage?: boolean;
69
73
  /** a download process's file, sent instead of the page (processes.ts) */
70
74
  download?: Download;
71
75
  /** running a background chain (process-jobs.ts): no request, no browser */
@@ -4,21 +4,47 @@ import { isAuthorized, Forbidden } from './authz.ts';
4
4
  import { bindValues, stripSemicolon, substitute, type PageContext } from './context.ts';
5
5
  import { documentData, documentFilename, documentPdf, fillTemplate, type DocumentTemplate } from './document.ts';
6
6
  import { layoutFor, layoutText } from './pdf.ts';
7
+ import { exportDownload, type ExportFormat } from './data-export.ts';
8
+ import type { Process } from '../metadata.ts';
7
9
 
8
10
  // ?doc=NAME on a page: the document template filled with the page's session
9
- // state (after the URL's checksummed item values), as the app's role.
11
+ // state (after the URL's checksummed item values), as the app's role; (104)
12
+ // without a template of that name, the report query of it.
13
+
14
+ /** (104) A report query: a named query printed as PDF, Excel, CSV, JSON, XML or HTML. */
15
+ interface ReportQuery {
16
+ name: string;
17
+ description: string | null;
18
+ query: string;
19
+ format: ExportFormat;
20
+ layout: string | null;
21
+ file_name: string | null;
22
+ title: string | null;
23
+ authz: string | null;
24
+ }
10
25
 
11
26
  export async function renderDocument(ctx: PageContext, name: string) {
12
27
  const c = ctx.client!;
13
28
  // metadata through the runtime connection: the page's transaction already runs as the app's role
14
29
  const tpl = await runtime.one<DocumentTemplate>('select * from meta.document_template where app_id = $1 and name = upper($2)', [ctx.app.id, name.slice(0, 100)]);
15
- if (!tpl || !(await isAuthorized(ctx, tpl.authz))) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
30
+ // (104) without a document template of that name, a report query of it
31
+ const rq = tpl ? null : await runtime.one<ReportQuery>('select * from meta.report_query where app_id = $1 and name = upper($2)', [ctx.app.id, name.slice(0, 100)]);
32
+ const found = tpl ?? rq;
33
+ if (!found || !(await isAuthorized(ctx, found.authz))) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
16
34
  // without signing in (a public page of an app that has sign-in) only the documents the page itself offers
17
35
  // with a visible document button: ?doc= on any public page would otherwise hand out every template
18
36
  if (ctx.app.authentication !== 'none' && !ctx.session.username) {
19
- const offered = [...(ctx.vis?.buttons.values() ?? [])].some((b) => b.action === 'document' && (b.document ?? '').toUpperCase() === tpl.name);
37
+ const offered = [...(ctx.vis?.buttons.values() ?? [])].some((b) => b.action === 'document' && (b.document ?? '').toUpperCase() === found.name);
20
38
  if (!offered) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
21
39
  }
40
+ if (!tpl) {
41
+ const q = rq!;
42
+ const d = await exportDownload(ctx, { name: q.name, code: q.query } as Process, {
43
+ export: q.format, layout: q.layout ?? undefined, file_name: q.file_name ?? undefined, title: q.title ?? q.description ?? undefined,
44
+ });
45
+ // sent like a download process's file (sandboxed, never shown as a page of the application)
46
+ return { download: { ...d, inline: false } };
47
+ }
22
48
  const now = new Date().toISOString();
23
49
  const data = await savepoint(c, () =>
24
50
  documentData(c, stripSemicolon(applyBinds(tpl.query, bindValues(ctx))), {
@@ -173,8 +173,8 @@ export async function validate(ctx: PageContext) {
173
173
  const v = state[i.name];
174
174
  if (!v || !vis.editable.has(i.name) || errors.items[i.name]) continue;
175
175
  const label = i.label ?? i.name;
176
- if (i.type === 'rating' && !(/^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= ratingMax(i)))
177
- fail(i.name, ctx.locale.t('error.rating', { label, max: ratingMax(i) }));
176
+ if (i.type === 'rating' && !(/^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= ratingMax(i, ctx.app.component_settings)))
177
+ fail(i.name, ctx.locale.t('error.rating', { label, max: ratingMax(i, ctx.app.component_settings) }));
178
178
  // a list item posts return values: each must be one its list of values returns (config.any_value: true allows others)
179
179
  if (LOV_CHECKED.has(i.type) && i.lov && i.config?.any_value !== true && !(await lovContains(ctx, i.lov, MULTI_VALUE.has(i.type) ? splitValues(v) : [v])))
180
180
  fail(i.name, ctx.locale.t('error.lov_value', { label }));
@@ -1,3 +1,4 @@
1
+ import { componentSetting } from './component-settings.ts';
1
2
  import pg from 'pg';
2
3
  import { literal } from '../binds.ts';
3
4
  import { savepoint, type Client } from '../db.ts';
@@ -116,6 +117,17 @@ function control(col: GridColumn, name: string, value: string, label: string) {
116
117
  return html`<input type="text" name="${name}" value="${value}"${aria}>`;
117
118
  }
118
119
 
120
+ /**
121
+ * (APEX: cell navigation mode) An editable cell's value as text, with its column's format mask or the
122
+ * option's display value: with JavaScript the cell reads as this text until it is edited (app.css,
123
+ * app.js); the control holds the raw value. Not for a checkbox, which reads as itself.
124
+ */
125
+ function cellText(col: GridColumn, value: string, shown: string) {
126
+ if (col.typeOid === 16 || value === '') return '';
127
+ const text = col.options ? col.options.find((o) => o.value === value)?.display ?? value : shown;
128
+ return html`<span class="grid-text${NUMERIC.has(col.typeOid) && !col.options ? ' num' : ''}" aria-hidden="true">${text}</span>`;
129
+ }
130
+
119
131
  /** Width of each leading column (master selection, delete, row actions), see app.css .grid-lead. */
120
132
  const LEAD_WIDTH = 44;
121
133
 
@@ -169,7 +181,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
169
181
  const base = reportState(ctx, r);
170
182
  const views = gridViews(r);
171
183
  const view: View = views.includes(base.view) ? base.view : 'report';
172
- const pageSize = Math.max(1, Math.min(200, Number(r.config.page_size) || 25));
184
+ const pageSize = Math.max(1, Math.min(200, Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'grid_rows')));
173
185
  // the single row view: one row of the search at a time (r<id>_row=N from 1), or a new row (r<id>_row=new)
174
186
  const rowParam = ctx.params.get(key(r, 'row'));
175
187
  const adding = view === 'row' && rowParam === 'new';
@@ -240,7 +252,13 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
240
252
  // master-detail: this grid selects rows for detail regions; or it follows a master
241
253
  const select = selectRowOf(ctx.page, r);
242
254
  const selIdx = select ? fields.findIndex((f) => f.name.toLowerCase() === select.column.toLowerCase()) : -1;
243
- const selected = select ? ctx.session.state[select.item] ?? null : null;
255
+ let selected = select ? ctx.session.state[select.item] ?? null : null;
256
+ // as in APEX, a page shows the first master row selected: when nothing is selected, or the
257
+ // selected row is not among the rows shown (after a search or filter); the regions below follow it
258
+ if (select && selIdx >= 0 && ctx.wholePage && view === 'report' && info.rows.length) {
259
+ const shown = info.rows.map((row) => toState(row[selIdx]) ?? '');
260
+ if (selected === null || !shown.includes(selected)) ctx.session.state[select.item] = selected = shown[0];
261
+ }
244
262
  const details = select ? detailsOf(ctx.page, r, ctx.vis!.regions) : [];
245
263
  const masterCol = masterColumnOf(r);
246
264
  const masterValue = masterItemOf(r) ? ctx.session.state[masterItemOf(r)!] ?? '' : '';
@@ -297,7 +315,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
297
315
  return i === -1 ? undefined : (toState(row[i]) ?? '');
298
316
  });
299
317
 
300
- // read-only cells show their column's format mask; editable cells keep the raw value
318
+ // read-only cells show their column's format mask; an editable cell's control keeps the raw value
301
319
  const fmtOf = columnFormats(ctx, r);
302
320
  const rows = info.rows.map((row, i) => {
303
321
  const pk = toState(row[pkIdx]) ?? '';
@@ -311,7 +329,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
311
329
  if (!col.editable) return html`<td class="${colCls(p, pIdx, NUMERIC.has(col.typeOid) ? 'num' : null)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cell(row[col.idx], col.typeOid, fmtOf(col.name))}</td>`;
312
330
  const name = `${g}_${i}_c${col.ci}`;
313
331
  const value = pi !== undefined ? (col.typeOid === 16 ? (pv(`${g}_${pi}_c${col.ci}`) === 'true' ? 'true' : 'false') : (pv(`${g}_${pi}_c${col.ci}`) ?? dbValue)) : dbValue;
314
- return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${control(col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
332
+ return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cellText(col, value, value === dbValue ? cell(row[col.idx], col.typeOid, fmtOf(col.name)) : value)}${control(col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
315
333
  });
316
334
  const deleted = pi !== undefined && pv(`${g}_${pi}_del`) === 'true';
317
335
  const selValue = selIdx >= 0 ? toState(row[selIdx]) ?? '' : null;
@@ -8,6 +8,8 @@ import type { Item } from '../metadata.ts';
8
8
  import { bindValues, publicError, stripSemicolon, toState, type PageContext } from './context.ts';
9
9
  import { canPreview, fileInfo, fileList, fileUrl, formatSize, isMultiple, maxFiles, removals } from './files.ts';
10
10
  import { restSql } from './rest-sources.ts';
11
+ import { expandShortcuts } from './shortcuts.ts';
12
+ import { componentSetting, type ComponentSettings } from './component-settings.ts';
11
13
  import { markdownHtml, sanitizeHtml } from '../richtext.ts';
12
14
  import { qrSvg, type Ecc } from '../qrcode.ts';
13
15
  import { formatNumber, isPlainNumber, maskError } from '../numformat.ts';
@@ -77,9 +79,10 @@ async function lovColumns(ctx: PageContext, sql: string) {
77
79
 
78
80
  /** Rows of a popup LOV's dialog: at most 100 per page. */
79
81
  export const POPUP_PAGE_MAX = 100;
80
- export const popupPageSize = (item: Item, asked?: unknown) => {
81
- const n = Math.floor(Number(asked ?? item.config?.page_size ?? 25));
82
- return Number.isFinite(n) && n >= 1 ? Math.min(n, POPUP_PAGE_MAX) : 25;
82
+ export const popupPageSize = (item: Item, asked?: unknown, settings?: ComponentSettings) => {
83
+ const def = componentSetting(settings, 'popup_lov_rows');
84
+ const n = Math.floor(Number(asked ?? item.config?.page_size ?? def));
85
+ return Number.isFinite(n) && n >= 1 ? Math.min(n, POPUP_PAGE_MAX) : def;
83
86
  };
84
87
 
85
88
  export interface LovPage {
@@ -160,9 +163,9 @@ export const LOV_CHECKED = new Set(['select', 'radio', 'checkbox_group', 'multis
160
163
 
161
164
  /** Rows a list of values reads when its item sets no config.max_rows (1 to 50,000). */
162
165
  export const LOV_MAX_ROWS = 5000;
163
- export const lovMax = (item: Item) => {
166
+ export const lovMax = (item: Item, settings?: ComponentSettings) => {
164
167
  const n = Math.floor(Number(item.config?.max_rows));
165
- return Number.isFinite(n) && n >= 1 ? Math.min(n, 50_000) : LOV_MAX_ROWS;
168
+ return Number.isFinite(n) && n >= 1 ? Math.min(n, 50_000) : settings ? componentSetting(settings, 'lov_max_rows') : LOV_MAX_ROWS;
166
169
  };
167
170
 
168
171
  const LOV_TYPES = new Set(['select', 'radio', 'checkbox_group', 'multiselect', 'popup_lov', 'combobox']);
@@ -174,7 +177,7 @@ export const splitValues = (v: string) => (v ? v.split(':') : []);
174
177
  /** A combobox holds several values (colon-separated) unless {"multiple": false}. */
175
178
  export const comboMultiple = (item: Item) => item.config?.multiple !== false;
176
179
  /** The highest star of a rating item ({"max": 5}, 3 to 10). */
177
- export const ratingMax = (item: Item) => Math.min(Math.max(Math.round(Number(item.config?.max)) || 5, 3), 10);
180
+ export const ratingMax = (item: Item, settings?: ComponentSettings) => Math.min(Math.max(Math.round(Number(item.config?.max)) || componentSetting(settings, 'rating_max'), 3), 10);
178
181
  /** A date range item's value "from:to" (ISO dates, either may be empty). */
179
182
  export const splitRange = (v: string | null | undefined): [string, string] => {
180
183
  const [from = '', to = ''] = (v ?? '').split(':');
@@ -226,7 +229,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
226
229
  let lovError: Raw | '' = '';
227
230
  if (hasLov(item)) {
228
231
  try {
229
- options = await lovOptions(ctx, item.lov, lovMax(item));
232
+ options = await lovOptions(ctx, item.lov, lovMax(item, ctx.app.component_settings));
230
233
  } catch (e) {
231
234
  lovError = html`<small class="error">${await publicError(ctx, e, `list of values of ${item.name}`)}</small>`;
232
235
  }
@@ -249,9 +252,9 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
249
252
  // rebuilt from the allow-list every time: the value may come from the table, not from this form
250
253
  control = html`<div class="display-value rich-text" id="${id}">${raw((item.type === 'markdown' ? markdownHtml(value) : sanitizeHtml(value)) || ' ')}</div>`;
251
254
  } else if (!editable && item.type === 'rating') {
252
- const n = /^\d+$/.test(value) ? Math.min(Number(value), ratingMax(item)) : 0;
255
+ const n = /^\d+$/.test(value) ? Math.min(Number(value), ratingMax(item, ctx.app.component_settings)) : 0;
253
256
  control = n
254
- ? html`<div class="display-value rating-value" id="${id}"><span aria-hidden="true">${'★'.repeat(n)}<span class="rating-off">${'★'.repeat(ratingMax(item) - n)}</span></span><span class="sr-only">${ctx.locale.t('item.rating_of', { n, max: ratingMax(item) })}</span></div>`
257
+ ? html`<div class="display-value rating-value" id="${id}"><span aria-hidden="true">${'★'.repeat(n)}<span class="rating-off">${'★'.repeat(ratingMax(item, ctx.app.component_settings) - n)}</span></span><span class="sr-only">${ctx.locale.t('item.rating_of', { n, max: ratingMax(item, ctx.app.component_settings) })}</span></div>`
255
258
  : html`<div class="display-value" id="${id}"> </div>`;
256
259
  } else if (!editable && item.type === 'combobox') {
257
260
  const values = comboMultiple(item) ? splitValues(value) : value ? [value] : [];
@@ -352,7 +355,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
352
355
  case 'rating': {
353
356
  // radio buttons (keyboard and screen readers as usual); CSS draws them as stars
354
357
  useLegend = true;
355
- const max = ratingMax(item);
358
+ const max = ratingMax(item, ctx.app.component_settings);
356
359
  const stars = [];
357
360
  for (let n = 1; n <= max; n++)
358
361
  stars.push(
@@ -461,7 +464,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
461
464
  const tag = useLegend ? 'fieldset' : 'div';
462
465
  return html`${raw(`<${tag}`)} class="field field-${item.type}${error ? ' has-error' : ''}${editable ? '' : ' readonly'}${templateClasses('item', item.template_options)}" data-item="${item.name}"${attrs}${pluginAttrs}>
463
466
  ${labelHtml}${control}${lovError}
464
- ${item.help ? html`<small class="help" id="${id}_help">${item.help}</small>` : ''}
467
+ ${item.help ? html`<small class="help" id="${id}_help">${await expandShortcuts(ctx, item.help, 'text')}</small>` : ''}
465
468
  ${error ? html`<small class="error" id="${id}_error">${error}</small>` : ''}
466
469
  ${raw(`</${tag}>`)}`;
467
470
  }
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { applyBinds } from '../binds.ts';
3
3
  import { runtime, savepoint } from '../db.ts';
4
- import type { Branch, Computation, Condition } from '../metadata.ts';
4
+ import type { AppComputation, Branch, Computation, Condition } from '../metadata.ts';
5
5
  import { isAuthorized, sqlTrue } from './authz.ts';
6
6
  import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, toState, type PageContext } from './context.ts';
7
7
  import { pageHref } from './links.ts';
@@ -112,19 +112,24 @@ export class ComputationFailed extends Error {}
112
112
  * items only. Before the page is shown, an error becomes a message on the
113
113
  * page; after a submit, it stops the submit (ComputationFailed).
114
114
  */
115
- export async function runComputations(ctx: PageContext, point: Computation['point']) {
115
+ export async function runComputations(ctx: PageContext, point: AppComputation['point']) {
116
116
  const names = new Set([...ctx.app.app_items, ...ctx.page.items.map((i) => i.name)]);
117
- for (const comp of ctx.page.computations ?? []) {
118
- if (comp.point !== point) continue;
119
- const where = `computation of ${comp.item_name}`;
117
+ // (102) the application's computations of the point first (as in APEX), then the page's
118
+ const comps: { comp: Computation | AppComputation; app: boolean }[] = [
119
+ ...(ctx.app.app_computations ?? []).filter((c) => c.point === point).map((comp) => ({ comp, app: true })),
120
+ ...(ctx.page.computations ?? []).filter((c) => c.point === point).map((comp) => ({ comp, app: false })),
121
+ ];
122
+ for (const { comp, app } of comps) {
123
+ const where = `${app ? 'application computation' : 'computation'} of ${comp.item_name}`;
120
124
  if (!names.has(comp.item_name)) {
121
- ctx.errors.page.push(ctx.locale.t('logic.unknown_item', { item: comp.item_name }));
125
+ // an application computation of a page item computes it on that item's page only
126
+ if (!app) ctx.errors.page.push(ctx.locale.t('logic.unknown_item', { item: comp.item_name }));
122
127
  continue;
123
128
  }
124
129
  if (!(await isAuthorized(ctx, comp.authz))) continue;
125
130
  if (!(await conditionHolds(ctx, comp, `condition of the ${where}`))) continue;
126
131
  try {
127
- ctx.session.state[comp.item_name] = await timed(ctx, 6, 'computation', `computation of ${comp.item_name} (${comp.type})`, () => computeValue(ctx, comp));
132
+ ctx.session.state[comp.item_name] = await timed(ctx, 6, 'computation', `${where} (${comp.type})`, () => computeValue(ctx, comp));
128
133
  dbg(ctx, 9, 'computation', () => `${comp.item_name} := ${ctx.page.items.some((i) => i.name === comp.item_name && i.type === 'password') ? '(password, not shown)' : JSON.stringify((ctx.session.state[comp.item_name] ?? '').slice(0, 200))}`);
129
134
  } catch (e) {
130
135
  const message = await publicError(ctx, e, where);
@@ -8,6 +8,7 @@ import type { Button, Region } from '../metadata.ts';
8
8
  import { isAuthorized, pageAllowed } from './authz.ts';
9
9
  import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, type PageContext } from './context.ts';
10
10
  import { renderItems } from './items.ts';
11
+ import { expandShortcuts } from './shortcuts.ts';
11
12
  import { fillItems, linkAttrs, linkColumns } from './links.ts';
12
13
  import { renderCalendar } from './calendar.ts';
13
14
  import { CHART_KINDS, renderChartBody, wallClockIn, type GaugeConfig } from './charts.ts';
@@ -66,7 +67,7 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
66
67
  for (const e of b.menu ?? []) {
67
68
  if (!(await isAuthorized(ctx, e.authz))) continue;
68
69
  const ic = e.icon ? html`${icon(e.icon)} ` : '';
69
- const confirm = e.confirm ? raw(` data-confirm="${esc(e.confirm)}"`) : '';
70
+ const confirm = e.confirm ? raw(` data-confirm="${esc(await expandShortcuts(ctx, e.confirm, 'text'))}"`) : '';
70
71
  if (e.page) {
71
72
  if (!(await pageAllowed(ctx, e.page))) continue;
72
73
  entries.push(html`<a ${linkAttrs(ctx, e.page, e.items ?? {})}${confirm}>${ic}${e.label}</a>`);
@@ -82,7 +83,7 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
82
83
 
83
84
  export async function renderButton(ctx: PageContext, b: Button) {
84
85
  const cls = `btn${b.hot ? ' btn-hot' : ''}${b.name === 'DELETE' ? ' btn-danger' : ''}${templateClasses('button', b.template_options)}`;
85
- const confirm = b.confirm ? raw(` data-confirm="${esc(b.confirm)}"`) : '';
86
+ const confirm = b.confirm ? raw(` data-confirm="${esc(await expandShortcuts(ctx, b.confirm, 'text'))}"`) : '';
86
87
  const badge = b.badge || b.badge_query ? await badgeOf(ctx, b) : '';
87
88
  if (b.action === 'menu') return renderMenu(ctx, b, cls, badge);
88
89
  if (b.action === 'redirect') {
@@ -401,7 +402,7 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
401
402
  break;
402
403
  case 'static': {
403
404
  const itemsHtml = await renderItems(ctx, items, hidden);
404
- body = html`${raw(substitute(r.source ?? '', ctx, esc))}${itemsHtml.length ? html`<div class="form-grid">${itemsHtml}</div>` : ''}`;
405
+ body = html`${raw(substitute(await expandShortcuts(ctx, r.source ?? '', 'html'), ctx, esc))}${itemsHtml.length ? html`<div class="form-grid">${itemsHtml}</div>` : ''}`;
405
406
  break;
406
407
  }
407
408
  }
@@ -364,6 +364,7 @@ const timeZoneMeta = (ctx: PageContext) =>
364
364
 
365
365
  export async function renderPage(ctx: PageContext) {
366
366
  const t = ctx.locale.t;
367
+ ctx.wholePage = true;
367
368
  const hidden = initiallyHidden(ctx);
368
369
  const defaultButton = [...ctx.vis!.buttons.values()].find((b) => b.hot && b.action === 'submit');
369
370
  const pageItems = await renderItems(ctx, ctx.page.items.filter((i) => i.region_id === null), hidden);
@@ -1,3 +1,4 @@
1
+ import { componentSetting } from './component-settings.ts';
1
2
  import { icon } from '../icons.ts';
2
3
  import pg from 'pg';
3
4
  import { templateClasses } from './template-options.ts';
@@ -237,7 +238,7 @@ export function selectionOf(page: { items: { name: string }[] }, r: Region) {
237
238
 
238
239
  export function reportState(ctx: PageContext, r: Region): ReportState {
239
240
  const p = ctx.params;
240
- const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || 15;
241
+ const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'report_rows');
241
242
  const st: ReportState = {
242
243
  search: (p.get(key(r, 'q')) ?? '').trim(),
243
244
  searchCol: (p.get(key(r, 'qc')) ?? '').trim() || null,
@@ -141,7 +141,8 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
141
141
  if (shared) s.state.__SHARED = shared;
142
142
  await saveState(s);
143
143
 
144
- if (a.app_processes.some((p) => p.point === 'after_login')) {
144
+ // the application processes "after login", then (102) the application computations "after authentication"
145
+ if (a.app_processes.some((p) => p.point === 'after_login') || (a.app_computations ?? []).some((c) => c.point === 'after_authentication')) {
145
146
  const home = (await loadPage(a.id, a.home_page)) ?? ({ page_no: a.home_page, items: [], regions: [], buttons: [], dynamic_actions: [], validations: [], processes: [], computations: [], branches: [] } as unknown as Page);
146
147
  const ctx: PageContext = {
147
148
  app: a, page: home, session: s, base, params: new URLSearchParams(), request: '', user: username,
@@ -151,6 +152,7 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
151
152
  await appTx(txContext(ctx), async (c) => {
152
153
  ctx.client = c;
153
154
  await runAppProcesses(ctx, 'after_login');
155
+ await runComputations(ctx, 'after_authentication');
154
156
  });
155
157
  await saveState(s);
156
158
  }
@@ -1025,7 +1027,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1025
1027
  if (!item || !vis.editable.has(item.name)) throw new Forbidden(ctx.locale.t('error.unknown_item'));
1026
1028
  if (item.config?.cascade_parents) applyPostedItems(ctx, body, list(item.config.cascade_parents));
1027
1029
  const page = Math.min(Math.max(Math.floor(Number(body.p)) || 0, 0), 10_000);
1028
- return searchLov(ctx, item, String(body.q ?? '').slice(0, 200), page, popupPageSize(item, body.n));
1030
+ return searchLov(ctx, item, String(body.q ?? '').slice(0, 200), page, popupPageSize(item, body.n, ctx.app.component_settings));
1029
1031
  });
1030
1032
  return reply.send(out);
1031
1033
  } catch (e) {
@@ -0,0 +1,43 @@
1
+ import { esc } from '../html.ts';
2
+ import { publicError, type PageContext } from './context.ts';
3
+ import { computeValue } from './logic.ts';
4
+
5
+ // Shortcuts (migration 103; APEX: Shared Components → Shortcuts). "NAME" (an
6
+ // upper-case name in double quotes) in a static region's content, an item's
7
+ // help text and a button's or menu entry's confirm message is replaced by the
8
+ // application's shortcut of that name, before &ITEM. substitutions. Other
9
+ // quoted words (class="NAV" with no shortcut NAV) stay as they are.
10
+ //
11
+ // text the value; escaped where it goes into HTML
12
+ // html the value as HTML
13
+ // function_body PL/pgSQL returning the text (HTML), run once per request
14
+ // as the application's role
15
+
16
+ const NAME = /"([A-Z][A-Z0-9_]{0,59})"/g;
17
+
18
+ /** A shortcut's text (html: as it goes into HTML), computed once per request; null: no such shortcut. */
19
+ async function valueOf(ctx: PageContext, name: string, asHtml: boolean): Promise<string | null> {
20
+ const s = ctx.app.shortcuts?.find((x) => x.name === name);
21
+ if (!s) return null;
22
+ if (s.type === 'text') return asHtml ? esc(s.value) : s.value;
23
+ if (s.type === 'html') return s.value;
24
+ const cache = (ctx.shortcutValues ??= new Map());
25
+ if (!cache.has(name)) {
26
+ try {
27
+ cache.set(name, (await computeValue(ctx, { type: 'function_body', expression: s.value })) ?? '');
28
+ } catch (e) {
29
+ ctx.errors.page.push(await publicError(ctx, e, `shortcut "${name}"`));
30
+ cache.set(name, '');
31
+ }
32
+ }
33
+ return cache.get(name)!;
34
+ }
35
+
36
+ /** The text with its "NAME" shortcuts replaced (as HTML or as plain text). */
37
+ export async function expandShortcuts(ctx: PageContext, text: string, as: 'html' | 'text'): Promise<string> {
38
+ if (!ctx.app.shortcuts?.length || !text.includes('"')) return text;
39
+ const names = new Set([...text.matchAll(NAME)].map((m) => m[1]));
40
+ const values = new Map<string, string | null>();
41
+ for (const n of names) values.set(n, await valueOf(ctx, n, as === 'html'));
42
+ return text.replace(NAME, (m, n: string) => values.get(n) ?? m);
43
+ }
package/src/security.ts CHANGED
@@ -96,7 +96,8 @@ export function securityHeaders(app: FastifyInstance) {
96
96
  // the app's own pages may use the camera (photos, scanning) and the position (location items); nothing else may
97
97
  reply.header('Permissions-Policy', 'camera=(self), microphone=(), geolocation=(self)');
98
98
  const type = String(reply.getHeader('content-type') ?? '');
99
- if (type.startsWith('text/html')) {
99
+ // a file's own stricter policy (downloads, uploaded files: default-src 'none'; sandbox) is kept
100
+ if (type.startsWith('text/html') && !reply.hasHeader('content-security-policy')) {
100
101
  // No inline scripts and no inline styles: behaviour lives in /static/app.js,
101
102
  // styling in /static/app.css. The only <style> a page has (theme colours,
102
103
  // chart geometry) carries this response's nonce; style="" attributes are refused.