pgkiln 0.42.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 (52) hide show
  1. package/db/migrations/099_layout_positions.sql +29 -0
  2. package/docs/apex-feature-parity.md +34 -7
  3. package/docs/guide/03-builder.md +67 -13
  4. package/docs/guide/04-pages-and-regions.md +46 -3
  5. package/docs/guide/05-items.md +1 -1
  6. package/docs/guide/09-reference.md +4 -1
  7. package/docs/guide/12-development.md +2 -1
  8. package/package.json +1 -1
  9. package/public/app.css +58 -0
  10. package/public/app.js +183 -0
  11. package/public/builder.css +112 -0
  12. package/public/builder.js +280 -3
  13. package/src/builder/arrange.ts +133 -17
  14. package/src/builder/components.ts +28 -22
  15. package/src/builder/designer.ts +67 -18
  16. package/src/builder/forms.ts +4 -1
  17. package/src/builder/object-browser.ts +342 -0
  18. package/src/builder/pwa.ts +3 -3
  19. package/src/builder/routes.ts +85 -26
  20. package/src/builder/shared.ts +103 -1
  21. package/src/builder/sql.ts +13 -65
  22. package/src/builder/ui.ts +21 -6
  23. package/src/i18n/ar.ts +16 -0
  24. package/src/i18n/cs.ts +16 -0
  25. package/src/i18n/da.ts +16 -0
  26. package/src/i18n/de.ts +16 -0
  27. package/src/i18n/el.ts +16 -0
  28. package/src/i18n/es.ts +16 -0
  29. package/src/i18n/fi.ts +16 -0
  30. package/src/i18n/fr.ts +16 -0
  31. package/src/i18n/he.ts +16 -0
  32. package/src/i18n/it.ts +16 -0
  33. package/src/i18n/ja.ts +16 -0
  34. package/src/i18n/ko.ts +16 -0
  35. package/src/i18n/nb.ts +16 -0
  36. package/src/i18n/pl.ts +16 -0
  37. package/src/i18n/pt.ts +16 -0
  38. package/src/i18n/ru.ts +16 -0
  39. package/src/i18n/sv.ts +16 -0
  40. package/src/i18n/tr.ts +16 -0
  41. package/src/i18n/uk.ts +16 -0
  42. package/src/i18n/zh.ts +16 -0
  43. package/src/i18n.ts +32 -0
  44. package/src/metadata.ts +5 -1
  45. package/src/runtime/engine.ts +6 -0
  46. package/src/runtime/format.ts +52 -0
  47. package/src/runtime/items.ts +27 -0
  48. package/src/runtime/locale.ts +3 -0
  49. package/src/runtime/regions.ts +36 -2
  50. package/src/runtime/render.ts +20 -4
  51. package/src/runtime/report.ts +134 -79
  52. package/src/runtime/routes.ts +34 -3
package/public/app.js CHANGED
@@ -3725,3 +3725,186 @@ function areaFilter(map, f) {
3725
3725
  }, true);
3726
3726
  document.addEventListener('keydown', (e) => { if (on && e.key === 'Escape') set(false); });
3727
3727
  })();
