pgkiln 0.43.0 → 0.45.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/100_generated_labels.sql +381 -0
- package/db/migrations/101_theme_choice.sql +15 -0
- 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/docs/apex-feature-parity.md +12 -12
- package/docs/guide/02-concepts.md +24 -0
- package/docs/guide/03-builder.md +48 -9
- package/docs/guide/04-pages-and-regions.md +20 -4
- package/docs/guide/06-processing.md +22 -0
- package/docs/guide/09-reference.md +14 -0
- package/docs/guide/12-development.md +2 -1
- package/docs/guide/14-globalization.md +5 -1
- package/docs/guide/16-files.md +23 -0
- package/docs/guide/18-cli.md +4 -0
- package/package.json +1 -1
- package/public/app.css +40 -1
- package/public/app.js +69 -1
- package/src/appfiles.ts +18 -2
- package/src/builder/advisor.ts +7 -2
- package/src/builder/appsheets.ts +53 -13
- package/src/builder/appwizard.ts +5 -3
- package/src/builder/code-editor.ts +6 -1
- package/src/builder/components.ts +85 -3
- package/src/builder/forms.ts +1 -1
- package/src/builder/shared.ts +39 -6
- package/src/builder/subscriptions.ts +30 -4
- package/src/cli/replace.ts +6 -2
- package/src/i18n/ar.ts +2 -0
- package/src/i18n/cs.ts +2 -0
- package/src/i18n/da.ts +2 -0
- package/src/i18n/de.ts +2 -0
- package/src/i18n/el.ts +2 -0
- package/src/i18n/es.ts +2 -0
- package/src/i18n/fi.ts +2 -0
- package/src/i18n/fr.ts +2 -0
- package/src/i18n/he.ts +2 -0
- package/src/i18n/it.ts +2 -0
- package/src/i18n/ja.ts +2 -0
- package/src/i18n/ko.ts +2 -0
- package/src/i18n/nb.ts +2 -0
- package/src/i18n/pl.ts +2 -0
- package/src/i18n/pt.ts +2 -0
- package/src/i18n/ru.ts +2 -0
- package/src/i18n/sv.ts +2 -0
- package/src/i18n/tr.ts +2 -0
- package/src/i18n/uk.ts +2 -0
- package/src/i18n/zh.ts +2 -0
- package/src/i18n.ts +4 -0
- package/src/metadata.ts +14 -1
- package/src/runtime/account.ts +5 -5
- package/src/runtime/component-settings.ts +42 -0
- package/src/runtime/context.ts +4 -0
- package/src/runtime/devbar.ts +16 -13
- package/src/runtime/documents.ts +29 -3
- package/src/runtime/engine.ts +2 -2
- package/src/runtime/facets.ts +34 -3
- package/src/runtime/grid.ts +22 -4
- package/src/runtime/items.ts +14 -11
- package/src/runtime/locale.ts +4 -1
- package/src/runtime/logic.ts +12 -7
- package/src/runtime/regions.ts +4 -3
- package/src/runtime/render.ts +8 -2
- package/src/runtime/report.ts +16 -10
- package/src/runtime/routes.ts +9 -6
- package/src/runtime/shortcuts.ts +43 -0
- package/src/security.ts +2 -1
- package/src/subscriptions.ts +67 -0
package/src/i18n/ru.ts
CHANGED
|
@@ -390,6 +390,8 @@ export const ru: Record<MessageKey, string> = {
|
|
|
390
390
|
'facets.stars_up': "{n} звезды и выше",
|
|
391
391
|
'facets.star_up': "1 звезда и выше",
|
|
392
392
|
'facets.clear_short': "Очистить",
|
|
393
|
+
'facets.show_all': "Показать все ({n})",
|
|
394
|
+
'facets.total': "Всего строк {n}",
|
|
393
395
|
'smart.placeholder': "Поиск или фильтр…",
|
|
394
396
|
'smart.active': "Действующие фильтры",
|
|
395
397
|
'smart.remove': "Удалить фильтр {filter}",
|
package/src/i18n/sv.ts
CHANGED
|
@@ -390,6 +390,8 @@ export const sv: Record<MessageKey, string> = {
|
|
|
390
390
|
'facets.stars_up': "{n} stjärnor och uppåt",
|
|
391
391
|
'facets.star_up': "1 stjärna och uppåt",
|
|
392
392
|
'facets.clear_short': "Rensa",
|
|
393
|
+
'facets.show_all': "Visa alla ({n})",
|
|
394
|
+
'facets.total': "Totalt antal rader {n}",
|
|
393
395
|
'smart.placeholder': "Sök eller filtrera…",
|
|
394
396
|
'smart.active': "Filter som används",
|
|
395
397
|
'smart.remove': "Ta bort filtret {filter}",
|
package/src/i18n/tr.ts
CHANGED
|
@@ -390,6 +390,8 @@ export const tr: Record<MessageKey, string> = {
|
|
|
390
390
|
'facets.stars_up': "{n} yıldız ve üzeri",
|
|
391
391
|
'facets.star_up': "1 yıldız ve üzeri",
|
|
392
392
|
'facets.clear_short': "Temizle",
|
|
393
|
+
'facets.show_all': "Tümünü göster ({n})",
|
|
394
|
+
'facets.total': "Toplam satır sayısı {n}",
|
|
393
395
|
'smart.placeholder': "Ara veya süz…",
|
|
394
396
|
'smart.active': "Kullanılan süzgeçler",
|
|
395
397
|
'smart.remove': "{filter} süzgecini kaldır",
|
package/src/i18n/uk.ts
CHANGED
|
@@ -390,6 +390,8 @@ export const uk: Record<MessageKey, string> = {
|
|
|
390
390
|
'facets.stars_up': "{n} зірки й вище",
|
|
391
391
|
'facets.star_up': "1 зірка й вище",
|
|
392
392
|
'facets.clear_short': "Очистити",
|
|
393
|
+
'facets.show_all': "Показати все ({n})",
|
|
394
|
+
'facets.total': "Усього рядків {n}",
|
|
393
395
|
'smart.placeholder': "Пошук або фільтр…",
|
|
394
396
|
'smart.active': "Чинні фільтри",
|
|
395
397
|
'smart.remove': "Видалити фільтр {filter}",
|
package/src/i18n/zh.ts
CHANGED
|
@@ -390,6 +390,8 @@ export const zh: Record<MessageKey, string> = {
|
|
|
390
390
|
'facets.stars_up': "{n} 星及以上",
|
|
391
391
|
'facets.star_up': "1 星及以上",
|
|
392
392
|
'facets.clear_short': "清除",
|
|
393
|
+
'facets.show_all': "显示全部 ({n})",
|
|
394
|
+
'facets.total': "总行数 {n}",
|
|
393
395
|
'smart.placeholder': "搜索或筛选…",
|
|
394
396
|
'smart.active': "正在使用的筛选器",
|
|
395
397
|
'smart.remove': "删除筛选器 {filter}",
|
package/src/i18n.ts
CHANGED
|
@@ -417,6 +417,8 @@ const en = {
|
|
|
417
417
|
'facets.stars_up': '{n} stars and up',
|
|
418
418
|
'facets.star_up': '1 star and up',
|
|
419
419
|
'facets.clear_short': 'Clear',
|
|
420
|
+
'facets.show_all': 'Show all ({n})',
|
|
421
|
+
'facets.total': 'Total row count {n}',
|
|
420
422
|
'smart.placeholder': 'Search or filter…',
|
|
421
423
|
'smart.active': 'Filters in use',
|
|
422
424
|
'smart.remove': 'Remove filter {filter}',
|
|
@@ -1121,6 +1123,8 @@ const nl: Record<MessageKey, string> = {
|
|
|
1121
1123
|
'facets.stars_up': '{n} sterren of meer',
|
|
1122
1124
|
'facets.star_up': '1 ster of meer',
|
|
1123
1125
|
'facets.clear_short': 'Wissen',
|
|
1126
|
+
'facets.show_all': 'Alle tonen ({n})',
|
|
1127
|
+
'facets.total': 'Totaal aantal rijen {n}',
|
|
1124
1128
|
'smart.placeholder': 'Zoeken of filteren…',
|
|
1125
1129
|
'smart.active': 'Gebruikte filters',
|
|
1126
1130
|
'smart.remove': 'Filter {filter} verwijderen',
|
package/src/metadata.ts
CHANGED
|
@@ -106,6 +106,12 @@ export interface App {
|
|
|
106
106
|
authz_schemes: AuthzScheme[];
|
|
107
107
|
app_items: string[];
|
|
108
108
|
app_processes: AppProcess[];
|
|
109
|
+
/** (102) application computations: run for every page at their point, before the page's own */
|
|
110
|
+
app_computations: AppComputation[];
|
|
111
|
+
/** (105) defaults of the built-in component types (runtime/component-settings.ts) */
|
|
112
|
+
component_settings: import('./runtime/component-settings.ts').ComponentSettings;
|
|
113
|
+
/** (103) shortcuts: "NAME" in static content, help texts and confirm messages (runtime/shortcuts.ts) */
|
|
114
|
+
shortcuts?: { name: string; type: 'text' | 'html' | 'function_body'; value: string }[];
|
|
109
115
|
lovs: { name: string; query: string; rest_source?: string | null }[];
|
|
110
116
|
theme: {
|
|
111
117
|
accent?: string; header?: string; nav?: 'side' | 'top'; mode?: 'auto' | 'light' | 'dark'; user_choice?: boolean;
|
|
@@ -238,6 +244,11 @@ export interface Computation extends Condition {
|
|
|
238
244
|
authz: string | null;
|
|
239
245
|
}
|
|
240
246
|
|
|
247
|
+
/** (102) An application computation: like a page's, for every page of the application. */
|
|
248
|
+
export interface AppComputation extends Omit<Computation, 'point'> {
|
|
249
|
+
point: 'after_authentication' | 'before_header' | 'after_submit';
|
|
250
|
+
}
|
|
251
|
+
|
|
241
252
|
export interface Branch extends Condition {
|
|
242
253
|
id: number;
|
|
243
254
|
seq: number;
|
|
@@ -349,7 +360,7 @@ const agg = (table: string, fk: string, parent: string, appId: string) =>
|
|
|
349
360
|
export async function loadApp(alias: string) {
|
|
350
361
|
return runtime.one<App>(
|
|
351
362
|
`select a.id, a.alias, a.name, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
|
|
352
|
-
a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds,
|
|
363
|
+
a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds, a.component_settings,
|
|
353
364
|
a.language, a.languages, a.language_from, a.date_format, a.timestamp_format, a.time_zone, a.time_zone_auto, a.currency,
|
|
354
365
|
coalesce((select jsonb_agg(jsonb_build_object('name', l.name, 'query', l.query, 'rest_source', l.rest_source)) from meta.lov l where l.app_id = a.id), '[]') as lovs,
|
|
355
366
|
coalesce((select jsonb_agg(jsonb_build_object('page_no', p.page_no, 'name', p.name, 'title', p.title,
|
|
@@ -361,6 +372,8 @@ export async function loadApp(alias: string) {
|
|
|
361
372
|
from meta.authz_scheme s where s.app_id = a.id), '[]') as authz_schemes,
|
|
362
373
|
coalesce((select jsonb_agg(i.name) from meta.app_item i where i.app_id = a.id), '[]') as app_items,
|
|
363
374
|
${agg('meta.app_process', 'app_id', 'a', 'a.id')} as app_processes,
|
|
375
|
+
${agg('meta.app_computation', 'app_id', 'a', 'a.id')} as app_computations,
|
|
376
|
+
coalesce((select jsonb_agg(jsonb_build_object('name', s.name, 'type', s.type, 'value', s.value)) from meta.shortcut s where s.app_id = a.id), '[]') as shortcuts,
|
|
364
377
|
${includes('a.static_includes', 'a.id')} as static_includes,
|
|
365
378
|
coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' - 'install_sql') from meta.plugin x where x.app_id = a.id), '[]') as plugins,
|
|
366
379
|
coalesce((select jsonb_agg(jsonb_build_object('name', f.name, 'v', floor(extract(epoch from f.updated_at))::bigint))
|
package/src/runtime/account.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { forgetAllRemembered, rememberCookie, rememberedCount } from '../remembe
|
|
|
7
7
|
import { getSession, loginThrottled, logActivity, saveState, takeFlash } from '../session.ts';
|
|
8
8
|
import { loginWindowMinutes } from '../security.ts';
|
|
9
9
|
import type { PageContext } from './context.ts';
|
|
10
|
-
import { databaseTimeZone, isTheme, matchLanguage, sameOffset, THEME_COOKIE, timeZoneFor, timeZoneNames, validTimeZone } from './locale.ts';
|
|
10
|
+
import { databaseTimeZone, isTheme, matchLanguage, sameOffset, THEME_COOKIE, themeCookieOptions, timeZoneFor, timeZoneNames, validTimeZone } from './locale.ts';
|
|
11
11
|
import { chrome, clientTexts } from './render.ts';
|
|
12
12
|
import { appStyles, choosable, chosenStyleName, styleChoice } from './styles.ts';
|
|
13
13
|
import { loadApp } from '../metadata.ts';
|
|
@@ -28,8 +28,8 @@ async function saveStyle(appId: number, username: string, style: string) {
|
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// (sprint 79) per application: a choice in one application doesn't change another's mode
|
|
32
|
+
const themeCookie = (reply: FastifyReply, theme: string, base: string) => reply.setCookie(THEME_COOKIE, theme, themeCookieOptions(base));
|
|
33
33
|
|
|
34
34
|
/** My account → Time zone: automatic (the browser's) or one of PostgreSQL's time zones. */
|
|
35
35
|
async function timeZoneField(ctx: PageContext) {
|
|
@@ -160,7 +160,7 @@ export async function accountRoutes(app: FastifyInstance) {
|
|
|
160
160
|
}
|
|
161
161
|
if (theme) {
|
|
162
162
|
ctx.session.state.__THEME = theme;
|
|
163
|
-
themeCookie(reply, theme);
|
|
163
|
+
themeCookie(reply, theme, ctx.base);
|
|
164
164
|
}
|
|
165
165
|
if (language) ctx.session.state.__LANG = language;
|
|
166
166
|
// a style variant: only one of the app's own styles ('' = the base colours)
|
|
@@ -263,7 +263,7 @@ export async function accountRoutes(app: FastifyInstance) {
|
|
|
263
263
|
const to = safeNext(a, b.next);
|
|
264
264
|
if (b.__csrf !== session.csrf_token || a.theme?.user_choice === false || !isTheme(b.theme)) return reply.redirect(to, 303);
|
|
265
265
|
session.state.__THEME = b.theme;
|
|
266
|
-
themeCookie(reply, b.theme);
|
|
266
|
+
themeCookie(reply, b.theme, `/a/${a.alias}`);
|
|
267
267
|
if (session.username) {
|
|
268
268
|
await runtime.query('update meta.account set theme_pref = $2 where lower(username) = lower($1)', [session.username, b.theme]);
|
|
269
269
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Component settings (migration 105; APEX: Shared Components → Component
|
|
2
|
+
// Settings): an application's defaults for its built-in component types, used
|
|
3
|
+
// where a component sets nothing itself. Every value is bounded here, so a
|
|
4
|
+
// hand-edited or imported setting can't make a page read without limits.
|
|
5
|
+
|
|
6
|
+
export interface ComponentSettings {
|
|
7
|
+
report_rows?: number;
|
|
8
|
+
grid_rows?: number;
|
|
9
|
+
popup_lov_rows?: number;
|
|
10
|
+
lov_max_rows?: number;
|
|
11
|
+
rating_max?: number;
|
|
12
|
+
}
|
|
13
|
+
export type ComponentSetting = keyof ComponentSettings;
|
|
14
|
+
|
|
15
|
+
export const COMPONENT_SETTINGS: { key: ComponentSetting; label: string; group: string; min: number; max: number; def: number; help: string }[] = [
|
|
16
|
+
{ key: 'report_rows', label: 'Rows per page', group: 'Reports', min: 1, max: 1000, def: 15, help: 'Of a classic or interactive report without its own page size (users can still choose).' },
|
|
17
|
+
{ key: 'grid_rows', label: 'Rows per page', group: 'Interactive grids', min: 1, max: 200, def: 25, help: 'Of an interactive grid without its own page size.' },
|
|
18
|
+
{ key: 'popup_lov_rows', label: 'Rows per page', group: 'Popup lists of values', min: 1, max: 100, def: 25, help: 'In a popup list of values\' dialog, for items without their own page size.' },
|
|
19
|
+
{ key: 'lov_max_rows', label: 'Maximum rows', group: 'Lists of values', min: 1, max: 50_000, def: 5000, help: 'Rows a select list, radio group, checkbox group or popup list reads, for items without their own maximum.' },
|
|
20
|
+
{ key: 'rating_max', label: 'Stars', group: 'Star ratings', min: 3, max: 10, def: 5, help: 'Of a star rating item without its own maximum.' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** The value of a setting: the application's (bounded), else the built-in default. */
|
|
24
|
+
export function componentSetting(settings: ComponentSettings | null | undefined, key: ComponentSetting): number {
|
|
25
|
+
const spec = COMPONENT_SETTINGS.find((s) => s.key === key)!;
|
|
26
|
+
const n = Math.floor(Number(settings?.[key]));
|
|
27
|
+
return Number.isFinite(n) && n >= spec.min && n <= spec.max ? n : spec.def;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The settings from the builder's form: whole numbers within bounds; empty leaves a setting at its default. */
|
|
31
|
+
export function componentSettingsFrom(form: Record<string, unknown>): { settings: ComponentSettings; problems: string[] } {
|
|
32
|
+
const settings: ComponentSettings = {};
|
|
33
|
+
const problems: string[] = [];
|
|
34
|
+
for (const s of COMPONENT_SETTINGS) {
|
|
35
|
+
const raw = String(form[s.key] ?? '').trim();
|
|
36
|
+
if (raw === '') continue;
|
|
37
|
+
const n = Number(raw);
|
|
38
|
+
if (!Number.isInteger(n) || n < s.min || n > s.max) problems.push(`${s.group}, ${s.label}: a whole number from ${s.min} to ${s.max.toLocaleString('en')}.`);
|
|
39
|
+
else settings[s.key] = n;
|
|
40
|
+
}
|
|
41
|
+
return { settings, problems };
|
|
42
|
+
}
|
package/src/runtime/context.ts
CHANGED
|
@@ -66,6 +66,10 @@ export interface PageContext {
|
|
|
66
66
|
cacheRefresh?: boolean;
|
|
67
67
|
/** the lazy region the request renders (GET …/region/:id), not as a placeholder */
|
|
68
68
|
loadNow?: number;
|
|
69
|
+
/** (103) the values of the function-body shortcuts this request used */
|
|
70
|
+
shortcutValues?: Map<string, string>;
|
|
71
|
+
/** the whole page is rendered (renderPage), not one region on its own */
|
|
72
|
+
wholePage?: boolean;
|
|
69
73
|
/** a download process's file, sent instead of the page (processes.ts) */
|
|
70
74
|
download?: Download;
|
|
71
75
|
/** running a background chain (process-jobs.ts): no request, no browser */
|
package/src/runtime/devbar.ts
CHANGED
|
@@ -86,26 +86,28 @@ export function devbarHtml(ctx: PageContext): Raw | '' {
|
|
|
86
86
|
const level = sessionDebugLevel(ctx.session);
|
|
87
87
|
const appLevel = Number(ctx.app.debug_level ?? 0);
|
|
88
88
|
const page = ctx.page.page_no;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
html`<li
|
|
89
|
+
// (sprint 79, UI-4; APEX: App 77536, Page 8, Debug ⏻) each tool with a short text; "Icons only" in Settings
|
|
90
|
+
const text = (s: string) => html`<span class="devbar-text" aria-hidden="true">${s}</span>`;
|
|
91
|
+
const tool = (href: string, label: string, name: string, short: string) =>
|
|
92
|
+
html`<li><a class="devbar-tool" href="${href}" title="${label}" aria-label="${label}">${icon(name)}${text(short)}</a></li>`;
|
|
93
|
+
const menu = (label: string, name: string, cls: string, body: Raw, hidden = false, short = label) =>
|
|
94
|
+
html`<li${hidden ? raw(' hidden') : ''}><details class="devbar-menu ${cls}"><summary class="devbar-tool" title="${label}" aria-label="${label}">${icon(name)}${text(short)}</summary>
|
|
93
95
|
<div class="devbar-pop" role="group" aria-label="${label}">${body}</div></details></li>`;
|
|
94
96
|
return html`<div class="devbar" data-dock="bottom">
|
|
95
97
|
<nav aria-label="Developer toolbar"><ul class="devbar-tools">
|
|
96
|
-
${tool(BUILDER, 'App Builder', 'home')}
|
|
97
|
-
${tool(`${BUILDER}/apps/${ctx.app.id}`, `Application ${ctx.app.id}`, 'grid')}
|
|
98
|
-
${tool(`${BUILDER}/pages/${ctx.page.id}`, `Edit page ${page}`, 'edit')}
|
|
99
|
-
${tool(`${ctx.base}/${page}/devbar/session`, 'Session', 'database')}
|
|
100
|
-
${menu(
|
|
98
|
+
${tool(BUILDER, 'App Builder', 'home', 'Home')}
|
|
99
|
+
${tool(`${BUILDER}/apps/${ctx.app.id}`, `Application ${ctx.app.id}`, 'grid', `App ${ctx.app.id}`)}
|
|
100
|
+
${tool(`${BUILDER}/pages/${ctx.page.id}`, `Edit page ${page}`, 'edit', `Page ${page}`)}
|
|
101
|
+
${tool(`${ctx.base}/${page}/devbar/session`, 'Session', 'database', 'Session')}
|
|
102
|
+
${menu(`Debug: ${level || appLevel ? 'on' : 'off'}`, 'bolt', `devbar-debug-menu${level || appLevel ? ' is-on' : ''}`, html`<form method="post" action="${ctx.base}/${page}/devbar/debug" class="devbar-debug">
|
|
101
103
|
<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
|
|
102
104
|
<label for="devbar-debug">Debug messages</label>
|
|
103
105
|
<select id="devbar-debug" name="level">${DEBUG_LEVELS.map(([n, label]) =>
|
|
104
106
|
html`<option value="${n}"${n === level ? raw(' selected') : ''}>${n === 0 ? (appLevel ? `Application (${appLevel})` : 'Off') : label}</option>`)}</select>
|
|
105
107
|
<button>Set</button>
|
|
106
|
-
</form>${level || appLevel ? html`<a href="${BUILDER}/apps/${ctx.app.id}/debug">View debug</a>` : ''}`)}
|
|
107
|
-
<li><button type="button" class="devbar-tool devbar-quick" data-quick-edit="${BUILDER}/pages/${ctx.page.id}" aria-pressed="false" title="Quick edit" aria-label="Quick edit" hidden>${icon('scan')}</button></li>
|
|
108
|
-
${tool(`${BUILDER}/apps/${ctx.app.id}/theme`, 'Customize (Theme Roller)', 'sliders')}
|
|
108
|
+
</form>${level || appLevel ? html`<a href="${BUILDER}/apps/${ctx.app.id}/debug">View debug</a>` : ''}`, false, `Debug ${level || appLevel ? 'on' : 'off'}`)}
|
|
109
|
+
<li><button type="button" class="devbar-tool devbar-quick" data-quick-edit="${BUILDER}/pages/${ctx.page.id}" aria-pressed="false" title="Quick edit" aria-label="Quick edit" hidden>${icon('scan')}${text('Quick edit')}</button></li>
|
|
110
|
+
${tool(`${BUILDER}/apps/${ctx.app.id}/theme`, 'Customize (Theme Roller)', 'sliders', 'Customize')}
|
|
109
111
|
${menu('Info', 'info', 'devbar-info', html`<dl class="devbar-facts">
|
|
110
112
|
<dt>Application</dt><dd>${ctx.app.name} (${ctx.app.id})</dd>
|
|
111
113
|
<dt>Page</dt><dd>${page}: ${ctx.page.name}</dd>
|
|
@@ -115,7 +117,8 @@ export function devbarHtml(ctx: PageContext): Raw | '' {
|
|
|
115
117
|
${menu('Settings', 'settings', 'devbar-settings', html`<fieldset class="devbar-dock"><legend>Position</legend>
|
|
116
118
|
${(['bottom', 'top', 'left', 'right'] as const).map((d) => html`<label><input type="radio" name="devbar-dock" value="${d}"${d === 'bottom' ? raw(' checked') : ''}> ${d[0].toUpperCase() + d.slice(1)}</label>`)}
|
|
117
119
|
</fieldset>
|
|
118
|
-
<label class="devbar-autohide"><input type="checkbox" name="devbar-autohide" checked> Hide when not used</label
|
|
120
|
+
<label class="devbar-autohide"><input type="checkbox" name="devbar-autohide" checked> Hide when not used</label>
|
|
121
|
+
<label class="devbar-autohide"><input type="checkbox" name="devbar-compact"> Icons only</label>`, true)}
|
|
119
122
|
</ul></nav>
|
|
120
123
|
</div>`;
|
|
121
124
|
}
|
package/src/runtime/documents.ts
CHANGED
|
@@ -4,21 +4,47 @@ import { isAuthorized, Forbidden } from './authz.ts';
|
|
|
4
4
|
import { bindValues, stripSemicolon, substitute, type PageContext } from './context.ts';
|
|
5
5
|
import { documentData, documentFilename, documentPdf, fillTemplate, type DocumentTemplate } from './document.ts';
|
|
6
6
|
import { layoutFor, layoutText } from './pdf.ts';
|
|
7
|
+
import { exportDownload, type ExportFormat } from './data-export.ts';
|
|
8
|
+
import type { Process } from '../metadata.ts';
|
|
7
9
|
|
|
8
10
|
// ?doc=NAME on a page: the document template filled with the page's session
|
|
9
|
-
// state (after the URL's checksummed item values), as the app's role
|
|
11
|
+
// state (after the URL's checksummed item values), as the app's role; (104)
|
|
12
|
+
// without a template of that name, the report query of it.
|
|
13
|
+
|
|
14
|
+
/** (104) A report query: a named query printed as PDF, Excel, CSV, JSON, XML or HTML. */
|
|
15
|
+
interface ReportQuery {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string | null;
|
|
18
|
+
query: string;
|
|
19
|
+
format: ExportFormat;
|
|
20
|
+
layout: string | null;
|
|
21
|
+
file_name: string | null;
|
|
22
|
+
title: string | null;
|
|
23
|
+
authz: string | null;
|
|
24
|
+
}
|
|
10
25
|
|
|
11
26
|
export async function renderDocument(ctx: PageContext, name: string) {
|
|
12
27
|
const c = ctx.client!;
|
|
13
28
|
// metadata through the runtime connection: the page's transaction already runs as the app's role
|
|
14
29
|
const tpl = await runtime.one<DocumentTemplate>('select * from meta.document_template where app_id = $1 and name = upper($2)', [ctx.app.id, name.slice(0, 100)]);
|
|
15
|
-
|
|
30
|
+
// (104) without a document template of that name, a report query of it
|
|
31
|
+
const rq = tpl ? null : await runtime.one<ReportQuery>('select * from meta.report_query where app_id = $1 and name = upper($2)', [ctx.app.id, name.slice(0, 100)]);
|
|
32
|
+
const found = tpl ?? rq;
|
|
33
|
+
if (!found || !(await isAuthorized(ctx, found.authz))) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
|
|
16
34
|
// without signing in (a public page of an app that has sign-in) only the documents the page itself offers
|
|
17
35
|
// with a visible document button: ?doc= on any public page would otherwise hand out every template
|
|
18
36
|
if (ctx.app.authentication !== 'none' && !ctx.session.username) {
|
|
19
|
-
const offered = [...(ctx.vis?.buttons.values() ?? [])].some((b) => b.action === 'document' && (b.document ?? '').toUpperCase() ===
|
|
37
|
+
const offered = [...(ctx.vis?.buttons.values() ?? [])].some((b) => b.action === 'document' && (b.document ?? '').toUpperCase() === found.name);
|
|
20
38
|
if (!offered) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
|
|
21
39
|
}
|
|
40
|
+
if (!tpl) {
|
|
41
|
+
const q = rq!;
|
|
42
|
+
const d = await exportDownload(ctx, { name: q.name, code: q.query } as Process, {
|
|
43
|
+
export: q.format, layout: q.layout ?? undefined, file_name: q.file_name ?? undefined, title: q.title ?? q.description ?? undefined,
|
|
44
|
+
});
|
|
45
|
+
// sent like a download process's file (sandboxed, never shown as a page of the application)
|
|
46
|
+
return { download: { ...d, inline: false } };
|
|
47
|
+
}
|
|
22
48
|
const now = new Date().toISOString();
|
|
23
49
|
const data = await savepoint(c, () =>
|
|
24
50
|
documentData(c, stripSemicolon(applyBinds(tpl.query, bindValues(ctx))), {
|
package/src/runtime/engine.ts
CHANGED
|
@@ -173,8 +173,8 @@ export async function validate(ctx: PageContext) {
|
|
|
173
173
|
const v = state[i.name];
|
|
174
174
|
if (!v || !vis.editable.has(i.name) || errors.items[i.name]) continue;
|
|
175
175
|
const label = i.label ?? i.name;
|
|
176
|
-
if (i.type === 'rating' && !(/^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= ratingMax(i)))
|
|
177
|
-
fail(i.name, ctx.locale.t('error.rating', { label, max: ratingMax(i) }));
|
|
176
|
+
if (i.type === 'rating' && !(/^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= ratingMax(i, ctx.app.component_settings)))
|
|
177
|
+
fail(i.name, ctx.locale.t('error.rating', { label, max: ratingMax(i, ctx.app.component_settings) }));
|
|
178
178
|
// a list item posts return values: each must be one its list of values returns (config.any_value: true allows others)
|
|
179
179
|
if (LOV_CHECKED.has(i.type) && i.lov && i.config?.any_value !== true && !(await lovContains(ctx, i.lov, MULTI_VALUE.has(i.type) ? splitValues(v) : [v])))
|
|
180
180
|
fail(i.name, ctx.locale.t('error.lov_value', { label }));
|
package/src/runtime/facets.ts
CHANGED
|
@@ -99,6 +99,20 @@ export async function rangeCounts(ctx: PageContext, fs: FacetSource, f: FacetDef
|
|
|
99
99
|
return res.rows[0] as number[];
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** (sprint 78) Checkbox facet values shown before "Show all" (APEX shows seven). */
|
|
103
|
+
export const FACET_SHOWN = 7;
|
|
104
|
+
|
|
105
|
+
/** (sprint 78) The rows the report shows: the search and every facet's filter. */
|
|
106
|
+
export async function totalRows(ctx: PageContext, fs: FacetSource): Promise<number> {
|
|
107
|
+
const p = new SqlParams();
|
|
108
|
+
const where = othersWhere(ctx, fs, '', p);
|
|
109
|
+
const c = ctx.client!;
|
|
110
|
+
const res = await savepoint(c, () =>
|
|
111
|
+
c.query<{ n: number }>({ text: `select count(*)::int as n from (\n${fs.src}\n) "__q"${where.length ? ` where ${where.join(' and ')}` : ''}`, values: queryValues(p.values) }),
|
|
112
|
+
);
|
|
113
|
+
return res.rows[0].n;
|
|
114
|
+
}
|
|
115
|
+
|
|
102
116
|
export const facetLabel = (ctx: PageContext, f: FacetDef) => f.label ?? ctx.locale.tr(heading(f.column));
|
|
103
117
|
|
|
104
118
|
/** A star rating drawn with stars, read as "4 stars and up". */
|
|
@@ -156,15 +170,24 @@ export async function renderFacets(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
156
170
|
// keep selected values visible even when their count dropped to 0
|
|
157
171
|
for (const s of selected) if (s && !values.some((x) => x.v === s)) values.push({ v: s, n: 0 });
|
|
158
172
|
const excluded = f.exclude && ctx.params.get(k.exclude + f.column) === '1';
|
|
173
|
+
// (sprint 78) a boolean's values as Yes and No, not true and false
|
|
174
|
+
const bool = fs.cols.get(f.column) === 16;
|
|
175
|
+
const shown = (v: string) => (bool && (v === 'true' || v === 'false') ? t(v === 'true' ? 'item.yes' : 'item.no') : (toState(v) ?? ''));
|
|
159
176
|
const chart = facetChart(ctx, report, f, label, values.map((x) => ({
|
|
160
|
-
label:
|
|
177
|
+
label: shown(x.v), n: x.n,
|
|
161
178
|
apply: (p) => { p.delete(k.values + f.column); p.delete(k.exclude + f.column); p.append(k.values + f.column, x.v); },
|
|
162
179
|
})));
|
|
180
|
+
const value = (x: { v: string; n: number }) =>
|
|
181
|
+
html`<label class="check facet-value"><input type="checkbox" form="${formId}" name="${k.values}${f.column}" value="${x.v}"${selected.has(x.v) ? raw(' checked') : ''} data-facet>
|
|
182
|
+
<span class="facet-label">${shown(x.v)}</span><span class="facet-count">${x.n}</span></label>`;
|
|
163
183
|
groups.push(html`<fieldset class="facet${excluded ? ' facet-excluded' : ''}">
|
|
164
184
|
<legend>${label}${clearLink(f)}</legend>
|
|
165
185
|
${chart}
|
|
166
|
-
${values.
|
|
167
|
-
|
|
186
|
+
${values.slice(0, FACET_SHOWN).map(value)}
|
|
187
|
+
${values.length > FACET_SHOWN
|
|
188
|
+
? html`<details class="facet-more"${values.slice(FACET_SHOWN).some((x) => selected.has(x.v)) ? raw(' open') : ''}><summary>${t('facets.show_all', { n: String(values.length) })}</summary>
|
|
189
|
+
${values.slice(FACET_SHOWN).map(value)}</details>`
|
|
190
|
+
: ''}
|
|
168
191
|
${f.exclude
|
|
169
192
|
? html`<label class="check facet-exclude"><input type="checkbox" form="${formId}" name="${k.exclude}${f.column}" value="1"${excluded ? raw(' checked') : ''} data-facet> ${t('facets.exclude')}</label>`
|
|
170
193
|
: ''}
|
|
@@ -225,7 +248,15 @@ export async function renderFacets(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
225
248
|
html`<form id="${formId}" method="get" action="${ctx.base}/${ctx.page.page_no}">${keep.map(([name, v]) => html`<input type="hidden" name="${name}" value="${v}">`)}</form>`,
|
|
226
249
|
);
|
|
227
250
|
const any = defs.some((f) => active.has(f.column)) || (r.config.search === true && fs.search !== '');
|
|
251
|
+
// (sprint 78; APEX: the results' "Total Row Count") the rows the filters leave
|
|
252
|
+
let total: number | null = null;
|
|
253
|
+
try {
|
|
254
|
+
total = await totalRows(ctx, fs);
|
|
255
|
+
} catch {
|
|
256
|
+
total = null; // the facets' own errors are shown above
|
|
257
|
+
}
|
|
228
258
|
return html`<div class="facets">
|
|
259
|
+
${total !== null ? html`<p class="facet-total" role="status">${t('facets.total', { n: total.toLocaleString(ctx.locale.lang) })}</p>` : ''}
|
|
229
260
|
${groups}
|
|
230
261
|
<div class="buttons facet-actions">
|
|
231
262
|
<button class="btn btn-hot" form="${formId}">${t('report.apply')}</button>
|
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';
|
|
@@ -116,6 +117,17 @@ function control(col: GridColumn, name: string, value: string, label: string) {
|
|
|
116
117
|
return html`<input type="text" name="${name}" value="${value}"${aria}>`;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* (APEX: cell navigation mode) An editable cell's value as text, with its column's format mask or the
|
|
122
|
+
* option's display value: with JavaScript the cell reads as this text until it is edited (app.css,
|
|
123
|
+
* app.js); the control holds the raw value. Not for a checkbox, which reads as itself.
|
|
124
|
+
*/
|
|
125
|
+
function cellText(col: GridColumn, value: string, shown: string) {
|
|
126
|
+
if (col.typeOid === 16 || value === '') return '';
|
|
127
|
+
const text = col.options ? col.options.find((o) => o.value === value)?.display ?? value : shown;
|
|
128
|
+
return html`<span class="grid-text${NUMERIC.has(col.typeOid) && !col.options ? ' num' : ''}" aria-hidden="true">${text}</span>`;
|
|
129
|
+
}
|
|
130
|
+
|
|
119
131
|
/** Width of each leading column (master selection, delete, row actions), see app.css .grid-lead. */
|
|
120
132
|
const LEAD_WIDTH = 44;
|
|
121
133
|
|
|
@@ -169,7 +181,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
169
181
|
const base = reportState(ctx, r);
|
|
170
182
|
const views = gridViews(r);
|
|
171
183
|
const view: View = views.includes(base.view) ? base.view : 'report';
|
|
172
|
-
const pageSize = Math.max(1, Math.min(200, Number(r.config.page_size) ||
|
|
184
|
+
const pageSize = Math.max(1, Math.min(200, Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'grid_rows')));
|
|
173
185
|
// 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
186
|
const rowParam = ctx.params.get(key(r, 'row'));
|
|
175
187
|
const adding = view === 'row' && rowParam === 'new';
|
|
@@ -240,7 +252,13 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
240
252
|
// master-detail: this grid selects rows for detail regions; or it follows a master
|
|
241
253
|
const select = selectRowOf(ctx.page, r);
|
|
242
254
|
const selIdx = select ? fields.findIndex((f) => f.name.toLowerCase() === select.column.toLowerCase()) : -1;
|
|
243
|
-
|
|
255
|
+
let selected = select ? ctx.session.state[select.item] ?? null : null;
|
|
256
|
+
// as in APEX, a page shows the first master row selected: when nothing is selected, or the
|
|
257
|
+
// selected row is not among the rows shown (after a search or filter); the regions below follow it
|
|
258
|
+
if (select && selIdx >= 0 && ctx.wholePage && view === 'report' && info.rows.length) {
|
|
259
|
+
const shown = info.rows.map((row) => toState(row[selIdx]) ?? '');
|
|
260
|
+
if (selected === null || !shown.includes(selected)) ctx.session.state[select.item] = selected = shown[0];
|
|
261
|
+
}
|
|
244
262
|
const details = select ? detailsOf(ctx.page, r, ctx.vis!.regions) : [];
|
|
245
263
|
const masterCol = masterColumnOf(r);
|
|
246
264
|
const masterValue = masterItemOf(r) ? ctx.session.state[masterItemOf(r)!] ?? '' : '';
|
|
@@ -297,7 +315,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
297
315
|
return i === -1 ? undefined : (toState(row[i]) ?? '');
|
|
298
316
|
});
|
|
299
317
|
|
|
300
|
-
// read-only cells show their column's format mask; editable
|
|
318
|
+
// read-only cells show their column's format mask; an editable cell's control keeps the raw value
|
|
301
319
|
const fmtOf = columnFormats(ctx, r);
|
|
302
320
|
const rows = info.rows.map((row, i) => {
|
|
303
321
|
const pk = toState(row[pkIdx]) ?? '';
|
|
@@ -311,7 +329,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
311
329
|
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
330
|
const name = `${g}_${i}_c${col.ci}`;
|
|
313
331
|
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>`;
|
|
332
|
+
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(col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
|
|
315
333
|
});
|
|
316
334
|
const deleted = pi !== undefined && pv(`${g}_${pi}_del`) === 'true';
|
|
317
335
|
const selValue = selIdx >= 0 ? toState(row[selIdx]) ?? '' : null;
|
package/src/runtime/items.ts
CHANGED
|
@@ -8,6 +8,8 @@ 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';
|
|
@@ -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,7 +177,7 @@ 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(':');
|
|
@@ -226,7 +229,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
226
229
|
let lovError: Raw | '' = '';
|
|
227
230
|
if (hasLov(item)) {
|
|
228
231
|
try {
|
|
229
|
-
options = await lovOptions(ctx, item.lov, lovMax(item));
|
|
232
|
+
options = await lovOptions(ctx, item.lov, lovMax(item, ctx.app.component_settings));
|
|
230
233
|
} catch (e) {
|
|
231
234
|
lovError = html`<small class="error">${await publicError(ctx, e, `list of values of ${item.name}`)}</small>`;
|
|
232
235
|
}
|
|
@@ -249,9 +252,9 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
249
252
|
// rebuilt from the allow-list every time: the value may come from the table, not from this form
|
|
250
253
|
control = html`<div class="display-value rich-text" id="${id}">${raw((item.type === 'markdown' ? markdownHtml(value) : sanitizeHtml(value)) || ' ')}</div>`;
|
|
251
254
|
} else if (!editable && item.type === 'rating') {
|
|
252
|
-
const n = /^\d+$/.test(value) ? Math.min(Number(value), ratingMax(item)) : 0;
|
|
255
|
+
const n = /^\d+$/.test(value) ? Math.min(Number(value), ratingMax(item, ctx.app.component_settings)) : 0;
|
|
253
256
|
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>`
|
|
257
|
+
? 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
258
|
: html`<div class="display-value" id="${id}"> </div>`;
|
|
256
259
|
} else if (!editable && item.type === 'combobox') {
|
|
257
260
|
const values = comboMultiple(item) ? splitValues(value) : value ? [value] : [];
|
|
@@ -352,7 +355,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
352
355
|
case 'rating': {
|
|
353
356
|
// radio buttons (keyboard and screen readers as usual); CSS draws them as stars
|
|
354
357
|
useLegend = true;
|
|
355
|
-
const max = ratingMax(item);
|
|
358
|
+
const max = ratingMax(item, ctx.app.component_settings);
|
|
356
359
|
const stars = [];
|
|
357
360
|
for (let n = 1; n <= max; n++)
|
|
358
361
|
stars.push(
|
|
@@ -461,7 +464,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
461
464
|
const tag = useLegend ? 'fieldset' : 'div';
|
|
462
465
|
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
466
|
${labelHtml}${control}${lovError}
|
|
464
|
-
${item.help ? html`<small class="help" id="${id}_help">${item.help}</small>` : ''}
|
|
467
|
+
${item.help ? html`<small class="help" id="${id}_help">${await expandShortcuts(ctx, item.help, 'text')}</small>` : ''}
|
|
465
468
|
${error ? html`<small class="error" id="${id}_error">${error}</small>` : ''}
|
|
466
469
|
${raw(`</${tag}>`)}`;
|
|
467
470
|
}
|
package/src/runtime/locale.ts
CHANGED
|
@@ -44,7 +44,10 @@ export interface Locale {
|
|
|
44
44
|
timeZoneFrom: 'user' | 'browser' | 'app' | 'database';
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/** (sprint 79) The light/dark choice of a user who is not signed in, per application (path /a/<alias>). */
|
|
48
|
+
export const THEME_COOKIE = 'pgkiln_mode';
|
|
49
|
+
/** The cookie's options: the application's path, a year. */
|
|
50
|
+
export const themeCookieOptions = (base: string) => ({ path: base, sameSite: 'lax' as const, secure: process.env.COOKIE_SECURE === 'true', maxAge: 365 * 86400 });
|
|
48
51
|
const THEMES: ThemeMode[] = ['auto', 'light', 'dark'];
|
|
49
52
|
export const isTheme = (v: unknown): v is ThemeMode => THEMES.includes(v as ThemeMode);
|
|
50
53
|
|
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);
|