pgkiln 0.44.0 → 0.46.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 (91) hide show
  1. package/db/migrations/102_app_computations.sql +345 -0
  2. package/db/migrations/103_shortcuts.sql +341 -0
  3. package/db/migrations/104_report_queries.sql +413 -0
  4. package/db/migrations/105_component_settings_groups.sql +367 -0
  5. package/db/migrations/106_form_designer.sql +102 -0
  6. package/db/migrations/107_app_version.sql +9 -0
  7. package/docs/apex-feature-parity.md +15 -12
  8. package/docs/guide/02-concepts.md +24 -0
  9. package/docs/guide/03-builder.md +69 -13
  10. package/docs/guide/04-pages-and-regions.md +84 -3
  11. package/docs/guide/05-items.md +14 -2
  12. package/docs/guide/06-processing.md +22 -0
  13. package/docs/guide/09-reference.md +14 -1
  14. package/docs/guide/11-from-apex.md +2 -1
  15. package/docs/guide/12-development.md +8 -2
  16. package/docs/guide/16-files.md +23 -0
  17. package/docs/guide/18-cli.md +4 -0
  18. package/examples/hr/hr_61_form_designer.sql +80 -0
  19. package/package.json +1 -1
  20. package/public/app.css +79 -2
  21. package/public/app.js +307 -35
  22. package/public/builder.css +21 -1
  23. package/public/builder.js +67 -0
  24. package/public/sw.js +2 -1
  25. package/src/app.ts +9 -1
  26. package/src/appfiles.ts +18 -2
  27. package/src/builder/advisor.ts +7 -2
  28. package/src/builder/appwizard.ts +22 -6
  29. package/src/builder/arrange.ts +33 -12
  30. package/src/builder/code-editor.ts +7 -2
  31. package/src/builder/components.ts +86 -4
  32. package/src/builder/designer.ts +44 -17
  33. package/src/builder/errors.ts +65 -0
  34. package/src/builder/form-designer.ts +229 -0
  35. package/src/builder/forms.ts +1 -1
  36. package/src/builder/locks.ts +6 -0
  37. package/src/builder/newapp.ts +2 -1
  38. package/src/builder/object-browser.ts +81 -18
  39. package/src/builder/region-help.ts +2 -0
  40. package/src/builder/region-settings.ts +2 -0
  41. package/src/builder/reporter.ts +1 -1
  42. package/src/builder/routes.ts +7 -5
  43. package/src/builder/search.ts +3 -3
  44. package/src/builder/shared.ts +41 -8
  45. package/src/builder/subscriptions.ts +30 -4
  46. package/src/builder/ui.ts +2 -0
  47. package/src/cli/replace.ts +16 -2
  48. package/src/i18n/ar.ts +62 -0
  49. package/src/i18n/cs.ts +62 -0
  50. package/src/i18n/da.ts +62 -0
  51. package/src/i18n/de.ts +62 -0
  52. package/src/i18n/el.ts +62 -0
  53. package/src/i18n/es.ts +62 -0
  54. package/src/i18n/fi.ts +62 -0
  55. package/src/i18n/fr.ts +62 -0
  56. package/src/i18n/he.ts +62 -0
  57. package/src/i18n/it.ts +62 -0
  58. package/src/i18n/ja.ts +62 -0
  59. package/src/i18n/ko.ts +62 -0
  60. package/src/i18n/nb.ts +62 -0
  61. package/src/i18n/pl.ts +62 -0
  62. package/src/i18n/pt.ts +62 -0
  63. package/src/i18n/ru.ts +62 -0
  64. package/src/i18n/sv.ts +62 -0
  65. package/src/i18n/tr.ts +62 -0
  66. package/src/i18n/uk.ts +62 -0
  67. package/src/i18n/zh.ts +62 -0
  68. package/src/i18n.ts +124 -0
  69. package/src/metadata.ts +18 -3
  70. package/src/runtime/component-settings.ts +42 -0
  71. package/src/runtime/context.ts +4 -0
  72. package/src/runtime/devbar.ts +1 -1
  73. package/src/runtime/documents.ts +29 -3
  74. package/src/runtime/engine.ts +2 -2
  75. package/src/runtime/facet-state.ts +10 -5
  76. package/src/runtime/facets.ts +29 -5
  77. package/src/runtime/form-designer.ts +676 -0
  78. package/src/runtime/grid.ts +36 -11
  79. package/src/runtime/items.ts +47 -32
  80. package/src/runtime/lists.ts +1 -1
  81. package/src/runtime/logic.ts +12 -7
  82. package/src/runtime/pdf.ts +2 -1
  83. package/src/runtime/regions.ts +8 -3
  84. package/src/runtime/render.ts +8 -3
  85. package/src/runtime/report.ts +4 -3
  86. package/src/runtime/routes.ts +7 -3
  87. package/src/runtime/shortcuts.ts +43 -0
  88. package/src/runtime/smart-filters.ts +2 -1
  89. package/src/runtime/tasks.ts +6 -3
  90. package/src/security.ts +2 -1
  91. package/src/subscriptions.ts +67 -0
@@ -1,3 +1,4 @@
1
+ import { componentSetting } from './component-settings.ts';
1
2
  import pg from 'pg';
2
3
  import { literal } from '../binds.ts';
3
4
  import { savepoint, type Client } from '../db.ts';
@@ -8,7 +9,8 @@ import { checksumValid, urlChecksum } from '../security.ts';
8
9
  import { isAuthorized, pageAllowed } from './authz.ts';
9
10
  import { publicError, toState, type PageContext } from './context.ts';
10
11
  import { arrange, cleanLayout, currentLayout, defaultLayout, layoutJson, layoutParam, MAX_FROZEN, MAX_WIDTH, MIN_WIDTH, parseLayout, type GridLayout, type Placed } from './grid-layout.ts';
11
- import { lovContains, lovOptions, type LovOption } from './items.ts';
12
+ import { appDateMask, dateField, lovContains, lovOptions, type LovOption } from './items.ts';
13
+ import { parseDateMask } from './format.ts';
12
14
  import { resolveRestRegion, restAllows, restGridCall, restWritable } from './rest-sources.ts';
13
15
  import { fillItems, linkAttrs } from './links.ts';
14
16
  import { detailsOf, masterColumnOf, masterItemOf, selectHref, selectRowOf } from './master-detail.ts';
