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
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
  }
@@ -859,3 +1087,83 @@ document.addEventListener('click', async (e) => {
859
1087
  show(e.target.closest?.('.pe [data-prop]'));
860
1088
  });
861
1089
  })();
1090
+
1091
+ // (sprint 73) a proposed value is selected on the first focus, so typing replaces it (data-select-once)
1092
+ document.querySelectorAll('input[data-select-once]').forEach((el) => {
1093
+ el.addEventListener('focus', () => {
1094
+ el.select();
1095
+ // the click that focused it would put the caret back: keep the selection
1096
+ el.addEventListener('mouseup', (e) => e.preventDefault(), { once: true });
1097
+ }, { once: true });
1098
+ });
1099
+
1100
+ // (sprint 73) a form that takes a while (data-progress="Creating …"): an overlay with the message while the server works
1101
+ document.addEventListener('submit', (e) => {
1102
+ const form = e.target.closest?.('form[data-progress]');
1103
+ if (!form || e.defaultPrevented || !form.checkValidity()) return;
1104
+ const overlay = document.createElement('div');
1105
+ overlay.className = 'progress-overlay';
1106
+ overlay.setAttribute('role', 'status');
1107
+ overlay.setAttribute('aria-live', 'polite');
1108
+ const box = document.createElement('div');
1109
+ box.className = 'progress-box';
1110
+ const spin = document.createElement('span');
1111
+ spin.className = 'progress-spinner';
1112
+ spin.setAttribute('aria-hidden', 'true');
1113
+ const text = document.createElement('span');
1114
+ text.textContent = form.dataset.progress;
1115
+ box.append(spin, text);
1116
+ overlay.append(box);
1117
+ document.body.append(overlay);
1118
+ // back from the browser's cache (the back button): no overlay
1119
+ window.addEventListener('pageshow', (ev) => { if (ev.persisted) overlay.remove(); }, { once: true });
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
+ });
@@ -21,7 +21,7 @@ import type { Session } from '../session.ts';
21
21
  import { MAX_MB, preview, resultHtml } from './dataload.ts';
22
22
  import { buildAppFromSheets, MAX_SHEETS, parseBook, sheetsForm, type BuiltBook, type NamedSheet } from './appsheets.ts';
23
23
  import { appWizardRoutes, PASTED } from './appwizard.ts';
24
- import { checkNewApp, createApp, createAppError, reservedSchema, type CheckedApp } from './newapp.ts';
24
+ import { finishGeneratedApp, checkNewApp, createApp, createAppError, reservedSchema, type CheckedApp } from './newapp.ts';
25
25
  import { currentWorkspace } from './workspaces.ts';
26
26
  import { BASE, csrf, developer, input, region, select, send, shell, type Body, type Req } from './ui.ts';
27
27
 
@@ -149,9 +149,9 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
149
149
  const generate = async (kind: string, page: number, options: Record<string, unknown>) => {
150
150
  await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, kind, T, page, JSON.stringify(options)]);
151
151
  };
152
- const label = (await c.query(`select meta.wizard_label($1) as l`, [tableName])).rows[0].l as string;
153
- await generate('report_form', 2, { form_page: 3, label });
154
- pages.push({ page: 2, label }, { page: 3, label: `${label} form` });
152
+ const { label, singular } = (await c.query('select meta.wizard_table_label($1::regclass) as label, meta.wizard_table_label($1::regclass, false) as singular', [T])).rows[0] as { label: string; singular: string };
153
+ await generate('report_form', 2, { form_page: 3 });
154
+ pages.push({ page: 2, label }, { page: 3, label: `${singular} form` });
155
155
  let next = 4;
156
156
  // the statistics tell which columns group the rows: a few values, repeated (not a name per row)
157
157
  const rows = result.inserted;
@@ -178,6 +178,7 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
178
178
  pages.push({ page: next++, label: `Faceted search (${facets.join(', ')})` });
179
179
  }
180
180
  }
181
+ await finishGeneratedApp(c, app.id, { description: checked.description });
181
182
  return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, table: T, result, pages, notes };
182
183
  });
183
184
  }
@@ -19,7 +19,7 @@ import {
19
19
  type Sheet,
20
20
  } from '../dataload.ts';
21
21
  import { preview } from './dataload.ts';
22
- import { createApp, createAppError, type CheckedApp } from './newapp.ts';
22
+ import { finishGeneratedApp, createApp, createAppError, type CheckedApp } from './newapp.ts';
23
23
  import { groupColumn } from './appfromfile.ts';
24
24
  import { region, type Body } from './ui.ts';
25
25
 
