pgkiln 0.42.0 → 0.44.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 (60) hide show
  1. package/db/migrations/099_layout_positions.sql +29 -0
  2. package/db/migrations/100_generated_labels.sql +381 -0
  3. package/db/migrations/101_theme_choice.sql +15 -0
  4. package/docs/apex-feature-parity.md +36 -9
  5. package/docs/guide/03-builder.md +82 -21
  6. package/docs/guide/04-pages-and-regions.md +55 -5
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/09-reference.md +5 -1
  9. package/docs/guide/12-development.md +2 -1
  10. package/docs/guide/14-globalization.md +5 -1
  11. package/package.json +1 -1
  12. package/public/app.css +80 -0
  13. package/public/app.js +189 -1
  14. package/public/builder.css +112 -0
  15. package/public/builder.js +280 -3
  16. package/src/builder/appsheets.ts +53 -13
  17. package/src/builder/appwizard.ts +5 -3
  18. package/src/builder/arrange.ts +133 -17
  19. package/src/builder/components.ts +28 -22
  20. package/src/builder/designer.ts +67 -18
  21. package/src/builder/forms.ts +4 -1
  22. package/src/builder/object-browser.ts +342 -0
  23. package/src/builder/pwa.ts +3 -3
  24. package/src/builder/routes.ts +85 -26
  25. package/src/builder/shared.ts +103 -1
  26. package/src/builder/sql.ts +13 -65
  27. package/src/builder/ui.ts +21 -6
  28. package/src/i18n/ar.ts +18 -0
  29. package/src/i18n/cs.ts +18 -0
  30. package/src/i18n/da.ts +18 -0
  31. package/src/i18n/de.ts +18 -0
  32. package/src/i18n/el.ts +18 -0
  33. package/src/i18n/es.ts +18 -0
  34. package/src/i18n/fi.ts +18 -0
  35. package/src/i18n/fr.ts +18 -0
  36. package/src/i18n/he.ts +18 -0
  37. package/src/i18n/it.ts +18 -0
  38. package/src/i18n/ja.ts +18 -0
  39. package/src/i18n/ko.ts +18 -0
  40. package/src/i18n/nb.ts +18 -0
  41. package/src/i18n/pl.ts +18 -0
  42. package/src/i18n/pt.ts +18 -0
  43. package/src/i18n/ru.ts +18 -0
  44. package/src/i18n/sv.ts +18 -0
  45. package/src/i18n/tr.ts +18 -0
  46. package/src/i18n/uk.ts +18 -0
  47. package/src/i18n/zh.ts +18 -0
  48. package/src/i18n.ts +36 -0
  49. package/src/metadata.ts +5 -1
  50. package/src/runtime/account.ts +5 -5
  51. package/src/runtime/devbar.ts +16 -13
  52. package/src/runtime/engine.ts +6 -0
  53. package/src/runtime/facets.ts +34 -3
  54. package/src/runtime/format.ts +52 -0
  55. package/src/runtime/items.ts +27 -0
  56. package/src/runtime/locale.ts +7 -1
  57. package/src/runtime/regions.ts +36 -2
  58. package/src/runtime/render.ts +27 -6
  59. package/src/runtime/report.ts +141 -81
  60. package/src/runtime/routes.ts +39 -7
package/public/builder.js CHANGED
@@ -209,6 +209,132 @@
209
209
  });
210
210
  }
211
211
 