@@ -101,7 +103,7 @@ const rowToken = (ctx: PageContext, r: Region, pk: string) => urlChecksum(ctx.ap
101
103
 
102
104
  // ---------------------------------------------------------------- render
103
105
 
104
- function control(col: GridColumn, name: string, value: string, label: string) {
106
+ function control(ctx: PageContext, col: GridColumn, name: string, value: string, label: string) {
105
107
  const aria = raw(` aria-label="${label.replace(/"/g, '"')}"`);
106
108
  if (col.options)
107
109
  return html`<select name="${name}"${aria}><option value=""></option>${col.options.map(
@@ -110,12 +112,24 @@ function control(col: GridColumn, name: string, value: string, label: string) {
110
112
  if (col.typeOid === 16)
111
113
  return html`<input type="checkbox" name="${name}" value="true"${value === 'true' ? raw(' checked') : ''}${aria}>`;
112
114
  if (NUMERIC.has(col.typeOid)) return html`<input type="number" step="any" inputmode="decimal" name="${name}" value="${value}"${aria}>`;
113
- if (col.typeOid === 1082) return html`<input type="date" name="${name}" value="${value}"${aria}>`;
115
+ // (sprint 84) a date in the application's date format, with pgkiln's date picker
116
+ if (col.typeOid === 1082) return dateField(ctx, { id: `f_${name}`, iso: value, mask: appDateMask(ctx), attrs: html` name="${name}"${aria}` });
114
117
  if (col.typeOid === 1114 || col.typeOid === 1184)
115
118
  return html`<input type="datetime-local" name="${name}" value="${value.slice(0, 16).replace(' ', 'T')}"${aria}>`;
116
119
  return html`<input type="text" name="${name}" value="${value}"${aria}>`;
117
120
  }
118
121
 
122
+ /**
123
+ * (APEX: cell navigation mode) An editable cell's value as text, with its column's format mask or the
124
+ * option's display value: with JavaScript the cell reads as this text until it is edited (app.css,
125
+ * app.js); the control holds the raw value. Not for a checkbox, which reads as itself.
126
+ */
127
+ function cellText(col: GridColumn, value: string, shown: string) {
128
+ if (col.typeOid === 16 || value === '') return '';
129
+ const text = col.options ? col.options.find((o) => o.value === value)?.display ?? value : shown;
130
+ return html`<span class="grid-text${NUMERIC.has(col.typeOid) && !col.options ? ' num' : ''}" aria-hidden="true">${text}</span>`;
131
+ }
132
+
119
133
  /** Width of each leading column (master selection, delete, row actions), see app.css .grid-lead. */
120
134
  const LEAD_WIDTH = 44;
121
135
 
@@ -169,7 +183,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
169
183
  const base = reportState(ctx, r);
170
184
  const views = gridViews(r);
171
185
  const view: View = views.includes(base.view) ? base.view : 'report';
172
- const pageSize = Math.max(1, Math.min(200, Number(r.config.page_size) || 25));
186
+ const pageSize = Math.max(1, Math.min(200, Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'grid_rows')));
173
187
  // the single row view: one row of the search at a time (r<id>_row=N from 1), or a new row (r<id>_row=new)
174
188
  const rowParam = ctx.params.get(key(r, 'row'));
175
189
  const adding = view === 'row' && rowParam === 'new';
@@ -240,7 +254,13 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
240
254
  // master-detail: this grid selects rows for detail regions; or it follows a master
241
255
  const select = selectRowOf(ctx.page, r);
242
256
  const selIdx = select ? fields.findIndex((f) => f.name.toLowerCase() === select.column.toLowerCase()) : -1;
243
- const selected = select ? ctx.session.state[select.item] ?? null : null;
257
+ let selected = select ? ctx.session.state[select.item] ?? null : null;
258
+ // as in APEX, a page shows the first master row selected: when nothing is selected, or the
259
+ // selected row is not among the rows shown (after a search or filter); the regions below follow it
260
+ if (select && selIdx >= 0 && ctx.wholePage && view === 'report' && info.rows.length) {
261
+ const shown = info.rows.map((row) => toState(row[selIdx]) ?? '');
262
+ if (selected === null || !shown.includes(selected)) ctx.session.state[select.item] = selected = shown[0];
263
+ }
244
264
  const details = select ? detailsOf(ctx.page, r, ctx.vis!.regions) : [];
245
265
  const masterCol = masterColumnOf(r);
246
266
  const masterValue = masterItemOf(r) ? ctx.session.state[masterItemOf(r)!] ?? '' : '';
@@ -297,7 +317,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
297
317
  return i === -1 ? undefined : (toState(row[i]) ?? '');
298
318
  });
299
319
 
300
- // read-only cells show their column's format mask; editable cells keep the raw value
320
+ // read-only cells show their column's format mask; an editable cell's control keeps the raw value
301
321
  const fmtOf = columnFormats(ctx, r);
302
322
  const rows = info.rows.map((row, i) => {
303
323
  const pk = toState(row[pkIdx]) ?? '';
@@ -311,7 +331,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
311
331
  if (!col.editable) return html`<td class="${colCls(p, pIdx, NUMERIC.has(col.typeOid) ? 'num' : null)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cell(row[col.idx], col.typeOid, fmtOf(col.name))}</td>`;
312
332
  const name = `${g}_${i}_c${col.ci}`;
313
333
  const value = pi !== undefined ? (col.typeOid === 16 ? (pv(`${g}_${pi}_c${col.ci}`) === 'true' ? 'true' : 'false') : (pv(`${g}_${pi}_c${col.ci}`) ?? dbValue)) : dbValue;
314
- return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${control(col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
334
+ return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cellText(col, value, value === dbValue ? cell(row[col.idx], col.typeOid, fmtOf(col.name)) : value)}${control(ctx, col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
315
335
  });
316
336
  const deleted = pi !== undefined && pv(`${g}_${pi}_del`) === 'true';
317
337
  const selValue = selIdx >= 0 ? toState(row[selIdx]) ?? '' : null;
@@ -348,7 +368,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
348
368
  const lbl = label(col.name);
349
369
  if (masterCol && col.name === masterCol) return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${masterValue}</td>`;
350
370
  return writable.has(col.name)
351
- ? html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${control({ ...col, editable: true }, `${g}_n${j}_c${col.ci}`, values[col.ci] ?? '', `${lbl}, new row`)}</td>`
371
+ ? html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${control(ctx, { ...col, editable: true }, `${g}_n${j}_c${col.ci}`, values[col.ci] ?? '', `${lbl}, new row`)}</td>`
352
372
  : html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}></td>`;
353
373
  })}
354
374
  </tr>`;
@@ -399,7 +419,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
399
419
  orig[col.ci] = dbValue;
400
420
  if (!col.editable) return field(p, cell(row[col.idx], col.typeOid, fmtOf(col.name)), false);
401
421
  const value = pi !== undefined ? (col.typeOid === 16 ? (pv(`${g}_${pi}_c${col.ci}`) === 'true' ? 'true' : 'false') : (pv(`${g}_${pi}_c${col.ci}`) ?? dbValue)) : dbValue;
402
- return field(p, control(col, `${g}_0_c${col.ci}`, value, label(col.name)), true);
422
+ return field(p, control(ctx, col, `${g}_0_c${col.ci}`, value, label(col.name)), true);
403
423
  });
404
424
  const deleted = pi !== undefined && pv(`${g}_${pi}_del`) === 'true';
405
425
  const links = [
@@ -420,7 +440,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
420
440
  const col = p.col;
421
441
  if (masterCol && col.name === masterCol) return [field(p, masterValue, false)];
422
442
  if (!writable.has(col.name)) return [];
423
- return [field(p, control({ ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? '', label(col.name)), true)];
443
+ return [field(p, control(ctx, { ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? '', label(col.name)), true)];
424
444
  });
425
445
  return html`<div class="grid-record grid-new" data-new-row="0"><div class="form-grid">${fieldsHtml}</div></div>
426
446
  ${recordNav()}`;
@@ -700,7 +720,12 @@ export async function gridDml(ctx: PageContext, p: Process): Promise<string | nu
700
720
  const lov = r.config.columns?.[name]?.lov;
701
721
  return !lov || v === '' || (await lovContains(ctx, lov, [v]));
702
722
  };
703
- const norm = (ci: number, v: string | undefined) => (cols[ci].dataTypeID === 16 ? (v === 'true' ? 'true' : 'false') : v ?? '');
723
+ // (sprint 84) a date typed in the date format (or YYYY-MM-DD) → YYYY-MM-DD; other text goes to the database as it is (which refuses it)
724
+ const dateMask = appDateMask(ctx);
725
+ const norm = (ci: number, v: string | undefined) =>
726
+ cols[ci].dataTypeID === 16 ? (v === 'true' ? 'true' : 'false')
727
+ : cols[ci].dataTypeID === 1082 && v?.trim() ? (parseDateMask(v, dateMask, ctx.locale.lang) ?? v)
728
+ : v ?? '';
704
729
 
705
730
  const errors: string[] = [];
706
731
  let inserted = 0, updated = 0, deleted = 0;
@@ -8,11 +8,13 @@ import type { Item } from '../metadata.ts';
8
8
  import { bindValues, publicError, stripSemicolon, toState, type PageContext } from './context.ts';
9
9
  import { canPreview, fileInfo, fileList, fileUrl, formatSize, isMultiple, maxFiles, removals } from './files.ts';
10
10
  import { restSql } from './rest-sources.ts';
11
+ import { expandShortcuts } from './shortcuts.ts';
12
+ import { componentSetting, type ComponentSettings } from './component-settings.ts';
11
13
  import { markdownHtml, sanitizeHtml } from '../richtext.ts';
12
14
  import { qrSvg, type Ecc } from '../qrcode.ts';
13
15
  import { formatNumber, isPlainNumber, maskError } from '../numformat.ts';
14
16
  import { addressControl, imageControl } from './data-items.ts';
15
- import { applyMask, formatMaskError, monthNames } from './format.ts';
17
+ import { applyMask, formatMaskError, monthNames, parseDateMask } from './format.ts';
16
18
 
17
19
  const TRUTHY = new Set(['true', 't', 'on', '1', 'yes', 'y']);
18
20
  export const isTruthy = (v: string | null | undefined) => !!v && TRUTHY.has(v.toLowerCase());
@@ -77,9 +79,10 @@ async function lovColumns(ctx: PageContext, sql: string) {
77
79
 
78
80
  /** Rows of a popup LOV's dialog: at most 100 per page. */
79
81
  export const POPUP_PAGE_MAX = 100;
80
- export const popupPageSize = (item: Item, asked?: unknown) => {
81
- const n = Math.floor(Number(asked ?? item.config?.page_size ?? 25));
82
- return Number.isFinite(n) && n >= 1 ? Math.min(n, POPUP_PAGE_MAX) : 25;
82
+ export const popupPageSize = (item: Item, asked?: unknown, settings?: ComponentSettings) => {
83
+ const def = componentSetting(settings, 'popup_lov_rows');
84
+ const n = Math.floor(Number(asked ?? item.config?.page_size ?? def));
85
+ return Number.isFinite(n) && n >= 1 ? Math.min(n, POPUP_PAGE_MAX) : def;
83
86
  };
84
87
 
85
88
  export interface LovPage {
@@ -160,9 +163,9 @@ export const LOV_CHECKED = new Set(['select', 'radio', 'checkbox_group', 'multis
160
163
 
161
164
  /** Rows a list of values reads when its item sets no config.max_rows (1 to 50,000). */
162
165
  export const LOV_MAX_ROWS = 5000;
163
- export const lovMax = (item: Item) => {
166
+ export const lovMax = (item: Item, settings?: ComponentSettings) => {
164
167
  const n = Math.floor(Number(item.config?.max_rows));
165
- return Number.isFinite(n) && n >= 1 ? Math.min(n, 50_000) : LOV_MAX_ROWS;
168
+ return Number.isFinite(n) && n >= 1 ? Math.min(n, 50_000) : settings ? componentSetting(settings, 'lov_max_rows') : LOV_MAX_ROWS;
166
169
  };
167
170
 
168
171
  const LOV_TYPES = new Set(['select', 'radio', 'checkbox_group', 'multiselect', 'popup_lov', 'combobox']);
@@ -174,22 +177,43 @@ export const splitValues = (v: string) => (v ? v.split(':') : []);
174
177
  /** A combobox holds several values (colon-separated) unless {"multiple": false}. */
175
178
  export const comboMultiple = (item: Item) => item.config?.multiple !== false;
176
179
  /** The highest star of a rating item ({"max": 5}, 3 to 10). */
177
- export const ratingMax = (item: Item) => Math.min(Math.max(Math.round(Number(item.config?.max)) || 5, 3), 10);
180
+ export const ratingMax = (item: Item, settings?: ComponentSettings) => Math.min(Math.max(Math.round(Number(item.config?.max)) || componentSetting(settings, 'rating_max'), 3), 10);
178
181
  /** A date range item's value "from:to" (ISO dates, either may be empty). */
179
182
  export const splitRange = (v: string | null | undefined): [string, string] => {
180
183
  const [from = '', to = ''] = (v ?? '').split(':');
181
184
  return [from, to];
182
185
  };
183
186
 
187
+ /** (sprint 84) The application's date format, else ISO: every date field is text in it, with pgkiln's date picker. */
188
+ export const appDateMask = (ctx: PageContext) => ctx.locale.dateMask?.trim() || 'YYYY-MM-DD';
189
+ /** (sprint 84) A date typed in the application's date format (or YYYY-MM-DD) as YYYY-MM-DD; null when it isn't one. */
190
+ export const appDateReader = (ctx: PageContext) => (text: string) => parseDateMask(text, appDateMask(ctx), ctx.locale.lang);
191
+
184
192
  /**
185
- * (sprint 76) The date mask a date item shows its value in: its own format mask (a date mask), else the
186
- * application's date format; null: the browser's date field (in the browser's notation).
193
+ * (sprint 76) The date mask a date or date range item shows its value in: its own format mask (a date mask),
194
+ * else the application's date format, else YYYY-MM-DD; null for other items.
187
195
  */
188
196
  export function itemDateMask(ctx: PageContext, item: Item): string | null {
189
- if (item.type !== 'date') return null;
197
+ if (item.type !== 'date' && item.type !== 'daterange') return null;
190
198
  const own = typeof item.config?.format_mask === 'string' ? item.config.format_mask.trim() : '';
191
199
  if (own && /(YYYY|YY|MM|DD|MON)/i.test(own) && !formatMaskError(own)) return own;
192
- return ctx.locale.dateMask?.trim() || null;
200
+ return appDateMask(ctx);
201
+ }
202
+
203
+ /**
204
+ * (sprint 84; APEX: the date picker) A date as text in a date mask, with a calendar button that opens pgkiln's
205
+ * date picker (app.js). Without JavaScript the text is typed (in the mask, or as YYYY-MM-DD) and read back on
206
+ * submit. `attrs`: extra attributes of the text field (name, form, min/max, aria).
207
+ */
208
+ export function dateField(ctx: PageContext, o: { id: string; iso: string; mask: string; attrs?: Raw | string; editable?: boolean; typed?: string }) {
209
+ const iso = /^\d{4}-\d{2}-\d{2}/.test(o.iso) ? o.iso.slice(0, 10) : '';
210
+ const shown = iso ? applyMask(iso, o.mask, ctx.locale.lang) : (o.typed ?? o.iso);
211
+ const editable = o.editable !== false;
212
+ const t = ctx.locale.t;
213
+ // a number pad on phones, unless the mask has month or day names
214
+ const numeric = /MON|DAY|DY/i.test(o.mask.replace(/"[^"]*"/g, '')) ? '' : raw(' inputmode="numeric"');
215
+ return html`<span class="input-with-button date-field"><input type="text" id="${o.id}"${o.attrs ?? ''} value="${shown}" placeholder="${o.mask}" autocomplete="off"${numeric} data-date-mask="${o.mask}" data-months="${JSON.stringify(monthNames(ctx.locale.lang))}"${editable ? '' : raw(' readonly')}>${
216
+ editable ? html`<button type="button" class="btn" data-date-pick="${o.id}" aria-label="${t('item.choose_date')}" title="${t('item.choose_date')}" aria-haspopup="dialog">${icon('calendar')}</button>` : ''}</span>`;
193
217
  }
194
218
 
195
219
  /** A number or display item's number format mask ({"format_mask": "999G990D00"}), when it is a valid one. */
@@ -226,7 +250,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
226
250
  let lovError: Raw | '' = '';
227
251
  if (hasLov(item)) {
228
252
  try {
229
- options = await lovOptions(ctx, item.lov, lovMax(item));
253
+ options = await lovOptions(ctx, item.lov, lovMax(item, ctx.app.component_settings));
230
254
  } catch (e) {
231
255
  lovError = html`<small class="error">${await publicError(ctx, e, `list of values of ${item.name}`)}</small>`;
232
256
  }
@@ -249,9 +273,9 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
249
273
  // rebuilt from the allow-list every time: the value may come from the table, not from this form
250
274
  control = html`<div class="display-value rich-text" id="${id}">${raw((item.type === 'markdown' ? markdownHtml(value) : sanitizeHtml(value)) || ' ')}</div>`;
251
275
  } else if (!editable && item.type === 'rating') {
252
- const n = /^\d+$/.test(value) ? Math.min(Number(value), ratingMax(item)) : 0;
276
+ const n = /^\d+$/.test(value) ? Math.min(Number(value), ratingMax(item, ctx.app.component_settings)) : 0;
253
277
  control = n
254
- ? html`<div class="display-value rating-value" id="${id}"><span aria-hidden="true">${'★'.repeat(n)}<span class="rating-off">${'★'.repeat(ratingMax(item) - n)}</span></span><span class="sr-only">${ctx.locale.t('item.rating_of', { n, max: ratingMax(item) })}</span></div>`
278
+ ? html`<div class="display-value rating-value" id="${id}"><span aria-hidden="true">${'★'.repeat(n)}<span class="rating-off">${'★'.repeat(ratingMax(item, ctx.app.component_settings) - n)}</span></span><span class="sr-only">${ctx.locale.t('item.rating_of', { n, max: ratingMax(item, ctx.app.component_settings) })}</span></div>`
255
279
  : html`<div class="display-value" id="${id}"> </div>`;
256
280
  } else if (!editable && item.type === 'combobox') {
257
281
  const values = comboMultiple(item) ? splitValues(value) : value ? [value] : [];
@@ -352,7 +376,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
352
376
  case 'rating': {
353
377
  // radio buttons (keyboard and screen readers as usual); CSS draws them as stars
354
378
  useLegend = true;
355
- const max = ratingMax(item);
379
+ const max = ratingMax(item, ctx.app.component_settings);
356
380
  const stars = [];
357
381
  for (let n = 1; n <= max; n++)
358
382
  stars.push(
@@ -381,9 +405,11 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
381
405
  useLegend = true;
382
406
  const [from, to] = splitRange(value);
383
407
  const t = ctx.locale.t;
408
+ // (sprint 84) each part in the date format with the date picker; the picker keeps the from on or before the to
409
+ const mask = itemDateMask(ctx, item)!;
384
410
  control = html`<div class="date-range" data-range="${id}">
385
- <span class="range-part"><label class="sub" for="${id}">${t('item.range_from')}</label><input type="date" id="${id}" name="${id}" value="${from}"${to ? html` max="${to}"` : ''}${aria}></span>
386
- <span class="range-part"><label class="sub" for="${id}_TO">${t('item.range_to')}</label><input type="date" id="${id}_TO" name="${id}" value="${to}"${from ? html` min="${from}"` : ''}${aria}></span>
411
+ <span class="range-part"><label class="sub" for="${id}">${t('item.range_from')}</label>${dateField(ctx, { id, iso: from, mask, attrs: html` name="${id}"${aria}` })}</span>
412
+ <span class="range-part"><label class="sub" for="${id}_TO">${t('item.range_to')}</label>${dateField(ctx, { id: `${id}_TO`, iso: to, mask, attrs: html` name="${id}"${aria}` })}</span>
387
413
  </div>`;
388
414
  break;
389
415
  }
@@ -395,21 +421,10 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
395
421
  control = html`<div class="input-with-button"><input type="text" id="${id}" name="${id}" value="${value}" inputmode="decimal" placeholder="52.01160,4.35710"${aria}>
396
422
  <button type="button" class="btn" data-locate="${id}">${icon('map')} ${ctx.locale.t('item.locate')}</button></div>`;
397
423
  break;
398
- case 'date': {
399
- // (sprint 76) in the application's date format: a text field and a calendar button (app.js), read back on submit
400
- const mask = itemDateMask(ctx, item);
401
- if (!mask) {
402
- control = html`<input type="date" id="${id}" name="${id}" value="${value}"${aria}${editable ? '' : raw(' readonly')}>`;
403
- break;
404
- }
405
- const iso = /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '';
406
- const shown = iso ? applyMask(iso, mask, ctx.locale.lang) : value;
407
- const months = monthNames(ctx.locale.lang);
408
- control = html`<div class="input-with-button date-field"><input type="text" id="${id}" name="${id}" value="${shown}" placeholder="${mask}" autocomplete="off" data-date-mask="${mask}" data-months="${JSON.stringify(months)}"${aria}${editable ? '' : raw(' readonly')}>
409
- ${editable ? html`<input type="date" class="date-native" value="${iso}" tabindex="-1" aria-hidden="true" data-date-for="${id}">
410
- <button type="button" class="btn" data-date-pick="${id}" aria-label="${ctx.locale.t('item.choose_date')}" title="${ctx.locale.t('item.choose_date')}">${icon('calendar')}</button>` : ''}</div>`;
424
+ case 'date':
425
+ // (sprint 76, 84) in the date format: a text field and pgkiln's date picker (app.js), read back on submit
426
+ control = dateField(ctx, { id, iso: value, mask: itemDateMask(ctx, item)!, attrs: html` name="${id}"${aria}`, editable });
411
427
  break;
412
- }
413
428
  default: {
414
429
  const typed: Record<string, string> = { number: 'number', date: 'date', password: 'password', email: 'email', tel: 'tel', url: 'url' };
415
430
  // a number with a format mask is text in the language's notation (1.234,50), read back on submit
@@ -461,7 +476,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
461
476
  const tag = useLegend ? 'fieldset' : 'div';
462
477
  return html`${raw(`<${tag}`)} class="field field-${item.type}${error ? ' has-error' : ''}${editable ? '' : ' readonly'}${templateClasses('item', item.template_options)}" data-item="${item.name}"${attrs}${pluginAttrs}>
463
478
  ${labelHtml}${control}${lovError}
464
- ${item.help ? html`<small class="help" id="${id}_help">${item.help}</small>` : ''}
479
+ ${item.help ? html`<small class="help" id="${id}_help">${await expandShortcuts(ctx, item.help, 'text')}</small>` : ''}
465
480
  ${error ? html`<small class="error" id="${id}_error">${error}</small>` : ''}
466
481
  ${raw(`</${tag}>`)}`;
467
482
  }
@@ -225,7 +225,7 @@ export async function renderListRegion(ctx: PageContext, r: Region): Promise<Raw
225
225
  case 'badges':
226
226
  return html`<ul class="list-badges">${flat(nodes).map((n) => html`<li><a ${linkAttrs(n, 'list-badge')}><span class="list-badge-value">${n.badge ?? icon(n.icon ?? 'chevron')}</span><span class="list-badge-label">${n.label}</span></a></li>`)}</ul>`;
227
227
  case 'cards':
228
- return html`<ul class="cards list-cards">${flat(nodes).map((n) => html`<li><a ${linkAttrs(n, 'card list-card')}><span class="list-card-head">${icon(n.icon ?? 'chevron')}<h3>${n.label}</h3>${badge(n)}</span>${n.description ? html`<p class="muted">${n.description}</p>` : ''}</a></li>`)}</ul>`;
228
+ return html`<ul class="cards list-cards">${flat(nodes).map((n) => html`<li><a ${linkAttrs(n, 'card list-card')}><span class="list-card-head"><span class="list-card-icon">${icon(n.icon ?? 'chevron')}</span><h3>${n.label}</h3>${badge(n)}</span>${n.description ? html`<p class="muted">${n.description}</p>` : ''}</a></li>`)}</ul>`;
229
229
  case 'tabs':
230
230
  return html`<nav class="list-tabs" aria-label="${label}"><ul class="rds-list">${nodes.filter((n) => n.href).map((n) => html`<li><a ${linkAttrs(n, `rds-tab${n.inTrail ? ' is-current' : ''}`)}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</a></li>`)}</ul></nav>`;
231
231
  case 'wizard_progress': {
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { applyBinds } from '../binds.ts';
3
3
  import { runtime, savepoint } from '../db.ts';
4
- import type { Branch, Computation, Condition } from '../metadata.ts';
4
+ import type { AppComputation, Branch, Computation, Condition } from '../metadata.ts';
5
5
  import { isAuthorized, sqlTrue } from './authz.ts';
6
6
  import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, toState, type PageContext } from './context.ts';
7
7
  import { pageHref } from './links.ts';
@@ -112,19 +112,24 @@ export class ComputationFailed extends Error {}
112
112
  * items only. Before the page is shown, an error becomes a message on the
113
113
  * page; after a submit, it stops the submit (ComputationFailed).
114
114
  */
115
- export async function runComputations(ctx: PageContext, point: Computation['point']) {
115
+ export async function runComputations(ctx: PageContext, point: AppComputation['point']) {
116
116
  const names = new Set([...ctx.app.app_items, ...ctx.page.items.map((i) => i.name)]);
117
- for (const comp of ctx.page.computations ?? []) {
118
- if (comp.point !== point) continue;
119
- const where = `computation of ${comp.item_name}`;
117
+ // (102) the application's computations of the point first (as in APEX), then the page's
118
+ const comps: { comp: Computation | AppComputation; app: boolean }[] = [
119
+ ...(ctx.app.app_computations ?? []).filter((c) => c.point === point).map((comp) => ({ comp, app: true })),
120
+ ...(ctx.page.computations ?? []).filter((c) => c.point === point).map((comp) => ({ comp, app: false })),
121
+ ];
122
+ for (const { comp, app } of comps) {
123
+ const where = `${app ? 'application computation' : 'computation'} of ${comp.item_name}`;
120
124
  if (!names.has(comp.item_name)) {
121
- ctx.errors.page.push(ctx.locale.t('logic.unknown_item', { item: comp.item_name }));
125
+ // an application computation of a page item computes it on that item's page only
126
+ if (!app) ctx.errors.page.push(ctx.locale.t('logic.unknown_item', { item: comp.item_name }));
122
127
  continue;
123
128
  }
124
129
  if (!(await isAuthorized(ctx, comp.authz))) continue;
125
130
  if (!(await conditionHolds(ctx, comp, `condition of the ${where}`))) continue;
126
131
  try {
127
- ctx.session.state[comp.item_name] = await timed(ctx, 6, 'computation', `computation of ${comp.item_name} (${comp.type})`, () => computeValue(ctx, comp));
132
+ ctx.session.state[comp.item_name] = await timed(ctx, 6, 'computation', `${where} (${comp.type})`, () => computeValue(ctx, comp));
128
133
  dbg(ctx, 9, 'computation', () => `${comp.item_name} := ${ctx.page.items.some((i) => i.name === comp.item_name && i.type === 'password') ? '(password, not shown)' : JSON.stringify((ctx.session.state[comp.item_name] ?? '').slice(0, 200))}`);
129
134
  } catch (e) {
130
135
  const message = await publicError(ctx, e, where);
@@ -1,3 +1,4 @@
1
+ import { appDateReader } from './items.ts';
1
2
  import { existsSync } from 'node:fs';
2
3
  import PDFDocument from 'pdfkit';
3
4
  import type pg from 'pg';
@@ -192,7 +193,7 @@ export async function reportPdf(ctx: PageContext, r: Region): Promise<Buffer> {
192
193
  ...st.filters.map((f) => `${headingOf(r, f.column, ctx.locale.tr)} ${OPERATORS[f.op]?.label ?? f.op} ${OPERATORS[f.op]?.noValue ? '' : f.value}`.trim()),
193
194
  ...(() => {
194
195
  const defs = reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions);
195
- return facetFilters(ctx.params, r.id, defs).map((f) => describeFacetFilter(f, defs.get(f.column)?.label ?? headingOf(r, f.column, ctx.locale.tr), t, defs.get(f.column)));
196
+ return facetFilters(ctx.params, r.id, defs, undefined, appDateReader(ctx)).map((f) => describeFacetFilter(f, defs.get(f.column)?.label ?? headingOf(r, f.column, ctx.locale.tr), t, defs.get(f.column)));
196
197
  })(),
197
198
  ...(st.search ? [`${t('report.search')}: "${st.search}"`] : []),
198
199
  ].map((s) => printable(oneLine(s)))
@@ -8,6 +8,7 @@ import type { Button, Region } from '../metadata.ts';
8
8
  import { isAuthorized, pageAllowed } from './authz.ts';
9
9
  import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, type PageContext } from './context.ts';
10
10
  import { renderItems } from './items.ts';
11
+ import { expandShortcuts } from './shortcuts.ts';
11
12
  import { fillItems, linkAttrs, linkColumns } from './links.ts';
12
13
  import { renderCalendar } from './calendar.ts';
13
14
  import { CHART_KINDS, renderChartBody, wallClockIn, type GaugeConfig } from './charts.ts';
@@ -20,6 +21,7 @@ import { renderGrid } from './grid.ts';
20
21
  import { masterItemOf, mastersOf } from './master-detail.ts';
21
22
  import { renderTasks } from './tasks.ts';
22
23
  import { renderDataReporter } from './data-reporter.ts';
24
+ import { renderFormDesigner } from './form-designer.ts';
23
25
  import { renderAssistant } from './assistant.ts';
24
26
  import { renderAiFilter } from './ai-filter.ts';
25
27
  import { renderWorkflows } from './workflows.ts';
@@ -66,7 +68,7 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
66
68
  for (const e of b.menu ?? []) {
67
69
  if (!(await isAuthorized(ctx, e.authz))) continue;
68
70
  const ic = e.icon ? html`${icon(e.icon)} ` : '';
69
- const confirm = e.confirm ? raw(` data-confirm="${esc(e.confirm)}"`) : '';
71
+ const confirm = e.confirm ? raw(` data-confirm="${esc(await expandShortcuts(ctx, e.confirm, 'text'))}"`) : '';
70
72
  if (e.page) {
71
73
  if (!(await pageAllowed(ctx, e.page))) continue;
72
74
  entries.push(html`<a ${linkAttrs(ctx, e.page, e.items ?? {})}${confirm}>${ic}${e.label}</a>`);
@@ -82,7 +84,7 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
82
84
 
83
85
  export async function renderButton(ctx: PageContext, b: Button) {
84
86
  const cls = `btn${b.hot ? ' btn-hot' : ''}${b.name === 'DELETE' ? ' btn-danger' : ''}${templateClasses('button', b.template_options)}`;
85
- const confirm = b.confirm ? raw(` data-confirm="${esc(b.confirm)}"`) : '';
87
+ const confirm = b.confirm ? raw(` data-confirm="${esc(await expandShortcuts(ctx, b.confirm, 'text'))}"`) : '';
86
88
  const badge = b.badge || b.badge_query ? await badgeOf(ctx, b) : '';
87
89
  if (b.action === 'menu') return renderMenu(ctx, b, cls, badge);
88
90
  if (b.action === 'redirect') {
@@ -377,6 +379,9 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
377
379
  case 'data_reporter':
378
380
  body = await renderDataReporter(ctx, r);
379
381
  break;
382
+ case 'form_designer':
383
+ body = await renderFormDesigner(ctx, r);
384
+ break;
380
385
  case 'ai_assistant':
381
386
  body = await renderAssistant(ctx, r);
382
387
  break;
@@ -401,7 +406,7 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
401
406
  break;
402
407
  case 'static': {
403
408
  const itemsHtml = await renderItems(ctx, items, hidden);
404
- body = html`${raw(substitute(r.source ?? '', ctx, esc))}${itemsHtml.length ? html`<div class="form-grid">${itemsHtml}</div>` : ''}`;
409
+ body = html`${raw(substitute(await expandShortcuts(ctx, r.source ?? '', 'html'), ctx, esc))}${itemsHtml.length ? html`<div class="form-grid">${itemsHtml}</div>` : ''}`;
405
410
  break;
406
411
  }
407
412
  }
@@ -268,10 +268,11 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
268
268
  const navbar = navbarNodes ? navbarMarkup(navbarNodes, t('list.navbar')) : '';
269
269
  // (sprint 72) the account's display name in the user menu
270
270
  const shown = signedIn ? ((await displayName(ctx.user)) ?? ctx.user) : ctx.user;
271
+ // (sprint 88, GEN-4; APEX: the page footer) under the content: the application's version and "Back to top" (to the header, #top)
271
272
  return documentShell(
272
273
  `${title} · ${ctx.app.name}`,
273
274
  html`<a class="skip-link" href="#main">${t('common.skip')}</a>
274
- <header class="t-header">
275
+ <header class="t-header" id="top">
275
276
  <a href="#t-nav" class="t-nav-toggle icon-button" role="button" aria-label="${t('common.toggle_nav')}" aria-controls="t-nav">${icon('menu')}</a>
276
277
  <a class="t-logo" href="${ctx.base}/${ctx.app.home_page}">${ctx.app.name}</a>
277
278
  <span class="t-spacer"></span>
@@ -306,7 +307,8 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
306
307
  <div class="t-body">
307
308
  <nav id="t-nav" class="t-nav" aria-label="Main"><ul>${nav}</ul></nav>
308
309
  <a href="#" class="t-nav-backdrop" tabindex="-1" aria-hidden="true"></a>
309
- <main class="t-main" id="main">${main}</main>
310
+ <main class="t-main" id="main">${main}
311
+ <footer class="t-footer">${ctx.app.version ? html`<span class="t-version">${ctx.locale.tr(ctx.app.version)}</span>` : ''}<a class="t-top" href="#top">${icon('arrow-up')} ${t('common.back_to_top')}</a></footer></main>
310
312
  </div>
311
313
  ${devbarHtml(ctx)}`,
312
314
  `t-app${topNav ? ' nav-top' : ''}`,
@@ -338,7 +340,9 @@ const CLIENT_TEXTS = ['pwa.offline_banner', 'pwa.queued', 'pwa.queue_waiting', '
338
340
  'push.turn_on', 'push.turn_off', 'push.on', 'push.off', 'push.blocked', 'push.unsupported', 'push.failed', 'page.unsaved_changes', 'page.link_copied',
339
341
  'session.warning', 'session.stay', 'session.max', 'session.ended', 'login.sign_in_link',
340
342
  // (sprint 76) a report column heading's menu
341
- 'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values'] as const;
343
+ 'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values',
344
+ // (sprint 84) the date picker
345
+ 'item.choose_date', 'calendar.previous', 'calendar.next', 'calendar.today', 'facets.clear_short'] as const;
342
346
  export const clientTexts = (ctx: PageContext) => Object.fromEntries(CLIENT_TEXTS.map((k) => [k, ctx.locale.t(k)]));
343
347
 
344
348
  /**
@@ -364,6 +368,7 @@ const timeZoneMeta = (ctx: PageContext) =>
364
368
 
365
369
  export async function renderPage(ctx: PageContext) {
366
370
  const t = ctx.locale.t;
371
+ ctx.wholePage = true;
367
372
  const hidden = initiallyHidden(ctx);
368
373
  const defaultButton = [...ctx.vis!.buttons.values()].find((b) => b.hot && b.action === 'submit');
369
374
  const pageItems = await renderItems(ctx, ctx.page.items.filter((i) => i.region_id === null), hidden);
@@ -1,3 +1,4 @@
1
+ import { componentSetting } from './component-settings.ts';
1
2
  import { icon } from '../icons.ts';
2
3
  import pg from 'pg';
3
4
  import { templateClasses } from './template-options.ts';
@@ -7,7 +8,7 @@ import { esc, html, raw, type Raw } from '../html.ts';
7
8
  import type { Region } from '../metadata.ts';
8
9
  import { isAuthorized, pageAllowed } from './authz.ts';
9
10
  import { bindValues, publicError, stripSemicolon, type PageContext } from './context.ts';
10
- import { heading, splitValues } from './items.ts';
11
+ import { appDateReader, heading, splitValues } from './items.ts';
11
12
  import { linkAttrs } from './links.ts';
12
13
  import type { Translate } from '../i18n.ts';
13
14
  import type { Formatter } from './format.ts';
@@ -237,7 +238,7 @@ export function selectionOf(page: { items: { name: string }[] }, r: Region) {
237
238
 
238
239
  export function reportState(ctx: PageContext, r: Region): ReportState {
239
240
  const p = ctx.params;
240
- const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || 15;
241
+ const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'report_rows');
241
242
  const st: ReportState = {
242
243
  search: (p.get(key(r, 'q')) ?? '').trim(),
243
244
  searchCol: (p.get(key(r, 'qc')) ?? '').trim() || null,
@@ -420,7 +421,7 @@ export async function filtered(ctx: PageContext, r: Region, st: ReportState) {
420
421
  // the search term, facet values and range bounds are query parameters
421
422
  const p = new SqlParams();
422
423
  const hides = hidesColumns(r);
423
- const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions));
424
+ const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions), undefined, appDateReader(ctx));
424
425
  const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort)) || (st.search && st.searchCol);
425
426
  // column name → type oid
426
427
  const fields = needCols ? await fieldsOf(ctx, src) : [];
@@ -141,7 +141,8 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
141
141
  if (shared) s.state.__SHARED = shared;
142
142
  await saveState(s);
143
143
 
144
- if (a.app_processes.some((p) => p.point === 'after_login')) {
144
+ // the application processes "after login", then (102) the application computations "after authentication"
145
+ if (a.app_processes.some((p) => p.point === 'after_login') || (a.app_computations ?? []).some((c) => c.point === 'after_authentication')) {
145
146
  const home = (await loadPage(a.id, a.home_page)) ?? ({ page_no: a.home_page, items: [], regions: [], buttons: [], dynamic_actions: [], validations: [], processes: [], computations: [], branches: [] } as unknown as Page);
146
147
  const ctx: PageContext = {
147
148
  app: a, page: home, session: s, base, params: new URLSearchParams(), request: '', user: username,
@@ -151,6 +152,7 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
151
152
  await appTx(txContext(ctx), async (c) => {
152
153
  ctx.client = c;
153
154
  await runAppProcesses(ctx, 'after_login');
155
+ await runComputations(ctx, 'after_authentication');
154
156
  });
155
157
  await saveState(s);
156
158
  }
@@ -358,7 +360,9 @@ function applyPostedItems(ctx: PageContext, body: Body, only?: string[]) {
358
360
  }
359
361
  if (item.type === 'daterange') {
360
362
  // two date inputs with the item's name (or "from:to" from a dynamic action); format checked in validate()
361
- const parts = (Array.isArray(raw) ? raw : raw ? String(raw).split(':') : []).map((v) => String(v).trim());
363
+ // (sprint 84) each part typed in the date format (or YYYY-MM-DD) → YYYY-MM-DD; other text is kept, and validate() reports it
364
+ const mask = itemDateMask(ctx, item)!;
365
+ const parts = (Array.isArray(raw) ? raw : raw ? String(raw).split(':') : []).map((v) => String(v).trim()).map((v) => (v ? (parseDateMask(v, mask, ctx.locale.lang) ?? v) : v));
362
366
  ctx.session.state[item.name] = parts.some(Boolean) ? (parts.length === 1 ? `${parts[0]}:` : parts.join(':')) : null;
363
367
  continue;
364
368
  }
@@ -1025,7 +1029,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1025
1029
  if (!item || !vis.editable.has(item.name)) throw new Forbidden(ctx.locale.t('error.unknown_item'));
1026
1030
  if (item.config?.cascade_parents) applyPostedItems(ctx, body, list(item.config.cascade_parents));
1027
1031
  const page = Math.min(Math.max(Math.floor(Number(body.p)) || 0, 0), 10_000);
1028
- return searchLov(ctx, item, String(body.q ?? '').slice(0, 200), page, popupPageSize(item, body.n));
1032
+ return searchLov(ctx, item, String(body.q ?? '').slice(0, 200), page, popupPageSize(item, body.n, ctx.app.component_settings));
1029
1033
  });
1030
1034
  return reply.send(out);
1031
1035
  } catch (e) {
@@ -0,0 +1,43 @@
1
+ import { esc } from '../html.ts';
2
+ import { publicError, type PageContext } from './context.ts';
3
+ import { computeValue } from './logic.ts';
4
+
5
+ // Shortcuts (migration 103; APEX: Shared Components → Shortcuts). "NAME" (an
6
+ // upper-case name in double quotes) in a static region's content, an item's
7
+ // help text and a button's or menu entry's confirm message is replaced by the
8
+ // application's shortcut of that name, before &ITEM. substitutions. Other
9
+ // quoted words (class="NAV" with no shortcut NAV) stay as they are.
10
+ //
11
+ // text the value; escaped where it goes into HTML
12
+ // html the value as HTML
13
+ // function_body PL/pgSQL returning the text (HTML), run once per request
14
+ // as the application's role
15
+
16
+ const NAME = /"([A-Z][A-Z0-9_]{0,59})"/g;
17
+
18
+ /** A shortcut's text (html: as it goes into HTML), computed once per request; null: no such shortcut. */
19
+ async function valueOf(ctx: PageContext, name: string, asHtml: boolean): Promise<string | null> {
20
+ const s = ctx.app.shortcuts?.find((x) => x.name === name);
21
+ if (!s) return null;
22
+ if (s.type === 'text') return asHtml ? esc(s.value) : s.value;
23
+ if (s.type === 'html') return s.value;
24
+ const cache = (ctx.shortcutValues ??= new Map());
25
+ if (!cache.has(name)) {
26
+ try {
27
+ cache.set(name, (await computeValue(ctx, { type: 'function_body', expression: s.value })) ?? '');
28
+ } catch (e) {
29
+ ctx.errors.page.push(await publicError(ctx, e, `shortcut "${name}"`));
30
+ cache.set(name, '');
31
+ }
32
+ }
33
+ return cache.get(name)!;
34
+ }
35
+
36
+ /** The text with its "NAME" shortcuts replaced (as HTML or as plain text). */
37
+ export async function expandShortcuts(ctx: PageContext, text: string, as: 'html' | 'text'): Promise<string> {
38
+ if (!ctx.app.shortcuts?.length || !text.includes('"')) return text;
39
+ const names = new Set([...text.matchAll(NAME)].map((m) => m[1]));
40
+ const values = new Map<string, string | null>();
41
+ for (const n of names) values.set(n, await valueOf(ctx, n, as === 'html'));
42
+ return text.replace(NAME, (m, n: string) => values.get(n) ?? m);
43
+ }
@@ -1,3 +1,4 @@
1
+ import { appDateReader } from './items.ts';
1
2
  import { html, type Raw } from '../html.ts';
2
3
  import { icon } from '../icons.ts';
3
4
  import type { Region } from '../metadata.ts';
@@ -34,7 +35,7 @@ export async function renderSmartFilters(ctx: PageContext, r: Region): Promise<R
34
35
  const formId = `sf${r.id}`;
35
36
  const defs = facetDefs(r.config).filter((f) => fs.cols.has(f.column));
36
37
  const per = Number.isInteger(r.config.suggestions) ? Math.max(0, Math.min(MAX_SUGGESTIONS, r.config.suggestions)) : 3;
37
- const filters = facetFilters(ctx.params, report.id, fs.all);
38
+ const filters = facetFilters(ctx.params, report.id, fs.all, undefined, appDateReader(ctx));
38
39
  const own = new Map<string, FacetFilter>(filters.filter((f) => defs.some((d) => d.column === f.column)).map((f) => [f.column, f]));
39
40
 
40
41
  // ---- chips: the search term and each filter of this region's facets, each with a remove link