@@ -278,14 +278,21 @@ interface Catalog {
278
278
  * A dashboard page: a chart per table (at most six) with the number of rows per
279
279
  * parent row (the first foreign key), or per value of a text, yes/no or date
280
280
  * column whose values repeat (from the statistics). Null when no table has one.
281
+ * (097) Tables with row level security are left out (the application's role may
282
+ * see none of their rows: an empty chart); the charts are titled "Employees by
283
+ * Manager", and the Dashboard comes right after Home in the navigation menu.
281
284
  */
282
- export async function addDashboard(c: Client, alias: string, tables: string[], page: number): Promise<{ page: number; label: string } | null> {
285
+ export async function addDashboard(c: Client, alias: string, tables: string[], page: number, skipped: string[] = []): Promise<{ page: number; label: string } | null> {
283
286
  const picks: { table: string; group: string }[] = [];
284
287
  for (const t of tables) {
285
288
  if (picks.length >= MAX_CHARTS) break;
289
+ const rel = (await c.query('select relrowsecurity as rls, greatest(reltuples, 0) as n from pg_class where oid = $1::regclass', [t])).rows[0];
290
+ if (rel.rls) {
291
+ skipped.push(t);
292
+ continue;
293
+ }
286
294
  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;
287
- const rows = Number((await c.query('select greatest(reltuples, 0) as n from pg_class where oid = $1::regclass', [t])).rows[0].n);
288
- const group = catalog.find((x) => x.fk_table && !x.is_pk)?.column_name ?? groupColumn(catalog, rows);
295
+ const group = catalog.find((x) => x.fk_table && !x.is_pk)?.column_name ?? groupColumn(catalog, Number(rel.n));
289
296
  if (group) picks.push({ table: t, group });
290
297
  }
291
298
  if (!picks.length) return null;
@@ -295,7 +302,7 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
295
302
  JSON.stringify({ label: 'Dashboard', icon: 'chart', nav, label_column: group, function: 'count', value_column: null }),
296
303
  ]);
297
304
  const title = async (table: string, group: string) =>
298
- (await c.query(`select meta.wizard_label(relname) || ' per ' || lower(meta.wizard_label(regexp_replace($2, '_(id|no|code)$', ''))) as t from pg_class where oid = $1::regclass`, [table, group])).rows[0].t as 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;
299
306
  const dash = (await generate(picks[0].table, picks[0].group, page, true)).rows[0].id;
300
307
  await c.query('update meta.region set title = $2 where page_id = $1', [dash, await title(picks[0].table, picks[0].group)]);
301
308
  for (const [n, p] of picks.slice(1).entries()) {
@@ -306,7 +313,13 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
306
313
  await c.query('delete from meta.page where id = $1', [tmp]);
307
314
  }
308
315
  if (picks.length > 1) await c.query('update meta.region set columns = 6 where page_id = $1', [dash]);
309
- return { page, label: `Dashboard (${picks.map((p) => `${p.table.replace(/^.*\./, '')} per ${p.group}`).join(', ')})` };
316
+ // right after Home (the first top-level entry)
317
+ await c.query(
318
+ `update meta.nav_entry e set seq = coalesce((select min(seq) from meta.nav_entry h where h.app_id = e.app_id and h.parent_id is null and h.target_page = a.home_page), 0) + 1
319
+ 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
+ [alias, page],
321
+ );
322
+ return { page, label: `Dashboard (${(await Promise.all(picks.map((p) => title(p.table, p.group)))).join(', ')})` };
310
323
  }
311
324
 
312
325
  /** Create the application, the tables, the rows, the foreign keys and the pages, in one transaction. */
@@ -371,9 +384,9 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
371
384
  let next = 2;
372
385
  for (const p of plans) {
373
386
  const T = qualified.get(p.k)!;
374
- const label = (await c.query('select meta.wizard_label($1) as l', [p.table])).rows[0].l as string;
375
- await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, 'report_form', T, next, JSON.stringify({ form_page: next + 1, label })]);
376
- pages.push({ page: next, label }, { page: next + 1, label: `${label} form` });
387
+ const { label, singular } = (await c.query('select meta.wizard_table_label($1::regclass) as label, meta.wizard_table_label($1::regclass, false) as singular', [T])).rows[0] as { label: string; singular: string };
388
+ await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, 'report_form', T, next, JSON.stringify({ form_page: next + 1 })]);
389
+ pages.push({ page: next, label }, { page: next + 1, label: `${singular} form` });
377
390
  next += 2;
378
391
  }
379
392
  if (b.chart === 'true') {
@@ -381,6 +394,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
381
394
  if (dash) pages.push(dash);
382
395
  else notes.push('No dashboard: no table has a foreign key or a text, yes/no or date column with a few values that repeat, to count the rows by.');
383
396
  }
397
+ await finishGeneratedApp(c, app.id, { description: checked.description });
384
398
  return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, tables, foreignKeys, pages, notes };
385
399
  });
386
400
  }