212
+ // ---------------------------------------------------------------- the rendering tree's context menu (sprint 77)
213
+ // Right-click (or Shift+F10 / the menu key) on a region, item or button: create in or below it, duplicate,
214
+ // copy to another page, expand or collapse everything below, delete. Every entry is a link or a form post
215
+ // the server also answers without JavaScript.
216
+ function setupTreeMenu(tree) {
217
+ const pid = tree.dataset.pageId;
218
+ const base = `/builder/pages/${pid}`;
219
+ const token = document.querySelector('input[name="__csrf"]')?.value ?? '';
220
+ let menu = null;
221
+ let opener = null;
222
+ const close = (refocus = true) => {
223
+ menu?.remove();
224
+ menu = null;
225
+ if (refocus) opener?.focus();
226
+ };
227
+ const submit = (action, fields, confirmText) => {
228
+ if (confirmText && !window.confirm(confirmText)) return;
229
+ const f = document.createElement('form');
230
+ f.method = 'post';
231
+ f.action = action;
232
+ for (const [k, v] of Object.entries({ __csrf: token, ...fields })) {
233
+ const i = document.createElement('input');
234
+ i.type = 'hidden';
235
+ i.name = k;
236
+ i.value = v;
237
+ f.appendChild(i);
238
+ }
239
+ document.body.appendChild(f);
240
+ f.submit();
241
+ };
242
+ const setAll = (node, open) => {
243
+ const li = node.closest('li');
244
+ for (const sub of [li, ...li.querySelectorAll('li')]) {
245
+ const n = sub.querySelector(':scope > .pd-node.has-kids');
246
+ if (!n) continue;
247
+ sub.toggleAttribute('data-collapsed', !open);
248
+ n.setAttribute('aria-expanded', String(open));
249
+ }
250
+ };
251
+ const entries = (node) => {
252
+ const kind = node.dataset.kind;
253
+ const id = node.dataset.id;
254
+ const label = node.querySelector('.pd-label')?.textContent ?? '';
255
+ const go = (q) => () => window.location.assign(`${base}?${q}`);
256
+ const copyDialog = () => {
257
+ const d = document.getElementById('pd-copy-dialog');
258
+ if (!d) return;
259
+ d.querySelector('input[name="kind"]').value = kind;
260
+ d.querySelector('input[name="id"]').value = id;
261
+ d.querySelector('[data-copy-what]').textContent = `${kind[0].toUpperCase()}${kind.slice(1)}: ${label}`;
262
+ d.showModal();
263
+ };
264
+ const list = [];
265
+ if (kind === 'region') {
266
+ list.push(['Create page item', go(`new=item&region=${id}`)], ['Create button', go(`new=button&region=${id}`)],
267
+ ['Create dynamic action', go('new=dynamic_action')],
268
+ ['Create region below', () => submit(`${base}/layout/create`, { kind: 'region', type: 'static', position: node.dataset.position ?? '', before: node.dataset.next ?? '' })], null);
269
+ }
270
+ list.push(['Duplicate', () => submit(`${base}/layout/duplicate`, { kind, id })], ['Copy to other page…', copyDialog]);
271
+ if (kind !== 'region') list.push(['Create dynamic action', go('new=dynamic_action')]);
272
+ if (node.classList.contains('has-kids')) list.push(null, ['Expand all below', () => setAll(node, true)], ['Collapse all below', () => setAll(node, false)]);
273
+ list.push(null, ['Delete', () => submit(`${base}/c/${kind}/${id}/delete`, {}, `Delete ${kind} ${label}?`), 'danger']);
274
+ return list;
275
+ };
276
+ const open = (node, x, y) => {
277
+ close(false);
278
+ opener = node;
279
+ menu = document.createElement('div');
280
+ menu.className = 'pd-menu';
281
+ menu.setAttribute('role', 'menu');
282
+ menu.setAttribute('aria-label', `Actions: ${node.querySelector('.pd-label')?.textContent ?? ''}`);
283
+ for (const e of entries(node)) {
284
+ if (!e) {
285
+ const sep = document.createElement('div');
286
+ sep.setAttribute('role', 'separator');
287
+ menu.appendChild(sep);
288
+ continue;
289
+ }
290
+ const b = document.createElement('button');
291
+ b.type = 'button';
292
+ b.setAttribute('role', 'menuitem');
293
+ b.tabIndex = -1;
294
+ b.textContent = e[0];
295
+ if (e[2]) b.classList.add(`is-${e[2]}`);
296
+ b.addEventListener('click', () => {
297
+ close(e[0] !== 'Copy to other page…');
298
+ e[1]();
299
+ });
300
+ menu.appendChild(b);
301
+ }
302
+ document.body.appendChild(menu);
303
+ const r = menu.getBoundingClientRect();
304
+ menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - r.width - 4))}px`;
305
+ menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - r.height - 4))}px`;
306
+ menu.querySelector('[role=menuitem]')?.focus();
307
+ menu.addEventListener('keydown', (e) => {
308
+ const items = [...menu.querySelectorAll('[role=menuitem]')];
309
+ const i = items.indexOf(document.activeElement);
310
+ if (e.key === 'ArrowDown') items[(i + 1) % items.length].focus();
311
+ else if (e.key === 'ArrowUp') items[(i - 1 + items.length) % items.length].focus();
312
+ else if (e.key === 'Home') items[0].focus();
313
+ else if (e.key === 'End') items[items.length - 1].focus();
314
+ else if (e.key === 'Escape' || e.key === 'Tab') close();
315
+ else return;
316
+ e.preventDefault();
317
+ });
318
+ };
319
+ tree.addEventListener('contextmenu', (e) => {
320
+ const node = e.target.closest('.pd-node[data-kind][data-id]');
321
+ if (!node || !['region', 'item', 'button'].includes(node.dataset.kind)) return;
322
+ e.preventDefault();
323
+ const r = node.getBoundingClientRect();
324
+ // the keyboard's menu key has no pointer position
325
+ open(node, e.clientX || r.left + 24, e.clientY || r.bottom);
326
+ });
327
+ document.addEventListener('pointerdown', (e) => {
328
+ if (menu && !menu.contains(e.target)) close(false);
329
+ });
330
+ window.addEventListener('blur', () => close(false));
331
+ const dialog = document.getElementById('pd-copy-dialog');
332
+ dialog?.querySelector('[data-dialog-close]')?.addEventListener('click', () => {
333
+ dialog.close();
334
+ opener?.focus();
335
+ });
336
+ }
337
+
212
338
  // ---------------------------------------------------------------- property editor
