pgkiln 0.41.0 → 0.42.0

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