pgkiln 0.44.0 → 0.46.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 (91) hide show
  1. package/db/migrations/102_app_computations.sql +345 -0
  2. package/db/migrations/103_shortcuts.sql +341 -0
  3. package/db/migrations/104_report_queries.sql +413 -0
  4. package/db/migrations/105_component_settings_groups.sql +367 -0
  5. package/db/migrations/106_form_designer.sql +102 -0
  6. package/db/migrations/107_app_version.sql +9 -0
  7. package/docs/apex-feature-parity.md +15 -12
  8. package/docs/guide/02-concepts.md +24 -0
  9. package/docs/guide/03-builder.md +69 -13
  10. package/docs/guide/04-pages-and-regions.md +84 -3
  11. package/docs/guide/05-items.md +14 -2
  12. package/docs/guide/06-processing.md +22 -0
  13. package/docs/guide/09-reference.md +14 -1
  14. package/docs/guide/11-from-apex.md +2 -1
  15. package/docs/guide/12-development.md +8 -2
  16. package/docs/guide/16-files.md +23 -0
  17. package/docs/guide/18-cli.md +4 -0
  18. package/examples/hr/hr_61_form_designer.sql +80 -0
  19. package/package.json +1 -1
  20. package/public/app.css +79 -2
  21. package/public/app.js +307 -35
  22. package/public/builder.css +21 -1
  23. package/public/builder.js +67 -0
  24. package/public/sw.js +2 -1
  25. package/src/app.ts +9 -1
  26. package/src/appfiles.ts +18 -2
  27. package/src/builder/advisor.ts +7 -2
  28. package/src/builder/appwizard.ts +22 -6
  29. package/src/builder/arrange.ts +33 -12
  30. package/src/builder/code-editor.ts +7 -2
  31. package/src/builder/components.ts +86 -4
  32. package/src/builder/designer.ts +44 -17
  33. package/src/builder/errors.ts +65 -0
  34. package/src/builder/form-designer.ts +229 -0
  35. package/src/builder/forms.ts +1 -1
  36. package/src/builder/locks.ts +6 -0
  37. package/src/builder/newapp.ts +2 -1
  38. package/src/builder/object-browser.ts +81 -18
  39. package/src/builder/region-help.ts +2 -0
  40. package/src/builder/region-settings.ts +2 -0
  41. package/src/builder/reporter.ts +1 -1
  42. package/src/builder/routes.ts +7 -5
  43. package/src/builder/search.ts +3 -3
  44. package/src/builder/shared.ts +41 -8
  45. package/src/builder/subscriptions.ts +30 -4
  46. package/src/builder/ui.ts +2 -0
  47. package/src/cli/replace.ts +16 -2
  48. package/src/i18n/ar.ts +62 -0
  49. package/src/i18n/cs.ts +62 -0
  50. package/src/i18n/da.ts +62 -0
  51. package/src/i18n/de.ts +62 -0
  52. package/src/i18n/el.ts +62 -0
  53. package/src/i18n/es.ts +62 -0
  54. package/src/i18n/fi.ts +62 -0
  55. package/src/i18n/fr.ts +62 -0
  56. package/src/i18n/he.ts +62 -0
  57. package/src/i18n/it.ts +62 -0
  58. package/src/i18n/ja.ts +62 -0
  59. package/src/i18n/ko.ts +62 -0
  60. package/src/i18n/nb.ts +62 -0
  61. package/src/i18n/pl.ts +62 -0
  62. package/src/i18n/pt.ts +62 -0
  63. package/src/i18n/ru.ts +62 -0
  64. package/src/i18n/sv.ts +62 -0
  65. package/src/i18n/tr.ts +62 -0
  66. package/src/i18n/uk.ts +62 -0
  67. package/src/i18n/zh.ts +62 -0
  68. package/src/i18n.ts +124 -0
  69. package/src/metadata.ts +18 -3
  70. package/src/runtime/component-settings.ts +42 -0
  71. package/src/runtime/context.ts +4 -0
  72. package/src/runtime/devbar.ts +1 -1
  73. package/src/runtime/documents.ts +29 -3
  74. package/src/runtime/engine.ts +2 -2
  75. package/src/runtime/facet-state.ts +10 -5
  76. package/src/runtime/facets.ts +29 -5
  77. package/src/runtime/form-designer.ts +676 -0
  78. package/src/runtime/grid.ts +36 -11
  79. package/src/runtime/items.ts +47 -32
  80. package/src/runtime/lists.ts +1 -1
  81. package/src/runtime/logic.ts +12 -7
  82. package/src/runtime/pdf.ts +2 -1
  83. package/src/runtime/regions.ts +8 -3
  84. package/src/runtime/render.ts +8 -3
  85. package/src/runtime/report.ts +4 -3
  86. package/src/runtime/routes.ts +7 -3
  87. package/src/runtime/shortcuts.ts +43 -0
  88. package/src/runtime/smart-filters.ts +2 -1
  89. package/src/runtime/tasks.ts +6 -3
  90. package/src/security.ts +2 -1
  91. package/src/subscriptions.ts +67 -0
package/public/app.js CHANGED
@@ -4,6 +4,23 @@
4
4
  // inline scripts.
5
5
  document.documentElement.classList.add('js');
6
6
 
