pgkiln 0.72.0 → 0.73.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 (54) hide show
  1. package/db/migrations/140_item_region_types.sql +27 -0
  2. package/docs/apex-feature-parity.md +84 -8
  3. package/docs/guide/04-pages-and-regions.md +41 -1
  4. package/docs/guide/05-items.md +43 -3
  5. package/docs/guide/11-from-apex.md +3 -0
  6. package/docs/guide/12-development.md +10 -2
  7. package/examples/hr/hr_75_item_region_types.sql +81 -0
  8. package/package.json +1 -1
  9. package/public/app.css +48 -0
  10. package/public/app.js +377 -1
  11. package/src/builder/advisor.ts +5 -1
  12. package/src/builder/arrange.ts +7 -0
  13. package/src/builder/components.ts +5 -5
  14. package/src/builder/designer.ts +8 -2
  15. package/src/builder/i18n/de.ts +6 -0
  16. package/src/builder/i18n/en.ts +6 -0
  17. package/src/builder/i18n/es.ts +6 -0
  18. package/src/builder/i18n/fr.ts +6 -0
  19. package/src/builder/i18n/it.ts +6 -0
  20. package/src/builder/i18n/ja.ts +6 -0
  21. package/src/builder/i18n/ko.ts +6 -0
  22. package/src/builder/i18n/nl.ts +6 -0
  23. package/src/builder/i18n/pt.ts +6 -0
  24. package/src/builder/i18n/zh-cn.ts +6 -0
  25. package/src/builder/i18n/zh-tw.ts +6 -0
  26. package/src/builder/region-help.ts +8 -2
  27. package/src/i18n/ar.ts +13 -0
  28. package/src/i18n/cs.ts +13 -0
  29. package/src/i18n/da.ts +13 -0
  30. package/src/i18n/de.ts +13 -0
  31. package/src/i18n/el.ts +13 -0
  32. package/src/i18n/es.ts +13 -0
  33. package/src/i18n/fi.ts +13 -0
  34. package/src/i18n/fr.ts +13 -0
  35. package/src/i18n/he.ts +13 -0
  36. package/src/i18n/it.ts +13 -0
  37. package/src/i18n/ja.ts +13 -0
  38. package/src/i18n/ko.ts +13 -0
  39. package/src/i18n/nb.ts +13 -0
  40. package/src/i18n/pl.ts +13 -0
  41. package/src/i18n/pt.ts +13 -0
  42. package/src/i18n/ru.ts +13 -0
  43. package/src/i18n/sv.ts +13 -0
  44. package/src/i18n/tr.ts +13 -0
  45. package/src/i18n/uk.ts +13 -0
  46. package/src/i18n/zh.ts +13 -0
  47. package/src/i18n.ts +26 -0
  48. package/src/metadata.ts +5 -2
  49. package/src/runtime/items.ts +32 -3
  50. package/src/runtime/page-regions.ts +50 -0
  51. package/src/runtime/region-cache.ts +1 -1
  52. package/src/runtime/regions.ts +11 -0
  53. package/src/runtime/render.ts +3 -17
  54. package/src/runtime/report.ts +55 -3