213
339
  function setupProperties() {
214
340
  const pe = document.querySelector('.pe');
@@ -269,6 +395,102 @@
269
395
  }
270
396
  });
271
397
  }
398
+ // (sprint 77, APEX: Show Common / Show All, Go to Group) next to the filter
399
+ if (row && groups.length) {
400
+ const bar = document.createElement('div');
401
+ bar.className = 'pe-view';
402
+ if (pe.querySelector('[data-more]')) {
403
+ const seg = document.createElement('div');
404
+ seg.className = 'segmented pe-common';
405
+ seg.setAttribute('role', 'group');
406
+ seg.setAttribute('aria-label', 'Properties shown');
407
+ const showCommon = (on, remember) => {
408
+ pe.classList.toggle('pe-show-common', on);
409
+ for (const b of seg.querySelectorAll('button')) b.setAttribute('aria-pressed', String((b.value === 'common') === on));
410
+ for (const fs of groups) fs.classList.toggle('pe-group-more', on && !fs.querySelector('.field:not([data-more] .field), .options-field:not([data-more] .options-field)'));
411
+ if (remember) store.set('pe.common', on ? '1' : null);
412
+ };
413
+ for (const [v, label] of [['common', 'Common'], ['all', 'All']]) {
414
+ const b = document.createElement('button');
415
+ b.type = 'button';
416
+ b.value = v;
417
+ b.textContent = label;
418
+ b.addEventListener('click', () => showCommon(v === 'common', true));
419
+ seg.appendChild(b);
420
+ }
421
+ bar.appendChild(seg);
422
+ showCommon(store.get('pe.common') === '1', false);
423
+ }
424
+ const named = groups.filter((fs) => fs.querySelector(':scope > legend'));
425
+ if (named.length > 1) {
426
+ const sel = document.createElement('select');
427
+ sel.className = 'pe-goto';
428
+ sel.setAttribute('aria-label', 'Go to group');
429
+ sel.innerHTML = '<option value="">Go to group</option>';
430
+ named.forEach((fs, i) => {
431
+ const o = document.createElement('option');
432
+ o.value = String(i);
433
+ o.textContent = fs.querySelector(':scope > legend').textContent.trim();
434
+ sel.appendChild(o);
435
+ });
436
+ sel.addEventListener('change', () => {
437
+ const fs = named[Number(sel.value)];
438
+ sel.value = '';
439
+ if (!fs) return;
440
+ // a group on another tab (a region's Attributes): open that tab first (tabify names its tab <panel id>-tab)
441
+ const panel = fs.closest('[data-tab]');
442
+ if (panel?.hidden) document.getElementById(`${panel.id}-tab`)?.click();
443
+ const t = fs.querySelector('.pe-toggle');
444
+ if (t?.getAttribute('aria-expanded') === 'false') t.click();
445
+ fs.scrollIntoView({ block: 'start', behavior: 'smooth' });
446
+ t?.focus({ preventScroll: true });
447
+ });
448
+ bar.appendChild(sel);
449
+ }
450
+ if (bar.children.length) row.after(bar);
451
+ }
452
+ // (sprint 77, APEX: the Template Options dialog) the checkboxes in a dialog, opened from a button that counts them.
453
+ // The dialog is the body's (not inside the form or the property editor's wrappers, which Common hides); the
454
+ // checkboxes belong to their form through the form attribute, so they are saved with it.
455
+ let optionDialogs = 0;
456
+ for (const fs of pe.querySelectorAll('fieldset.options-field')) {
457
+ const form = fs.closest('form');
458
+ if (!form) continue;
459
+ if (!form.id) form.id = `pe-form-${++optionDialogs}`;
460
+ const boxes = [...fs.querySelectorAll('input[type=checkbox]')];
461
+ for (const x of boxes) x.setAttribute('form', form.id);
462
+ const legend = fs.querySelector('legend')?.textContent.trim() ?? 'Options';
463
+ const holder = document.createElement('div');
464
+ holder.className = 'field pe-options-open';
465
+ const b = document.createElement('button');
466
+ b.type = 'button';
467
+ b.className = 'btn btn-sm';
468
+ const count = () => {
469
+ const n = boxes.filter((x) => x.checked).length;
470
+ b.textContent = `${legend}${n ? ` (${n})` : ''}…`;
471
+ };
472
+ const d = document.createElement('dialog');
473
+ d.className = 'pd-dialog';
474
+ d.setAttribute('aria-label', legend);
475
+ fs.replaceWith(holder);
476
+ holder.appendChild(b);
477
+ d.appendChild(fs);
478
+ const ok = document.createElement('div');
479
+ ok.className = 'pd-dialog-buttons';
480
+ ok.innerHTML = '<button type="button" class="btn btn-primary">OK</button>';
481
+ d.appendChild(ok);
482
+ document.body.appendChild(d);
483
+ // the count follows every change (Escape closes the dialog without OK); a change is an unsaved change
484
+ for (const x of boxes)
485
+ x.addEventListener('change', () => {
486
+ count();
487
+ document.querySelector(`.tb-save[form="${form.id}"]`)?.classList.add('is-dirty');
488
+ });
489
+ ok.querySelector('button').addEventListener('click', () => d.close());
490
+ d.addEventListener('close', () => b.focus());
491
+ b.addEventListener('click', () => d.showModal());
492
+ count();
493
+ }
272
494
  if (head) {
273
495
  const h = () => pe.style.setProperty('--pe-head-h', `${head.offsetHeight}px`);
274
496
  h();
@@ -360,9 +582,12 @@
360
582
  e.dataTransfer.effectAllowed = drag.isNew ? 'copy' : 'move';
361
583
  e.dataTransfer.setData('text/plain', drag.isNew ? `new ${drag.kind} ${drag.type}` : `${drag.kind} ${drag.id}`);
362
584
  (drag.kind === 'region' && !drag.isNew ? src.closest('.pd-region') : src).classList.add('is-dragging');
585
+ // (sprint 77) empty display positions and button positions show while something can drop there
586
+ canvas.classList.add(`pd-dragging-${drag.kind}`);
363
587
  });