3728
+
3729
+ // (sprint 76) the page's texts for the report menus below (render.ts CLIENT_TEXTS)
3730
+ const reportTexts = (() => {
3731
+ try {
3732
+ return JSON.parse(document.getElementById('pgkiln-meta')?.textContent || '{}').texts || {};
3733
+ } catch {
3734
+ return {};
3735
+ }
3736
+ })();
3737
+
3738
+ // (sprint 76) The interactive report's Actions menu: an entry opens its form in a dialog (APEX: the
3739
+ // Actions dialogs); the form fields stay the same elements, moved into the dialog and back on close.
3740
+ (() => {
3741
+ const close = (details) => { if (details) details.open = false; };
3742
+ document.addEventListener('toggle', (e) => {
3743
+ const item = e.target;
3744
+ if (!(item instanceof HTMLDetailsElement) || !item.matches('.ir-item[data-ir-dialog]') || !item.open) return;
3745
+ const body = item.querySelector(':scope > .ir-body');
3746
+ const menu = item.closest('details.actions-menu');
3747
+ if (!body || typeof HTMLDialogElement !== 'function') return;
3748
+ item.open = false;
3749
+ menu?.querySelectorAll('details.ir-sub[open]').forEach(close);
3750
+ close(menu);
3751
+ const dialog = document.createElement('dialog');
3752
+ dialog.className = 'ir-dialog';
3753
+ const title = document.createElement('h2');
3754
+ title.textContent = item.querySelector('summary span')?.textContent ?? '';
3755
+ const x = document.createElement('button');
3756
+ x.type = 'button';
3757
+ x.className = 'icon-button ir-dialog-close';
3758
+ x.setAttribute('aria-label', reportTexts['common.dismiss'] || 'Close');
3759
+ x.textContent = '×';
3760
+ x.addEventListener('click', () => dialog.close());
3761
+ const head = document.createElement('div');
3762
+ head.className = 'ir-dialog-head';
3763
+ head.append(title, x);
3764
+ dialog.append(head, body);
3765
+ document.body.append(dialog);
3766
+ dialog.addEventListener('close', () => {
3767
+ item.append(body);
3768
+ dialog.remove();
3769
+ menu?.querySelector('summary')?.focus();
3770
+ });
3771
+ dialog.showModal();
3772
+ body.querySelector('select, input, a, button')?.focus();
3773
+ }, true);
3774
+ // one submenu open at a time
3775
+ document.addEventListener('toggle', (e) => {
3776
+ const sub = e.target;
3777
+ if (!(sub instanceof HTMLDetailsElement) || !sub.matches('.ir-sub') || !sub.open) return;
3778
+ sub.parentElement?.parentElement?.querySelectorAll(':scope > li > details.ir-sub[open]').forEach((d) => { if (d !== sub) d.open = false; });
3779
+ }, true);
3780
+ })();
3781
+
3782
+ // (sprint 76) A report column heading opens a menu: sort ascending or descending, hide, control break, and
3783
+ // the column's values (with counts, a search box) to filter by; the sort arrow still sorts at once.
3784
+ (() => {
3785
+ let open = null;
3786
+ const shut = () => { open?.remove(); open = null; };
3787
+ document.addEventListener('click', (e) => {
3788
+ const a = e.target.closest?.('th[data-col] > a');
3789
+ if (!a) {
3790
+ if (open && !open.contains(e.target)) shut();
3791
+ return;
3792
+ }
3793
+ const th = a.parentElement;
3794
+ // the sort indicator keeps sorting
3795
+ if (e.target.closest('.sort-ind') && th.getAttribute('aria-sort') !== 'none') return;
3796
+ e.preventDefault();
3797
+ shut();
3798
+ const menu = document.createElement('div');
3799
+ menu.className = 'th-menu menu-panel';
3800
+ menu.setAttribute('role', 'dialog');
3801
+ menu.setAttribute('aria-label', th.dataset.label);
3802
+ const t = (k, d) => reportTexts[k] || d;
3803
+ const link = (href, text) => { const l = document.createElement('a'); l.href = href; l.textContent = text; return l; };
3804
+ const actions = document.createElement('div');
3805
+ actions.className = 'th-menu-actions';
3806
+ actions.append(link(th.dataset.asc, `▲ ${t('report.sort_asc', 'Sort ascending')}`), link(th.dataset.desc, `▼ ${t('report.sort_desc', 'Sort descending')}`),
3807
+ link(th.dataset.hide, t('report.hide', 'Hide column')), link(th.dataset.break, t('report.break', 'Control break')));
3808
+ const search = document.createElement('input');
3809
+ search.type = 'search';
3810
+ search.placeholder = t('report.filter_values', 'Filter the values');
3811
+ search.setAttribute('aria-label', search.placeholder);
3812
+ const list = document.createElement('ul');
3813
+ list.className = 'th-menu-values';
3814
+ menu.append(actions, search, list);
3815
+ document.body.append(menu);
3816
+ const box = th.getBoundingClientRect();
3817
+ menu.style.setProperty('position', 'absolute');
3818
+ menu.style.setProperty('top', `${window.scrollY + box.bottom + 4}px`);
3819
+ menu.style.setProperty('left', `${Math.max(8, Math.min(window.scrollX + box.left, window.scrollX + document.documentElement.clientWidth - 288))}px`);
3820
+ open = menu;
3821
+ const here = new URL(location.href);
3822
+ const url = new URL(th.dataset.values, location.href);
3823
+ here.searchParams.forEach((v, k) => url.searchParams.append(k, v));
3824
+ url.searchParams.set('col', th.dataset.col);
3825
+ fetch(url, { headers: { accept: 'application/json' }, credentials: 'same-origin' })
3826
+ .then((r) => (r.ok ? r.json() : { values: [] }))
3827
+ .then(({ values }) => {
3828
+ if (!values?.length) {
3829
+ const li = document.createElement('li');
3830
+ li.className = 'muted';
3831
+ li.textContent = t('report.no_values', 'No values');
3832
+ list.append(li);
3833
+ return;
3834
+ }
3835
+ for (const { value, count } of values) {
3836
+ if (value === null) continue;
3837
+ const target = new URL(location.href);
3838
+ for (const k of [...target.searchParams.keys()]) if (/^r\d+_(p|k)$/.test(k)) target.searchParams.delete(k);
3839
+ target.searchParams.append(th.dataset.filter, `${th.dataset.col}|eq|${value}`);
3840
+ const li = document.createElement('li');
3841
+ const l = link(target.toString(), value);
3842
+ const n = document.createElement('span');
3843
+ n.className = 'muted';
3844
+ n.textContent = String(count);
3845
+ l.append(n);
3846
+ li.append(l);
3847
+ list.append(li);
3848
+ }
3849
+ })
3850
+ .catch(() => {});
3851
+ search.addEventListener('input', () => {
3852
+ const q = search.value.trim().toLowerCase();
3853
+ list.querySelectorAll('li').forEach((li) => { li.hidden = !!q && !li.textContent.toLowerCase().includes(q); });
3854
+ });
3855
+ actions.querySelector('a')?.focus();
3856
+ menu.addEventListener('keydown', (k) => { if (k.key === 'Escape') { shut(); a.focus(); } });
3857
+ });
3858
+ window.addEventListener('resize', shut);
3859
+ })();
3860
+
3861
+ // (sprint 76) the report pager's rows per page: sent on change
3862
+ document.addEventListener('change', (e) => {
3863
+ if (e.target.matches?.('select[data-go]')) e.target.form?.submit();
3864
+ });
3865
+
3866
+ // (sprint 76) A date item in the application's date format: the calendar button opens the browser's date
3867
+ // picker on a hidden date field; the chosen date is written into the text field in the mask.
3868
+ (() => {
3869
+ const pad = (n) => String(n).padStart(2, '0');
3870
+ const TOKEN = /"[^"]*"|YYYY|YY|MONTH|MON|MM|DD/gi;
3871
+ const format = (iso, mask, months) => {
3872
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
3873
+ if (!m) return '';
3874
+ const [y, mo, d] = m.slice(1).map(Number);
3875
+ return mask.replace(TOKEN, (tok) => {
3876
+ if (tok.startsWith('"')) return tok.slice(1, -1);
3877
+ const up = tok.toUpperCase();
3878
+ const cased = (s) => (tok === up ? s.toUpperCase() : tok[1] && tok[1] !== up[1] ? s[0].toUpperCase() + s.slice(1) : s);
3879
+ if (up === 'YYYY') return String(y);
3880
+ if (up === 'YY') return pad(y % 100);
3881
+ if (up === 'MM') return pad(mo);
3882
+ if (up === 'DD') return pad(d);
3883
+ if (up === 'MONTH') return cased(months.long[mo - 1]);
3884
+ if (up === 'MON') return cased(months.short[mo - 1]);
3885
+ return tok;
3886
+ });
3887
+ };
3888
+ document.addEventListener('click', (e) => {
3889
+ const btn = e.target.closest?.('[data-date-pick]');
3890
+ if (!btn) return;
3891
+ const native = document.querySelector(`input[data-date-for="${CSS.escape(btn.dataset.datePick)}"]`);
3892
+ if (!native) return;
3893
+ try {
3894
+ native.showPicker();
3895
+ } catch {
3896
+ native.focus();
3897
+ }
3898
+ });
3899
+ document.addEventListener('change', (e) => {
3900
+ const native = e.target;
3901
+ if (!native.matches?.('input[data-date-for]')) return;
3902
+ const text = document.getElementById(native.dataset.dateFor);
3903
+ if (!text) return;
3904
+ let months = { long: [], short: [] };
3905
+ try { months = JSON.parse(text.dataset.months || '{}'); } catch {}
3906
+ text.value = format(native.value, text.dataset.dateMask || 'YYYY-MM-DD', months);
3907
+ text.dispatchEvent(new Event('change', { bubbles: true }));
3908
+ text.focus();
3909
+ });
3910
+ })();
@@ -44,6 +44,7 @@
44
44
  --bg: #1d1c1b; --surface: #2a2927; --surface-2: #31302d; --surface-3: #3b3936; --text: #e9e6e1; --muted: #aaa59d;
