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.
- package/db/migrations/099_layout_positions.sql +29 -0
- package/db/migrations/100_generated_labels.sql +381 -0
- package/db/migrations/101_theme_choice.sql +15 -0
- package/docs/apex-feature-parity.md +36 -9
- package/docs/guide/03-builder.md +82 -21
- package/docs/guide/04-pages-and-regions.md +55 -5
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/09-reference.md +5 -1
- package/docs/guide/12-development.md +2 -1
- package/docs/guide/14-globalization.md +5 -1
- package/package.json +1 -1
- package/public/app.css +80 -0
- package/public/app.js +189 -1
- package/public/builder.css +112 -0
- package/public/builder.js +280 -3
- package/src/builder/appsheets.ts +53 -13
- package/src/builder/appwizard.ts +5 -3
- package/src/builder/arrange.ts +133 -17
- package/src/builder/components.ts +28 -22
- package/src/builder/designer.ts +67 -18
- package/src/builder/forms.ts +4 -1
- package/src/builder/object-browser.ts +342 -0
- package/src/builder/pwa.ts +3 -3
- package/src/builder/routes.ts +85 -26
- package/src/builder/shared.ts +103 -1
- package/src/builder/sql.ts +13 -65
- package/src/builder/ui.ts +21 -6
- package/src/i18n/ar.ts +18 -0
- package/src/i18n/cs.ts +18 -0
- package/src/i18n/da.ts +18 -0
- package/src/i18n/de.ts +18 -0
- package/src/i18n/el.ts +18 -0
- package/src/i18n/es.ts +18 -0
- package/src/i18n/fi.ts +18 -0
- package/src/i18n/fr.ts +18 -0
- package/src/i18n/he.ts +18 -0
- package/src/i18n/it.ts +18 -0
- package/src/i18n/ja.ts +18 -0
- package/src/i18n/ko.ts +18 -0
- package/src/i18n/nb.ts +18 -0
- package/src/i18n/pl.ts +18 -0
- package/src/i18n/pt.ts +18 -0
- package/src/i18n/ru.ts +18 -0
- package/src/i18n/sv.ts +18 -0
- package/src/i18n/tr.ts +18 -0
- package/src/i18n/uk.ts +18 -0
- package/src/i18n/zh.ts +18 -0
- package/src/i18n.ts +36 -0
- package/src/metadata.ts +5 -1
- package/src/runtime/account.ts +5 -5
- package/src/runtime/devbar.ts +16 -13
- package/src/runtime/engine.ts +6 -0
- package/src/runtime/facets.ts +34 -3
- package/src/runtime/format.ts +52 -0
- package/src/runtime/items.ts +27 -0
- package/src/runtime/locale.ts +7 -1
- package/src/runtime/regions.ts +36 -2
- package/src/runtime/render.ts +27 -6
- package/src/runtime/report.ts +141 -81
- 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®ion=${id}`)], ['Create button', go(`new=button®ion=${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
|
-
|
|
415
|
-
|
|
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
|
+
});
|
package/src/builder/appsheets.ts
CHANGED
|
@@ -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:
|
|
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
|
|
296
|
-
if (
|
|
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 = (
|
|
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:
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
|
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
|
|
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. */
|
package/src/builder/appwizard.ts
CHANGED
|
@@ -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.
|
|
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>
|