pgkiln 0.45.0 → 0.47.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 (93) hide show
  1. package/db/migrations/106_form_designer.sql +102 -0
  2. package/db/migrations/107_app_version.sql +9 -0
  3. package/db/migrations/108_sql_history.sql +25 -0
  4. package/docs/apex-feature-parity.md +33 -4
  5. package/docs/guide/03-builder.md +123 -28
  6. package/docs/guide/04-pages-and-regions.md +74 -2
  7. package/docs/guide/05-items.md +14 -2
  8. package/docs/guide/08-security.md +1 -1
  9. package/docs/guide/09-reference.md +1 -1
  10. package/docs/guide/10-tutorial.md +1 -1
  11. package/docs/guide/11-from-apex.md +2 -1
  12. package/docs/guide/12-development.md +26 -3
  13. package/docs/guide/16-files.md +2 -2
  14. package/examples/hr/hr_61_form_designer.sql +80 -0
  15. package/package.json +1 -1
  16. package/public/app.css +62 -2
  17. package/public/app.js +244 -35
  18. package/public/builder.css +21 -1
  19. package/public/builder.js +67 -0
  20. package/public/sw.js +2 -1
  21. package/public/worksheet.css +244 -0
  22. package/public/worksheet.js +665 -0
  23. package/src/app.ts +9 -1
  24. package/src/builder/ai-builder.ts +3 -3
  25. package/src/builder/appwizard.ts +22 -6
  26. package/src/builder/arrange.ts +33 -12
  27. package/src/builder/code-editor.ts +1 -1
  28. package/src/builder/components.ts +1 -1
  29. package/src/builder/designer.ts +44 -17
  30. package/src/builder/errors.ts +65 -0
  31. package/src/builder/form-designer.ts +229 -0
  32. package/src/builder/locks.ts +6 -0
  33. package/src/builder/newapp.ts +2 -1
  34. package/src/builder/object-actions.ts +166 -0
  35. package/src/builder/object-browser.ts +111 -33
  36. package/src/builder/querybuilder.ts +1 -1
  37. package/src/builder/region-help.ts +2 -0
  38. package/src/builder/region-settings.ts +2 -0
  39. package/src/builder/reporter.ts +1 -1
  40. package/src/builder/routes.ts +7 -5
  41. package/src/builder/sampledata.ts +2 -2
  42. package/src/builder/scripts.ts +3 -3
  43. package/src/builder/search.ts +3 -3
  44. package/src/builder/shared.ts +2 -2
  45. package/src/builder/sql.ts +15 -73
  46. package/src/builder/table-editor.ts +219 -0
  47. package/src/builder/ui.ts +5 -3
  48. package/src/builder/unload.ts +8 -4
  49. package/src/builder/worksheet.ts +569 -0
  50. package/src/builder/workspaces.ts +1 -1
  51. package/src/cli/replace.ts +10 -0
  52. package/src/i18n/ar.ts +62 -0
  53. package/src/i18n/cs.ts +62 -0
  54. package/src/i18n/da.ts +62 -0
  55. package/src/i18n/de.ts +62 -0
  56. package/src/i18n/el.ts +62 -0
  57. package/src/i18n/es.ts +62 -0
  58. package/src/i18n/fi.ts +62 -0
  59. package/src/i18n/fr.ts +62 -0
  60. package/src/i18n/he.ts +62 -0
  61. package/src/i18n/it.ts +62 -0
  62. package/src/i18n/ja.ts +62 -0
  63. package/src/i18n/ko.ts +62 -0
  64. package/src/i18n/nb.ts +62 -0
  65. package/src/i18n/pl.ts +62 -0
  66. package/src/i18n/pt.ts +62 -0
  67. package/src/i18n/ru.ts +62 -0
  68. package/src/i18n/sv.ts +62 -0
  69. package/src/i18n/tr.ts +62 -0
  70. package/src/i18n/uk.ts +62 -0
  71. package/src/i18n/zh.ts +62 -0
  72. package/src/i18n.ts +124 -0
  73. package/src/metadata.ts +4 -2
  74. package/src/runtime/devbar.ts +1 -1
  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 +15 -8
  79. package/src/runtime/items.ts +33 -21
  80. package/src/runtime/lists.ts +1 -1
  81. package/src/runtime/pdf.ts +2 -1
  82. package/src/runtime/regions.ts +4 -0
  83. package/src/runtime/render.ts +7 -3
  84. package/src/runtime/report.ts +2 -2
  85. package/src/runtime/routes.ts +3 -1
  86. package/src/runtime/smart-filters.ts +2 -1
  87. package/src/runtime/tasks.ts +6 -3
  88. package/src/sqlformat.ts +208 -0
  89. package/src/sqlplan.ts +133 -0
  90. package/src/sqlscript.ts +6 -2
  91. package/src/sqlworksheet.ts +278 -0
  92. package/src/tabledef.ts +226 -0
  93. package/src/unload.ts +14 -3
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
@@ -3492,23 +3509,10 @@ function areaFilter(map, f) {
3492
3509
  draw();
3493
3510
  }