package/src/i18n/uk.ts CHANGED
@@ -424,6 +424,19 @@ export const uk: Record<MessageKey, string> = {
424
424
  'lov.loading': "Завантаження…",
425
425
  'da.wait': "Зачекайте…",
426
426
  'lov.no_rows': "Нічого не знайдено.",
427
+ 'shuttle.available': "Доступні",
428
+ 'shuttle.chosen': "Вибрані",
429
+ 'shuttle.move': "Перемістити",
430
+ 'shuttle.move_all': "Перемістити всі",
431
+ 'shuttle.remove': "Прибрати",
432
+ 'shuttle.remove_all': "Прибрати всі",
433
+ 'shuttle.up': "Вгору",
434
+ 'shuttle.down': "Вниз",
435
+ 'shuttle.top': "На початок",
436
+ 'shuttle.bottom': "У кінець",
437
+ 'shuttle.filter': "Фільтр",
438
+ 'region.breadcrumb': "Навігаційний ланцюжок",
439
+ 'region.no_help': "Для цієї сторінки немає довідки.",
427
440
  'search.did_you_mean': "Можливо, ви мали на увазі:",
428
441
  'error.lov_value': "{label}: виберіть значення зі списку.",
429
442
  'item.yes': "Так",
package/src/i18n/zh.ts CHANGED
@@ -424,6 +424,19 @@ export const zh: Record<MessageKey, string> = {
424
424
  'lov.loading': "正在加载更多…",
425
425
  'da.wait': "请稍候…",
426
426
  'lov.no_rows': "未找到任何内容。",
427
+ 'shuttle.available': "可选",
428
+ 'shuttle.chosen': "已选",
429
+ 'shuttle.move': "移入",
430
+ 'shuttle.move_all': "全部移入",
431
+ 'shuttle.remove': "移除",
432
+ 'shuttle.remove_all': "全部移除",
433
+ 'shuttle.up': "上移",
434
+ 'shuttle.down': "下移",
435
+ 'shuttle.top': "移到顶部",
436
+ 'shuttle.bottom': "移到底部",
437
+ 'shuttle.filter': "筛选",
438
+ 'region.breadcrumb': "面包屑导航",
439
+ 'region.no_help': "此页面没有帮助信息。",
427
440
  'search.did_you_mean': "您是不是要找:",
428
441
  'error.lov_value': "{label}:请从列表中选择一个值。",
429
442
  'item.yes': "是",
package/src/i18n.ts CHANGED
@@ -451,6 +451,19 @@ const en = {
451
451
  'lov.loading': 'Loading more…',
452
452
  'da.wait': 'Please wait…',
453
453
  'lov.no_rows': 'Nothing found.',
454
+ 'shuttle.available': 'Available',
455
+ 'shuttle.chosen': 'Selected',
456
+ 'shuttle.move': 'Move',
457
+ 'shuttle.move_all': 'Move all',
458
+ 'shuttle.remove': 'Remove',
459
+ 'shuttle.remove_all': 'Remove all',
460
+ 'shuttle.up': 'Move up',
461
+ 'shuttle.down': 'Move down',
462
+ 'shuttle.top': 'Move to top',
463
+ 'shuttle.bottom': 'Move to bottom',
464
+ 'shuttle.filter': 'Filter',
465
+ 'region.breadcrumb': 'Breadcrumb',
466
+ 'region.no_help': 'No help is available for this page.',
454
467
  'search.did_you_mean': 'Did you mean:',
455
468
  'error.lov_value': '{label}: choose a value from the list.',
456
469
  'item.yes': 'Yes',
@@ -1319,6 +1332,19 @@ const nl: Record<MessageKey, string> = {
1319
1332
  'lov.loading': 'Meer laden…',
1320
1333
  'da.wait': 'Even geduld…',
1321
1334
  'lov.no_rows': 'Niets gevonden.',
1335
+ 'shuttle.available': 'Beschikbaar',
1336
+ 'shuttle.chosen': 'Gekozen',
1337
+ 'shuttle.move': 'Verplaatsen',
1338
+ 'shuttle.move_all': 'Alles verplaatsen',
1339
+ 'shuttle.remove': 'Verwijderen',
1340
+ 'shuttle.remove_all': 'Alles verwijderen',
1341
+ 'shuttle.up': 'Omhoog',
1342
+ 'shuttle.down': 'Omlaag',
1343
+ 'shuttle.top': 'Bovenaan',
1344
+ 'shuttle.bottom': 'Onderaan',
1345
+ 'shuttle.filter': 'Filteren',
1346
+ 'region.breadcrumb': 'Kruimelpad',
1347
+ 'region.no_help': 'Er is geen help voor deze pagina.',
1322
1348
  'search.did_you_mean': 'Bedoelde u:',
1323
1349
  'error.lov_value': '{label}: kies een waarde uit de lijst.',
1324
1350
  'item.yes': 'Ja',
package/src/metadata.ts CHANGED
@@ -159,7 +159,7 @@ export interface Region {
159
159
  title: string | null;
160
160
  /** (0.31, 073) optional, unique on the page */
161
161
  static_id?: string | null;
162
- type: 'login' | 'report' | 'form' | 'chart' | 'cards' | 'static' | 'grid' | 'calendar' | 'dynamic' | 'facets' | 'tasks' | 'workflows' | 'map' | 'tree' | 'template_component' | 'smart_filters' | 'display_selector' | 'list' | 'data_reporter' | 'ai_assistant' | 'plugin' | 'url' | 'search' | 'form_designer';
162
+ type: 'login' | 'report' | 'form' | 'chart' | 'cards' | 'static' | 'grid' | 'calendar' | 'dynamic' | 'facets' | 'tasks' | 'workflows' | 'map' | 'tree' | 'template_component' | 'smart_filters' | 'display_selector' | 'list' | 'data_reporter' | 'ai_assistant' | 'plugin' | 'url' | 'search' | 'form_designer' | 'classic_report' | 'breadcrumb' | 'help_text';
163
163
  source: string | null;
164
164
  table_name: string | null;
165
165
  pk_column: string | null;
@@ -182,6 +182,7 @@ export type ItemType =
182
182
  | 'checkbox' | 'switch' | 'hidden' | 'display' | 'password'
183
183
  | 'checkbox_group' | 'multiselect' | 'popup_lov' | 'email' | 'tel' | 'url' | 'color' | 'file' | 'location'
184
184
  | 'richtext' | 'markdown' | 'rating' | 'combobox' | 'daterange' | 'qrcode' | 'plugin'
185
+ | 'shuttle' | 'select_one' | 'select_many'
185
186
  // (086) display image, geocoded address
186
187
  | 'image' | 'address';
187
188
 
@@ -363,6 +364,8 @@ export interface Page extends PageSummary {
363
364
  warn_unsaved: boolean;
364
365
  /** (087) 'minimal': no header and navigation */
365
366
  page_template: 'standard' | 'minimal' | 'left_side_column' | 'right_side_column';
367
+ /** (140) the page's help text (Markdown), shown by a help_text region */
368
+ help: string | null;
366
369
  }
367
370
 
368
371
  /** The static files a list of names refers to, in that order, with their version. */
@@ -406,7 +409,7 @@ export async function loadApp(alias: string) {
406
409
  export async function loadPage(appId: number, pageNo: number) {
407
410
  return runtime.one<Page>(
408
411
  `select p.id, p.app_id, p.page_no, p.name, p.title, p.requires_auth, p.parent_page, p.mode,
409
- p.protection, p.authz, p.warn_unsaved, p.page_template,
412
+ p.protection, p.authz, p.warn_unsaved, p.page_template, p.help,
410
413
  ${agg('meta.region', 'page_id', 'p', 'p.app_id')} as regions,
411
414
  ${agg('meta.item', 'page_id', 'p', 'p.app_id')} as items,
412
415
  ${agg('meta.button', 'page_id', 'p', 'p.app_id')} as buttons,
@@ -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
+ }