45
45
  --border: #44423e; --accent: #62b2ea; --accent-text: #0b1824; --accent-soft: #233a4c; --gold: #e3b75e;
46
46
  --canvas: #211f1e; --block: #33322f; --block-head: #3d3b38; --code-bg: #242321; --warn: #e0a740;
47
+ --pd-c-region: #7fb4ea; --pd-c-item: #7ccf8c; --pd-c-button: #f0a35e; --pd-c-da: #c49cf0; --pd-c-process: #ee8fbf;
47
48
  color-scheme: dark;
48
49
  }
49
50
  }
@@ -51,6 +52,7 @@
51
52
  --bg: #1d1c1b; --surface: #2a2927; --surface-2: #31302d; --surface-3: #3b3936; --text: #e9e6e1; --muted: #aaa59d;
52
53
  --border: #44423e; --accent: #62b2ea; --accent-text: #0b1824; --accent-soft: #233a4c; --gold: #e3b75e;
53
54
  --canvas: #211f1e; --block: #33322f; --block-head: #3d3b38; --code-bg: #242321; --warn: #e0a740;
55
+ --pd-c-region: #7fb4ea; --pd-c-item: #7ccf8c; --pd-c-button: #f0a35e; --pd-c-da: #c49cf0; --pd-c-process: #ee8fbf;
54
56
  color-scheme: dark;