3494
3511
 
3495
- // Date range: the end date can't be before the start date
3496
- function range(box) {
3497
- const [from, to] = box.querySelectorAll('input[type=date]');
3498
- if (!from || !to) return;
3499
- const limit = () => {
3500
- to.min = from.value;
3501
- from.max = to.value;
3502
- };
3503
- from.addEventListener('change', limit);
3504
- to.addEventListener('change', limit);
3505
- }
3506
-
3507
3512
  function enhance(root) {
3508
3513
  root.querySelectorAll('[data-richtext]').forEach(richText);
3509
3514
  root.querySelectorAll('[data-markdown]').forEach(markdown);
3510
3515
  root.querySelectorAll('[data-tags]').forEach(tags);
3511
- root.querySelectorAll('[data-range]').forEach(range);
3512
3516
  root.querySelectorAll('[data-reveal]').forEach((b) => (b.hidden = false));
3513
3517
  }
3514
3518
  enhance(document);
@@ -3931,11 +3935,42 @@ document.addEventListener('change', (e) => {
3931
3935
  if (e.target.matches?.('select[data-go]')) e.target.form?.submit();
3932
3936
  });
3933
3937
 
3934
- // (sprint 76) A date item in the application's date format: the calendar button opens the browser's date
3935
- // 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).
3936
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';
3937
3946
  const pad = (n) => String(n).padStart(2, '0');
3938
- 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 {}; } };
3939
3974
  const format = (iso, mask, months) => {
3940
3975
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
3941
3976
  if (!m) return '';
@@ -3948,31 +3983,205 @@ document.addEventListener('change', (e) => {
3948
3983
  if (up === 'YY') return pad(y % 100);
3949
3984
  if (up === 'MM') return pad(mo);
3950
3985
  if (up === 'DD') return pad(d);
3951
- if (up === 'MONTH') return cased(months.long[mo - 1]);
3952
- 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)));
3953
3990
  return tok;
3954
3991
  });
3955
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
+ }
3956
4168
  document.addEventListener('click', (e) => {
3957
4169
  const btn = e.target.closest?.('[data-date-pick]');
3958
- if (!btn) return;
3959
- const native = document.querySelector(`input[data-date-for="${CSS.escape(btn.dataset.datePick)}"]`);
3960
- if (!native) return;
3961
- try {
3962
- native.showPicker();
3963
- } catch {
3964
- 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;
3965
4177
  }
4178
+ if (open && !e.target.closest?.('.date-picker')) close(false);
3966
4179
  });
3967
- document.addEventListener('change', (e) => {
3968
- const native = e.target;
3969
- if (!native.matches?.('input[data-date-for]')) return;
3970
- const text = document.getElementById(native.dataset.dateFor);
3971
- if (!text) return;
3972
- let months = { long: [], short: [] };
3973
- try { months = JSON.parse(text.dataset.months || '{}'); } catch {}
3974
- text.value = format(native.value, text.dataset.dateMask || 'YYYY-MM-DD', months);
3975
- text.dispatchEvent(new Event('change', { bubbles: true }));
3976
- 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
+ }
3977
4186
  });
3978
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
  });