364
588
  document.addEventListener('dragend', () => {
365
589
  for (const el of document.querySelectorAll('.is-dragging')) el.classList.remove('is-dragging');
590
+ canvas.classList.remove('pd-dragging-region', 'pd-dragging-item', 'pd-dragging-button');
366
591
  clear();
367
592
  drag = null;
368
593
  });
@@ -411,8 +636,10 @@
411
636
  clear();
412
637
  const before = t.before ? t.before.dataset.id : '';
413
638
  const region = d.kind === 'region' ? '' : t.zone.dataset.region;
414
- if (d.isNew) post('create', { kind: d.kind, type: d.type, region, before });
415
- else if (before !== d.id) post('move', { kind: d.kind, id: d.id, region, before });
639
+ // a region's display position, a button's position in its region
640
+ const place = d.kind === 'region' ? { position: t.zone.dataset.position ?? '' } : d.kind === 'button' ? { slot: t.zone.dataset.slot ?? '' } : {};
641
+ if (d.isNew) post('create', { kind: d.kind, type: d.type, region, before, ...place });
642
+ else if (before !== d.id || t.zone !== canvas.querySelector(`[data-kind="${d.kind}"][data-id="${d.id}"]`)?.closest('.pd-grid, .pd-slot')) post('move', { kind: d.kind, id: d.id, region, before, ...place });
416
643
  });
417
644
 
418
645
  // ------------------------------------------------ resizing a region (drag its right edge)
@@ -476,7 +703,7 @@
476
703
  // ---------------------------------------------------------------- small things
