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.
Files changed (79) hide show
  1. package/db/migrations/140_item_region_types.sql +27 -0
  2. package/db/migrations/141_workspace_resources.sql +115 -0
  3. package/docs/apex-feature-parity.md +91 -12
  4. package/docs/guide/01-installation.md +1 -0
  5. package/docs/guide/03-builder.md +47 -4
  6. package/docs/guide/04-pages-and-regions.md +41 -1
  7. package/docs/guide/05-items.md +43 -3
  8. package/docs/guide/11-from-apex.md +3 -0
  9. package/docs/guide/12-development.md +25 -3
  10. package/docs/guide/16-files.md +29 -0
  11. package/docs/guide/19-rest-data-sources.md +41 -3
  12. package/examples/hr/hr_75_item_region_types.sql +81 -0
  13. package/examples/hr/hr_76_remote_servers.sql +48 -0
  14. package/package.json +1 -1
  15. package/public/app.css +48 -0
  16. package/public/app.js +377 -1
  17. package/src/ai/service.ts +10 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/advisor.ts +18 -2
  20. package/src/builder/ai.ts +29 -3
  21. package/src/builder/arrange.ts +7 -0
  22. package/src/builder/components.ts +5 -5
  23. package/src/builder/designer.ts +9 -3
  24. package/src/builder/home.ts +3 -0
  25. package/src/builder/i18n/de.ts +6 -0
  26. package/src/builder/i18n/en.ts +6 -0
  27. package/src/builder/i18n/es.ts +6 -0
  28. package/src/builder/i18n/fr.ts +6 -0
  29. package/src/builder/i18n/it.ts +6 -0
  30. package/src/builder/i18n/ja.ts +6 -0
  31. package/src/builder/i18n/ko.ts +6 -0
  32. package/src/builder/i18n/nl.ts +6 -0
  33. package/src/builder/i18n/pt.ts +6 -0
  34. package/src/builder/i18n/zh-cn.ts +6 -0
  35. package/src/builder/i18n/zh-tw.ts +6 -0
  36. package/src/builder/network-access.ts +33 -0
  37. package/src/builder/region-help.ts +8 -2
  38. package/src/builder/rest-catalogs.ts +22 -14
  39. package/src/builder/routes.ts +5 -1
  40. package/src/builder/static-files.ts +14 -4
  41. package/src/builder/websources.ts +12 -6
  42. package/src/builder/workflows.ts +2 -1
  43. package/src/builder/workspace-resources.ts +528 -0
  44. package/src/i18n/ar.ts +13 -0
  45. package/src/i18n/cs.ts +13 -0
  46. package/src/i18n/da.ts +13 -0
  47. package/src/i18n/de.ts +13 -0
  48. package/src/i18n/el.ts +13 -0
  49. package/src/i18n/es.ts +13 -0
  50. package/src/i18n/fi.ts +13 -0
  51. package/src/i18n/fr.ts +13 -0
  52. package/src/i18n/he.ts +13 -0
  53. package/src/i18n/it.ts +13 -0
  54. package/src/i18n/ja.ts +13 -0
  55. package/src/i18n/ko.ts +13 -0
  56. package/src/i18n/nb.ts +13 -0
  57. package/src/i18n/pl.ts +13 -0
  58. package/src/i18n/pt.ts +13 -0
  59. package/src/i18n/ru.ts +13 -0
  60. package/src/i18n/sv.ts +13 -0
  61. package/src/i18n/tr.ts +13 -0
  62. package/src/i18n/uk.ts +13 -0
  63. package/src/i18n/zh.ts +13 -0
  64. package/src/i18n.ts +26 -0
  65. package/src/metadata.ts +22 -5
  66. package/src/remote-servers.ts +133 -0
  67. package/src/restcatalog.ts +21 -10
  68. package/src/restsync.ts +1 -1
  69. package/src/runtime/context.ts +2 -0
  70. package/src/runtime/items.ts +32 -3
  71. package/src/runtime/page-regions.ts +50 -0
  72. package/src/runtime/region-cache.ts +1 -1
  73. package/src/runtime/regions.ts +11 -0
  74. package/src/runtime/render.ts +3 -17
  75. package/src/runtime/report.ts +55 -3
  76. package/src/runtime/static-files.ts +46 -17
  77. package/src/runtime/template-components.ts +2 -1
  78. package/src/websources.ts +43 -18
  79. package/src/workspace-export.ts +80 -1
@@ -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.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.');
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
- url: serviceUrl(c.base_url, s.path),
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
  );
@@ -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$')) {
@@ -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
- const LOV_TYPES = new Set(['select', 'radio', 'checkbox_group', 'multiselect', 'popup_lov', 'combobox']);
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';
@@ -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;
@@ -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
- const crumbs = ctx.dialog ? [] : await breadcrumb(ctx);
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`
@@ -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
- const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'report_rows');
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
- const searchable = r.config.searchable !== false;
818
- const interactive = r.config.interactive !== false && searchable;
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
- import type { FastifyInstance } from 'fastify';
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
- const src = `${ctx.base}/static/${encodeURIComponent(f.name)}?v=${f.v}`;
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
- const etag = `"${Number(f.v).toString(36)}"`;
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
- const col: Lookup = (n) => columns.get(n) ?? extra[n];
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)) {