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.
- package/db/migrations/097_generated_app_quality.sql +911 -0
- package/db/migrations/098_app_activity.sql +37 -0
- package/db/migrations/099_layout_positions.sql +29 -0
- package/docs/apex-feature-parity.md +38 -11
- package/docs/guide/03-builder.md +133 -31
- package/docs/guide/04-pages-and-regions.md +46 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/09-reference.md +10 -3
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/12-development.md +3 -2
- package/docs/guide/14-globalization.md +7 -3
- package/docs/guide/17-mobile.md +1 -1
- package/package.json +1 -1
- package/public/app.css +233 -33
- package/public/app.js +263 -0
- package/public/builder.css +142 -2
- package/public/builder.js +311 -3
- package/src/builder/appfromfile.ts +5 -4
- package/src/builder/appsheets.ts +23 -9
- package/src/builder/appwizard.ts +215 -70
- package/src/builder/arrange.ts +133 -17
- package/src/builder/blueprints.ts +2 -1
- package/src/builder/components.ts +29 -23
- package/src/builder/designer.ts +67 -18
- package/src/builder/forms.ts +4 -1
- package/src/builder/home.ts +2 -32
- package/src/builder/newapp.ts +182 -5
- package/src/builder/object-browser.ts +342 -0
- package/src/builder/pwa.ts +3 -3
- package/src/builder/routes.ts +86 -51
- 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 +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +2 -2
- package/src/metadata.ts +5 -1
- package/src/runtime/charts.ts +8 -3
- package/src/runtime/devbar.ts +35 -14
- package/src/runtime/engine.ts +13 -4
- package/src/runtime/format.ts +52 -0
- package/src/runtime/items.ts +27 -0
- package/src/runtime/locale.ts +5 -2
- package/src/runtime/regions.ts +61 -4
- package/src/runtime/render.ts +38 -8
- package/src/runtime/report.ts +149 -88
- package/src/runtime/routes.ts +34 -3
- 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®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
|
}
|
|
@@ -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(
|
|
153
|
-
await generate('report_form', 2, { form_page: 3
|
|
154
|
-
pages.push({ page: 2, label }, { page: 3, label: `${
|
|
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
|
}
|
package/src/builder/appsheets.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
376
|
-
pages.push({ page: next, label }, { page: next + 1, label: `${
|
|
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
|
}
|