pgkiln 0.72.0 → 0.74.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/140_item_region_types.sql +27 -0
- package/db/migrations/141_workspace_resources.sql +115 -0
- package/docs/apex-feature-parity.md +91 -12
- package/docs/guide/01-installation.md +1 -0
- package/docs/guide/03-builder.md +47 -4
- package/docs/guide/04-pages-and-regions.md +41 -1
- package/docs/guide/05-items.md +43 -3
- package/docs/guide/11-from-apex.md +3 -0
- package/docs/guide/12-development.md +25 -3
- package/docs/guide/16-files.md +29 -0
- package/docs/guide/19-rest-data-sources.md +41 -3
- package/examples/hr/hr_75_item_region_types.sql +81 -0
- package/examples/hr/hr_76_remote_servers.sql +48 -0
- package/package.json +1 -1
- package/public/app.css +48 -0
- package/public/app.js +377 -1
- package/src/ai/service.ts +10 -1
- package/src/app.ts +2 -0
- package/src/builder/advisor.ts +18 -2
- package/src/builder/ai.ts +29 -3
- package/src/builder/arrange.ts +7 -0
- package/src/builder/components.ts +5 -5
- package/src/builder/designer.ts +9 -3
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/it.ts +6 -0
- package/src/builder/i18n/ja.ts +6 -0
- package/src/builder/i18n/ko.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/i18n/zh-cn.ts +6 -0
- package/src/builder/i18n/zh-tw.ts +6 -0
- package/src/builder/network-access.ts +33 -0
- package/src/builder/region-help.ts +8 -2
- package/src/builder/rest-catalogs.ts +22 -14
- package/src/builder/routes.ts +5 -1
- package/src/builder/static-files.ts +14 -4
- package/src/builder/websources.ts +12 -6
- package/src/builder/workflows.ts +2 -1
- package/src/builder/workspace-resources.ts +528 -0
- package/src/i18n/ar.ts +13 -0
- package/src/i18n/cs.ts +13 -0
- package/src/i18n/da.ts +13 -0
- package/src/i18n/de.ts +13 -0
- package/src/i18n/el.ts +13 -0
- package/src/i18n/es.ts +13 -0
- package/src/i18n/fi.ts +13 -0
- package/src/i18n/fr.ts +13 -0
- package/src/i18n/he.ts +13 -0
- package/src/i18n/it.ts +13 -0
- package/src/i18n/ja.ts +13 -0
- package/src/i18n/ko.ts +13 -0
- package/src/i18n/nb.ts +13 -0
- package/src/i18n/pl.ts +13 -0
- package/src/i18n/pt.ts +13 -0
- package/src/i18n/ru.ts +13 -0
- package/src/i18n/sv.ts +13 -0
- package/src/i18n/tr.ts +13 -0
- package/src/i18n/uk.ts +13 -0
- package/src/i18n/zh.ts +13 -0
- package/src/i18n.ts +26 -0
- package/src/metadata.ts +22 -5
- package/src/remote-servers.ts +133 -0
- package/src/restcatalog.ts +21 -10
- package/src/restsync.ts +1 -1
- package/src/runtime/context.ts +2 -0
- package/src/runtime/items.ts +32 -3
- package/src/runtime/page-regions.ts +50 -0
- package/src/runtime/region-cache.ts +1 -1
- package/src/runtime/regions.ts +11 -0
- package/src/runtime/render.ts +3 -17
- package/src/runtime/report.ts +55 -3
- package/src/runtime/static-files.ts +46 -17
- package/src/runtime/template-components.ts +2 -1
- package/src/websources.ts +43 -18
- package/src/workspace-export.ts +80 -1
package/src/restcatalog.ts
CHANGED
|
@@ -32,7 +32,10 @@ export interface CatalogService {
|
|
|
32
32
|
export interface Catalog {
|
|
33
33
|
name: string;
|
|
34
34
|
description: string | null;
|
|
35
|
+
/** (141) may be empty when the catalog is on a remote server */
|
|
35
36
|
base_url: string;
|
|
37
|
+
/** (141) a remote server of the workspace (static id): data sources made from the catalog use it */
|
|
38
|
+
server?: string | null;
|
|
36
39
|
source: 'manual' | 'openapi';
|
|
37
40
|
api_version: string | null;
|
|
38
41
|
services: CatalogService[];
|
|
@@ -54,10 +57,14 @@ export const trimBase = (url: string) => url.trim().replace(/\/+$/, '');
|
|
|
54
57
|
export const serviceUrl = (baseUrl: string, path: string) => `${trimBase(baseUrl)}${path}`;
|
|
55
58
|
|
|
56
59
|
/** Problems with a catalog's own fields (empty: fine). */
|
|
57
|
-
export function catalogProblems(c: { name?: unknown; base_url?: unknown; description?: unknown; api_version?: unknown }): string[] {
|
|
60
|
+
export function catalogProblems(c: { name?: unknown; base_url?: unknown; server?: unknown; description?: unknown; api_version?: unknown }): string[] {
|
|
58
61
|
const out: string[] = [];
|
|
59
62
|
if (typeof c.name !== 'string' || !CATALOG_NAME.test(c.name)) out.push('The name is 1 to 60 characters, without spaces at the start or end.');
|
|
60
|
-
if (typeof c.
|
|
63
|
+
if (c.server != null && c.server !== '' && (typeof c.server !== 'string' || !SERVICE_NAME.test(c.server))) out.push('The remote server is its static id, e.g. CRM.');
|
|
64
|
+
// (141) on a remote server the base URL is optional (the document's, kept for reference)
|
|
65
|
+
if (c.server && (c.base_url == null || c.base_url === '')) {
|
|
66
|
+
// nothing to check
|
|
67
|
+
} else if (typeof c.base_url !== 'string' || !/^https?:\/\/[^\s<>"{}]+$/i.test(c.base_url) || c.base_url.length > 2000) out.push('The base URL starts with http:// or https://, without spaces or {parameters}, e.g. https://api.example.com/v1.');
|
|
61
68
|
else if (c.base_url.endsWith('/')) out.push('The base URL ends without a slash: the services\' paths start with one.');
|
|
62
69
|
else {
|
|
63
70
|
try {
|
|
@@ -97,7 +104,7 @@ export function wholeCatalogProblems(c: Catalog): string[] {
|
|
|
97
104
|
if (seen.has(s.name)) out.push(`Service ${s.name} is there twice.`);
|
|
98
105
|
seen.add(s.name);
|
|
99
106
|
// (enough to fix the first ones: a big broken document would list thousands)
|
|
100
|
-
if (out.length < 50) out.push(...serviceProblems(typeof c.base_url === 'string' ? c.base_url : 'https://x', s));
|
|
107
|
+
if (out.length < 50) out.push(...serviceProblems(typeof c.base_url === 'string' && c.base_url ? c.base_url : 'https://x', s));
|
|
101
108
|
}
|
|
102
109
|
return out;
|
|
103
110
|
}
|
|
@@ -470,6 +477,7 @@ export function catalogDocument(c: Catalog) {
|
|
|
470
477
|
name: c.name,
|
|
471
478
|
description: c.description,
|
|
472
479
|
base_url: c.base_url,
|
|
480
|
+
...(c.server ? { server: c.server } : {}),
|
|
473
481
|
source: c.source,
|
|
474
482
|
api_version: c.api_version,
|
|
475
483
|
services: c.services.map((s) => ({
|
|
@@ -487,6 +495,7 @@ export function fromCatalogDocument(doc: Json): Catalog {
|
|
|
487
495
|
name: String(doc.name ?? ''),
|
|
488
496
|
description: str(doc.description),
|
|
489
497
|
base_url: String(doc.base_url ?? ''),
|
|
498
|
+
...(typeof doc.server === 'string' && doc.server ? { server: doc.server } : {}),
|
|
490
499
|
source: doc.source === 'openapi' ? 'openapi' : 'manual',
|
|
491
500
|
api_version: str(doc.api_version),
|
|
492
501
|
services: (Array.isArray(doc.services) ? doc.services : []).map((s: Json) => ({
|
|
@@ -554,9 +563,9 @@ export async function loadCatalog(id: number | string, db: Q = owner): Promise<S
|
|
|
554
563
|
/** Insert a checked catalog into a workspace (inside the caller's transaction); its id. */
|
|
555
564
|
export async function insertCatalog(db: Q, workspaceId: number, c: Catalog, by: string | null): Promise<number> {
|
|
556
565
|
const { id } = (await db.query(
|
|
557
|
-
`insert into meta.rest_catalog (workspace_id, name, description, base_url, source, api_version, created_by)
|
|
558
|
-
values ($1, $2, $3, $4, $5, $6, $7) returning id`,
|
|
559
|
-
[workspaceId, c.name, c.description, c.base_url, c.source, c.api_version, by],
|
|
566
|
+
`insert into meta.rest_catalog (workspace_id, name, description, base_url, source, api_version, created_by, server)
|
|
567
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8) returning id`,
|
|
568
|
+
[workspaceId, c.name, c.description, c.base_url || null, c.source, c.api_version, by, c.server || null],
|
|
560
569
|
)).rows[0];
|
|
561
570
|
await insertServices(db, id, c.services);
|
|
562
571
|
return id;
|
|
@@ -565,8 +574,8 @@ export async function insertCatalog(db: Q, workspaceId: number, c: Catalog, by:
|
|
|
565
574
|
/** Replace a catalog's fields and services (inside the caller's transaction). */
|
|
566
575
|
export async function replaceCatalog(db: Q, id: number, c: Catalog) {
|
|
567
576
|
await db.query(
|
|
568
|
-
`update meta.rest_catalog set name = $2, description = $3, base_url = $4, source = $5, api_version = $6, updated_at = now() where id = $1`,
|
|
569
|
-
[id, c.name, c.description, c.base_url, c.source, c.api_version],
|
|
577
|
+
`update meta.rest_catalog set name = $2, description = $3, base_url = $4, source = $5, api_version = $6, server = $7, updated_at = now() where id = $1`,
|
|
578
|
+
[id, c.name, c.description, c.base_url || null, c.source, c.api_version, c.server || null],
|
|
570
579
|
);
|
|
571
580
|
await db.query('delete from meta.rest_catalog_service where catalog_id = $1', [id]);
|
|
572
581
|
await insertServices(db, id, c.services);
|
|
@@ -587,11 +596,13 @@ async function insertServices(db: Q, catalogId: number, services: CatalogService
|
|
|
587
596
|
* The values of a REST data source made from a catalog service (meta.rest_source
|
|
588
597
|
* columns): the full URL and a copy of the request and response settings.
|
|
589
598
|
*/
|
|
590
|
-
export function sourceFromService(c: { base_url: string; name: string }, s: CatalogService, opts: { name?: string; credential?: string | null } = {}) {
|
|
599
|
+
export function sourceFromService(c: { base_url: string | null; name: string; server?: string | null }, s: CatalogService, opts: { name?: string; credential?: string | null } = {}) {
|
|
591
600
|
return {
|
|
592
601
|
name: (opts.name?.trim() || s.name).toUpperCase(),
|
|
593
602
|
description: s.description ?? `From the REST source catalog ${c.name}.`,
|
|
594
|
-
|
|
603
|
+
// (141) a catalog on a remote server: the source names the server and the service's path
|
|
604
|
+
url: c.server ? s.path : serviceUrl(c.base_url ?? '', s.path),
|
|
605
|
+
server: c.server || null,
|
|
595
606
|
method: s.method,
|
|
596
607
|
credential: opts.credential?.trim() ? opts.credential.trim().toUpperCase() : null,
|
|
597
608
|
params: s.params,
|
package/src/restsync.ts
CHANGED
|
@@ -74,7 +74,7 @@ async function loadSyncSource(id: number) {
|
|
|
74
74
|
return owner.one<SyncSource>(
|
|
75
75
|
`select s.id, s.app_id, s.name, s.url, s.method, s.credential, s.headers, s.params, s.body, s.row_selector, s.columns, s.cache_seconds,
|
|
76
76
|
s.timeout_s, s.max_rows, s.key_columns, s.operations, s.sync_table, s.sync_mode, s.sync_delete, s.sync_schedule, s.sync_time_zone,
|
|
77
|
-
s.sync_enabled, a.db_role
|
|
77
|
+
s.sync_enabled, s.server, a.db_role
|
|
78
78
|
from meta.rest_source s join meta.app a on a.id = s.app_id where s.id = $1`,
|
|
79
79
|
[id],
|
|
80
80
|
);
|
package/src/runtime/context.ts
CHANGED
|
@@ -120,6 +120,8 @@ export function bindValues(ctx: PageContext): BindValues {
|
|
|
120
120
|
/** Replace &NAME. substitution strings (APEX syntax); `encode` escapes each value. */
|
|
121
121
|
export function substitute(text: string, ctx: PageContext, encode: (v: string) => string) {
|
|
122
122
|
const values = bindValues(ctx);
|
|
123
|
+
// (141) #APP_FILES# and #WORKSPACE_FILES#: where the application's and its workspace's static files are served
|
|
124
|
+
if (text.includes('_FILES#')) text = text.replace(/#(APP|WORKSPACE)_FILES#/g, (_, kind: string) => `${ctx.base}/${kind === 'APP' ? 'static' : 'workspace-files'}/`);
|
|
123
125
|
return text.replace(/&([A-Za-z][A-Za-z0-9_]*(?:\$[A-Za-z0-9_.-]+?)?)\./g, (m, name: string) => {
|
|
124
126
|
const upper = name.toUpperCase();
|
|
125
127
|
if (upper.startsWith('APP_TEXT$')) {
|
package/src/runtime/items.ts
CHANGED
|
@@ -159,7 +159,7 @@ export async function lovContains(ctx: PageContext, lov: string | null, values:
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/** Item types whose posted value must come from their list of values (a combobox takes free text by design). */
|
|
162
|
-
export const LOV_CHECKED = new Set(['select', 'radio', 'checkbox_group', 'multiselect', 'popup_lov']);
|
|
162
|
+
export const LOV_CHECKED = new Set(['select', 'radio', 'checkbox_group', 'multiselect', 'popup_lov', 'shuttle', 'select_one', 'select_many']);
|
|
163
163
|
|
|
164
164
|
/** Rows a list of values reads when its item sets no config.max_rows (1 to 50,000). */
|
|
165
165
|
export const LOV_MAX_ROWS = 5000;
|
|
@@ -168,9 +168,10 @@ export const lovMax = (item: Item, settings?: ComponentSettings) => {
|
|
|
168
168
|
return Number.isFinite(n) && n >= 1 ? Math.min(n, 50_000) : settings ? componentSetting(settings, 'lov_max_rows') : LOV_MAX_ROWS;
|
|
169
169
|
};
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
/** Item types that show a list of values (the Page Designer's "List of values"). */
|
|
172
|
+
export const LOV_TYPES = new Set(['select', 'radio', 'checkbox_group', 'multiselect', 'popup_lov', 'combobox', 'shuttle', 'select_one', 'select_many']);
|
|
172
173
|
const hasLov = (item: Item) => LOV_TYPES.has(item.type) || (item.type === 'display' && !!item.lov);
|
|
173
|
-
export const MULTI_VALUE = new Set(['checkbox_group', 'multiselect']);
|
|
174
|
+
export const MULTI_VALUE = new Set(['checkbox_group', 'multiselect', 'shuttle', 'select_many']);
|
|
174
175
|
/** Multi-value items store their values colon-separated, as in APEX. */
|
|
175
176
|
export const splitValues = (v: string) => (v ? v.split(':') : []);
|
|
176
177
|
|
|
@@ -324,6 +325,34 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
324
325
|
</select>`;
|
|
325
326
|
break;
|
|
326
327
|
}
|
|
328
|
+
case 'shuttle': {
|
|
329
|
+
// (140; APEX: Shuttle) a <select multiple> that works without JavaScript; app.js turns it into two lists
|
|
330
|
+
// (available, chosen) with move and order buttons. The chosen values keep their order: they come first.
|
|
331
|
+
const chosen = splitValues(value);
|
|
332
|
+
const set = new Set(chosen);
|
|
333
|
+
const indexed = options.map((o, i) => ({ ...o, i }));
|
|
334
|
+
const ordered = [...chosen.flatMap((v) => indexed.filter((o) => o.value === v).slice(0, 1)), ...indexed.filter((o) => !set.has(o.value))];
|
|
335
|
+
const t = ctx.locale.t;
|
|
336
|
+
control = html`<div class="shuttle" data-shuttle="${id}" data-filter-label="${t('shuttle.filter')}" data-available-label="${t('shuttle.available')}" data-chosen-label="${t('shuttle.chosen')}" data-move-label="${t('shuttle.move')}" data-move-all-label="${t('shuttle.move_all')}" data-remove-label="${t('shuttle.remove')}" data-remove-all-label="${t('shuttle.remove_all')}" data-up-label="${t('shuttle.up')}" data-down-label="${t('shuttle.down')}" data-top-label="${t('shuttle.top')}" data-bottom-label="${t('shuttle.bottom')}">
|
|
337
|
+
<select id="${id}" name="${id}" multiple size="${Math.min(Math.max(options.length, 4), 10)}"${aria}>
|
|
338
|
+
${ordered.map((o) => html`<option value="${o.value}" data-i="${o.i}"${set.has(o.value) ? raw(' selected') : ''}>${o.display}</option>`)}
|
|
339
|
+
</select></div>`;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
case 'select_one':
|
|
343
|
+
case 'select_many': {
|
|
344
|
+
// (140; APEX 24.2: Select One, Select Many) a <select> (or <select multiple>) that works without JavaScript;
|
|
345
|
+
// app.js adds a search field with a list box that filters the options as one types, and chips for the chosen values
|
|
346
|
+
const many = item.type === 'select_many';
|
|
347
|
+
const chosen = new Set(many ? splitValues(value) : value ? [value] : []);
|
|
348
|
+
const t = ctx.locale.t;
|
|
349
|
+
control = html`<div class="select-pick" data-select-pick="${many ? 'many' : 'one'}" data-search-label="${t('lov.search')}" data-none-label="${t('lov.no_rows')}" data-remove-label="${t('item.tag_remove')}">
|
|
350
|
+
<select id="${id}" name="${id}"${many ? html` multiple size="${Math.min(Math.max(options.length, 3), 8)}"` : ''}${aria}>
|
|
351
|
+
${many || item.config?.null_label === false ? '' : html`<option value="">${item.config?.null_label ?? t('lov.none')}</option>`}
|
|
352
|
+
${options.map((o) => html`<option value="${o.value}"${chosen.has(o.value) ? raw(' selected') : ''}>${o.display}</option>`)}
|
|
353
|
+
</select></div>`;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
327
356
|
case 'popup_lov': {
|
|
328
357
|
// a select list (the LOV's first max_rows rows) that works without JavaScript; app.js
|
|
329
358
|
// turns it into a field with a button that searches the whole LOV on the server
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// (140) Two region types about the page itself: breadcrumb (APEX: Breadcrumb) shows the page's
|
|
2
|
+
// breadcrumb trail, the pages above it by "Breadcrumb parent" (else Home), and help_text (APEX: Help
|
|
3
|
+
// Text) shows the page's help text. The page's title bar shows the trail too, unless the page has a
|
|
4
|
+
// breadcrumb region of its own.
|
|
5
|
+
|
|
6
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
7
|
+
import type { Region } from '../metadata.ts';
|
|
8
|
+
import { markdownHtml } from '../richtext.ts';
|
|
9
|
+
import { pageAllowed } from './authz.ts';
|
|
10
|
+
import { substitute, type PageContext } from './context.ts';
|
|
11
|
+
import { expandShortcuts } from './shortcuts.ts';
|
|
12
|
+
|
|
13
|
+
/** The pages above the current one (at most six), as links the user may follow. */
|
|
14
|
+
export async function breadcrumbLinks(ctx: PageContext): Promise<Raw[]> {
|
|
15
|
+
const trail: { page_no: number; name: string }[] = [];
|
|
16
|
+
let p = ctx.app.pages.find((x) => x.page_no === ctx.page.parent_page);
|
|
17
|
+
for (let guard = 0; p && guard < 6; guard++) {
|
|
18
|
+
trail.unshift(p);
|
|
19
|
+
p = ctx.app.pages.find((x) => x.page_no === p!.parent_page);
|
|
20
|
+
}
|
|
21
|
+
if (!trail.length && ctx.page.page_no !== ctx.app.home_page) {
|
|
22
|
+
const home = ctx.app.pages.find((x) => x.page_no === ctx.app.home_page);
|
|
23
|
+
if (home) trail.push(home);
|
|
24
|
+
}
|
|
25
|
+
const links = [];
|
|
26
|
+
for (const t of trail) if (await pageAllowed(ctx, t.page_no)) links.push(html`<li><a href="${ctx.base}/${t.page_no}">${t.name}</a></li>`);
|
|
27
|
+
return links;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether the page shows a breadcrumb region (then its title bar leaves the trail out). */
|
|
31
|
+
export const hasBreadcrumbRegion = (ctx: PageContext) => ctx.page.regions.some((r) => r.type === 'breadcrumb' && ctx.vis?.regions.has(r.id));
|
|
32
|
+
|
|
33
|
+
/** A breadcrumb region: the trail and ({"current": false} leaves it out) the current page. */
|
|
34
|
+
export async function renderBreadcrumbRegion(ctx: PageContext, r: Region): Promise<Raw> {
|
|
35
|
+
const links = await breadcrumbLinks(ctx);
|
|
36
|
+
const title = substitute(ctx.page.title ?? ctx.page.name, ctx, (v) => v);
|
|
37
|
+
const current = r.config?.current === false ? '' : html`<li><span aria-current="page">${title}</span></li>`;
|
|
38
|
+
return html`<nav class="region-crumbs" aria-label="${r.title || ctx.locale.t('region.breadcrumb')}"><ol class="crumbs">${links}${current}</ol></nav>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Markdown's punctuation, escaped in item values put into a help text (they stay text). */
|
|
42
|
+
const mdEscape = (v: string) => v.replace(/[\\`*_{}[\]()#+\-.!~>|]/g, '\\$&');
|
|
43
|
+
|
|
44
|
+
/** A help text region: the page's help text (Markdown with &ITEM. substitutions), else {"empty": "…"} or "No help…". */
|
|
45
|
+
export async function renderHelpTextRegion(ctx: PageContext, r: Region): Promise<Raw> {
|
|
46
|
+
const help = ctx.page.help?.trim();
|
|
47
|
+
if (!help) return html`<p class="muted help-text-none">${typeof r.config?.empty === 'string' ? r.config.empty : ctx.locale.t('region.no_help')}</p>`;
|
|
48
|
+
const text = substitute(await expandShortcuts(ctx, help, 'text'), ctx, mdEscape);
|
|
49
|
+
return html`<div class="help-text rich-text">${raw(markdownHtml(text))}</div>`;
|
|
50
|
+
}
|
|
@@ -23,7 +23,7 @@ import { bindValues, type PageContext } from './context.ts';
|
|
|
23
23
|
// the placeholder is a link that renders the page with the region (r<id>_load=1).
|
|
24
24
|
|
|
25
25
|
/** Region types that can be cached or loaded lazily (no form state of their own). */
|
|
26
|
-
export const DEFERRABLE = new Set<Region['type']>(['report', 'chart', 'cards', 'dynamic', 'tree', 'template_component']);
|
|
26
|
+
export const DEFERRABLE = new Set<Region['type']>(['report', 'classic_report', 'chart', 'cards', 'dynamic', 'tree', 'template_component']);
|
|
27
27
|
|
|
28
28
|
export interface CacheSetting {
|
|
29
29
|
scope: 'user' | 'session' | 'all';
|
package/src/runtime/regions.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { renderPluginRegion } from './plugins.ts';
|
|
|
37
37
|
import { renderUrlRegion } from './url-region.ts';
|
|
38
38
|
import { renderSearch } from './search.ts';
|
|
39
39
|
import { renderLoginRegion } from './signin.ts';
|
|
40
|
+
import { renderBreadcrumbRegion, renderHelpTextRegion } from './page-regions.ts';
|
|
40
41
|
|
|
41
42
|
// ---------------------------------------------------------------- buttons
|
|
42
43
|
|
|
@@ -335,6 +336,16 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
|
|
|
335
336
|
case 'report':
|
|
336
337
|
body = html`${await renderAiFilter(ctx, r)}${await renderReport(ctx, r, await renderItems(ctx, items, hidden))}`;
|
|
337
338
|
break;
|
|
339
|
+
case 'classic_report':
|
|
340
|
+
// (140) the report renderer without the interactive features (report.ts: classic)
|
|
341
|
+
body = await renderReport(ctx, r, await renderItems(ctx, items, hidden));
|
|
342
|
+
break;
|
|
343
|
+
case 'breadcrumb':
|
|
344
|
+
body = await renderBreadcrumbRegion(ctx, r);
|
|
345
|
+
break;
|
|
346
|
+
case 'help_text':
|
|
347
|
+
body = await renderHelpTextRegion(ctx, r);
|
|
348
|
+
break;
|
|
338
349
|
case 'form':
|
|
339
350
|
body = html`<div class="form-grid">${await renderItems(ctx, items, hidden)}</div>`;
|
|
340
351
|
break;
|
package/src/runtime/render.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { buttonsFor, renderRegion } from './regions.ts';
|
|
|
20
20
|
import { listTree, navbarMarkup, navMarkup } from './lists.ts';
|
|
21
21
|
import { aiInputs, aiOutputs, aiProcessOf } from './ai.ts';
|
|
22
22
|
import { frameOrigins } from './url-region.ts';
|
|
23
|
+
import { breadcrumbLinks, hasBreadcrumbRegion } from './page-regions.ts';
|
|
23
24
|
import { devbarHtml } from './devbar.ts';
|
|
24
25
|
import { feedbackNode } from './feedback.ts';
|
|
25
26
|
import { sessionLimits } from '../session.ts';
|
|
@@ -121,22 +122,6 @@ async function navTree(ctx: PageContext, topNav = false) {
|
|
|
121
122
|
return render(null);
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
async function breadcrumb(ctx: PageContext) {
|
|
125
|
-
const trail: { page_no: number; name: string }[] = [];
|
|
126
|
-
let p = ctx.app.pages.find((x) => x.page_no === ctx.page.parent_page);
|
|
127
|
-
for (let guard = 0; p && guard < 6; guard++) {
|
|
128
|
-
trail.unshift(p);
|
|
129
|
-
p = ctx.app.pages.find((x) => x.page_no === p!.parent_page);
|
|
130
|
-
}
|
|
131
|
-
if (!trail.length && ctx.page.page_no !== ctx.app.home_page) {
|
|
132
|
-
const home = ctx.app.pages.find((x) => x.page_no === ctx.app.home_page);
|
|
133
|
-
if (home) trail.push(home);
|
|
134
|
-
}
|
|
135
|
-
const links = [];
|
|
136
|
-
for (const t of trail) if (await pageAllowed(ctx, t.page_no)) links.push(html`<li><a href="${ctx.base}/${t.page_no}">${t.name}</a></li>`);
|
|
137
|
-
return links;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
125
|
// ---------------------------------------------------------------- theme
|
|
141
126
|
|
|
142
127
|
/** Per-app colours (Theme Roller) and the style variant in use. Only checked values reach the CSS (styles.ts). */
|
|
@@ -392,7 +377,8 @@ export async function renderPage(ctx: PageContext) {
|
|
|
392
377
|
// (078) the origins of the page's URL regions (also those a refresh or lazy load shows later)
|
|
393
378
|
for (const o of frameOrigins(ctx.page.regions.filter((r) => ctx.vis!.regions.has(r.id)))) ctx.frameSrc?.add(o);
|
|
394
379
|
const title = substitute(ctx.page.title ?? ctx.page.name, ctx, (v) => v);
|
|
395
|
-
|
|
380
|
+
// (140) a page with a breadcrumb region shows the trail there instead
|
|
381
|
+
const crumbs = ctx.dialog || hasBreadcrumbRegion(ctx) ? [] : await breadcrumbLinks(ctx);
|
|
396
382
|
const das = dynamicActionsJson(ctx);
|
|
397
383
|
|
|
398
384
|
const main = html`
|
package/src/runtime/report.ts
CHANGED
|
@@ -239,7 +239,10 @@ export function selectionOf(page: { items: { name: string }[] }, r: Region) {
|
|
|
239
239
|
|
|
240
240
|
export function reportState(ctx: PageContext, r: Region): ReportState {
|
|
241
241
|
const p = ctx.params;
|
|
242
|
-
|
|
242
|
+
// (140) a classic report: no rows-per-page choice; "pagination": "none" shows its rows on one page
|
|
243
|
+
const classic = r.type === 'classic_report';
|
|
244
|
+
const size = classic && r.config.pagination === 'none' ? Math.min(maxRows(r, 500) ?? 500, 500)
|
|
245
|
+
: (!classic && parseInt(p.get(key(r, 'n')) ?? '', 10)) || Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'report_rows');
|
|
243
246
|
const st: ReportState = {
|
|
244
247
|
search: (p.get(key(r, 'q')) ?? '').trim(),
|
|
245
248
|
searchCol: (p.get(key(r, 'qc')) ?? '').trim() || null,
|
|
@@ -288,6 +291,13 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
|
|
|
288
291
|
seek: null as Seek | null,
|
|
289
292
|
};
|
|
290
293
|
st.seek = parseSeek(ctx, r, st);
|
|
294
|
+
// (140) a classic report has none of an interactive report's settings: sorting, paging and (when searchable) the search only
|
|
295
|
+
if (classic) {
|
|
296
|
+
Object.assign(st, { searchCol: null, hideCols: [], filters: [], breakCol: null, aggregates: [], highlights: [], computations: [], view: 'report',
|
|
297
|
+
groupBy: { columns: [], functions: [] }, pivot: null, chart: null, area: null, near: null });
|
|
298
|
+
if (r.config.searchable !== true) st.search = '';
|
|
299
|
+
if (r.config.pagination === 'none') st.page = 1;
|
|
300
|
+
}
|
|
291
301
|
return st;
|
|
292
302
|
}
|
|
293
303
|
|
|
@@ -814,8 +824,10 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
814
824
|
const t = ctx.locale.t;
|
|
815
825
|
const c = ctx.client!;
|
|
816
826
|
const st = reportState(ctx, r);
|
|
817
|
-
|
|
818
|
-
const
|
|
827
|
+
// (140; APEX: Classic Report) no Actions menu, column menus or saved reports; a search field only with {"searchable": true}
|
|
828
|
+
const classic = r.type === 'classic_report';
|
|
829
|
+
const searchable = classic ? r.config.searchable === true : r.config.searchable !== false;
|
|
830
|
+
const interactive = !classic && r.config.interactive !== false && searchable;
|
|
819
831
|
// (sprint 78; APEX: a faceted search's results have no search bar) one search field: the facets'
|
|
820
832
|
const searchBox = !ctx.page.regions.some((x) => x.type === 'facets' && x.config.search === true && Number(x.config.report) === r.id && ctx.vis?.regions.has(x.id) !== false);
|
|
821
833
|
|
|
@@ -1193,6 +1205,17 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1193
1205
|
? html`<p class="sel-count muted" data-sel-count="${r.id}" aria-live="polite">${selected.size ? t('report.selected_count', { n: selected.size }) : ''}</p>
|
|
1194
1206
|
${elsewhere.map((v) => html`<input type="hidden" name="${selection!.item}" value="${v}" data-sel-other>`)}`
|
|
1195
1207
|
: '';
|
|
1208
|
+
if (classic) {
|
|
1209
|
+
const pager = r.config.pagination === 'none'
|
|
1210
|
+
? (info.more ? html`<p class="region-limit">${t('region.first_rows', { n: st.size })}</p>` : '')
|
|
1211
|
+
: info.more || info.page > 1 || searchable ? pagerNav(ctx, r, info) : '';
|
|
1212
|
+
const shaped = classicTemplate(ctx, r, cols, info.rows, fmtOf, linkIdx >= 0 ? (row) => linkAttrs(ctx, link!.page, rowItems(row)) : null);
|
|
1213
|
+
if (shaped) return html`${toolbar}${shaped}${pager}`;
|
|
1214
|
+
return html`${toolbar}<div class="table-wrap"><table class="report report-classic${r.config.mobile === 'scroll' ? '' : ' report-reflow'}">
|
|
1215
|
+
<thead><tr>${header}</tr></thead>
|
|
1216
|
+
<tbody>${body.length ? body : html`<tr><td colspan="${cols.length + lead || 1}" class="empty">${empty}</td></tr>`}</tbody>
|
|
1217
|
+
</table></div>${pager}`;
|
|
1218
|
+
}
|
|
1196
1219
|
return html`${toolbar}${switcher}${errors}${selectionNote}
|
|
1197
1220
|
<div class="table-wrap"><table class="report${r.config.mobile === 'scroll' ? '' : ' report-reflow'}"${selIdx >= 0 ? raw(` data-sel-region="${r.id}"`) : ''}>
|
|
1198
1221
|
<thead><tr>${header}</tr></thead>
|
|
@@ -1201,3 +1224,32 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1201
1224
|
</table></div>
|
|
1202
1225
|
${info.more || info.page > 1 || searchable ? pagerNav(ctx, r, info) : ''}`;
|
|
1203
1226
|
}
|
|
1227
|
+
|
|
1228
|
+
/** (140) A classic report's templates besides the table ({"report_template": …}). */
|
|
1229
|
+
export const CLASSIC_TEMPLATES = ['standard', 'value_pairs', 'badges', 'media_list'] as const;
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* (140; APEX: the classic report templates Value Attribute Pairs, Badge List and Media List) The rows in another shape
|
|
1233
|
+
* than a table; null for the table ("standard"). value_pairs: each row as headings and values; badges: the first column
|
|
1234
|
+
* as the label and the second as the value; media_list: a title (column title, else the first) and a text (column
|
|
1235
|
+
* description, text or body, else the second), the title a link when the report has one.
|
|
1236
|
+
*/
|
|
1237
|
+
function classicTemplate(ctx: PageContext, r: Region, cols: { f: pg.FieldDef; i: number }[], rows: unknown[][], fmtOf: (name: string) => Formatter,
|
|
1238
|
+
linkOf: ((row: unknown[]) => Raw) | null): Raw | null {
|
|
1239
|
+
const tpl = r.config.report_template;
|
|
1240
|
+
if (!tpl || tpl === 'standard' || !(CLASSIC_TEMPLATES as readonly string[]).includes(tpl)) return null;
|
|
1241
|
+
const text = (row: unknown[], c: { f: pg.FieldDef; i: number } | undefined) => (c ? cell(row[c.i], c.f.dataTypeID, fmtOf(c.f.name)) : '');
|
|
1242
|
+
if (!rows.length) return html`<p class="empty">${r.config.empty ?? ctx.locale.t('report.no_data')}</p>`;
|
|
1243
|
+
if (tpl === 'value_pairs')
|
|
1244
|
+
return html`<div class="cr-pairs">${rows.map((row) => html`<dl>${cols.map((c) => html`<dt>${headingOf(r, c.f.name, ctx.locale.tr)}</dt><dd>${text(row, c)}</dd>`)}</dl>`)}</div>`;
|
|
1245
|
+
if (tpl === 'badges')
|
|
1246
|
+
return html`<ul class="cr-badges">${rows.map((row) => {
|
|
1247
|
+
const inner = html`<span class="cr-badge-value">${text(row, cols[1] ?? cols[0])}</span><span class="cr-badge-label">${cols[1] ? text(row, cols[0]) : ''}</span>`;
|
|
1248
|
+
return html`<li>${linkOf ? html`<a ${linkOf(row)}>${inner}</a>` : inner}</li>`;
|
|
1249
|
+
})}</ul>`;
|
|
1250
|
+
const named = (...names: string[]) => cols.find((c) => names.includes(c.f.name.toLowerCase()));
|
|
1251
|
+
const titleCol = named('title') ?? cols[0];
|
|
1252
|
+
const textCol = named('description', 'text', 'body') ?? cols.find((c) => c !== titleCol);
|
|
1253
|
+
return html`<ul class="cr-media">${rows.map((row) => html`<li><div class="cr-media-title">${linkOf ? html`<a ${linkOf(row)}>${text(row, titleCol)}</a>` : text(row, titleCol)}</div>${
|
|
1254
|
+
textCol ? html`<p class="cr-media-text">${text(row, textCol)}</p>` : ''}</li>`)}</ul>`;
|
|
1255
|
+
}
|
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
// inline. HTML (and anything else a browser would run as a document) is
|
|
7
7
|
// refused by the database and by the type list below, and SVG is served
|
|
8
8
|
// with a policy of its own so a script in it never runs.
|
|
9
|
-
|
|
9
|
+
// (sprint 141, AP-7) Static workspace files (meta.workspace_file) are served
|
|
10
|
+
// the same way to every application of their workspace, at
|
|
11
|
+
// /a/<alias>/workspace-files/<name>; lists name them #WORKSPACE_FILES#<name>,
|
|
12
|
+
// templates and settings use #APP_FILES# and #WORKSPACE_FILES# (context.ts
|
|
13
|
+
// substitute, template-components.ts rowLookup).
|
|
14
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
10
15
|
import { html } from '../html.ts';
|
|
11
|
-
import { runtime } from '../db.ts';
|
|
12
|
-
import { loadApp } from '../metadata.ts';
|
|
16
|
+
import { owner, runtime } from '../db.ts';
|
|
17
|
+
import { loadApp, type StaticInclude } from '../metadata.ts';
|
|
13
18
|
import type { PageContext } from './context.ts';
|
|
14
19
|
import { pluginsOnPage } from './plugins.ts';
|
|
15
20
|
|
|
@@ -43,17 +48,51 @@ export function staticHead(ctx: PageContext) {
|
|
|
43
48
|
// the plug-ins' files (at their version) after the application's, then the page's
|
|
44
49
|
const version = new Map<string, number>((ctx.app.static_files ?? []).map((f) => [f.name, f.v]));
|
|
45
50
|
const pluginFiles = pluginsOnPage(ctx).flatMap((p) => p.files.filter((n) => version.has(n)).map((name) => ({ name, v: version.get(name)! })));
|
|
46
|
-
const files = [...(ctx.app.static_includes ?? []), ...pluginFiles, ...(ctx.page.static_includes ?? [])];
|
|
51
|
+
const files: StaticInclude[] = [...(ctx.app.static_includes ?? []), ...pluginFiles, ...(ctx.page.static_includes ?? [])];
|
|
47
52
|
const seen = new Set<string>();
|
|
48
53
|
return files
|
|
49
|
-
.filter((f) => includable(f.name) && !seen.has(f.name) && seen.add(f.name))
|
|
54
|
+
.filter((f) => includable(f.name) && !seen.has(`${f.ws ? 'w' : 'a'}:${f.name}`) && seen.add(`${f.ws ? 'w' : 'a'}:${f.name}`))
|
|
50
55
|
.map((f) => {
|
|
51
|
-
|
|
56
|
+
// (141) a static workspace file: from the workspace of this application, on its own origin
|
|
57
|
+
const src = `${ctx.base}/${f.ws ? 'workspace-files' : 'static'}/${encodeURIComponent(f.name)}?v=${f.v}`;
|
|
52
58
|
return /\.css$/i.test(f.name) ? html`<link rel="stylesheet" href="${src}">` : html`<script src="${src}" defer></script>`;
|
|
53
59
|
});
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
/** Send a static file with the headers both kinds share (type, cache, a policy for SVG and PDF). */
|
|
63
|
+
function sendFile(req: FastifyRequest<{ Querystring: { v?: string } }>, reply: FastifyReply, type: string, f: { content: Buffer; v: string }) {
|
|
64
|
+
const etag = `"${Number(f.v).toString(36)}"`;
|
|
65
|
+
reply
|
|
66
|
+
.type(type.startsWith('text/') ? `${type}; charset=utf-8` : type)
|
|
67
|
+
.header('etag', etag)
|
|
68
|
+
// never sniffed into something else (the app's pages set it too; a file may be fetched on its own)
|
|
69
|
+
.header('x-content-type-options', 'nosniff')
|
|
70
|
+
// a versioned link (?v=…, as pages write them) never changes; a bare one is checked again
|
|
71
|
+
.header('cache-control', req.query.v ? 'public, max-age=31536000, immutable' : 'no-cache');
|
|
72
|
+
// opened on its own, an SVG or PDF is a document: no scripts, no requests
|
|
73
|
+
if (type === 'image/svg+xml' || type === 'application/pdf')
|
|
74
|
+
reply.header('content-security-policy', `default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'${type === 'image/svg+xml' ? '; sandbox' : ''}`);
|
|
75
|
+
if (req.headers['if-none-match'] === etag) return reply.code(304).send();
|
|
76
|
+
return reply.send(f.content);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** (141) A static workspace file to serve to an application: its own workspace's only (the owner pool). */
|
|
80
|
+
export async function workspaceFileFor(alias: string, name: string) {
|
|
81
|
+
return owner.one<{ content: Buffer; v: string }>(
|
|
82
|
+
`select f.content, floor(extract(epoch from f.updated_at) * 1000)::bigint::text as v
|
|
83
|
+
from meta.app a join meta.workspace_file f on f.workspace_id = meta.app_workspace(a.id) and f.name = $2
|
|
84
|
+
where a.alias = $1`, [alias, name]);
|
|
85
|
+
}
|
|
86
|
+
|
|
56
87
|
export async function staticFileRoutes(app: FastifyInstance) {
|
|
88
|
+
// (141) static workspace files, served to every application of the workspace on its own origin
|
|
89
|
+
app.get<{ Params: { alias: string; name: string }; Querystring: { v?: string } }>('/a/:alias/workspace-files/:name', async (req, reply) => {
|
|
90
|
+
const type = staticType(req.params.name);
|
|
91
|
+
const f = type ? await workspaceFileFor(req.params.alias, req.params.name) : null;
|
|
92
|
+
if (!f) return reply.code(404).type('text/plain').send('Not found');
|
|
93
|
+
return sendFile(req, reply, type!, f);
|
|
94
|
+
});
|
|
95
|
+
|
|
57
96
|
app.get<{ Params: { alias: string; name: string }; Querystring: { v?: string } }>('/a/:alias/static/:name', async (req, reply) => {
|
|
58
97
|
const type = staticType(req.params.name);
|
|
59
98
|
const a = type ? await loadApp(req.params.alias) : null;
|
|
@@ -61,16 +100,6 @@ export async function staticFileRoutes(app: FastifyInstance) {
|
|
|
61
100
|
? await runtime.one<{ content: Buffer; v: string }>('select content, floor(extract(epoch from updated_at) * 1000)::bigint::text as v from meta.static_file where app_id = $1 and name = $2', [a.id, req.params.name])
|
|
62
101
|
: null;
|
|
63
102
|
if (!f) return reply.code(404).type('text/plain').send('Not found');
|
|
64
|
-
|
|
65
|
-
reply
|
|
66
|
-
.type(type!.startsWith('text/') ? `${type}; charset=utf-8` : type!)
|
|
67
|
-
.header('etag', etag)
|
|
68
|
-
// a versioned link (?v=…, as pages write them) never changes; a bare one is checked again
|
|
69
|
-
.header('cache-control', req.query.v ? 'public, max-age=31536000, immutable' : 'no-cache');
|
|
70
|
-
// opened on its own, an SVG or PDF is a document: no scripts, no requests
|
|
71
|
-
if (type === 'image/svg+xml' || type === 'application/pdf')
|
|
72
|
-
reply.header('content-security-policy', `default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'${type === 'image/svg+xml' ? '; sandbox' : ''}`);
|
|
73
|
-
if (req.headers['if-none-match'] === etag) return reply.code(304).send();
|
|
74
|
-
return reply.send(f.content);
|
|
103
|
+
return sendFile(req, reply, type!, f);
|
|
75
104
|
});
|
|
76
105
|
}
|
|
@@ -573,7 +573,8 @@ export function attributeValues(c: TemplateComponent, use: TcUse) {
|
|
|
573
573
|
* filled in), then LINK, APEX$ROW_NUM, then the row's columns.
|
|
574
574
|
*/
|
|
575
575
|
export function rowLookup(ctx: PageContext | null, attrs: Map<string, string>, columns: Map<string, string>, extra: Record<string, string | undefined> = {}): Lookup {
|
|
576
|
-
|
|
576
|
+
// (141) #APP_FILES# / #WORKSPACE_FILES#: where the application's and its workspace's static files are served
|
|
577
|
+
const col: Lookup = (n) => columns.get(n) ?? extra[n] ?? (ctx && (n === 'APP_FILES' || n === 'WORKSPACE_FILES') ? `${ctx.base}/${n === 'APP_FILES' ? 'static' : 'workspace-files'}/` : undefined);
|
|
577
578
|
const memo = new Map<string, string>();
|
|
578
579
|
return (name) => {
|
|
579
580
|
if (attrs.has(name)) {
|