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.
Files changed (68) hide show
  1. package/db/migrations/097_generated_app_quality.sql +911 -0
  2. package/db/migrations/098_app_activity.sql +37 -0
  3. package/db/migrations/099_layout_positions.sql +29 -0
  4. package/docs/apex-feature-parity.md +38 -11
  5. package/docs/guide/03-builder.md +133 -31
  6. package/docs/guide/04-pages-and-regions.md +46 -3
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/09-reference.md +10 -3
  9. package/docs/guide/11-from-apex.md +1 -1
  10. package/docs/guide/12-development.md +3 -2
  11. package/docs/guide/14-globalization.md +7 -3
  12. package/docs/guide/17-mobile.md +1 -1
  13. package/package.json +1 -1
  14. package/public/app.css +233 -33
  15. package/public/app.js +263 -0
  16. package/public/builder.css +142 -2
  17. package/public/builder.js +311 -3
  18. package/src/builder/appfromfile.ts +5 -4
  19. package/src/builder/appsheets.ts +23 -9
  20. package/src/builder/appwizard.ts +215 -70
  21. package/src/builder/arrange.ts +133 -17
  22. package/src/builder/blueprints.ts +2 -1
  23. package/src/builder/components.ts +29 -23
  24. package/src/builder/designer.ts +67 -18
  25. package/src/builder/forms.ts +4 -1
  26. package/src/builder/home.ts +2 -32
  27. package/src/builder/newapp.ts +182 -5
  28. package/src/builder/object-browser.ts +342 -0
  29. package/src/builder/pwa.ts +3 -3
  30. package/src/builder/routes.ts +86 -51
  31. package/src/builder/shared.ts +103 -1
  32. package/src/builder/sql.ts +13 -65
  33. package/src/builder/ui.ts +21 -6
  34. package/src/i18n/ar.ts +17 -0
  35. package/src/i18n/cs.ts +17 -0
  36. package/src/i18n/da.ts +17 -0
  37. package/src/i18n/de.ts +17 -0
  38. package/src/i18n/el.ts +17 -0
  39. package/src/i18n/es.ts +17 -0
  40. package/src/i18n/fi.ts +17 -0
  41. package/src/i18n/fr.ts +17 -0
  42. package/src/i18n/he.ts +17 -0
  43. package/src/i18n/it.ts +17 -0
  44. package/src/i18n/ja.ts +17 -0
  45. package/src/i18n/ko.ts +17 -0
  46. package/src/i18n/nb.ts +17 -0
  47. package/src/i18n/pl.ts +17 -0
  48. package/src/i18n/pt.ts +17 -0
  49. package/src/i18n/ru.ts +17 -0
  50. package/src/i18n/sv.ts +17 -0
  51. package/src/i18n/tr.ts +17 -0
  52. package/src/i18n/uk.ts +17 -0
  53. package/src/i18n/zh.ts +17 -0
  54. package/src/i18n.ts +34 -0
  55. package/src/icons.ts +2 -1
  56. package/src/layout.ts +2 -2
  57. package/src/metadata.ts +5 -1
  58. package/src/runtime/charts.ts +8 -3
  59. package/src/runtime/devbar.ts +35 -14
  60. package/src/runtime/engine.ts +13 -4
  61. package/src/runtime/format.ts +52 -0
  62. package/src/runtime/items.ts +27 -0
  63. package/src/runtime/locale.ts +5 -2
  64. package/src/runtime/regions.ts +61 -4
  65. package/src/runtime/render.ts +38 -8
  66. package/src/runtime/report.ts +149 -88
  67. package/src/runtime/routes.ts +34 -3
  68. package/src/runtime/styles.ts +5 -2
@@ -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
- : html`<li><a href="${ctx.base}/${e.target_page}"${active ? raw(' aria-current="page"') : inTrail ? raw(' class="in-trail"') : ''}>${label}</a></li>`,
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>${ctx.user}</span></summary>
283
+ <summary>${icon('user')}<span>${shown}</span></summary>
280
284
  <div class="menu-panel align-right">
281
- <div class="menu-section"><strong>${ctx.user}</strong>${ctx.roles.length ? html`<div class="muted">${t('account.roles')}: ${ctx.roles.join(', ')}</div>` : ''}</div>
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'] as const;
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
- const regions = [];
360
- for (const r of ctx.page.regions) regions.push(await renderRegion(ctx, r, hidden));
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
- <div class="t-regions">${regions}</div>
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
+ }
@@ -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
- if (st.search) where.push(searchSql(st.search, p, hides ? [...userCols.keys()] : undefined));
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
- if (!info.more && info.page <= 1) return info.total ? html`<div class="pager"><span>${t('report.rows')}: ${info.total}</span></div>` : '';
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
- const allCols = visibleColumns(r, fields);
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> } | undefined;
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
- const lead = selIdx >= 0 ? 1 : 0;
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
- 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>`;
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 (lead) 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>`);
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}">${lead ? html`<td class="row-select"></td>` : ''}${cols.map(({ f }, ci) => {
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 = lead
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
- body.push(html`<tr class="${hl ? `hl-${hl.color}` : null}">${pick}${cols.map(({ f, i }) => {
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
- <div class="menu-section">
978
- <strong>${t('report.filter')}</strong>
979
- <div class="filter-row">
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
- </div>
986
- ${r.config.sortable === false
987
- ? ''
988
- : html`<div class="menu-section"><strong>${t('report.sort')}</strong>
989
- <div class="seg">${cols.map(({ f, i }) => {
990
- const pos = i + 1;
991
- const active = st.sort === pos;
992
- const href = regionUrl(ctx, r, (p) => {
993
- p.set(key(r, 's'), String(pos));
994
- if (active && !st.desc) p.set(key(r, 'd'), 'desc');
995
- else p.delete(key(r, 'd'));
996
- p.delete(key(r, 'p'));
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
- </div>
1010
- </div>
1011
- <div class="menu-section">
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
- </div>
1028
- </div>
1029
- <div class="menu-section">
1030
- <strong>${t('report.compute')}</strong>
1031
- <div class="filter-row">
1032
- <input name="${key(r, 'cn')}" form="${computeForm}" maxlength="40" aria-label="${t('report.compute_name')}" placeholder="${t('report.compute_name')}">
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
- </div>
1048
- </div>
1049
- ${chartSection(ctx, r, st, allCols.map(({ f }) => f.name), chartForm)}
1050
- ${saved ?? ''}
1051
- <div class="menu-section"><strong>${t('report.rows_per_page')}</strong>
1052
- <div class="seg">${PAGE_SIZES.map((n) =>
1053
- 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>
1054
- </div>
1055
- <div class="menu-section menu-links">
1056
- <a href="${regionUrl(ctx, r, (p) => p.set(key(r, 'csv'), '1'))}" download>${icon('download')} ${t('report.download')}</a>
1057
- <a href="${regionUrl(ctx, r, (p) => p.set(key(r, 'xlsx'), '1'))}" download>${icon('download')} ${t('report.download_xlsx')}</a>
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
- <input type="search" name="${key(r, 'q')}" value="${st.search}" placeholder="${t('report.search_all')}" form="${searchForm}" aria-label="${t('report.search')} ${r.title ?? ''}">
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 = lead
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'}"${lead ? raw(` data-sel-region="${r.id}"`) : ''}>
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 : ''}
@@ -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 (itemMask(item) && posted !== undefined && posted !== '') {
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
@@ -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: '#5146d8', header: '#1e1a4d' },
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 => (theme?.base === 'iris' ? 'iris' : 'standard');
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