pgkiln 0.41.0 → 0.43.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/db/migrations/099_layout_positions.sql +29 -0
- package/docs/apex-feature-parity.md +38 -11
- package/docs/guide/03-builder.md +133 -31
- package/docs/guide/04-pages-and-regions.md +46 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/09-reference.md +10 -3
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/12-development.md +3 -2
- 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 +233 -33
- package/public/app.js +263 -0
- package/public/builder.css +142 -2
- package/public/builder.js +311 -3
- package/src/builder/appfromfile.ts +5 -4
- package/src/builder/appsheets.ts +23 -9
- package/src/builder/appwizard.ts +215 -70
- package/src/builder/arrange.ts +133 -17
- package/src/builder/blueprints.ts +2 -1
- package/src/builder/components.ts +29 -23
- package/src/builder/designer.ts +67 -18
- package/src/builder/forms.ts +4 -1
- package/src/builder/home.ts +2 -32
- package/src/builder/newapp.ts +182 -5
- package/src/builder/object-browser.ts +342 -0
- package/src/builder/pwa.ts +3 -3
- package/src/builder/routes.ts +86 -51
- package/src/builder/shared.ts +103 -1
- package/src/builder/sql.ts +13 -65
- package/src/builder/ui.ts +21 -6
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +2 -2
- package/src/metadata.ts +5 -1
- package/src/runtime/charts.ts +8 -3
- package/src/runtime/devbar.ts +35 -14
- package/src/runtime/engine.ts +13 -4
- package/src/runtime/format.ts +52 -0
- package/src/runtime/items.ts +27 -0
- package/src/runtime/locale.ts +5 -2
- package/src/runtime/regions.ts +61 -4
- package/src/runtime/render.ts +38 -8
- package/src/runtime/report.ts +149 -88
- package/src/runtime/routes.ts +34 -3
- package/src/runtime/styles.ts +5 -2
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))}
|
|
@@ -327,7 +331,9 @@ const CLIENT_TEXTS = ['pwa.offline_banner', 'pwa.queued', 'pwa.queue_waiting', '
|
|
|
327
331
|
'pwa.status.signin', 'pwa.status.invalid', 'pwa.status.error', 'item.locate_error', 'item.address_none', 'item.address_error', 'item.scan_close', 'common.dismiss',
|
|
328
332
|
'crop.title', 'crop.apply', 'crop.skip', 'crop.help',
|
|
329
333
|
'push.turn_on', 'push.turn_off', 'push.on', 'push.off', 'push.blocked', 'push.unsupported', 'push.failed', 'page.unsaved_changes', 'page.link_copied',
|
|
330
|
-
'session.warning', 'session.stay', 'session.max', 'session.ended', 'login.sign_in_link'
|
|
334
|
+
'session.warning', 'session.stay', 'session.max', 'session.ended', 'login.sign_in_link',
|
|
335
|
+
// (sprint 76) a report column heading's menu
|
|
336
|
+
'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values'] as const;
|
|
331
337
|
export const clientTexts = (ctx: PageContext) => Object.fromEntries(CLIENT_TEXTS.map((k) => [k, ctx.locale.t(k)]));
|
|
332
338
|
|
|
333
339
|
/**
|
|
@@ -356,8 +362,21 @@ export async function renderPage(ctx: PageContext) {
|
|
|
356
362
|
const hidden = initiallyHidden(ctx);
|
|
357
363
|
const defaultButton = [...ctx.vis!.buttons.values()].find((b) => b.hot && b.action === 'submit');
|
|
358
364
|
const pageItems = await renderItems(ctx, ctx.page.items.filter((i) => i.region_id === null), hidden);
|
|
359
|
-
|
|
360
|
-
|
|
365
|
+
// (099) regions by display position; the side column of a side-column page template, else its regions in the body
|
|
366
|
+
const side = ctx.page.page_template === 'left_side_column' ? 'left' : ctx.page.page_template === 'right_side_column' ? 'right' : null;
|
|
367
|
+
const at: Record<string, Raw[]> = { banner: [], breadcrumb: [], side: [], body: [], footer: [] };
|
|
368
|
+
for (const r of ctx.page.regions) {
|
|
369
|
+
const pos = r.position ?? 'body';
|
|
370
|
+
const into = pos === 'left' || pos === 'right' ? (pos === side ? 'side' : 'body') : pos in at ? pos : 'body';
|
|
371
|
+
// a region its condition or authorization leaves out ('') leaves no empty position behind
|
|
372
|
+
const out = await renderRegion(ctx, r, hidden);
|
|
373
|
+
if (out) at[into].push(out);
|
|
374
|
+
}
|
|
375
|
+
const area = (cls: string, list: Raw[]) => (list.length ? html`<div class="t-regions ${cls}">${list}</div>` : '');
|
|
376
|
+
const regions = side && at.side.length
|
|
377
|
+
? html`<div class="t-columns t-columns-${side}"><aside class="t-side" aria-label="${side === 'left' ? 'Left column' : 'Right column'}"><div class="t-regions t-pos-side">${at.side}</div></aside>
|
|
378
|
+
<div class="t-regions t-pos-body">${at.body}</div></div>`
|
|
379
|
+
: html`<div class="t-regions">${at.body}</div>`;
|
|
361
380
|
// (078) the origins of the page's URL regions (also those a refresh or lazy load shows later)
|
|
362
381
|
for (const o of frameOrigins(ctx.page.regions.filter((r) => ctx.vis!.regions.has(r.id)))) ctx.frameSrc?.add(o);
|
|
363
382
|
const title = substitute(ctx.page.title ?? ctx.page.name, ctx, (v) => v);
|
|
@@ -387,8 +406,9 @@ export async function renderPage(ctx: PageContext) {
|
|
|
387
406
|
${formKeys(ctx)}
|
|
388
407
|
${ctx.dialog ? html`<input type="hidden" name="__dialog" value="1">` : ''}
|
|
389
408
|
${defaultButton ? html`<button type="submit" name="__request" value="${defaultButton.name}" class="default-submit" tabindex="-1" aria-hidden="true"></button>` : ''}
|
|
409
|
+
${area('t-pos-banner', at.banner)}${area('t-pos-breadcrumb', at.breadcrumb)}
|
|
390
410
|
${pageItems.length ? html`<div class="page-items form-grid">${pageItems}</div>` : ''}
|
|
391
|
-
|
|
411
|
+
${regions}${area('t-pos-footer', at.footer)}
|
|
392
412
|
${await buttonsFor(ctx, null)}
|
|
393
413
|
</form>
|
|
394
414
|
${ctx.detached}
|
|
@@ -410,3 +430,13 @@ export function dialogClosePage(ctx: PageContext) {
|
|
|
410
430
|
{ lang: ctx.locale.lang, dir: ctx.locale.dir, theme: ctx.locale.theme, style: ctx.locale.style },
|
|
411
431
|
);
|
|
412
432
|
}
|
|
433
|
+
|
|
434
|
+
/** (sprint 72) An account's display name (null when it has none, or for users outside the user directory). */
|
|
435
|
+
async function displayName(username: string): Promise<string | null> {
|
|
436
|
+
try {
|
|
437
|
+
const r = await runtime.query<{ display_name: string | null }>('select display_name from meta.account where lower(username) = lower($1)', [username]);
|
|
438
|
+
return r.rows[0]?.display_name?.trim() || null;
|
|
439
|
+
} catch {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
package/src/runtime/report.ts
CHANGED
|
@@ -186,6 +186,10 @@ interface Highlight extends Filter {
|
|
|
186
186
|
|
|
187
187
|
export interface ReportState {
|
|
188
188
|
search: string;
|
|
189
|
+
/** (sprint 76) the column the search is limited to (the search field's column list), or null: all */
|
|
190
|
+
searchCol: string | null;
|
|
191
|
+
/** (sprint 76) columns the user hid (a column heading's menu → Hide) */
|
|
192
|
+
hideCols: string[];
|
|
189
193
|
sort: number;
|
|
190
194
|
desc: boolean;
|
|
191
195
|
page: number;
|
|
@@ -236,6 +240,8 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
|
|
|
236
240
|
const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || 15;
|
|
237
241
|
const st: ReportState = {
|
|
238
242
|
search: (p.get(key(r, 'q')) ?? '').trim(),
|
|
243
|
+
searchCol: (p.get(key(r, 'qc')) ?? '').trim() || null,
|
|
244
|
+
hideCols: p.getAll(key(r, 'x')).filter(Boolean).slice(0, 100),
|
|
239
245
|
sort: r.config.sortable === false ? 0 : Math.max(0, Math.min(1000, parseInt(p.get(key(r, 's')) ?? '0', 10) || 0)),
|
|
240
246
|
desc: p.get(key(r, 'd')) === 'desc',
|
|
241
247
|
page: Math.max(1, Math.min(MAX_PAGE, parseInt(p.get(key(r, 'p')) ?? '1', 10) || 1)),
|
|
@@ -415,13 +421,14 @@ export async function filtered(ctx: PageContext, r: Region, st: ReportState) {
|
|
|
415
421
|
const p = new SqlParams();
|
|
416
422
|
const hides = hidesColumns(r);
|
|
417
423
|
const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions));
|
|
418
|
-
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));
|
|
424
|
+
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);
|
|
419
425
|
// column name → type oid
|
|
420
426
|
const fields = needCols ? await fieldsOf(ctx, src) : [];
|
|
421
427
|
const cols = new Map<string, number>(fields.map((f) => [f.name, f.dataTypeID]));
|
|
422
428
|
// the columns the user may choose (filters, highlights, control break, aggregates, views, sorting): not the hidden ones
|
|
423
429
|
const userCols = new Map<string, number>(userColumnNames(r, [...cols.keys()]).map((n) => [n, cols.get(n)!]));
|
|
424
|
-
|
|
430
|
+
// (sprint 76) the search in one column the user may see, or in all
|
|
431
|
+
if (st.search) where.push(searchSql(st.search, p, st.searchCol && userCols.has(st.searchCol) ? [st.searchCol] : hides ? [...userCols.keys()] : undefined));
|
|
425
432
|
for (const f of st.filters) if (userCols.has(f.column)) where.push(OPERATORS[f.op].sql(q(f.column), f.value));
|
|
426
433
|
for (const f of facets) {
|
|
427
434
|
const cond = facetFilterSql(f, cols, p);
|
|
@@ -536,7 +543,9 @@ export function chartSection(ctx: PageContext, r: Region, st: ReportState, names
|
|
|
536
543
|
/** The pager under a report or grid: "X–Y of N" (or "Rows X–Y"), Previous and Next. */
|
|
537
544
|
export function pagerNav(ctx: PageContext, r: Region, info: PageInfo, linkAttr: Raw | '' = '') {
|
|
538
545
|
const t = ctx.locale.t;
|
|
539
|
-
|
|
546
|
+
// (sprint 76) "1 – 14 of 14" and the rows per page, also on a single page (APEX's pagination)
|
|
547
|
+
const sizes = sizeChooser(ctx, r, info);
|
|
548
|
+
if (!info.more && info.page <= 1) return info.total ? html`<div class="pager"><span>${t('report.range', { from: 1, to: info.total, total: info.total })}</span>${sizes}</div>` : '';
|
|
540
549
|
// a page number, and the keyset position when there is one
|
|
541
550
|
const go = (page: number, seek: string | null) => (p: URLSearchParams) => {
|
|
542
551
|
p.set(key(r, 'p'), String(page));
|
|
@@ -546,13 +555,51 @@ export function pagerNav(ctx: PageContext, r: Region, info: PageInfo, linkAttr:
|
|
|
546
555
|
const text =
|
|
547
556
|
info.total === null ? t('report.range_open', { from: info.from, to: info.to })
|
|
548
557
|
: t(info.capped ? 'report.range_more' : 'report.range', { from: info.from, to: info.to, total: info.total });
|
|
558
|
+
// the last page, when the total is known and pages are counted (not keyset paging)
|
|
559
|
+
const lastPage = info.total !== null && !info.capped && !info.next && !info.prev ? Math.ceil(info.total / reportState(ctx, r).size) : null;
|
|
549
560
|
return html`<nav class="pager" aria-label="${t('report.pagination')}">
|
|
550
561
|
<span>${text}</span>
|
|
562
|
+
${info.page > 2 ? html`<a class="btn" href="${regionUrl(ctx, r, go(1, null))}"${linkAttr} aria-label="${t('report.first')}" title="${t('report.first')}">«</a>` : ''}
|
|
551
563
|
${info.page > 1 ? html`<a class="btn" href="${regionUrl(ctx, r, go(info.page - 1, info.prev))}"${linkAttr}>‹ ${t('report.previous')}</a>` : ''}
|
|
552
564
|
${info.more ? html`<a class="btn" href="${regionUrl(ctx, r, go(info.page + 1, info.next))}"${linkAttr}>${t('report.next')} ›</a>` : ''}
|
|
565
|
+
${info.more && lastPage && lastPage > info.page + 1 ? html`<a class="btn" href="${regionUrl(ctx, r, go(lastPage, null))}"${linkAttr} aria-label="${t('report.last')}" title="${t('report.last')}">»</a>` : ''}
|
|
566
|
+
${sizes}
|
|
553
567
|
</nav>`;
|
|
554
568
|
}
|
|
555
569
|
|
|
570
|
+
/** (sprint 76) Rows per page as a select in the pager (a GET form; app.js sends it on change, else its button). */
|
|
571
|
+
function sizeChooser(ctx: PageContext, r: Region, info: PageInfo): Raw | '' {
|
|
572
|
+
if (r.type !== 'report' || r.config.interactive === false) return '';
|
|
573
|
+
const st = reportState(ctx, r);
|
|
574
|
+
const keep = [...ctx.params.entries()].filter(([k]) => k !== key(r, 'n') && k !== key(r, 'p') && k !== key(r, 'k') && !['clear', 'cs'].includes(k));
|
|
575
|
+
const t = ctx.locale.t;
|
|
576
|
+
if (info.total !== null && info.total <= PAGE_SIZES[0] && st.size === PAGE_SIZES[0]) return '';
|
|
577
|
+
return html`<form method="get" action="${ctx.base}/${ctx.page.page_no}" class="pager-size">${keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}
|
|
578
|
+
<label for="r${r.id}_size">${t('report.rows_per_page')}</label>
|
|
579
|
+
<select id="r${r.id}_size" name="${key(r, 'n')}" data-go>${[...new Set([...PAGE_SIZES, st.size])].sort((a, b) => a - b).map((n) => html`<option value="${n}"${n === st.size ? raw(' selected') : ''}>${n}</option>`)}</select>
|
|
580
|
+
<button class="btn btn-sm no-js-only">${t('report.go')}</button></form>`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** (sprint 76) The most values a column heading's menu lists. */
|
|
584
|
+
export const MAX_COLUMN_VALUES = 200;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* (sprint 76) A column's values with their counts, for its heading's menu (APEX: the column heading's quick
|
|
588
|
+
* filter list): the report's rows with the user's search and filters, only a column the user may see.
|
|
589
|
+
*/
|
|
590
|
+
export async function columnValues(ctx: PageContext, r: Region, column: string) {
|
|
591
|
+
const st = reportState(ctx, r);
|
|
592
|
+
const { src, where, userCols, values } = await filtered(ctx, r, { ...st, filters: [...st.filters] });
|
|
593
|
+
// the column's own type is needed: filtered() reads the columns when anything asks for them
|
|
594
|
+
const names = userCols.size ? userCols : new Map((await fieldsOf(ctx, src)).filter((f) => userColumnNames(r, [f.name]).length).map((f) => [f.name, f.dataTypeID]));
|
|
595
|
+
if (!names.has(column)) return null;
|
|
596
|
+
const c = ctx.client!;
|
|
597
|
+
const res = await savepoint(c, () =>
|
|
598
|
+
c.query({ text: `select ${q(column)}::text as v, count(*)::int as n from (\n${src}\n) "__q"${where} group by 1 order by 1 nulls first limit ${MAX_COLUMN_VALUES}`, values }),
|
|
599
|
+
);
|
|
600
|
+
return res.rows.map((row: { v: string | null; n: number }) => ({ value: row.v, count: row.n }));
|
|
601
|
+
}
|
|
602
|
+
|
|
556
603
|
/**
|
|
557
604
|
* The aggregates over all filtered rows (not just the page): the totals,
|
|
558
605
|
* and per control-break value when there is a break column.
|
|
@@ -783,7 +830,9 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
783
830
|
if (info.page > 2) info.prev = seekToken(ctx, r, st, info.page - 1, 'p', at(info.rows[0]));
|
|
784
831
|
}
|
|
785
832
|
const hlIdx = st.highlights.map((_, i) => res.fields.findIndex((f) => f.name === `__h${i}`));
|
|
786
|
-
|
|
833
|
+
// (sprint 76) without the columns the user hid (a heading's menu → Hide)
|
|
834
|
+
const userHidden = new Set(st.hideCols.map((c) => c.toLowerCase()));
|
|
835
|
+
const allCols = visibleColumns(r, fields).filter(({ f }) => !userHidden.has(f.name.toLowerCase()));
|
|
787
836
|
// a control break on a shown column only (its values head the groups)
|
|
788
837
|
const breakIdx = st.breakCol ? (allCols.find(({ f }) => f.name === st.breakCol)?.i ?? -1) : -1;
|
|
789
838
|
const cols = allCols.filter(({ i }) => i !== breakIdx);
|
|
@@ -794,7 +843,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
794
843
|
} catch (e) {
|
|
795
844
|
failure = await publicError(ctx, e, `report "${r.title ?? r.id}" aggregates`);
|
|
796
845
|
}
|
|
797
|
-
const link = r.config.link as { column: string; page: number; items?: Record<string, string
|
|
846
|
+
const link = r.config.link as { column: string; page: number; items?: Record<string, string>; icon?: boolean } | undefined;
|
|
798
847
|
const linkIdx = link && (await pageAllowed(ctx, link.page)) ? fields.findIndex((f) => f.name.toLowerCase() === link.column.toLowerCase()) : -1;
|
|
799
848
|
const pre = new Set<string>((r.config.preformatted ?? []).map((x: string) => x.toLowerCase()));
|
|
800
849
|
let computeErrors: { c: Computation; message: string }[] = [];
|
|
@@ -803,7 +852,9 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
803
852
|
const selection = st.view === 'report' ? selectionOf(ctx.page, r) : null;
|
|
804
853
|
const selIdx = selection ? fields.findIndex((f) => f.name.toLowerCase() === selection.column.toLowerCase()) : -1;
|
|
805
854
|
const selected = new Set(selIdx >= 0 ? splitValues(ctx.session.state[selection!.item] ?? '') : []);
|
|
806
|
-
|
|
855
|
+
// (097) {"link": {"icon": true}}: a pencil link in a column of its own; the key column is plain text
|
|
856
|
+
const editIcon = linkIdx >= 0 && link!.icon === true && st.view === 'report';
|
|
857
|
+
const lead = (selIdx >= 0 ? 1 : 0) + (editIcon ? 1 : 0);
|
|
807
858
|
// columns rendered through a template component (config.column_templates)
|
|
808
859
|
const fmtOf = columnFormats(ctx, r);
|
|
809
860
|
const templated = st.view === 'report' ? await columnTemplates(ctx, r, fields, (v, oid) => cell(v, oid, ctx.locale.format), (v) => cell(v)) : new Map();
|
|
@@ -838,14 +889,21 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
838
889
|
else p.delete(key(r, 'd'));
|
|
839
890
|
p.delete(key(r, 'p'));
|
|
840
891
|
});
|
|
841
|
-
|
|
892
|
+
// (sprint 76) a heading's menu (app.js): sort either way, hide, control break and the column's values to filter by;
|
|
893
|
+
// without JavaScript the heading sorts
|
|
894
|
+
const set = (fn: (p: URLSearchParams) => void) => regionUrl(ctx, r, (p) => { fn(p); p.delete(key(r, 'p')); p.delete(key(r, 'k')); });
|
|
895
|
+
const menu = interactive
|
|
896
|
+
? raw(` data-col="${esc(f.name)}" data-label="${esc(label)}" data-asc="${esc(set((p) => { p.set(key(r, 's'), String(pos)); p.delete(key(r, 'd')); }))}" data-desc="${esc(set((p) => { p.set(key(r, 's'), String(pos)); p.set(key(r, 'd'), 'desc'); }))}" data-hide="${esc(set((p) => p.append(key(r, 'x'), f.name)))}" data-break="${esc(set((p) => p.set(key(r, 'b'), f.name)))}" data-filter="${esc(key(r, 'f'))}" data-values="${esc(`${ctx.base}/${ctx.page.page_no}/report/${r.id}/values`)}"`)
|
|
897
|
+
: '';
|
|
898
|
+
return html`<th scope="col" class="${cls}" aria-sort="${active ? (st.desc ? 'descending' : 'ascending') : 'none'}"${menu}><a href="${href}">${label}<span class="sort-ind" aria-hidden="true">${active ? (st.desc ? '▼' : '▲') : ''}</span></a></th>`;
|
|
842
899
|
});
|
|
843
|
-
if (
|
|
900
|
+
if (editIcon) header.unshift(html`<th scope="col" class="row-edit" aria-label="${t('report.edit')}"></th>`);
|
|
901
|
+
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
902
|
|
|
845
903
|
// an aggregate row (totals, or a control break's subtotals): the values
|
|
846
904
|
// under their columns, the label in the first cell
|
|
847
905
|
const aggRow = (values: unknown[], label: string, cls: string) =>
|
|
848
|
-
html`<tr class="${cls}">${
|
|
906
|
+
html`<tr class="${cls}">${selIdx >= 0 ? html`<td class="row-select"></td>` : ''}${editIcon ? html`<td class="row-edit"></td>` : ''}${cols.map(({ f }, ci) => {
|
|
849
907
|
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
908
|
const text = [ci === 0 ? label : '', ...parts].filter(Boolean).join(' · ');
|
|
851
909
|
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 +925,20 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
867
925
|
}
|
|
868
926
|
}
|
|
869
927
|
const hl = st.highlights.find((_, hi) => hlIdx[hi] >= 0 && row[hlIdx[hi]] === true);
|
|
870
|
-
const pick =
|
|
928
|
+
const pick = selIdx >= 0
|
|
871
929
|
? 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
930
|
: '';
|
|
873
931
|
rowNum++;
|
|
874
|
-
|
|
932
|
+
const edit = editIcon
|
|
933
|
+
? 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>`
|
|
934
|
+
: '';
|
|
935
|
+
body.push(html`<tr class="${hl ? `hl-${hl.color}` : null}">${pick}${edit}${cols.map(({ f, i }) => {
|
|
875
936
|
const text = cell(row[i], f.dataTypeID, fmtOf(f.name));
|
|
876
937
|
const cls = [NUMERIC_OIDS.has(f.dataTypeID) ? 'num' : '', pre.has(f.name.toLowerCase()) ? 'pre' : '', colOpts(f.name)].filter(Boolean).join(' ') || null;
|
|
877
938
|
const label = headingOf(r, f.name, ctx.locale.tr);
|
|
878
939
|
const tpl = templated.get(i);
|
|
879
940
|
if (tpl) return html`<td class="${cls ? `${cls} tc-cell` : 'tc-cell'}" data-label="${label}">${tpl(row, rowNum)}</td>`;
|
|
880
|
-
return i === linkIdx
|
|
941
|
+
return i === linkIdx && !editIcon
|
|
881
942
|
? html`<td class="${cls}" data-label="${label}"><a ${linkAttrs(ctx, link!.page, rowItems(row))}>${text || t('report.edit')}</a></td>`
|
|
882
943
|
: html`<td class="${cls}" data-label="${label}">${text}</td>`;
|
|
883
944
|
})}</tr>`);
|
|
@@ -895,7 +956,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
895
956
|
.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`);
|
|
896
957
|
const action = `${ctx.base}/${ctx.page.page_no}`;
|
|
897
958
|
if (searchable) {
|
|
898
|
-
ctx.detached.push(html`<form id="${searchForm}" method="get" action="${action}">${hiddenInputs([key(r, 'q'), key(r, 'p')])}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
|
|
959
|
+
ctx.detached.push(html`<form id="${searchForm}" method="get" action="${action}">${hiddenInputs([key(r, 'q'), key(r, 'qc'), key(r, 'p')])}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
|
|
899
960
|
}
|
|
900
961
|
const breakForm = `rb${r.id}`;
|
|
901
962
|
const aggForm = `ra${r.id}`;
|
|
@@ -965,101 +1026,96 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
965
1026
|
if (st.area) chips.push(chip(t('report.map_area'), 'bb'));
|
|
966
1027
|
if (st.near) chips.push(chip(t('report.near', { km: ctx.locale.number.format(st.near.km) }), 'near'));
|
|
967
1028
|
}
|
|
1029
|
+
for (const c of st.hideCols)
|
|
1030
|
+
chips.push(html`<span class="chip">${t('report.hidden_column')}: <b>${headingOf(r, c, ctx.locale.tr)}</b>
|
|
1031
|
+
<a href="${regionUrl(ctx, r, (p) => { const rest = p.getAll(key(r, 'x')).filter((x) => x !== c); p.delete(key(r, 'x')); for (const x of rest) p.append(key(r, 'x'), x); })}" aria-label="${t('report.show_column')}">×</a></span>`);
|
|
968
1032
|
if (st.search)
|
|
969
1033
|
chips.unshift(
|
|
970
|
-
html`<span class="chip">${t('report.search_chip')} <b>${st.search}</b> <a href="${regionUrl(ctx, r, (p) => { p.delete(key(r, 'q')); p.delete(key(r, 'p')); })}" aria-label="${t('report.clear_search')}">×</a></span>`,
|
|
1034
|
+
html`<span class="chip">${t('report.search_chip')}${st.searchCol ? html` ${headingOf(r, st.searchCol, ctx.locale.tr)}:` : ''} <b>${st.search}</b> <a href="${regionUrl(ctx, r, (p) => { p.delete(key(r, 'q')); p.delete(key(r, 'qc')); p.delete(key(r, 'p')); })}" aria-label="${t('report.clear_search')}">×</a></span>`,
|
|
971
1035
|
);
|
|
972
1036
|
|
|
1037
|
+
// (sprint 76) Actions is a short menu like APEX's; each entry opens a dialog (app.js), or unfolds in place without JavaScript
|
|
1038
|
+
const columnsForm = `rx${r.id}`;
|
|
1039
|
+
if (interactive)
|
|
1040
|
+
ctx.detached.push(html`<form id="${columnsForm}" method="get" action="${ctx.base}/${ctx.page.page_no}">${hiddenInputs([key(r, 'x'), key(r, 'p')])}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
|
|
1041
|
+
const item = (k: string, ic: string, label: string, body: Raw | '') =>
|
|
1042
|
+
html`<li><details class="ir-item" data-ir-dialog="${k}"><summary>${icon(ic)}<span>${label}</span></summary><div class="ir-body" role="group" aria-label="${label}">${body}</div></details></li>`;
|
|
1043
|
+
const sub = (ic: string, label: string, items: (Raw | '')[]) =>
|
|
1044
|
+
html`<li><details class="ir-item ir-sub"><summary>${icon(ic)}<span>${label}</span><span class="ir-more" aria-hidden="true">›</span></summary><ul class="ir-menu">${items}</ul></details></li>`;
|
|
1045
|
+
const menuLink = (ic: string, label: Raw | string, href: string, attrs: Raw | '' = '') => html`<li><a class="ir-link" href="${href}"${attrs}>${icon(ic)}<span>${label}</span></a></li>`;
|
|
1046
|
+
const sortLinks = r.config.sortable === false
|
|
1047
|
+
? ''
|
|
1048
|
+
: html`<div class="seg">${cols.map(({ f, i }) => {
|
|
1049
|
+
const pos = i + 1;
|
|
1050
|
+
const active = st.sort === pos;
|
|
1051
|
+
const href = regionUrl(ctx, r, (p) => {
|
|
1052
|
+
p.set(key(r, 's'), String(pos));
|
|
1053
|
+
if (active && !st.desc) p.set(key(r, 'd'), 'desc');
|
|
1054
|
+
else p.delete(key(r, 'd'));
|
|
1055
|
+
p.delete(key(r, 'p'));
|
|
1056
|
+
});
|
|
1057
|
+
return html`<a href="${href}"${active ? raw(' aria-current="true"') : ''}>${headingOf(r, f.name, ctx.locale.tr)}${active ? (st.desc ? ' ▼' : ' ▲') : ''}</a>`;
|
|
1058
|
+
})}</div>`;
|
|
973
1059
|
const actionsMenu = interactive
|
|
974
1060
|
? html`<details class="menu actions-menu">
|
|
975
1061
|
<summary class="btn">${t('report.actions')} <span aria-hidden="true">▾</span></summary>
|
|
976
|
-
<div class="menu-panel">
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
<
|
|
1062
|
+
<div class="menu-panel"><ul class="ir-menu">
|
|
1063
|
+
${item('columns', 'eye-off', t('report.columns'), html`<fieldset class="ir-columns"><legend>${t('report.hide_columns')}</legend>
|
|
1064
|
+
${visibleColumns(r, fields).map(({ f }) => html`<label class="check"><input type="checkbox" name="${key(r, 'x')}" value="${f.name}" form="${columnsForm}"${userHidden.has(f.name.toLowerCase()) ? raw(' checked') : ''}> ${headingOf(r, f.name, ctx.locale.tr)}</label>`)}</fieldset>
|
|
1065
|
+
<button class="btn btn-hot" form="${columnsForm}">${t('report.apply')}</button>`)}
|
|
1066
|
+
${item('filter', 'filter', t('report.filter'), html`<div class="filter-row">
|
|
980
1067
|
<select name="${key(r, 'fc')}" form="${filterForm}" aria-label="${t('report.column')}">${cols.map(({ f }) => html`<option value="${f.name}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
981
1068
|
<select name="${key(r, 'fo')}" form="${filterForm}" aria-label="${t('report.operator')}">${Object.keys(OPERATORS).map((k) => html`<option value="${k}"${k === 'contains' ? raw(' selected') : ''}>${opLabel(t, k)}</option>`)}</select>
|
|
982
1069
|
<input name="${key(r, 'fv')}" form="${filterForm}" aria-label="${t('report.value')}" placeholder="${t('report.value')}">
|
|
983
1070
|
<button class="btn btn-hot" form="${filterForm}">${t('report.apply')}</button>
|
|
984
|
-
</div
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
});
|
|
998
|
-
return html`<a href="${href}"${active ? raw(' aria-current="true"') : ''}>${headingOf(r, f.name, ctx.locale.tr)}${active ? (st.desc ? ' ▼' : ' ▲') : ''}</a>`;
|
|
999
|
-
})}</div>
|
|
1000
|
-
</div>`}
|
|
1001
|
-
<div class="menu-section">
|
|
1002
|
-
<strong>${t('report.break')}</strong>
|
|
1003
|
-
<div class="filter-row">
|
|
1071
|
+
</div>`)}
|
|
1072
|
+
${sub('table', t('report.data'), [
|
|
1073
|
+
r.config.sortable === false ? '' : item('sort', 'arrow-down', t('report.sort'), sortLinks),
|
|
1074
|
+
item('aggregate', 'calculator', t('report.aggregate'), html`<div class="filter-row">
|
|
1075
|
+
<select name="${key(r, 'af')}" form="${aggForm}" aria-label="${t('report.function')}">${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}">${t(`agg.${fn}`)}</option>`)}</select>
|
|
1076
|
+
<select name="${key(r, 'ac')}" form="${aggForm}" aria-label="${t('report.column')}">${cols.map(({ f }) => html`<option value="${f.name}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1077
|
+
<button class="btn btn-hot" form="${aggForm}">${t('report.apply')}</button></div>`),
|
|
1078
|
+
item('compute', 'percent', t('report.compute'), html`<div class="filter-row">
|
|
1079
|
+
<input name="${key(r, 'cn')}" form="${computeForm}" maxlength="40" aria-label="${t('report.compute_name')}" placeholder="${t('report.compute_name')}">
|
|
1080
|
+
<input name="${key(r, 'ce')}" form="${computeForm}" maxlength="500" aria-label="${t('report.compute_expr')}" placeholder="${t('report.compute_expr')}">
|
|
1081
|
+
<button class="btn btn-hot" form="${computeForm}">${t('report.apply')}</button></div>
|
|
1082
|
+
<small class="help">${t('report.compute_help')}</small>`),
|
|
1083
|
+
item('break', 'list', t('report.break'), html`<div class="filter-row">
|
|
1004
1084
|
<select name="${key(r, 'bc')}" form="${breakForm}" aria-label="${t('report.break')}">
|
|
1005
1085
|
<option value="">${t('report.none')}</option>
|
|
1006
1086
|
${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.breakCol ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}
|
|
1007
1087
|
</select>
|
|
1008
|
-
<button class="btn" form="${breakForm}">${t('report.apply')}</button
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
<strong>${t('report.aggregate')}</strong>
|
|
1013
|
-
<div class="filter-row">
|
|
1014
|
-
<select name="${key(r, 'af')}" form="${aggForm}" aria-label="${t('report.function')}">${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}">${t(`agg.${fn}`)}</option>`)}</select>
|
|
1015
|
-
<select name="${key(r, 'ac')}" form="${aggForm}" aria-label="${t('report.column')}">${cols.map(({ f }) => html`<option value="${f.name}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1016
|
-
<button class="btn" form="${aggForm}">${t('report.apply')}</button>
|
|
1017
|
-
</div>
|
|
1018
|
-
</div>
|
|
1019
|
-
<div class="menu-section">
|
|
1020
|
-
<strong>${t('report.highlight')}</strong>
|
|
1021
|
-
<div class="filter-row">
|
|
1088
|
+
<button class="btn btn-hot" form="${breakForm}">${t('report.apply')}</button></div>`),
|
|
1089
|
+
])}
|
|
1090
|
+
${sub('sliders', t('report.format'), [
|
|
1091
|
+
item('highlight', 'tag', t('report.highlight'), html`<div class="filter-row">
|
|
1022
1092
|
<select name="${key(r, 'hc')}" form="${hlForm}" aria-label="${t('report.column')}">${allCols.map(({ f }) => html`<option value="${f.name}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1023
1093
|
<select name="${key(r, 'ho')}" form="${hlForm}" aria-label="${t('report.operator')}">${Object.keys(OPERATORS).map((k) => html`<option value="${k}"${k === 'eq' ? raw(' selected') : ''}>${opLabel(t, k)}</option>`)}</select>
|
|
1024
1094
|
<input name="${key(r, 'hv')}" form="${hlForm}" aria-label="${t('report.value')}" placeholder="${t('report.value')}">
|
|
1025
1095
|
<select name="${key(r, 'hk')}" form="${hlForm}" aria-label="${t('report.color')}">${HIGHLIGHT_COLORS.map((c) => html`<option value="${c}">${t(`color.${c}`)}</option>`)}</select>
|
|
1026
|
-
<button class="btn" form="${hlForm}">${t('report.apply')}</button
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
<input name="${key(r, 'ce')}" form="${computeForm}" maxlength="500" aria-label="${t('report.compute_expr')}" placeholder="${t('report.compute_expr')}">
|
|
1034
|
-
<button class="btn" form="${computeForm}">${t('report.apply')}</button>
|
|
1035
|
-
</div>
|
|
1036
|
-
<small class="help">${t('report.compute_help')}</small>
|
|
1037
|
-
</div>
|
|
1038
|
-
${groupBySection(ctx, r, st, allCols.map(({ f }) => f.name), groupForm)}
|
|
1039
|
-
<div class="menu-section">
|
|
1040
|
-
<strong>${t('report.pivot')}</strong>
|
|
1041
|
-
<div class="filter-row">
|
|
1096
|
+
<button class="btn btn-hot" form="${hlForm}">${t('report.apply')}</button></div>`),
|
|
1097
|
+
item('rows', 'hash', t('report.rows_per_page'), html`<div class="seg">${PAGE_SIZES.map((n) =>
|
|
1098
|
+
html`<a href="${regionUrl(ctx, r, (p) => { p.set(key(r, 'n'), String(n)); p.delete(key(r, 'p')); })}"${n === st.size ? raw(' aria-current="true"') : ''}>${n}</a>`)}</div>`),
|
|
1099
|
+
])}
|
|
1100
|
+
${item('chart', 'chart', t('report.chart'), chartSection(ctx, r, st, allCols.map(({ f }) => f.name), chartForm))}
|
|
1101
|
+
${item('group', 'layers', t('report.group_by'), groupBySection(ctx, r, st, allCols.map(({ f }) => f.name), groupForm))}
|
|
1102
|
+
${item('pivot', 'grid', t('report.pivot'), html`<div class="filter-row">
|
|
1042
1103
|
<select name="${key(r, 'pr')}" form="${pivotForm}" aria-label="${t('report.pivot_row')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.pivot?.row ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1043
1104
|
<select name="${key(r, 'pp')}" form="${pivotForm}" aria-label="${t('report.pivot_column')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.pivot?.column ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1044
1105
|
<select name="${key(r, 'pf')}" form="${pivotForm}" aria-label="${t('report.function')}">${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}"${fn === (st.pivot?.fn ?? 'count') ? raw(' selected') : ''}>${t(`agg.${fn}`)}</option>`)}</select>
|
|
1045
1106
|
<select name="${key(r, 'pc')}" form="${pivotForm}" aria-label="${t('report.value_column')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.pivot?.value ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1046
|
-
<button class="btn" form="${pivotForm}">${t('report.apply')}</button
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
<a href="${regionUrl(ctx, r, (p) => p.set(key(r, 'pdf'), '1'))}" download>${icon('download')} ${t('report.download_pdf')}</a>
|
|
1059
|
-
<button type="button" data-print>${icon('printer')} ${t('report.print')}</button>
|
|
1060
|
-
<a href="${regionUrl(ctx, r, (p) => { for (const k of [...p.keys()]) if (k.startsWith(`r${r.id}_`)) p.delete(k); })}">${icon('history')} ${t('report.reset')}</a>
|
|
1061
|
-
</div>
|
|
1062
|
-
</div>
|
|
1107
|
+
<button class="btn btn-hot" form="${pivotForm}">${t('report.apply')}</button></div>`)}
|
|
1108
|
+
${sub('bookmark', t('report.report'), [
|
|
1109
|
+
saved ? item('saved', 'save', t('report.saved_reports'), saved) : '',
|
|
1110
|
+
menuLink('history', t('report.reset'), regionUrl(ctx, r, (p) => { for (const k of [...p.keys()]) if (k.startsWith(`r${r.id}_`)) p.delete(k); })),
|
|
1111
|
+
])}
|
|
1112
|
+
${sub('download', t('report.download_menu'), [
|
|
1113
|
+
menuLink('download', t('report.download'), regionUrl(ctx, r, (p) => p.set(key(r, 'csv'), '1')), raw(' download')),
|
|
1114
|
+
menuLink('download', t('report.download_xlsx'), regionUrl(ctx, r, (p) => p.set(key(r, 'xlsx'), '1')), raw(' download')),
|
|
1115
|
+
menuLink('download', t('report.download_pdf'), regionUrl(ctx, r, (p) => p.set(key(r, 'pdf'), '1')), raw(' download')),
|
|
1116
|
+
html`<li><button type="button" class="ir-link" data-print>${icon('printer')}<span>${t('report.print')}</span></button></li>`,
|
|
1117
|
+
])}
|
|
1118
|
+
</ul></div>
|
|
1063
1119
|
</details>`
|
|
1064
1120
|
: '';
|
|
1065
1121
|
|
|
@@ -1069,7 +1125,12 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1069
1125
|
${filterItems}
|
|
1070
1126
|
${searchable
|
|
1071
1127
|
? html`<div class="search" role="search">
|
|
1072
|
-
<
|
|
1128
|
+
<div class="search-field">${interactive
|
|
1129
|
+
? html`<label class="sr-only" for="r${r.id}_qc">${t('report.search_column')}</label>
|
|
1130
|
+
<select id="r${r.id}_qc" class="search-scope" name="${key(r, 'qc')}" form="${searchForm}">
|
|
1131
|
+
<option value="">${t('report.all_columns')}</option>${cols.map(({ f }) => html`<option value="${f.name}"${f.name === st.searchCol ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>`
|
|
1132
|
+
: ''}
|
|
1133
|
+
<input type="search" name="${key(r, 'q')}" value="${st.search}" placeholder="${t('report.search_all')}" form="${searchForm}" aria-label="${t('report.search')} ${r.title ?? ''}"></div>
|
|
1073
1134
|
<button class="btn" form="${searchForm}">${t('report.go')}</button>
|
|
1074
1135
|
${actionsMenu}
|
|
1075
1136
|
</div>`
|
|
@@ -1098,12 +1159,12 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1098
1159
|
// app.js records each change (POST …/report/<id>/select), so paging keeps them
|
|
1099
1160
|
const onPage = new Set(selIdx >= 0 ? info.rows.map((row) => cell(row[selIdx])) : []);
|
|
1100
1161
|
const elsewhere = [...selected].filter((v) => !onPage.has(v));
|
|
1101
|
-
const selectionNote =
|
|
1162
|
+
const selectionNote = selIdx >= 0
|
|
1102
1163
|
? 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
1164
|
${elsewhere.map((v) => html`<input type="hidden" name="${selection!.item}" value="${v}" data-sel-other>`)}`
|
|
1104
1165
|
: '';
|
|
1105
1166
|
return html`${toolbar}${switcher}${errors}${selectionNote}
|
|
1106
|
-
<div class="table-wrap"><table class="report${r.config.mobile === 'scroll' ? '' : ' report-reflow'}"${
|
|
1167
|
+
<div class="table-wrap"><table class="report${r.config.mobile === 'scroll' ? '' : ' report-reflow'}"${selIdx >= 0 ? raw(` data-sel-region="${r.id}"`) : ''}>
|
|
1107
1168
|
<thead><tr>${header}</tr></thead>
|
|
1108
1169
|
<tbody>${body.length ? body : html`<tr><td colspan="${cols.length + lead || 1}" class="empty">${empty}</td></tr>`}</tbody>
|
|
1109
1170
|
${info.rows.length ? foot : ''}
|
package/src/runtime/routes.ts
CHANGED
|
@@ -24,15 +24,16 @@ import { startDebug } from '../debug.ts';
|
|
|
24
24
|
import { assignable, clearPageItems, fetchForms, processConditionHolds, ProcessFailed, runAppProcesses, runProcesses, runSql, validate, ValidationFailed } from './engine.ts';
|
|
25
25
|
import { aiInputs, aiProcessOf, runAiProcess } from './ai.ts';
|
|
26
26
|
import { branchTarget, ComputationFailed, runComputations } from './logic.ts';
|
|
27
|
-
import { comboMultiple, itemMask, MULTI_VALUE, popupPageSize, renderItem, searchLov, splitValues } from './items.ts';
|
|
27
|
+
import { comboMultiple, itemDateMask, itemMask, MULTI_VALUE, popupPageSize, renderItem, searchLov, splitValues } from './items.ts';
|
|
28
28
|
import { parseNumber } from '../numformat.ts';
|
|
29
|
+
import { parseDateMask } from './format.ts';
|
|
29
30
|
import { cleanRichText } from '../richtext.ts';
|
|
30
31
|
import { applyUploads, fileRoutes, readMultipart, type Upload } from './files.ts';
|
|
31
32
|
import { renderRegion } from './regions.ts';
|
|
32
33
|
import { downloadHeaders, type Download } from './processes.ts';
|
|
33
34
|
import { moveGanttTask } from './gantt-move.ts';
|
|
34
35
|
import { moveCalendarEvent } from './calendar.ts';
|
|
35
|
-
import { openDownload, reportParams, normaliseReportParams, selectionOf } from './report.ts';
|
|
36
|
+
import { columnValues, openDownload, reportParams, normaliseReportParams, selectionOf } from './report.ts';
|
|
36
37
|
import { invalidatePage, lazyOf } from './region-cache.ts';
|
|
37
38
|
import { applyRowSelection, mastersOf } from './master-detail.ts';
|
|
38
39
|
import { layerInArea, layerTile, parseTile, servedLayer } from './maps.ts';
|
|
@@ -373,7 +374,10 @@ function applyPostedItems(ctx: PageContext, body: Body, only?: string[]) {
|
|
|
373
374
|
else if (item.type === 'markdown') ctx.session.state[item.name] = posted ? String(posted).replace(/\r\n?/g, '\n') : null;
|
|
374
375
|
else if (item.type === 'checkbox' || item.type === 'switch') ctx.session.state[item.name] = posted === 'true' ? 'true' : 'false';
|
|
375
376
|
else if (item.type === 'password' && !posted) continue;
|
|
376
|
-
else if (
|
|
377
|
+
else if (item.type === 'date' && itemDateMask(ctx, item) && posted !== undefined && posted !== '') {
|
|
378
|
+
// (sprint 76) "23-05-1987" in the date format → "1987-05-23"; other text is kept as typed, and validate() reports it
|
|
379
|
+
ctx.session.state[item.name] = parseDateMask(String(posted), itemDateMask(ctx, item)!, ctx.locale.lang) ?? String(posted);
|
|
380
|
+
} else if (itemMask(item) && posted !== undefined && posted !== '') {
|
|
377
381
|
// "1.234,50 €" → "1234.5"; text that isn't a number is kept as typed, and validate() reports it
|
|
378
382
|
const parsed = parseNumber(String(posted), itemMask(item), ctx.locale.numbers);
|
|
379
383
|
ctx.session.state[item.name] = parsed === null ? String(posted) : parsed || null;
|
|
@@ -736,6 +740,33 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
736
740
|
}
|
|
737
741
|
});
|
|
738
742
|
|
|
743
|
+
// ---------------------------------------------------------------- a column heading's values
|
|
744
|
+
// (sprint 76) The values of a report column with their counts, for its heading's menu: the report must be
|
|
745
|
+
// visible and interactive; the query runs as the application's role with the request's search and filters;
|
|
746
|
+
// only a column the user may see. Read only.
|
|
747
|
+
app.get('/a/:alias/:page/report/:id/values', async (req: Req, reply) => {
|
|
748
|
+
const ctx = await loadContext(req, reply, { json: true });
|
|
749
|
+
if (!ctx) return;
|
|
750
|
+
reply.header('cache-control', 'private, no-store');
|
|
751
|
+
const column = String((req.query as Record<string, unknown>)?.col ?? '');
|
|
752
|
+
try {
|
|
753
|
+
const out = await appTx(txContext(ctx), async (c) => {
|
|
754
|
+
ctx.client = c;
|
|
755
|
+
await checkPageAccess(ctx);
|
|
756
|
+
const vis = await computeVisibility(ctx);
|
|
757
|
+
const r = ctx.page.regions.find((x) => x.id === Number(req.params.id) && x.type === 'report');
|
|
758
|
+
if (!r || !vis.regions.has(r.id) || r.config.interactive === false || r.config.searchable === false) throw new Forbidden(ctx.locale.t('error.access_denied'));
|
|
759
|
+
const list = column ? await columnValues(ctx, r, column) : null;
|
|
760
|
+
if (!list) throw new Forbidden(ctx.locale.t('error.access_denied'));
|
|
761
|
+
return { values: list };
|
|
762
|
+
});
|
|
763
|
+
return reply.send(out);
|
|
764
|
+
} catch (e) {
|
|
765
|
+
if (e instanceof Forbidden) return reply.code(403).send({ error: e.message });
|
|
766
|
+
return reply.code(400).send({ error: await publicError(ctx, e, 'column values') });
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
739
770
|
// ---------------------------------------------------------------- map layers loaded by the browser
|
|
740
771
|
// (0.31) A map layer with "visible_area" (JSON for ?bb=south,west,north,east)
|
|
741
772
|
// or "tiles" (Mapbox Vector Tiles, …/tiles/<layer>/<z>/<x>/<y>.mvt). Page
|
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
|
|