pgkiln 0.41.0 → 0.42.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/097_generated_app_quality.sql +911 -0
- package/db/migrations/098_app_activity.sql +37 -0
- package/docs/apex-feature-parity.md +5 -5
- package/docs/guide/03-builder.md +66 -18
- package/docs/guide/09-reference.md +6 -2
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/12-development.md +1 -1
- package/docs/guide/14-globalization.md +7 -3
- package/docs/guide/17-mobile.md +1 -1
- package/package.json +1 -1
- package/public/app.css +175 -33
- package/public/app.js +80 -0
- package/public/builder.css +30 -2
- package/public/builder.js +31 -0
- package/src/builder/appfromfile.ts +5 -4
- package/src/builder/appsheets.ts +23 -9
- package/src/builder/appwizard.ts +215 -70
- package/src/builder/blueprints.ts +2 -1
- package/src/builder/components.ts +1 -1
- package/src/builder/home.ts +2 -32
- package/src/builder/newapp.ts +182 -5
- package/src/builder/routes.ts +4 -28
- package/src/i18n/ar.ts +1 -0
- package/src/i18n/cs.ts +1 -0
- package/src/i18n/da.ts +1 -0
- package/src/i18n/de.ts +1 -0
- package/src/i18n/el.ts +1 -0
- package/src/i18n/es.ts +1 -0
- package/src/i18n/fi.ts +1 -0
- package/src/i18n/fr.ts +1 -0
- package/src/i18n/he.ts +1 -0
- package/src/i18n/it.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/nb.ts +1 -0
- package/src/i18n/pl.ts +1 -0
- package/src/i18n/pt.ts +1 -0
- package/src/i18n/ru.ts +1 -0
- package/src/i18n/sv.ts +1 -0
- package/src/i18n/tr.ts +1 -0
- package/src/i18n/uk.ts +1 -0
- package/src/i18n/zh.ts +1 -0
- package/src/i18n.ts +2 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +2 -2
- package/src/runtime/charts.ts +8 -3
- package/src/runtime/devbar.ts +35 -14
- package/src/runtime/engine.ts +7 -4
- package/src/runtime/locale.ts +2 -2
- package/src/runtime/regions.ts +25 -2
- package/src/runtime/render.ts +18 -4
- package/src/runtime/report.ts +15 -9
- package/src/runtime/styles.ts +5 -2
package/src/runtime/devbar.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { html, raw, type Raw } from '../html.ts';
|
|
|
22
22
|
import { hashToken, newToken } from '../security.ts';
|
|
23
23
|
import { saveState, sessionLimits, type Session } from '../session.ts';
|
|
24
24
|
import { documentShell } from '../layout.ts';
|
|
25
|
+
import { icon } from '../icons.ts';
|
|
25
26
|
import type { PageContext } from './context.ts';
|
|
26
27
|
|
|
27
28
|
export const DEVBAR_COOKIE = 'pgkiln_devbar';
|
|
@@ -73,30 +74,50 @@ export const sessionDebugLevel = (s: Session) => {
|
|
|
73
74
|
return DEBUG_LEVELS.some(([level]) => level === n) ? n : 0;
|
|
74
75
|
};
|
|
75
76
|
|
|
76
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* The toolbar (not in dialogs): (sprint 72) a compact bar of icons with tooltips,
|
|
79
|
+
* centred at the bottom like APEX's. Debug, Info and Settings open small panels
|
|
80
|
+
* (<details>, so they work without JavaScript). With JavaScript (app.js) the bar
|
|
81
|
+
* shrinks to a slim tab after a few seconds without use and can be docked at the
|
|
82
|
+
* top, left or right (Settings; kept in the browser).
|
|
83
|
+
*/
|
|
77
84
|
export function devbarHtml(ctx: PageContext): Raw | '' {
|
|
78
85
|
if (!ctx.devbar || ctx.dialog) return '';
|
|
79
86
|
const level = sessionDebugLevel(ctx.session);
|
|
80
87
|
const appLevel = Number(ctx.app.debug_level ?? 0);
|
|
81
88
|
const page = ctx.page.page_no;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
const tool = (href: string, label: string, name: string) =>
|
|
90
|
+
html`<li><a class="devbar-tool" href="${href}" title="${label}" aria-label="${label}">${icon(name)}</a></li>`;
|
|
91
|
+
const menu = (label: string, name: string, cls: string, body: Raw, hidden = false) =>
|
|
92
|
+
html`<li${hidden ? raw(' hidden') : ''}><details class="devbar-menu ${cls}"><summary class="devbar-tool" title="${label}" aria-label="${label}">${icon(name)}</summary>
|
|
93
|
+
<div class="devbar-pop" role="group" aria-label="${label}">${body}</div></details></li>`;
|
|
94
|
+
return html`<div class="devbar" data-dock="bottom">
|
|
95
|
+
<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('Debug', 'bolt', `devbar-debug-menu${level || appLevel ? ' is-on' : ''}`, html`<form method="post" action="${ctx.base}/${page}/devbar/debug" class="devbar-debug">
|
|
91
101
|
<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
|
|
92
|
-
<label for="devbar-debug">Debug</label>
|
|
102
|
+
<label for="devbar-debug">Debug messages</label>
|
|
93
103
|
<select id="devbar-debug" name="level">${DEBUG_LEVELS.map(([n, label]) =>
|
|
94
104
|
html`<option value="${n}"${n === level ? raw(' selected') : ''}>${n === 0 ? (appLevel ? `Application (${appLevel})` : 'Off') : label}</option>`)}</select>
|
|
95
105
|
<button>Set</button>
|
|
96
|
-
</form
|
|
97
|
-
|
|
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')}
|
|
109
|
+
${menu('Info', 'info', 'devbar-info', html`<dl class="devbar-facts">
|
|
110
|
+
<dt>Application</dt><dd>${ctx.app.name} (${ctx.app.id})</dd>
|
|
111
|
+
<dt>Page</dt><dd>${page}: ${ctx.page.name}</dd>
|
|
112
|
+
<dt>User</dt><dd>${ctx.user ?? 'nobody'}</dd>
|
|
113
|
+
<dt>Developer</dt><dd>${ctx.devbar.username}</dd>
|
|
114
|
+
</dl>`)}
|
|
115
|
+
${menu('Settings', 'settings', 'devbar-settings', html`<fieldset class="devbar-dock"><legend>Position</legend>
|
|
116
|
+
${(['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
|
+
</fieldset>
|
|
118
|
+
<label class="devbar-autohide"><input type="checkbox" name="devbar-autohide" checked> Hide when not used</label>`, true)}
|
|
98
119
|
</ul></nav>
|
|
99
|
-
</
|
|
120
|
+
</div>`;
|
|
100
121
|
}
|
|
101
122
|
|
|
102
123
|
const INTERNAL = /^__/;
|
package/src/runtime/engine.ts
CHANGED
|
@@ -271,7 +271,7 @@ async function formDml(ctx: PageContext, p: Process): Promise<string | null> {
|
|
|
271
271
|
if (op === 'update' || v !== null) assignments.push([ident(i.source_column!), literal(v)]);
|
|
272
272
|
continue;
|
|
273
273
|
}
|
|
274
|
-
const conf = (i.config ?? {}) as { filename_column?: string; mime_column?: string; size_column?: string };
|
|
274
|
+
const conf = (i.config ?? {}) as { filename_column?: string; mime_column?: string; size_column?: string; updated_column?: string };
|
|
275
275
|
const store = storeOf(i);
|
|
276
276
|
if (store && (isTempId(v) || (v === REMOVE && op === 'update'))) {
|
|
277
277
|
if (op === 'update') replaced.push([store, (await storedKey(i))!]);
|
|
@@ -282,8 +282,9 @@ async function formDml(ctx: PageContext, p: Process): Promise<string | null> {
|
|
|
282
282
|
if (conf.filename_column) assignments.push([ident(conf.filename_column), literal(f.filename)]);
|
|
283
283
|
if (conf.mime_column) assignments.push([ident(conf.mime_column), literal(f.mime)]);
|
|
284
284
|
if (conf.size_column) assignments.push([ident(conf.size_column), literal(String(f.size))]);
|
|
285
|
+
if (conf.updated_column) assignments.push([ident(conf.updated_column), 'now()']);
|
|
285
286
|
} else {
|
|
286
|
-
for (const col of [i.source_column!, conf.filename_column, conf.mime_column, conf.size_column]) if (col) assignments.push([ident(col), 'null']);
|
|
287
|
+
for (const col of [i.source_column!, conf.filename_column, conf.mime_column, conf.size_column, conf.updated_column]) if (col) assignments.push([ident(col), 'null']);
|
|
287
288
|
}
|
|
288
289
|
continue;
|
|
289
290
|
}
|
|
@@ -293,10 +294,12 @@ async function formDml(ctx: PageContext, p: Process): Promise<string | null> {
|
|
|
293
294
|
assignments.push([ident(i.source_column!), file('content')]);
|
|
294
295
|
if (conf.filename_column) assignments.push([ident(conf.filename_column), file('filename')]);
|
|
295
296
|
if (conf.mime_column) assignments.push([ident(conf.mime_column), file('mime_type')]);
|
|
297
|
+
// (097) the size and the time of the change, when the table keeps them
|
|
298
|
+
if (conf.size_column) assignments.push([ident(conf.size_column), file('size')]);
|
|
299
|
+
if (conf.updated_column) assignments.push([ident(conf.updated_column), 'now()']);
|
|
296
300
|
} else if (v === REMOVE && op === 'update') {
|
|
297
301
|
assignments.push([ident(i.source_column!), 'null']);
|
|
298
|
-
|
|
299
|
-
if (conf.mime_column) assignments.push([ident(conf.mime_column), 'null']);
|
|
302
|
+
for (const col of [conf.filename_column, conf.mime_column, conf.size_column, conf.updated_column]) if (col) assignments.push([ident(col), 'null']);
|
|
300
303
|
}
|
|
301
304
|
}
|
|
302
305
|
// saved into the row: the temporary files are no longer needed, replaced objects go after the commit
|
package/src/runtime/locale.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface Locale {
|
|
|
26
26
|
languages: string[];
|
|
27
27
|
theme: ThemeMode;
|
|
28
28
|
/** the app's base style when not Standard (html data-style) */
|
|
29
|
-
style?: 'iris';
|
|
29
|
+
style?: 'iris' | 'lavender';
|
|
30
30
|
/** whether users may pick light/dark themselves */
|
|
31
31
|
themeChoice: boolean;
|
|
32
32
|
/** dates and timestamps for display (undefined: not a date) */
|
|
@@ -199,7 +199,7 @@ export async function resolveLocale(req: FastifyRequest, app: App, session?: Ses
|
|
|
199
199
|
messages,
|
|
200
200
|
languages,
|
|
201
201
|
theme: themeFor(app, session, req),
|
|
202
|
-
style: baseStyleOf(app.theme) === '
|
|
202
|
+
style: baseStyleOf(app.theme) === 'standard' ? undefined : (baseStyleOf(app.theme) as 'iris' | 'lavender'),
|
|
203
203
|
themeChoice: app.theme?.user_choice !== false,
|
|
204
204
|
format,
|
|
205
205
|
number,
|
package/src/runtime/regions.ts
CHANGED
|
@@ -140,8 +140,15 @@ async function renderChart(ctx: PageContext, r: Region) {
|
|
|
140
140
|
}
|
|
141
141
|
if (!res.rows.length) return html`<p class="empty">${r.config.empty ?? ctx.locale.t('report.no_data')}</p>`;
|
|
142
142
|
const kind = CHART_KINDS.includes(r.config.kind) ? r.config.kind : 'bar';
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
const rows = topRows(r, kind, res.rows, ctx.locale.t('chart.other'));
|
|
144
|
+
const drill: { hidden?: string[]; link?: (i: number, si: number | null) => Raw | null } = await chartLink(ctx, r, rows, res.fields);
|
|
145
|
+
// the "Other" row of a top-N chart has no drill-down
|
|
146
|
+
if (drill.link && rows !== res.rows) {
|
|
147
|
+
const inner = drill.link;
|
|
148
|
+
drill.link = (i, si) => (i === rows.length - 1 ? null : inner(i, si));
|
|
149
|
+
}
|
|
150
|
+
return html`${renderChartBody(kind, r.title ?? '', rows, res.fields, ctx.css, ctx.locale.lang, ctx.locale.t, {
|
|
151
|
+
...drill,
|
|
145
152
|
gauge: gaugeConfig(r.config.gauge),
|
|
146
153
|
...chartFormat(ctx, r),
|
|
147
154
|
// the query's timestamps are in the session's time zone: so is "today"
|
|
@@ -151,6 +158,22 @@ async function renderChart(ctx: PageContext, r: Region) {
|
|
|
151
158
|
})}${firstRows(ctx, res)}`;
|
|
152
159
|
}
|
|
153
160
|
|
|
161
|
+
/** Charts that may show the top rows and an "Other" row (bar, pie, donut, funnel, column). */
|
|
162
|
+
const TOP_KINDS = new Set(['bar', 'column', 'donut', 'pie', 'funnel']);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* (097) {"top": 10}: the first top-1 rows and one "Other" row with the sums of
|
|
166
|
+
* the rest (the query orders the rows, largest first). Other charts, and
|
|
167
|
+
* charts with no more rows than that, keep their rows.
|
|
168
|
+
*/
|
|
169
|
+
export function topRows(r: Region, kind: string, rows: unknown[][], other: string): unknown[][] {
|
|
170
|
+
const top = Number(r.config.top);
|
|
171
|
+
if (!Number.isInteger(top) || top < 2 || top > 100 || !TOP_KINDS.has(kind) || rows.length <= top) return rows;
|
|
172
|
+
const rest = rows.slice(top - 1);
|
|
173
|
+
const sums = (rows[0] ?? []).map((_, ci) => (ci === 0 ? other : rest.reduce((n: number, row) => n + (Number(row[ci]) || 0), 0)));
|
|
174
|
+
return [...rows.slice(0, top - 1), sums];
|
|
175
|
+
}
|
|
176
|
+
|
|
154
177
|
/** {"format_mask": "FML999G990"}: the chart's values (labels, tips, data table) with a number format mask. */
|
|
155
178
|
function chartFormat(ctx: PageContext, r: Region): { format?: (v: number) => string } {
|
|
156
179
|
const mask = typeof r.config.format_mask === 'string' ? r.config.format_mask.trim() : '';
|
package/src/runtime/render.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { NavEntry } from '../metadata.ts';
|
|
|
14
14
|
import { isAuthorized, pageAllowed } from './authz.ts';
|
|
15
15
|
import { stripSemicolon, dbg, bindValues, substitute, type PageContext } from './context.ts';
|
|
16
16
|
import { applyBinds } from '../binds.ts';
|
|
17
|
-
import { savepoint } from '../db.ts';
|
|
17
|
+
import { runtime, savepoint } from '../db.ts';
|
|
18
18
|
import { renderItems } from './items.ts';
|
|
19
19
|
import { buttonsFor, renderRegion } from './regions.ts';
|
|
20
20
|
import { listTree, navbarMarkup, navMarkup } from './lists.ts';
|
|
@@ -112,7 +112,8 @@ async function navTree(ctx: PageContext, topNav = false) {
|
|
|
112
112
|
out.push(
|
|
113
113
|
children.length
|
|
114
114
|
? html`<li><details${inTrail && !topNav ? raw(' open') : ''}><summary class="${inTrail ? 'in-trail' : null}">${label}</summary><ul>${children}</ul></details></li>`
|
|
115
|
-
:
|
|
115
|
+
: // (097) every page sits below Home: the home entry is only marked when it is the current page
|
|
116
|
+
html`<li><a href="${ctx.base}/${e.target_page}"${active ? raw(' aria-current="page"') : inTrail && e.target_page !== ctx.app.home_page ? raw(' class="in-trail"') : ''}>${label}</a></li>`,
|
|
116
117
|
);
|
|
117
118
|
}
|
|
118
119
|
return out;
|
|
@@ -265,6 +266,8 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
265
266
|
const feedback = feedbackNode(ctx);
|
|
266
267
|
const navbarNodes = listNodes || feedback ? [...(listNodes ?? []), ...(feedback ? [feedback] : [])] : null;
|
|
267
268
|
const navbar = navbarNodes ? navbarMarkup(navbarNodes, t('list.navbar')) : '';
|
|
269
|
+
// (sprint 72) the account's display name in the user menu
|
|
270
|
+
const shown = signedIn ? ((await displayName(ctx.user)) ?? ctx.user) : ctx.user;
|
|
268
271
|
return documentShell(
|
|
269
272
|
`${title} · ${ctx.app.name}`,
|
|
270
273
|
html`<a class="skip-link" href="#main">${t('common.skip')}</a>
|
|
@@ -272,13 +275,14 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
272
275
|
<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>
|
|
273
276
|
<a class="t-logo" href="${ctx.base}/${ctx.app.home_page}">${ctx.app.name}</a>
|
|
274
277
|
<span class="t-spacer"></span>
|
|
278
|
+
${ctx.app.pwa ? html`<button type="button" class="icon-button t-install" data-pwa-install hidden title="${t('pwa.install')}" aria-label="${t('pwa.install')}">${icon('download')}</button>` : ''}
|
|
275
279
|
${navbar}
|
|
276
280
|
${ctx.app.authentication !== 'none'
|
|
277
281
|
? signedIn
|
|
278
282
|
? html`<details class="menu t-user">
|
|
279
|
-
<summary>${icon('user')}<span>${
|
|
283
|
+
<summary>${icon('user')}<span>${shown}</span></summary>
|
|
280
284
|
<div class="menu-panel align-right">
|
|
281
|
-
<div class="menu-section"><strong>${ctx.user}</
|
|
285
|
+
<div class="menu-section"><strong>${shown}</strong>${shown !== ctx.user ? html`<div class="muted">${ctx.user}</div>` : ''}${ctx.roles.length ? html`<div class="muted">${t('account.roles')}: ${ctx.roles.join(', ')}</div>` : ''}</div>
|
|
282
286
|
<div class="menu-section"><a href="${ctx.base}/account">${icon('user')} ${t('account.menu')}</a></div>
|
|
283
287
|
${themeSwitch(ctx, here(ctx))}
|
|
284
288
|
${styleSwitch(ctx, here(ctx))}
|
|
@@ -410,3 +414,13 @@ export function dialogClosePage(ctx: PageContext) {
|
|
|
410
414
|
{ lang: ctx.locale.lang, dir: ctx.locale.dir, theme: ctx.locale.theme, style: ctx.locale.style },
|
|
411
415
|
);
|
|
412
416
|
}
|
|
417
|
+
|
|
418
|
+
/** (sprint 72) An account's display name (null when it has none, or for users outside the user directory). */
|
|
419
|
+
async function displayName(username: string): Promise<string | null> {
|
|
420
|
+
try {
|
|
421
|
+
const r = await runtime.query<{ display_name: string | null }>('select display_name from meta.account where lower(username) = lower($1)', [username]);
|
|
422
|
+
return r.rows[0]?.display_name?.trim() || null;
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
package/src/runtime/report.ts
CHANGED
|
@@ -794,7 +794,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
794
794
|
} catch (e) {
|
|
795
795
|
failure = await publicError(ctx, e, `report "${r.title ?? r.id}" aggregates`);
|
|
796
796
|
}
|
|
797
|
-
const link = r.config.link as { column: string; page: number; items?: Record<string, string
|
|
797
|
+
const link = r.config.link as { column: string; page: number; items?: Record<string, string>; icon?: boolean } | undefined;
|
|
798
798
|
const linkIdx = link && (await pageAllowed(ctx, link.page)) ? fields.findIndex((f) => f.name.toLowerCase() === link.column.toLowerCase()) : -1;
|
|
799
799
|
const pre = new Set<string>((r.config.preformatted ?? []).map((x: string) => x.toLowerCase()));
|
|
800
800
|
let computeErrors: { c: Computation; message: string }[] = [];
|
|
@@ -803,7 +803,9 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
803
803
|
const selection = st.view === 'report' ? selectionOf(ctx.page, r) : null;
|
|
804
804
|
const selIdx = selection ? fields.findIndex((f) => f.name.toLowerCase() === selection.column.toLowerCase()) : -1;
|
|
805
805
|
const selected = new Set(selIdx >= 0 ? splitValues(ctx.session.state[selection!.item] ?? '') : []);
|
|
806
|
-
|
|
806
|
+
// (097) {"link": {"icon": true}}: a pencil link in a column of its own; the key column is plain text
|
|
807
|
+
const editIcon = linkIdx >= 0 && link!.icon === true && st.view === 'report';
|
|
808
|
+
const lead = (selIdx >= 0 ? 1 : 0) + (editIcon ? 1 : 0);
|
|
807
809
|
// columns rendered through a template component (config.column_templates)
|
|
808
810
|
const fmtOf = columnFormats(ctx, r);
|
|
809
811
|
const templated = st.view === 'report' ? await columnTemplates(ctx, r, fields, (v, oid) => cell(v, oid, ctx.locale.format), (v) => cell(v)) : new Map();
|
|
@@ -840,12 +842,13 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
840
842
|
});
|
|
841
843
|
return html`<th scope="col" class="${cls}" aria-sort="${active ? (st.desc ? 'descending' : 'ascending') : 'none'}"><a href="${href}">${label}<span class="sort-ind" aria-hidden="true">${active ? (st.desc ? '▼' : '▲') : ''}</span></a></th>`;
|
|
842
844
|
});
|
|
843
|
-
if (
|
|
845
|
+
if (editIcon) header.unshift(html`<th scope="col" class="row-edit" aria-label="${t('report.edit')}"></th>`);
|
|
846
|
+
if (selIdx >= 0) header.unshift(html`<th scope="col" class="row-select"><label><input type="checkbox" data-select-all="${selection!.item}" aria-label="${t('report.select_all')}"><span class="row-select-label" aria-hidden="true">${t('report.select_all')}</span></label></th>`);
|
|
844
847
|
|
|
845
848
|
// an aggregate row (totals, or a control break's subtotals): the values
|
|
846
849
|
// under their columns, the label in the first cell
|
|
847
850
|
const aggRow = (values: unknown[], label: string, cls: string) =>
|
|
848
|
-
html`<tr class="${cls}">${
|
|
851
|
+
html`<tr class="${cls}">${selIdx >= 0 ? html`<td class="row-select"></td>` : ''}${editIcon ? html`<td class="row-edit"></td>` : ''}${cols.map(({ f }, ci) => {
|
|
849
852
|
const parts = agg!.aggs.flatMap((a, ai) => (a.column === f.name ? [`${t(`agg.${a.fn}`)}: ${cell(values[ai], agg!.types[ai], aggregateFormat(a.fn, f.name, fmtOf, ctx.locale.format))}`] : []));
|
|
850
853
|
const text = [ci === 0 ? label : '', ...parts].filter(Boolean).join(' · ');
|
|
851
854
|
return html`<td class="${parts.length && NUMERIC_OIDS.has(f.dataTypeID) ? 'num' : null}" data-label="${parts.length ? headingOf(r, f.name, ctx.locale.tr) : ''}">${text}</td>`;
|
|
@@ -867,17 +870,20 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
867
870
|
}
|
|
868
871
|
}
|
|
869
872
|
const hl = st.highlights.find((_, hi) => hlIdx[hi] >= 0 && row[hlIdx[hi]] === true);
|
|
870
|
-
const pick =
|
|
873
|
+
const pick = selIdx >= 0
|
|
871
874
|
? html`<td class="row-select" data-label="${t('report.select_row')}"><input type="checkbox" name="${selection!.item}" value="${cell(row[selIdx])}"${selected.has(cell(row[selIdx])) ? raw(' checked') : ''} aria-label="${t('report.select_row')} ${cell(row[selIdx])}"></td>`
|
|
872
875
|
: '';
|
|
873
876
|
rowNum++;
|
|
874
|
-
|
|
877
|
+
const edit = editIcon
|
|
878
|
+
? html`<td class="row-edit"><a ${linkAttrs(ctx, link!.page, rowItems(row))} aria-label="${t('report.edit')} ${cell(row[linkIdx])}" title="${t('report.edit')}">${icon('edit')}</a></td>`
|
|
879
|
+
: '';
|
|
880
|
+
body.push(html`<tr class="${hl ? `hl-${hl.color}` : null}">${pick}${edit}${cols.map(({ f, i }) => {
|
|
875
881
|
const text = cell(row[i], f.dataTypeID, fmtOf(f.name));
|
|
876
882
|
const cls = [NUMERIC_OIDS.has(f.dataTypeID) ? 'num' : '', pre.has(f.name.toLowerCase()) ? 'pre' : '', colOpts(f.name)].filter(Boolean).join(' ') || null;
|
|
877
883
|
const label = headingOf(r, f.name, ctx.locale.tr);
|
|
878
884
|
const tpl = templated.get(i);
|
|
879
885
|
if (tpl) return html`<td class="${cls ? `${cls} tc-cell` : 'tc-cell'}" data-label="${label}">${tpl(row, rowNum)}</td>`;
|
|
880
|
-
return i === linkIdx
|
|
886
|
+
return i === linkIdx && !editIcon
|
|
881
887
|
? html`<td class="${cls}" data-label="${label}"><a ${linkAttrs(ctx, link!.page, rowItems(row))}>${text || t('report.edit')}</a></td>`
|
|
882
888
|
: html`<td class="${cls}" data-label="${label}">${text}</td>`;
|
|
883
889
|
})}</tr>`);
|
|
@@ -1098,12 +1104,12 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1098
1104
|
// app.js records each change (POST …/report/<id>/select), so paging keeps them
|
|
1099
1105
|
const onPage = new Set(selIdx >= 0 ? info.rows.map((row) => cell(row[selIdx])) : []);
|
|
1100
1106
|
const elsewhere = [...selected].filter((v) => !onPage.has(v));
|
|
1101
|
-
const selectionNote =
|
|
1107
|
+
const selectionNote = selIdx >= 0
|
|
1102
1108
|
? html`<p class="sel-count muted" data-sel-count="${r.id}" aria-live="polite">${selected.size ? t('report.selected_count', { n: selected.size }) : ''}</p>
|
|
1103
1109
|
${elsewhere.map((v) => html`<input type="hidden" name="${selection!.item}" value="${v}" data-sel-other>`)}`
|
|
1104
1110
|
: '';
|
|
1105
1111
|
return html`${toolbar}${switcher}${errors}${selectionNote}
|
|
1106
|
-
<div class="table-wrap"><table class="report${r.config.mobile === 'scroll' ? '' : ' report-reflow'}"${
|
|
1112
|
+
<div class="table-wrap"><table class="report${r.config.mobile === 'scroll' ? '' : ' report-reflow'}"${selIdx >= 0 ? raw(` data-sel-region="${r.id}"`) : ''}>
|
|
1107
1113
|
<thead><tr>${header}</tr></thead>
|
|
1108
1114
|
<tbody>${body.length ? body : html`<tr><td colspan="${cols.length + lead || 1}" class="empty">${empty}</td></tr>`}</tbody>
|
|
1109
1115
|
${info.rows.length ? foot : ''}
|
package/src/runtime/styles.ts
CHANGED
|
@@ -32,13 +32,16 @@ const MAX_CONDITION = 2000;
|
|
|
32
32
|
*/
|
|
33
33
|
export const BASE_STYLES = {
|
|
34
34
|
standard: { label: 'Standard', accent: '#0b63c5', header: '#13294b' },
|
|
35
|
-
iris: { label: 'Iris', accent: '#
|
|
35
|
+
iris: { label: 'Iris', accent: '#2b62c4', header: '#3a3632' },
|
|
36
|
+
/** (sprint 72) pgkiln's Iris until 0.41: lavender, rounded */
|
|
37
|
+
lavender: { label: 'Lavender', accent: '#5146d8', header: '#1e1a4d' },
|
|
36
38
|
} as const;
|
|
37
39
|
export type BaseStyle = keyof typeof BASE_STYLES;
|
|
38
40
|
/** New applications start with this base style. */
|
|
39
41
|
export const NEW_APP_BASE: BaseStyle = 'iris';
|
|
40
42
|
/** The application's base style (anything else than a known key: Standard). */
|
|
41
|
-
export const baseStyleOf = (theme: { base?: unknown } | undefined): BaseStyle =>
|
|
43
|
+
export const baseStyleOf = (theme: { base?: unknown } | undefined): BaseStyle =>
|
|
44
|
+
theme?.base === 'iris' || theme?.base === 'lavender' ? theme.base : 'standard';
|
|
42
45
|
export const STYLE_NAME = /^[A-Za-z0-9][A-Za-z0-9 _-]{0,29}$/;
|
|
43
46
|
export const MAX_STYLES = 10;
|
|
44
47
|
|