477
704
  // a select that navigates (the toolbar's page switcher)
478
705
  document.addEventListener('change', (e) => {
479
- if (e.target.matches?.('select[data-autosubmit]')) e.target.form?.submit();
706
+ if (e.target.matches?.('select[data-autosubmit], input[type=checkbox][data-autosubmit]')) e.target.form?.submit();
480
707
  });
481
708
 
482
709
  // a search box that filters a list as you type (the App Builder's applications);
@@ -776,6 +1003,7 @@
776
1003
  setupThemePreview();
777
1004
  setupTabs();
778
1005
  for (const tree of document.querySelectorAll('.pd-tree')) setupTree(tree);
1006
+ for (const tree of document.querySelectorAll('.pd-tree[data-tree-menu]')) setupTreeMenu(tree);
779
1007
  setupProperties();
780
1008
  setupCanvas();
781
1009
  }
@@ -890,3 +1118,52 @@ document.addEventListener('submit', (e) => {
890
1118
  // back from the browser's cache (the back button): no overlay
891
1119
  window.addEventListener('pageshow', (ev) => { if (ev.persisted) overlay.remove(); }, { once: true });
892
1120
  });
1121
+
1122
+ // (sprint 74) a search box that filters a list's rows while typing (data-filter-rows="tbody id"); the form still works without it
1123
+ document.querySelectorAll('input[data-filter-rows]').forEach((el) => {
1124
+ const body = document.getElementById(el.dataset.filterRows);
1125
+ if (!body) return;
1126
+ el.addEventListener('input', () => {
1127
+ const q = el.value.trim().toLowerCase();
1128
+ body.querySelectorAll(':scope > tr, :scope > li').forEach((tr) => { tr.hidden = !!q && !tr.textContent.toLowerCase().includes(q); });
1129
+ });
1130
+ });
1131
+
1132
+ // (sprint 74) Settings tabs without a reload; the form remembers the tab for the redirect after Save.
1133
+ // The authentication type shows only its own fields.
1134
+ (() => {
1135
+ const links = [...document.querySelectorAll('[data-tab-link]')];
1136
+ if (!links.length) return;
1137
+ const show = (tab) => {
1138
+ document.querySelectorAll('.settings-panel').forEach((p) => { p.hidden = p.dataset.tab !== tab; });
1139
+ links.forEach((l) => (l.dataset.tabLink === tab ? l.setAttribute('aria-current', 'page') : l.removeAttribute('aria-current')));
1140
+ document.querySelectorAll('[data-settings-tab]').forEach((i) => { i.value = tab; });
1141
+ const save = document.querySelector('.settings-save');
1142
+ if (save) save.hidden = tab === 'errors' || tab === 'pwa';
1143
+ history.replaceState(null, '', `?tab=${tab}`);
1144
+ };
1145
+ links.forEach((l) => l.addEventListener('click', (e) => { e.preventDefault(); show(l.dataset.tabLink); }));
1146
+ const auth = document.querySelector('.settings-form select[name="authentication"]');
1147
+ auth?.addEventListener('change', () => {
1148
+ document.querySelectorAll('.auth-part').forEach((p) => { p.hidden = p.dataset.auth !== auth.value; });
1149
+ });
1150
+ })();
1151
+
1152
+ // (sprint 75) the Object Browser's filter: objects whose name holds the text; folders without any are hidden
1153
+ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
1154
+ const tree = document.getElementById(el.dataset.filterTree);
1155
+ if (!tree) return;
1156
+ el.addEventListener('input', () => {
1157
+ const q = el.value.trim().toLowerCase();
1158
+ tree.querySelectorAll('details.ob-folder').forEach((folder) => {
1159
+ let n = 0;
1160
+ folder.querySelectorAll('li').forEach((li) => {
1161
+ const hit = !q || li.textContent.toLowerCase().includes(q);
1162
+ li.hidden = !hit;
1163
+ if (hit) n++;
1164
+ });
1165
+ folder.hidden = !!q && n === 0;
1166
+ if (q && n) folder.open = true;
1167
+ });
1168
+ });
1169
+ });
@@ -274,6 +274,45 @@ interface Catalog {
274
274
  fk_table: string | null;
275
275
  }
276
276
 