55
57
  }
56
58
 
@@ -900,3 +902,113 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
900
902
  .progress-spinner { width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); animation: progress-spin 0.8s linear infinite; }
901
903
  @keyframes progress-spin { to { transform: rotate(360deg); } }
902
904
  @media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
905
+
906
+ /* (sprint 74) the default-password warning: dismissible; a dot on the avatar */
907
+ .alert-dismissible { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
908
+ .alert-dismissible form { margin: 0; }
909
+ .rail-avatar.has-dot { position: relative; }
910
+ .rail-avatar.has-dot::after { content: ""; position: absolute; top: 2px; right: 2px; width: 9px; height: 9px; border-radius: 50%; background: var(--danger, #c23028); box-shadow: 0 0 0 2px var(--rail-bg, #1b2330); }
911
+ .weak-note { color: var(--danger, #c23028) !important; }
912
+
913
+ /* (sprint 74) Shared Components: groups of component types with counts; a list per type */
914
+ .sc-head, .sc-list-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
915
+ .sc-list-head h1 { margin: 0; }
916
+ .sc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: 1rem; align-items: start; }
917
+ .sc-group .region-header h2 { display: flex; align-items: center; gap: 0.5rem; }
918
+ .sc-links { list-style: none; margin: 0; padding: 0.4rem 0; }
919
+ .sc-links li { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.3rem 1rem; }
920
+ .sc-links a { overflow-wrap: anywhere; min-width: 0; }
921
+ .sc-count { font-size: 0.78rem; font-weight: 600; color: var(--muted); background: var(--surface-3); border-radius: 99px; padding: 0.05rem 0.5rem; }
922
+
923
+ /* (sprint 74) application Settings in tabs */
924
+ .settings-tabs { display: flex; flex-wrap: wrap; gap: 0.25rem; border-bottom: 1px solid var(--border); margin: 0 0 1rem; }
925
+ .settings-tabs a { padding: 0.5rem 0.85rem; color: var(--muted); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; }
926
+ .settings-tabs a:hover { color: var(--text); }
927
+ .settings-tabs a[aria-current="page"] { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
928
+ .settings-body { display: grid; gap: 1rem; max-width: 64rem; }
929
+ .settings-form { display: grid; gap: 1rem; }
930
+
931
+ /* (sprint 74) the application home: search, view and Create page above the pages; Tasks on the right */
932
+ .app-home { display: grid; grid-template-columns: minmax(0, 1fr) 15rem; gap: 1rem; align-items: start; }
933
+ @media (max-width: 1100px) { .app-home { grid-template-columns: 1fr; } }
934
+ .app-home-main { display: grid; gap: 1rem; min-width: 0; }
935
+ .pages-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
936
+ .pages-bar .search { flex: 1 1 16rem; margin: 0; }
937
+ .pages-bar .search input { flex: 1; min-width: 0; }
938
+ .view-toggle a { display: inline-flex; align-items: center; gap: 0.3rem; }
939
+ .page-icons { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: 0.6rem; }
940
+ .page-icon { display: grid; gap: 0.15rem; padding: 0.7rem 0.8rem; border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
941
+ .page-icon:hover, .page-icon:focus-visible { border-color: var(--accent); }
942
+ .page-icon-no { font-size: 1.3rem; font-weight: 700; color: var(--accent); }
943
+ .app-tasks .sc-links a { display: inline-flex; align-items: center; gap: 0.45rem; }
944
+
945
+ /* (sprint 75) SQL Workshop: main tools as tabs and a More menu (no horizontal scrolling); the Object Browser */
946
+ .sw-tabs { overflow: visible; flex-wrap: wrap; }
947
+ .sw-more { position: relative; }
948
+ .sw-more > summary { list-style: none; cursor: pointer; }
949
+ .sw-more > summary::-webkit-details-marker { display: none; }
950
+ .sw-more .menu-panel a[aria-current] { font-weight: 600; }
951
+ .ob-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.75rem; margin-bottom: 1rem; }
952
+ .ob-schema { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
953
+ .ob-schema .label { margin: 0; }
954
+ .ob-create > summary { list-style: none; }
955
+ .ob-create > summary::-webkit-details-marker { display: none; }
956
+ .ob-filter { padding: 0.5rem; }
957
+ .ob-filter input { width: 100%; }
958
+ .ob-folder > summary { display: flex; align-items: center; gap: 0.45rem; padding: 0.4rem 0.6rem; cursor: pointer; font-weight: 600; list-style: none; }
959
+ .ob-folder > summary::-webkit-details-marker { display: none; }
960
+ .ob-folder > summary .kind { margin-left: auto; }
961
+ .ob-folder .tree { padding-left: 0.75rem; }
962
+ .ob-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 0.5rem; margin-bottom: 0.75rem; }
963
+ .ob-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.5rem; overflow-wrap: anywhere; }
964
+ .ob-actions { display: grid; gap: 0.6rem; margin-top: 1rem; }
965
+ .ob-action { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
966
+ .ob-action input:not([type=checkbox]) { width: 10rem; }
967
+
968
+ /* (sprint 77) the page template's display positions and a region's button positions on the layout */
969
+ .pd-zone { display: grid; gap: 0.3em; margin-bottom: 10px; }
970
+ .pd-zone-label { color: var(--muted); font-size: 0.74em; font-weight: 650; letter-spacing: 0.06em; text-transform: uppercase; }
971
+ .pd-zone-label small { text-transform: none; letter-spacing: 0; font-weight: 400; }
972
+ .pd-zone.is-empty .pd-grid-end { padding: 0.3em; }
973
+ .pd-columns { display: grid; gap: 10px; align-items: start; }
974
+ .pd-columns > .pd-zone { margin-bottom: 0; min-width: 0; }
975
+ .pd-columns:has(> .pd-zone-left) { grid-template-columns: minmax(9rem, 1fr) 3fr; }
976
+ .pd-columns:has(> .pd-zone-right) { grid-template-columns: 3fr minmax(9rem, 1fr); }
977
+ .pd-columns:has(> .pd-zone-left):has(> .pd-zone-right) { grid-template-columns: minmax(9rem, 1fr) 3fr minmax(9rem, 1fr); }
978
+ .pd-columns + .pd-zone { margin-top: 10px; }
979
+ .pd-zone-left .pd-grid > .pd-region, .pd-zone-right .pd-grid > .pd-region { grid-column: 1 / -1; }
980
+ .pd-slots { display: flex; flex-wrap: wrap; gap: 0.3em; }
981
+ .pd-slots-bottom > [data-slot="next"] { margin-left: auto; }
982
+ .pd-slot-spare { display: none; min-height: 1.6em; }
983
+ .pd-dragging-button .pd-slot-spare { display: flex; }
984
+ .pd-canvas:not(.pd-dragging-button) .pd-slots:not(:has(> .pd-slot:not(.pd-slot-spare))) { display: none; }
985
+ @media (max-width: 640px) { .pd-canvas .pd-columns:has(> .pd-zone) { grid-template-columns: minmax(0, 1fr); } }
986
+
987
+ /* (sprint 77) the rendering tree's context menu, the Copy to other page dialog, component colours */
988
+ .pd-menu { position: fixed; z-index: 60; min-width: 13rem; padding: 4px; background: var(--surface, #fff); color: var(--text); border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 6px 20px rgb(0 0 0 / 0.18); font-size: 13px; }
989
+ .pd-menu [role=menuitem] { display: block; width: 100%; padding: 0.35rem 0.6rem; border: 0; border-radius: 3px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
990
+ .pd-menu [role=menuitem]:is(:hover, :focus-visible) { background: var(--sel-bg); color: var(--sel-text); outline: none; }
991
+ .pd-menu [role=menuitem].is-danger { color: var(--danger); }
992
+ .pd-menu [role=separator] { height: 1px; margin: 4px 2px; background: var(--border); }
993
+ .pd-dialog { width: min(28rem, calc(100vw - 32px)); padding: 1rem 1.1rem; border: 1px solid var(--border); border-radius: 6px; background: var(--surface, #fff); color: var(--text); }
994
+ .pd-dialog::backdrop { background: rgb(0 0 0 / 0.35); }
995
+ .pd-dialog h2 { margin: 0 0 0.5rem; font-size: 1.05rem; }
996
+ .pd-dialog-buttons { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
997
+ .pd-node[data-kind="region"] .icon { color: var(--pd-c-region, #2f6fb3); }
998
+ .pd-node[data-kind="item"] .icon { color: var(--pd-c-item, #2e8540); }
999
+ .pd-node[data-kind="button"] .icon { color: var(--pd-c-button, #b4570f); }
1000
+ .pd-node[data-kind="dynamic_action"] .icon { color: var(--pd-c-da, #8a4fbf); }
1001
+ .pd-node[data-kind="process"] .icon, .pd-node[data-kind="computation"] .icon { color: var(--pd-c-process, #a23b72); }
1002
+
1003
+ /* (sprint 77) the property editor: Common / All, Go to group, the Template Options button; the page number */
1004
+ .pe-more { display: contents; }
1005
+ .pe-show-common [data-more] { display: none; }
1006
+ .pe-show-common fieldset.pe-group-more { display: none; }
1007
+ .pe-view { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 4px 8px 6px; }
1008
+ .pe-view .segmented { margin-top: 0; }
1009
+ .pe-view .segmented button { font-size: 12px; padding: 2px 8px; min-height: 0; }
1010
+ .pe-goto { font-size: 12px; max-width: 12rem; }
1011
+ .pe-options-open .btn { justify-self: start; }
1012
+ .pd-goto-no input { width: 5.5em; font-variant-numeric: tabular-nums; }
1013
+ /* on narrower screens the previous / next arrows and the page list do it */
1014
+ @media (max-width: 1279px) { .pd-goto-no { display: none; } }
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
+ });