7
+ // (sprint 86) Values a browser kept under pgkiln's old name (pgapex.*, pgapex-push:*) move to
8
+ // pgkiln.* / pgkiln-push:* once: the navigation drawer, the builder's tabs and panes, the region
9
+ // display selector's tab, a device's push notifications. A value already kept under the new name wins.
10
+ for (const store of ['localStorage', 'sessionStorage']) {
11
+ try {
12
+ const s = window[store];
13
+ for (const k of Object.keys(s)) {
14
+ if (!/^pgapex[.-]/.test(k)) continue;
15
+ const neu = `pgkiln${k.slice(6)}`;
16
+ if (s.getItem(neu) === null) s.setItem(neu, s.getItem(k));
17
+ s.removeItem(k);
18
+ }
19
+ } catch {
20
+ /* storage off (private mode, a blocked cookie setting): nothing kept, nothing to move */
21
+ }
22
+ }
23
+
7
24
  // Push notifications (migration 074): the page of an app with notifications on carries
8
25
  // the app's public key (data-push) and the user (data-push-user). Subscribing needs a
9
26
  // click (My account → Notifications, or the dynamic action push_subscribe); the
@@ -1666,6 +1683,69 @@ const pgkilnUnsaved = (() => {
1666
1683
  data[i].forEach((v, j) => cells[c0 + j] && setCell(cells[c0 + j], v));
1667
1684
  }
1668
1685
  });
1686
+
1687
+ // ------------------------------------------------------------ cell navigation and edit mode (APEX)
1688
+ // An editable cell reads as text (app.css) until it is edited: a click in it, Enter or F2 or typing
1689
+ // edits it. On a cell the arrow keys move to the next one; while editing, Escape goes back to the
1690
+ // cell and Enter to the cell below. Tab moves through the cells' controls as usual.
1691
+ const cellSel = '[data-grid] table.grid-table > tbody:not(.grid-template) > tr > td[data-col]';
1692
+ function focusCell(td, edit) {
1693
+ if (!td) return;
1694
+ const el = control(td);
1695
+ if (edit && el && !el.disabled) {
1696
+ el.focus();
1697
+ if (el.type !== 'checkbox' && el.tagName !== 'SELECT') el.select?.();
1698
+ return;
1699
+ }
1700
+ if (!td.hasAttribute('tabindex')) td.tabIndex = -1;
1701
+ td.focus();
1702
+ }
1703
+ function neighbour(td, dr, dc) {
1704
+ const rows = rowsOf(td.closest('[data-grid]'));
1705
+ const { r, c } = at(td);
1706
+ return rows[r + dr] ? shownCells(rows[r + dr])[c + dc] : undefined;
1707
+ }
1708
+ const MOVES = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
1709
+ document.addEventListener('keydown', (e) => {
1710
+ if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
1711
+ const t = e.target;
1712
+ if (t.matches?.(cellSel)) {
1713
+ const el = control(t);
1714
+ if (MOVES[e.key]) {
1715
+ e.preventDefault();
1716
+ focusCell(neighbour(t, ...MOVES[e.key]), false);
1717
+ } else if (el && (e.key === 'Enter' || e.key === 'F2')) {
1718
+ e.preventDefault();
1719
+ focusCell(t, true);
1720
+ } else if (el?.type === 'checkbox' && e.key === ' ') {
1721
+ e.preventDefault();
1722
+ el.click();
1723
+ } else if (el && e.key.length === 1) focusCell(t, true); // the key types into the control
1724
+ return;
1725
+ }
1726
+ const td = t.closest?.(cellSel);
1727
+ if (!td || !t.matches('input, select')) return;
1728
+ if (e.key === 'Escape') focusCell(td, false);
1729
+ else if (e.key === 'Enter' && !e.shiftKey) {
1730
+ e.preventDefault();
1731
+ const below = neighbour(td, 1, 0);
1732
+ focusCell(below ?? td, !!below);
1733
+ } else if (e.key === 'F2') focusCell(td, false);
1734
+ });
1735
+ // a click in a cell beside its control edits it
1736
+ document.addEventListener('click', (e) => {
1737
+ if (!e.shiftKey && e.target.matches?.(cellSel)) focusCell(e.target, true);
1738
+ });
1739
+ // an edited value shows as itself (a list's display value)
1740
+ const syncText = (e) => {
1741
+ const span = e.target.closest?.('td[data-col]')?.querySelector(':scope > .grid-text');
1742
+ if (!span) return;
1743
+ const el = e.target;
1744
+ if (el.tagName === 'SELECT') span.textContent = el.selectedIndex > 0 ? el.options[el.selectedIndex].text : '';
1745
+ else span.remove();
1746
+ };
1747
+ document.addEventListener('input', syncText);
1748
+ document.addEventListener('change', syncText);
1669
1749
  })();
1670
1750
 
1671
1751
  // Faceted search: apply on change. A range facet's own from/to: typing picks
@@ -3429,23 +3509,10 @@ function areaFilter(map, f) {
3429
3509
  draw();
3430
3510
  }
3431
3511
 
3432
- // Date range: the end date can't be before the start date
3433
- function range(box) {
3434
- const [from, to] = box.querySelectorAll('input[type=date]');
3435
- if (!from || !to) return;
3436
- const limit = () => {
3437
- to.min = from.value;
3438
- from.max = to.value;
3439
- };
3440
- from.addEventListener('change', limit);
3441
- to.addEventListener('change', limit);
3442
- }
3443
-
3444
3512
  function enhance(root) {
3445
3513
  root.querySelectorAll('[data-richtext]').forEach(richText);
3446
3514
  root.querySelectorAll('[data-markdown]').forEach(markdown);
3447
3515
  root.querySelectorAll('[data-tags]').forEach(tags);
3448
- root.querySelectorAll('[data-range]').forEach(range);
3449
3516
  root.querySelectorAll('[data-reveal]').forEach((b) => (b.hidden = false));
3450
3517
  }