277
+ /** (sprint 79) A number column that holds an amount: a dashboard sums it (Salary by Department). */
278
+ const AMOUNT = /(^|_)(sal|salary|salaries|amount|amt|total|price|cost|costs|revenue|value|budget|balance|fee|fees|pay|wage|wages)(_|$)/i;
279
+
280
+ interface ChartPlan {
281
+ table: string;
282
+ group: string;
283
+ /** count, or sum of `value` */
284
+ fn: 'count' | 'sum';
285
+ value: string | null;
286
+ kind: 'donut' | 'line' | 'bar';
287
+ }
288
+
289
+ /**
290
+ * (sprint 79, GEN-6) How a table is charted: by its first foreign key that says something, else a text,
291
+ * yes/no or date column whose values repeat; the sum of an amount column when there is one, else the
292
+ * number of rows; a donut for six groups or fewer, a line for dates, else a sorted bar. A grouping that
293
+ * says nothing (one group, or every group the same) is skipped.
294
+ */
295
+ async function chartPlan(c: Client, table: string, catalog: Catalog[], rows: number): Promise<ChartPlan | null> {
296
+ const amount = catalog.find((x) => x.kind === 'number' && !x.is_pk && !x.fk_table && AMOUNT.test(x.column_name))?.column_name ?? null;
297
+ const groups = [...catalog.filter((x) => x.fk_table && !x.is_pk).map((x) => x.column_name), groupColumn(catalog, rows)].filter(
298
+ (g, i, all): g is string => !!g && all.indexOf(g) === i,
299
+ );
300
+ for (const group of groups) {
301
+ for (const value of amount ? [amount, null] : [null]) {
302
+ const measure = value ? `sum(${pg.escapeIdentifier(value)})` : 'count(*)';
303
+ const spread = (
304
+ await c.query<{ groups: number; lo: string | null; hi: string | null }>(
305
+ `select count(*)::int as groups, min(v)::text as lo, max(v)::text as hi from (select ${pg.escapeIdentifier(group)} as g, ${measure} as v from ${table} group by 1) x`,
306
+ )
307
+ ).rows[0];
308
+ if (spread.groups < 2 || spread.lo === spread.hi) continue;
309
+ const kind = catalog.find((x) => x.column_name === group)?.kind === 'date' ? 'line' : spread.groups <= 6 ? 'donut' : 'bar';
310
+ return { table, group, fn: value ? 'sum' : 'count', value, kind };
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+
277
316
  /**
278
317
  * A dashboard page: a chart per table (at most six) with the number of rows per
279
318
  * parent row (the first foreign key), or per value of a text, yes/no or date
@@ -283,7 +322,7 @@ interface Catalog {
283
322
  * Manager", and the Dashboard comes right after Home in the navigation menu.
284
323
  */
285
324
  export async function addDashboard(c: Client, alias: string, tables: string[], page: number, skipped: string[] = []): Promise<{ page: number; label: string } | null> {
286
- const picks: { table: string; group: string }[] = [];
325
+ const picks: ChartPlan[] = [];
287
326
  for (const t of tables) {
288
327
  if (picks.length >= MAX_CHARTS) break;
289
328
  const rel = (await c.query('select relrowsecurity as rls, greatest(reltuples, 0) as n from pg_class where oid = $1::regclass', [t])).rows[0];
@@ -292,24 +331,25 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
292
331
  continue;
293
332
  }
294
333
  const catalog = (await c.query<Catalog>('select column_name, kind, is_pk, distinct_values, fk_table::text from meta.wizard_catalog($1::regclass)', [t])).rows;
295
- const group = catalog.find((x) => x.fk_table && !x.is_pk)?.column_name ?? groupColumn(catalog, Number(rel.n));
296
- if (group) picks.push({ table: t, group });
334
+ const plan = await chartPlan(c, t, catalog, Number(rel.n));
335
+ if (plan) picks.push(plan);
297
336
  }
298
337
  if (!picks.length) return null;
299
- const generate = (table: string, group: string, no: number, nav: boolean) =>
338
+ const generate = (p: ChartPlan, no: number, nav: boolean) =>
300
339
  c.query<{ id: number }>('select meta.generate_page($1, \'chart\', $2::regclass, $3, $4::jsonb) as id', [
301
- alias, table, no,
302
- JSON.stringify({ label: 'Dashboard', icon: 'chart', nav, label_column: group, function: 'count', value_column: null }),
340
+ alias, p.table, no,
341
+ JSON.stringify({ label: 'Dashboard', icon: 'chart', nav, label_column: p.group, function: p.fn, value_column: p.value, chart: p.kind }),
303
342
  ]);
304
- const title = async (table: string, group: string) =>
305
- (await c.query(`select meta.wizard_table_label($1::regclass) || ' by ' || meta.wizard_column_label($1::regclass, $2) as t`, [table, group])).rows[0].t as string;
306
- const dash = (await generate(picks[0].table, picks[0].group, page, true)).rows[0].id;
307
- await c.query('update meta.region set title = $2 where page_id = $1', [dash, await title(picks[0].table, picks[0].group)]);
343
+ // Employees by Manager; a sum: Salary by Department
344
+ const title = async (p: ChartPlan) =>
345
+ (await c.query(`select coalesce(meta.wizard_column_label($1::regclass, $3), meta.wizard_table_label($1::regclass)) || ' by ' || meta.wizard_column_label($1::regclass, $2) as t`, [p.table, p.group, p.value])).rows[0].t as string;
346
+ const dash = (await generate(picks[0], page, true)).rows[0].id;
347
+ await c.query('update meta.region set title = $2 where page_id = $1', [dash, await title(picks[0])]);
308
348
  for (const [n, p] of picks.slice(1).entries()) {
309
349
  // generated on a page of its own, then moved onto the dashboard
310
350
  const spare = (await c.query('select max(page_no) + 1000 as n from meta.page where app_id = (select id from meta.app where alias = $1)', [alias])).rows[0].n as number;
311
- const tmp = (await generate(p.table, p.group, spare, false)).rows[0].id;
312
- await c.query('update meta.region set page_id = $2, seq = $3, title = $4 where page_id = $1', [tmp, dash, (n + 2) * 10, await title(p.table, p.group)]);
351
+ const tmp = (await generate(p, spare, false)).rows[0].id;
352
+ await c.query('update meta.region set page_id = $2, seq = $3, title = $4 where page_id = $1', [tmp, dash, (n + 2) * 10, await title(p)]);
313
353
  await c.query('delete from meta.page where id = $1', [tmp]);
314
354
  }
315
355
  if (picks.length > 1) await c.query('update meta.region set columns = 6 where page_id = $1', [dash]);
@@ -319,7 +359,7 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
319
359
  from meta.app a where a.alias = $1 and e.app_id = a.id and e.parent_id is null and e.target_page = $2`,
320
360
  [alias, page],
321
361
  );
322
- return { page, label: `Dashboard (${(await Promise.all(picks.map((p) => title(p.table, p.group)))).join(', ')})` };
362
+ return { page, label: `Dashboard (${(await Promise.all(picks.map((p) => title(p)))).join(', ')})` };
323
363
  }
324
364
 
325
365
  /** Create the application, the tables, the rows, the foreign keys and the pages, in one transaction. */
@@ -40,6 +40,8 @@ export const pastedName = (title: string) =>
40
40
 
41
41
  interface Relation {
42
42
  relname: string;
43
+ /** (sprint 78) the friendly name of its pages: Employees */
44
+ label: string;
43
45
  kind: 'table' | 'view';
44
46
  pk: string | null;
45
47
  /** the planner's estimate (null: never analyzed) */
@@ -54,7 +56,7 @@ interface Relation {
54
56
  export async function schemaRelations(schema: string): Promise<Relation[]> {
55
57
  return (
56
58
  await owner.query<Relation>(
57
- `select c.relname, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
59
+ `select c.relname, meta.wizard_table_label(c.oid) as label, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
58
60
  meta.wizard_pk(c.oid) as pk, case when c.reltuples >= 0 then c.reltuples::bigint end as rows,
59
61
  meta.wizard_defaults('calendar', c.oid) ->> 'start' is not null as calendar,
60
62
  meta.wizard_pk(c.oid) is not null and meta.wizard_defaults('master_detail', c.oid) ->> 'detail' is not null as detail
@@ -350,9 +352,9 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
350
352
  ${known
351
353
  ? relations.length
352
354
  ? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
353
- <label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.relname}</label>
355
+ <label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
354
356
  <select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${PAGE_KINDS[k]}</option>`)}</select>
355
- <small class="muted">${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small></li>`)
357
+ <small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small></li>`)
356
358
  : html`<li class="muted">${schema} has no tables or views.</li>`
357
359
  : html`<li class="muted">Choose a schema under Data for a page per table, or add pages with the page wizards later.</li>`}
358
360
  </ul>