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.
- package/db/migrations/102_app_computations.sql +345 -0
- package/db/migrations/103_shortcuts.sql +341 -0
- package/db/migrations/104_report_queries.sql +413 -0
- package/db/migrations/105_component_settings_groups.sql +367 -0
- package/db/migrations/106_form_designer.sql +102 -0
- package/db/migrations/107_app_version.sql +9 -0
- package/docs/apex-feature-parity.md +15 -12
- package/docs/guide/02-concepts.md +24 -0
- package/docs/guide/03-builder.md +69 -13
- package/docs/guide/04-pages-and-regions.md +84 -3
- package/docs/guide/05-items.md +14 -2
- package/docs/guide/06-processing.md +22 -0
- package/docs/guide/09-reference.md +14 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +8 -2
- package/docs/guide/16-files.md +23 -0
- package/docs/guide/18-cli.md +4 -0
- package/examples/hr/hr_61_form_designer.sql +80 -0
- package/package.json +1 -1
- package/public/app.css +79 -2
- package/public/app.js +307 -35
- package/public/builder.css +21 -1
- package/public/builder.js +67 -0
- package/public/sw.js +2 -1
- package/src/app.ts +9 -1
- package/src/appfiles.ts +18 -2
- package/src/builder/advisor.ts +7 -2
- package/src/builder/appwizard.ts +22 -6
- package/src/builder/arrange.ts +33 -12
- package/src/builder/code-editor.ts +7 -2
- package/src/builder/components.ts +86 -4
- package/src/builder/designer.ts +44 -17
- package/src/builder/errors.ts +65 -0
- package/src/builder/form-designer.ts +229 -0
- package/src/builder/forms.ts +1 -1
- package/src/builder/locks.ts +6 -0
- package/src/builder/newapp.ts +2 -1
- package/src/builder/object-browser.ts +81 -18
- package/src/builder/region-help.ts +2 -0
- package/src/builder/region-settings.ts +2 -0
- package/src/builder/reporter.ts +1 -1
- package/src/builder/routes.ts +7 -5
- package/src/builder/search.ts +3 -3
- package/src/builder/shared.ts +41 -8
- package/src/builder/subscriptions.ts +30 -4
- package/src/builder/ui.ts +2 -0
- package/src/cli/replace.ts +16 -2
- package/src/i18n/ar.ts +62 -0
- package/src/i18n/cs.ts +62 -0
- package/src/i18n/da.ts +62 -0
- package/src/i18n/de.ts +62 -0
- package/src/i18n/el.ts +62 -0
- package/src/i18n/es.ts +62 -0
- package/src/i18n/fi.ts +62 -0
- package/src/i18n/fr.ts +62 -0
- package/src/i18n/he.ts +62 -0
- package/src/i18n/it.ts +62 -0
- package/src/i18n/ja.ts +62 -0
- package/src/i18n/ko.ts +62 -0
- package/src/i18n/nb.ts +62 -0
- package/src/i18n/pl.ts +62 -0
- package/src/i18n/pt.ts +62 -0
- package/src/i18n/ru.ts +62 -0
- package/src/i18n/sv.ts +62 -0
- package/src/i18n/tr.ts +62 -0
- package/src/i18n/uk.ts +62 -0
- package/src/i18n/zh.ts +62 -0
- package/src/i18n.ts +124 -0
- package/src/metadata.ts +18 -3
- package/src/runtime/component-settings.ts +42 -0
- package/src/runtime/context.ts +4 -0
- package/src/runtime/devbar.ts +1 -1
- package/src/runtime/documents.ts +29 -3
- package/src/runtime/engine.ts +2 -2
- package/src/runtime/facet-state.ts +10 -5
- package/src/runtime/facets.ts +29 -5
- package/src/runtime/form-designer.ts +676 -0
- package/src/runtime/grid.ts +36 -11
- package/src/runtime/items.ts +47 -32
- package/src/runtime/lists.ts +1 -1
- package/src/runtime/logic.ts +12 -7
- package/src/runtime/pdf.ts +2 -1
- package/src/runtime/regions.ts +8 -3
- package/src/runtime/render.ts +8 -3
- package/src/runtime/report.ts +4 -3
- package/src/runtime/routes.ts +7 -3
- package/src/runtime/shortcuts.ts +43 -0
- package/src/runtime/smart-filters.ts +2 -1
- package/src/runtime/tasks.ts +6 -3
- package/src/security.ts +2 -1
- package/src/subscriptions.ts +67 -0
package/src/runtime/grid.ts
CHANGED
|
@@ -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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/runtime/items.ts
CHANGED
|
@@ -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
|
|
82
|
-
|
|
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)) ||
|
|
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),
|
|
186
|
-
* application's date format; null
|
|
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
|
|
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
|
|
386
|
-
<span class="range-part"><label class="sub" for="${id}_TO">${t('item.range_to')}</label
|
|
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
|
|
400
|
-
|
|
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
|
}
|
package/src/runtime/lists.ts
CHANGED
|
@@ -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')}
|
|
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': {
|
package/src/runtime/logic.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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',
|
|
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);
|
package/src/runtime/pdf.ts
CHANGED
|
@@ -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)))
|
package/src/runtime/regions.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtime/render.ts
CHANGED
|
@@ -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}
|
|
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'
|
|
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);
|
package/src/runtime/report.ts
CHANGED
|
@@ -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) ||
|
|
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) : [];
|
package/src/runtime/routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|