3451
3518
  enhance(document);
@@ -3868,11 +3935,42 @@ document.addEventListener('change', (e) => {
3868
3935
  if (e.target.matches?.('select[data-go]')) e.target.form?.submit();
3869
3936
  });
3870
3937
 
3871
- // (sprint 76) A date item in the application's date format: the calendar button opens the browser's date
3872
- // picker on a hidden date field; the chosen date is written into the text field in the mask.
3938
+ // (sprint 76, 84; APEX: the date picker) A date field in a date mask (items.ts dateField): its calendar button
3939
+ // opens pgkiln's date picker, a month of days under the field; the chosen date is written into the text field
3940
+ // in the mask. Keys: arrows (a day, a week), Page Up/Down (a month; with Shift a year), Home/End (the week),
3941
+ // Enter or Space (choose), Escape (close).
3873
3942
  (() => {
3943
+ const meta = (() => { try { return JSON.parse(document.getElementById('pgkiln-meta')?.textContent || '{}'); } catch { return {}; } })();
3944
+ const t = (k, d) => (meta.texts && meta.texts[k]) || d;
3945
+ const lang = document.documentElement.lang || 'en';
3874
3946
  const pad = (n) => String(n).padStart(2, '0');
3875
- const TOKEN = /"[^"]*"|YYYY|YY|MONTH|MON|MM|DD/gi;
3947
+ const isoOf = (d) => `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
3948
+ const dateOf = (iso) => { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)); };
3949
+ const addDays = (iso, n) => { const d = dateOf(iso); d.setUTCDate(d.getUTCDate() + n); return isoOf(d); };
3950
+ const addMonths = (iso, n) => {
3951
+ const d = dateOf(iso);
3952
+ const day = d.getUTCDate();
3953
+ d.setUTCDate(1);
3954
+ d.setUTCMonth(d.getUTCMonth() + n);
3955
+ const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
3956
+ d.setUTCDate(Math.min(day, last));
3957
+ return isoOf(d);
3958
+ };
3959
+ const today = () => { const n = new Date(); return `${n.getFullYear()}-${pad(n.getMonth() + 1)}-${pad(n.getDate())}`; };
3960
+ // the week starts on the language's first day (Monday where the browser doesn't say)
3961
+ const weekStart = (() => {
3962
+ try {
3963
+ const l = new Intl.Locale(lang);
3964
+ const w = (l.getWeekInfo && l.getWeekInfo()) || l.weekInfo;
3965
+ if (w && w.firstDay) return w.firstDay % 7;
3966
+ } catch {}
3967
+ return /^en-US$|^(ja|ko|zh|he|ar|pt-BR)/i.test(lang) ? 0 : 1;
3968
+ })();
3969
+ const fmt = (o) => { try { return new Intl.DateTimeFormat(lang, { ...o, timeZone: 'UTC' }); } catch { return new Intl.DateTimeFormat('en', { ...o, timeZone: 'UTC' }); } };
3970
+ const dayShort = fmt({ weekday: 'short' }), dayLong = fmt({ weekday: 'long' }), full = fmt({ dateStyle: 'full' }), title = fmt({ month: 'long', year: 'numeric' });
3971
+
3972
+ const TOKEN = /"[^"]*"|YYYY|YY|MONTH|MON|MM|DD|DAY|DY/gi;
3973
+ const monthsOf = (field) => { try { return JSON.parse(field.dataset.months || '{}'); } catch { return {}; } };
3876
3974
  const format = (iso, mask, months) => {
3877
3975
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
3878
3976
  if (!m) return '';
@@ -3885,31 +3983,205 @@ document.addEventListener('change', (e) => {
3885
3983
  if (up === 'YY') return pad(y % 100);
3886
3984
  if (up === 'MM') return pad(mo);
3887
3985
  if (up === 'DD') return pad(d);
3888
- if (up === 'MONTH') return cased(months.long[mo - 1]);
3889
- if (up === 'MON') return cased(months.short[mo - 1]);
3986
+ if (up === 'MONTH') return cased((months.long || [])[mo - 1] || '');
3987
+ if (up === 'MON') return cased((months.short || [])[mo - 1] || '');
3988
+ if (up === 'DAY') return cased(dayLong.format(dateOf(iso)));
3989
+ if (up === 'DY') return cased(dayShort.format(dateOf(iso)));
3890
3990
  return tok;
3891
3991
  });
3892
3992
  };
3993
+ // the text read back in the mask (as format.ts parseDateMask does), or as YYYY-MM-DD; null when it isn't a date
3994
+ const parse = (text, mask, months) => {
3995
+ const v = (text || '').trim();
3996
+ let y, mo, d;
3997
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
3998
+ if (iso) [y, mo, d] = iso.slice(1).map(Number);
3999
+ else {
4000
+ const esc = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4001
+ const names = (list) => `(${(list || []).map(esc).join('|') || '\\p{L}+'})`;
4002
+ const groups = [];
4003
+ let last = 0, re = '';
4004
+ for (const m of mask.matchAll(TOKEN)) {
4005
+ re += esc(mask.slice(last, m.index));
4006
+ last = m.index + m[0].length;
4007
+ if (m[0].startsWith('"')) { re += esc(m[0].slice(1, -1)); continue; }
4008
+ const up = m[0].toUpperCase();
4009
+ re += { YYYY: '(\\d{4})', YY: '(\\d{2})', MM: '(\\d{1,2})', DD: '(\\d{1,2})', MONTH: names(months.long), MON: names(months.short), DAY: '(\\p{L}+)', DY: '(\\p{L}+)' }[up];
4010
+ groups.push(up);
4011
+ }
4012
+ re += esc(mask.slice(last));
4013
+ const m = new RegExp(`^${re}$`, 'iu').exec(v);
4014
+ if (!m) return null;
4015
+ groups.forEach((g, i) => {
4016
+ const x = m[i + 1];
4017
+ if (g === 'YYYY') y = Number(x);
4018
+ else if (g === 'YY') y = 2000 + Number(x);
4019
+ else if (g === 'MM') mo = Number(x);
4020
+ else if (g === 'DD') d = Number(x);
4021
+ else if (g === 'MONTH' || g === 'MON') mo = ((g === 'MONTH' ? months.long : months.short) || []).findIndex((n) => n.toLowerCase() === x.toLowerCase()) + 1;
4022
+ });
4023
+ }
4024
+ if (!y || !mo || !d) return null;
4025
+ const out = `${String(y).padStart(4, '0')}-${pad(mo)}-${pad(d)}`;
4026
+ return isoOf(dateOf(out)) === out ? out : null;
4027
+ };
4028
+
4029
+ let open = null; // { box, field, btn, focus, view }
4030
+ function close(back) {
4031
+ if (!open) return;
4032
+ const { box, btn } = open;
4033
+ open = null;
4034
+ box.remove();
4035
+ btn.setAttribute('aria-expanded', 'false');
4036
+ if (back) btn.focus();
4037
+ }
4038
+ function choose(iso) {
4039
+ const { field } = open;
4040
+ field.value = iso ? format(iso, field.dataset.dateMask || 'YYYY-MM-DD', monthsOf(field)) : '';
4041
+ close(false);
4042
+ field.dispatchEvent(new Event('input', { bubbles: true }));
4043
+ field.dispatchEvent(new Event('change', { bubbles: true }));
4044
+ field.focus();
4045
+ }
4046
+ // a date range's to is on or after its from (and the from on or before the to)
4047
+ const allowed = (iso) => {
4048
+ const { field } = open;
4049
+ if (!field.closest('.date-range')) return true;
4050
+ const isTo = field.id.endsWith('_TO');
4051
+ const other = document.getElementById(isTo ? field.id.slice(0, -3) : `${field.id}_TO`);
4052
+ const bound = other ? parse(other.value, other.dataset.dateMask || 'YYYY-MM-DD', monthsOf(other)) : null;
4053
+ return !bound || (isTo ? iso >= bound : iso <= bound);
4054
+ };
4055
+ function render() {
4056
+ const { box, field } = open;
4057
+ const sel = parse(field.value, field.dataset.dateMask || 'YYYY-MM-DD', monthsOf(field));
4058
+ const now = today();
4059
+ const view = open.view;
4060
+ const first = dateOf(`${view.slice(0, 7)}-01`);
4061
+ const start = addDays(isoOf(first), -((first.getUTCDay() - weekStart + 7) % 7));
4062
+ box.textContent = '';
4063
+ const head = document.createElement('div');
4064
+ head.className = 'date-picker-head';
4065
+ const nav = (label, n, glyph) => {
4066
+ const b = document.createElement('button');
4067
+ b.type = 'button';
4068
+ b.className = 'btn btn-sm';
4069
+ b.setAttribute('aria-label', label);
4070
+ b.textContent = glyph;
4071
+ b.addEventListener('click', () => { open.focus = addMonths(open.focus, n); open.view = open.focus; render(); });
4072
+ return b;
4073
+ };
4074
+ const caption = document.createElement('div');
4075
+ caption.className = 'date-picker-title';
4076
+ caption.setAttribute('aria-live', 'polite');
4077
+ caption.id = `${field.id}_dp_title`;
4078
+ caption.textContent = title.format(first);
4079
+ head.append(nav(t('calendar.previous', 'Previous month'), -1, '‹'), caption, nav(t('calendar.next', 'Next month'), 1, '›'));
4080
+ const table = document.createElement('table');
4081
+ table.className = 'date-picker-grid';
4082
+ table.setAttribute('role', 'grid');
4083
+ table.setAttribute('aria-labelledby', caption.id);
4084
+ const hr = table.createTHead().insertRow();
4085
+ for (let i = 0; i < 7; i++) {
4086
+ const th = document.createElement('th');
4087
+ const day = addDays(start, i);
4088
+ th.scope = 'col';
4089
+ th.abbr = dayLong.format(dateOf(day));
4090
+ th.textContent = dayShort.format(dateOf(day));
4091
+ hr.append(th);
4092
+ }
4093
+ const body = table.createTBody();
4094
+ for (let w = 0; w < 6; w++) {
4095
+ const tr = body.insertRow();
4096
+ for (let i = 0; i < 7; i++) {
4097
+ const iso = addDays(start, w * 7 + i);
4098
+ const td = tr.insertCell();
4099
+ const b = document.createElement('button');
4100
+ b.type = 'button';
4101
+ b.dataset.iso = iso;
4102
+ b.textContent = String(Number(iso.slice(8)));
4103
+ b.setAttribute('aria-label', full.format(dateOf(iso)));
4104
+ b.tabIndex = iso === open.focus ? 0 : -1;
4105
+ if (iso.slice(0, 7) !== view.slice(0, 7)) b.classList.add('date-other');
4106
+ if (iso === now) { b.classList.add('date-today'); b.setAttribute('aria-current', 'date'); }
4107
+ if (iso === sel) { b.classList.add('date-selected'); b.setAttribute('aria-pressed', 'true'); }
4108
+ if (!allowed(iso)) b.disabled = true;
4109
+ td.append(b);
4110
+ }
4111
+ }
4112
+ const foot = document.createElement('div');
4113
+ foot.className = 'date-picker-foot';
4114
+ const act = (label, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-sm'; b.textContent = label; b.addEventListener('click', fn); return b; };
4115
+ foot.append(act(t('calendar.today', 'Today'), () => (allowed(now) ? choose(now) : null)), act(t('facets.clear_short', 'Clear'), () => choose('')));
4116
+ box.append(head, table, foot);
4117
+ box.querySelector('button[tabindex="0"]')?.focus({ preventScroll: true });
4118
+ }
4119
+ // fixed under the field (above it when there is no room), so a scrolling table or grid cell doesn't clip it
4120
+ function place() {
4121
+ if (!open) return;
4122
+ const { box, field } = open;
4123
+ const r = field.getBoundingClientRect();
4124
+ const h = box.offsetHeight, w = box.offsetWidth;
4125
+ box.style.position = 'fixed';
4126
+ box.style.top = `${Math.round(r.bottom + 4 + h > window.innerHeight - 8 && r.top - h - 4 > 8 ? r.top - h - 4 : r.bottom + 4)}px`;
4127
+ box.style.left = `${Math.round(Math.max(8, Math.min(r.left, window.innerWidth - w - 8)))}px`;
4128
+ }
4129
+ window.addEventListener('scroll', place, { passive: true, capture: true });
4130
+ window.addEventListener('resize', place);
4131
+ function show(field, btn) {
4132
+ close(false);
4133
+ const box = document.createElement('div');
4134
+ box.className = 'date-picker';
4135
+ box.setAttribute('role', 'dialog');
4136
+ box.setAttribute('aria-label', t('item.choose_date', 'Choose a date'));
4137
+ const sel = parse(field.value, field.dataset.dateMask || 'YYYY-MM-DD', monthsOf(field));
4138
+ open = { box, field, btn, focus: sel || today(), view: sel || today() };
4139
+ btn.setAttribute('aria-expanded', 'true');
4140
+ (field.closest('.date-field') || field.parentElement).append(box);
4141
+ render();
4142
+ place();
4143
+ box.addEventListener('keydown', (e) => {
4144
+ const day = e.target.dataset?.iso;
4145
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); return; }
4146
+ if (!day) return;
4147
+ const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key];
4148
+ let next = null;
4149
+ if (step) next = addDays(day, step);
4150
+ else if (e.key === 'PageUp' || e.key === 'PageDown') next = addMonths(day, (e.key === 'PageUp' ? -1 : 1) * (e.shiftKey ? 12 : 1));
4151
+ else if (e.key === 'Home') next = addDays(day, -((dateOf(day).getUTCDay() - weekStart + 7) % 7));
4152
+ else if (e.key === 'End') next = addDays(day, 6 - ((dateOf(day).getUTCDay() - weekStart + 7) % 7));
4153
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (allowed(day)) choose(day); return; }
4154
+ if (!next) return;
4155
+ e.preventDefault();
4156
+ open.focus = next;
4157
+ if (next.slice(0, 7) !== open.view.slice(0, 7)) { open.view = next; render(); }
4158
+ else {
4159
+ box.querySelectorAll('button[data-iso]').forEach((b) => (b.tabIndex = b.dataset.iso === next ? 0 : -1));
4160
+ box.querySelector(`button[data-iso="${next}"]`)?.focus({ preventScroll: true });
4161
+ }
4162
+ });
4163
+ box.addEventListener('click', (e) => {
4164
+ const b = e.target.closest('button[data-iso]');
4165
+ if (b && !b.disabled) choose(b.dataset.iso);
4166
+ });
4167
+ }
3893
4168
  document.addEventListener('click', (e) => {
3894
4169
  const btn = e.target.closest?.('[data-date-pick]');
3895
- if (!btn) return;
3896
- const native = document.querySelector(`input[data-date-for="${CSS.escape(btn.dataset.datePick)}"]`);
3897
- if (!native) return;
3898
- try {
3899
- native.showPicker();
3900
- } catch {
3901
- native.focus();
4170
+ if (btn) {
4171
+ const field = document.getElementById(btn.dataset.datePick);
4172
+ if (!field) return;
4173
+ e.preventDefault();
4174
+ if (open && open.btn === btn) close(true);
4175
+ else show(field, btn);
4176
+ return;
3902
4177
  }
4178
+ if (open && !e.target.closest?.('.date-picker')) close(false);
3903
4179
  });
3904
- document.addEventListener('change', (e) => {
3905
- const native = e.target;
3906
- if (!native.matches?.('input[data-date-for]')) return;
3907
- const text = document.getElementById(native.dataset.dateFor);
3908
- if (!text) return;
3909
- let months = { long: [], short: [] };
3910
- try { months = JSON.parse(text.dataset.months || '{}'); } catch {}
3911
- text.value = format(native.value, text.dataset.dateMask || 'YYYY-MM-DD', months);
3912
- text.dispatchEvent(new Event('change', { bubbles: true }));
3913
- text.focus();
4180
+ // Alt+Down in the field opens the picker (as a native date field does)
4181
+ document.addEventListener('keydown', (e) => {
4182
+ if (e.altKey && e.key === 'ArrowDown' && e.target.matches?.('input[data-date-mask]')) {
4183
+ const btn = e.target.parentElement?.querySelector('[data-date-pick]');
4184
+ if (btn) { e.preventDefault(); show(e.target, btn); }
4185
+ }
3914
4186
  });
3915
4187
  })();
@@ -368,7 +368,7 @@ a.pd-node:hover { background: var(--surface-3); }
368
368
  .js .pd-gal { cursor: grab; }
369
369
  .pd-gal:hover { background: var(--surface-3); }
370
370
  .pd-gal .icon { width: 22px; height: 22px; color: var(--muted); stroke-width: 1.5; }
371
- .pd-gal span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
371
+ .pd-gal span { min-width: 0; overflow-wrap: anywhere; line-height: 1.2; }
372
372
 
373
373
  /* ------------------------------------------------------------ help and search tabs */
374
374
  .pd-help, .pd-search { padding: 12px 16px; max-width: 60rem; }
@@ -1012,3 +1012,23 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
1012
1012
  .pd-goto-no input { width: 5.5em; font-variant-numeric: tabular-nums; }
1013
1013
  /* on narrower screens the previous / next arrows and the page list do it */
1014
1014
  @media (max-width: 1279px) { .pd-goto-no { display: none; } }
1015
+
1016
+ /* (sprint 89, SQL-4) the Object Browser: a table's tabs on one row with More, and the Columns toolbar */
1017
+ .ob-tabs { flex-wrap: nowrap; align-items: stretch; }
1018
+ .ob-tabs > a { white-space: nowrap; }
1019
+ .ob-more { position: relative; }
1020
+ .ob-more > summary { display: flex; align-items: center; gap: 0.25rem; height: 100%; padding: 0.5rem 0.85rem; color: var(--muted); cursor: pointer; white-space: nowrap; }
1021
+ .ob-more > summary:hover, .ob-more[open] > summary { color: var(--text); }
1022
+ .ob-more .menu-panel { right: 0; left: auto; min-width: 12rem; }
1023
+ .ob-more .icon { width: 0.9rem; height: 0.9rem; }
1024
+ .ob-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
1025
+ .ob-tool { position: relative; }
1026
+ .ob-tool > summary { list-style: none; }
1027
+ .ob-tool .menu-panel { left: 0; min-width: 18rem; padding: 0.75rem; }
1028
+ .ob-tool .ob-action { flex-direction: column; align-items: stretch; }
1029
+ .ob-tool .ob-action input:not([type=checkbox]), .ob-tool .ob-action select { width: 100%; }
1030
+ .ob-toolbar-note { font-size: 0.85rem; }
1031
+ @media (max-width: 640px) { .ob-tabs { overflow-x: auto; } .ob-tool .menu-panel { min-width: min(18rem, 90vw); } }
1032
+
1033
+ /* (sprint 90, PD-4) a commented-out component in the tree (APEX: Comment Out): struck through and muted */
1034
+ .pd-node[data-commented] .pd-label { text-decoration: line-through; color: var(--muted); }
package/public/builder.js CHANGED
@@ -270,6 +270,11 @@
270
270
  list.push(['Duplicate', () => submit(`${base}/layout/duplicate`, { kind, id })], ['Copy to other page…', copyDialog]);
271
271
  if (kind !== 'region') list.push(['Create dynamic action', go('new=dynamic_action')]);
272
272
  if (node.classList.contains('has-kids')) list.push(null, ['Expand all below', () => setAll(node, true)], ['Collapse all below', () => setAll(node, false)]);
273
+ // (sprint 90; APEX: Comment Out) leave a component out of the page without deleting it (build option COMMENTED_OUT)
274
+ if (['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'].includes(kind))
275
+ list.push(null, node.hasAttribute('data-commented')
276
+ ? ['Uncomment', () => submit(`${base}/layout/comment`, { kind, id, on: 'false' })]
277
+ : ['Comment out', () => submit(`${base}/layout/comment`, { kind, id, on: 'true' })]);
273
278
  list.push(null, ['Delete', () => submit(`${base}/c/${kind}/${id}/delete`, {}, `Delete ${kind} ${label}?`), 'danger']);
274
279
  return list;
275
280
  };
@@ -1167,3 +1172,65 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
1167
1172
  });
1168
1173
  });
1169
1174
  });
1175
+
1176
+ // (sprint 87) The create application wizard:
1177
+ // - an alias follows its name (data-derive-from) until the developer types in it, like aliasFor() on the server;
1178
+ // - choosing a schema under Data reloads the Pages list (and the proposed name and alias, while untouched)
1179
+ // without leaving the page; without JavaScript, "Show its tables" does the same with a reload;
1180
+ // - First user and Password are hidden while Authentication is None (the server ignores them then).
1181
+ (() => {
1182
+ const slug = (v) => {
1183
+ let a = v.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
1184
+ if (a && !/^[a-z]/.test(a)) a = `app-${a}`;
1185
+ return a;
1186
+ };
1187
+ document.querySelectorAll('input[data-derive-from]').forEach((alias) => {
1188
+ const name = document.getElementById(alias.dataset.deriveFrom);
1189
+ if (!name) return;
1190
+ alias.addEventListener('input', () => alias.setAttribute('data-edited', ''));
1191
+ name.addEventListener('input', () => {
1192
+ if (!alias.hasAttribute('data-edited')) alias.value = slug(name.value);
1193
+ });
1194
+ });
1195
+
1196
+ document.querySelectorAll('select[name="authentication"]').forEach((auth) => {
1197
+ const form = auth.form;
1198
+ const fields = ['admin_user', 'admin_password'].map((n) => form?.elements.namedItem(n)?.closest?.('.field')).filter(Boolean);
1199
+ if (!fields.length) return;
1200
+ const sync = () => fields.forEach((f) => (f.hidden = auth.value === 'none'));
1201
+ auth.addEventListener('change', sync);
1202
+ sync();
1203
+ });
1204
+
1205
+ const picker = document.querySelector('form.create-schema select[name="schema"]');
1206
+ const pages = document.getElementById('aw-pages');
1207
+ const hidden = document.getElementById('aw-schema-fields');
1208
+ if (!picker || !pages || !hidden) return;
1209
+ const name = document.getElementById('f_name');
1210
+ let nameTouched = false;
1211
+ name?.addEventListener('input', () => (nameTouched = true));
1212
+ picker.addEventListener('change', async () => {
1213
+ const url = `${picker.form.action}?schema=${encodeURIComponent(picker.value)}`;
1214
+ pages.setAttribute('aria-busy', 'true');
1215
+ try {
1216
+ const res = await fetch(url, { headers: { accept: 'text/html' }, credentials: 'same-origin' });
1217
+ if (!res.ok) throw new Error(String(res.status));
1218
+ const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
1219
+ const nextPages = doc.getElementById('aw-pages');
1220
+ const nextHidden = doc.getElementById('aw-schema-fields');
1221
+ if (!nextPages || !nextHidden) throw new Error('unexpected page');
1222
+ pages.replaceWith(nextPages);
1223
+ hidden.replaceWith(nextHidden);
1224
+ // the proposals for the new schema, unless the developer already typed a name or alias
1225
+ const alias = document.querySelector('input[data-derive-from]');
1226
+ if (!nameTouched && name) name.value = doc.getElementById('f_name')?.value ?? name.value;
1227
+ if (alias && !alias.hasAttribute('data-edited')) alias.value = doc.getElementById('f_alias')?.value ?? alias.value;
1228
+ history.replaceState(null, '', url);
1229
+ document.getElementById('aw-pages')?.removeAttribute('aria-busy');
1230
+ } catch {
1231
+ picker.form.submit();
1232
+ }
1233
+ });
1234
+ // the button is for pages without JavaScript
1235
+ picker.form.querySelector('button')?.setAttribute('hidden', '');
1236
+ })();
package/public/sw.js CHANGED
@@ -15,11 +15,12 @@ self.addEventListener('install', (event) => {
15
15
  event.waitUntil(caches.open(STATIC).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
16
16
  });
17
17
 
18
+ // older versions' static files, and (sprint 86) the caches of pgkiln's old name (pgapex-*)
18
19
  self.addEventListener('activate', (event) => {
19
20
  event.waitUntil(
20
21
  caches
21
22
  .keys()
22
- .then((keys) => Promise.all(keys.filter((k) => k.startsWith('pgkiln-static-') && k !== STATIC).map((k) => caches.delete(k))))
23
+ .then((keys) => Promise.all(keys.filter((k) => (k.startsWith('pgkiln-static-') && k !== STATIC) || k.startsWith('pgapex-')).map((k) => caches.delete(k))))
23
24
  .then(() => self.clients.claim()),
24
25
  );
25
26
  });
package/src/app.ts CHANGED
@@ -49,6 +49,9 @@ import { accountRoutes } from './runtime/account.ts';
49
49
  import { taskRoutes } from './runtime/tasks.ts';
50
50
  import { dataReporterRoutes } from './runtime/data-reporter.ts';
51
51
  import { reporterBuilderRoutes } from './builder/reporter.ts';
52
+ import { formDesignerRoutes } from './runtime/form-designer.ts';
53
+ import { formDesignerBuilderRoutes } from './builder/form-designer.ts';
54
+ import { builderErrorPages, builderServerError } from './builder/errors.ts';
52
55
  import { workflowRoutes } from './runtime/workflows.ts';
53
56
  import { pwaRoutes } from './runtime/pwa.ts';
54
57
  import { pushRoutes } from './runtime/push.ts';
@@ -138,10 +141,13 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
138
141
  });
139
142
  // An id in the URL that isn't a number (or is too big) fails in PostgreSQL:
140
143
  // that's a page that doesn't exist, not a server error.
141
- app.setErrorHandler((err: Error & { code?: string }, _req, reply) => {
144
+ app.setErrorHandler((err: Error & { code?: string }, req, reply) => {
142
145
  if (err.code === '22P02' || err.code === '22003') return reply.code(404).type('text/plain').send('Not found');
146
+ // (sprint 86) a browser on a builder page gets an HTML error page
147
+ if (builderServerError(req, reply, err)) return reply;
143
148
  return reply.send(err);
144
149
  });
150
+ builderErrorPages(app);
145
151
  await app.register(cookie);
146
152
  await app.register(formbody, { bodyLimit: 5 * 1024 * 1024 });
147
153
  // file upload items; oversized files are cut off and reported, not thrown
@@ -223,6 +229,8 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
223
229
  await app.register(codeEditorRoutes);
224
230
  await app.register(dataReporterRoutes);
225
231
  await app.register(reporterBuilderRoutes);
232
+ await app.register(formDesignerRoutes);
233
+ await app.register(formDesignerBuilderRoutes);
226
234
  await app.register(sampleDataRoutes);
227
235
  await app.register(assistantRoutes);
228
236
  await app.register(aiFilterRoutes);
package/src/appfiles.ts CHANGED
@@ -116,6 +116,10 @@ const CODE: Record<string, Record<string, string>> = {
116
116
  computation: { expression: 'sql', condition_expr: 'sql' },
117
117
  branch: { condition_expr: 'sql' },
118
118
  app_process: { code: 'sql' },
119
+ app_computation: { expression: 'sql', condition_expr: 'sql' },
120
+ // (103) a shortcut's value: text, HTML or PL/pgSQL (see codeExt)
121
+ shortcut: { value: 'txt' },
122
+ report_query: { query: 'sql' },
119
123
  authz_scheme: { value: 'sql' },
120
124
  lov: { query: 'sql' },
121
125
  automation: { query: 'sql', code: 'sql' },
@@ -134,7 +138,9 @@ const CODE: Record<string, Record<string, string>> = {
134
138
  };
135
139
  const isLong = (v: unknown): v is string => typeof v === 'string' && (v.includes('\n') || v.length > 60);
136
140
  const codeExt = (table: string, column: string, row: any) =>
137
- table === 'region' && column === 'source' && row.type === 'static' ? 'html' : CODE[table][column];
141
+ table === 'region' && column === 'source' && row.type === 'static' ? 'html'
142
+ : table === 'shortcut' ? (row.type === 'html' ? 'html' : row.type === 'function_body' ? 'sql' : 'txt')
143
+ : CODE[table][column];
138
144
 
139
145
  export type Style = 'json' | 'text';
140
146
 
@@ -191,6 +197,11 @@ const NAMED: [section: string, dir: string, table: string][] = [
191
197
  // (086) application settings
192
198
  ['app_settings', 'shared/app-settings', 'app_setting'],
193
199
  ['page_groups', 'shared/page-groups', 'page_group'],
200
+ // (103) shortcuts, (104) report queries
201
+ ['shortcuts', 'shared/shortcuts', 'shortcut'],
202
+ ['report_queries', 'shared/report-queries', 'report_query'],
203
+ // (105) component groups
204
+ ['component_groups', 'shared/component-groups', 'component_group'],
194
205
  ];
195
206
 
196
207
  /** Components of a page: [array in the document, directory, table, key source]. */
@@ -214,10 +225,11 @@ const SINGLE: [section: string, path: string, sort: string[]][] = [
214
225
  ];
215
226
 
216
227
  const ACTIONS_DIR = 'shared/automation-actions';
228
+ const APP_COMPUTATIONS_DIR = 'shared/app-computations';
217
229
  const STATIC_DIR = 'static';
218
230
  const STATIC_INDEX = `${STATIC_DIR}/files.json`;
219
231
 
220
- const KNOWN = new Set(['format', 'app', 'static_files', 'app_processes', 'translations', 'nav', 'list_entries', 'automation_actions', 'pages', ...NAMED.map((n) => n[0]), ...SINGLE.map((s) => s[0])]);
232
+ const KNOWN = new Set(['format', 'app', 'static_files', 'app_processes', 'app_computations', 'translations', 'nav', 'list_entries', 'automation_actions', 'pages', ...NAMED.map((n) => n[0]), ...SINGLE.map((s) => s[0])]);
221
233
 
222
234
  const byColumns = (cols: string[]) => (a: any, b: any) => {
223
235
  for (const c of cols) {
@@ -258,6 +270,9 @@ export function docToFiles(doc: Doc, style: Style = 'json'): FileMap {
258
270
 
259
271
  const procs: any[] = doc.app_processes ?? [];
260
272
  uniqueKeys(procs, (r) => r.name, 'process').forEach((k, i) => w.record('shared/app-processes', `${seqPrefix(procs[i].seq)}-${k}`, 'app_process', procs[i]));
273
+ // (102) application computations, by sequence, item and point
274
+ const comps: any[] = doc.app_computations ?? [];
275
+ uniqueKeys(comps, (r) => `${r.item_name ?? ''} ${r.point ?? ''}`, 'computation').forEach((k, i) => w.record(APP_COMPUTATIONS_DIR, `${seqPrefix(comps[i].seq)}-${k}`, 'app_computation', comps[i]));
261
276
 
262
277
  for (const [section, path, sort] of SINGLE) w.json(path, [...(doc[section] ?? [])].sort(byColumns(sort)));
263
278
 
@@ -453,6 +468,7 @@ export function filesToDoc(files: FileMap): Doc {
453
468
  });
454
469
  }
455
470
  doc.app_processes = readRecords(files, 'shared/app-processes').sort(SEQ_SORT).map(mergeCode);
471
+ doc.app_computations = readRecords(files, APP_COMPUTATIONS_DIR).sort(SEQ_SORT).map(mergeCode);
456
472
  for (const [section, path] of SINGLE) doc[section] = read(path);
457
473
  doc.translations = [...files.keys()]
458
474
  .filter((p) => /^globalization\/translations\/[^/]+\.(json|yaml)$/.test(p))