pgkiln 0.35.0 → 0.36.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 (76) hide show
  1. package/db/migrations/087_login_page.sql +175 -0
  2. package/db/migrations/088_region_templates.sql +18 -0
  3. package/db/migrations/089_page_groups.sql +330 -0
  4. package/docs/apex-feature-parity.md +22 -20
  5. package/docs/guide/03-builder.md +36 -2
  6. package/docs/guide/04-pages-and-regions.md +42 -3
  7. package/docs/guide/08-security.md +46 -5
  8. package/docs/guide/11-from-apex.md +4 -1
  9. package/docs/guide/12-development.md +7 -1
  10. package/docs/guide/17-mobile.md +3 -1
  11. package/docs/guide/18-cli.md +2 -0
  12. package/docs/guide/20-ai-agents.md +2 -0
  13. package/examples/hr/hr_54_region_templates.sql +67 -0
  14. package/examples/hr/hr_55_page_groups.sql +24 -0
  15. package/package.json +1 -1
  16. package/public/app.css +60 -3
  17. package/public/app.js +89 -3
  18. package/public/builder.css +11 -0
  19. package/public/builder.js +50 -0
  20. package/public/code-editor.css +3 -3
  21. package/public/sw.js +5 -3
  22. package/src/app.ts +2 -0
  23. package/src/appfiles.ts +1 -0
  24. package/src/builder/advisor.ts +14 -0
  25. package/src/builder/components.ts +15 -2
  26. package/src/builder/ddl-tools.ts +114 -0
  27. package/src/builder/designer.ts +29 -3
  28. package/src/builder/forms.ts +6 -4
  29. package/src/builder/login-page.ts +82 -0
  30. package/src/builder/region-help.ts +4 -2
  31. package/src/builder/region-settings.ts +19 -3
  32. package/src/builder/routes.ts +16 -4
  33. package/src/builder/shared.ts +1 -1
  34. package/src/builder/sql.ts +3 -0
  35. package/src/builder/ui.ts +2 -2
  36. package/src/cli/main.ts +45 -0
  37. package/src/cli/mcp.ts +42 -0
  38. package/src/cli/replace.ts +4 -0
  39. package/src/ddl.ts +509 -0
  40. package/src/i18n/ar.ts +17 -0
  41. package/src/i18n/cs.ts +17 -0
  42. package/src/i18n/da.ts +17 -0
  43. package/src/i18n/de.ts +17 -0
  44. package/src/i18n/el.ts +17 -0
  45. package/src/i18n/es.ts +17 -0
  46. package/src/i18n/fi.ts +17 -0
  47. package/src/i18n/fr.ts +17 -0
  48. package/src/i18n/he.ts +17 -0
  49. package/src/i18n/it.ts +17 -0
  50. package/src/i18n/ja.ts +17 -0
  51. package/src/i18n/ko.ts +17 -0
  52. package/src/i18n/nb.ts +17 -0
  53. package/src/i18n/pl.ts +17 -0
  54. package/src/i18n/pt.ts +17 -0
  55. package/src/i18n/ru.ts +17 -0
  56. package/src/i18n/sv.ts +17 -0
  57. package/src/i18n/tr.ts +17 -0
  58. package/src/i18n/uk.ts +17 -0
  59. package/src/i18n/zh.ts +17 -0
  60. package/src/i18n.ts +34 -0
  61. package/src/icons.ts +2 -1
  62. package/src/layout.ts +3 -2
  63. package/src/metadata.ts +10 -4
  64. package/src/runtime/devbar.ts +3 -1
  65. package/src/runtime/display-selector.ts +12 -4
  66. package/src/runtime/grid.ts +170 -26
  67. package/src/runtime/lists.ts +11 -1
  68. package/src/runtime/password-reset.ts +171 -0
  69. package/src/runtime/pwa.ts +7 -10
  70. package/src/runtime/regions.ts +33 -0
  71. package/src/runtime/render.ts +15 -0
  72. package/src/runtime/report-views.ts +4 -2
  73. package/src/runtime/report.ts +36 -22
  74. package/src/runtime/routes.ts +35 -32
  75. package/src/runtime/signin.ts +80 -0
  76. package/src/static-version.ts +34 -0
@@ -0,0 +1,24 @@
1
+ -- =====================================================================
2
+ -- HR example, part 55: page groups (migration 089;
3
+ -- docs/guide/03-builder.md "Page groups")
4
+ --
5
+ -- The HR pages in four groups, shown and filtered in the App Builder's
6
+ -- page list (Shared Components → Page groups).
7
+ -- =====================================================================
8
+ insert into meta.page_group (app_id, name, description)
9
+ select a.id, g.name, g.description
10
+ from meta.app a, (values
11
+ ('Employees', 'Employees, departments and their reviews'),
12
+ ('Leave', 'Leave requests, the calendar and the planner'),
13
+ ('Administration', 'Pages for administrators'),
14
+ ('Showcase', 'Pages that show one feature each')
15
+ ) as g (name, description)
16
+ where a.alias = 'hr';
17
+
18
+ update meta.page p set page_group = g.name
19
+ from meta.app a, (values
20
+ ('Employees', array[2, 3, 4, 5, 8, 11, 19, 20, 27]),
21
+ ('Leave', array[6, 7, 12, 22, 24]),
22
+ ('Administration', array[9, 10, 43, 44])
23
+ ) as g (name, pages)
24
+ where a.alias = 'hr' and p.app_id = a.id and p.page_no = any (g.pages);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.css CHANGED
@@ -254,7 +254,43 @@ details.menu > summary::-webkit-details-marker { display: none; }
254
254
 
255
255
  /* ---------- regions ---------- */
256
256
  .region { min-width: 0; }
257
- .region-standard, .region-collapsible { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
257
+ .region-standard, .region-collapsible, .region-wizard, .region-inline_dialog, .region-inline_drawer { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
258
+ /* (088) more region templates */
259
+ .region-hero { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 1rem 1.5rem; padding: 2rem 1.75rem; border-radius: var(--radius);
260
+ background: var(--accent-soft); color: var(--text); }
261
+ .region-hero-icon .icon { width: 3rem; height: 3rem; color: var(--accent); }
262
+ .region-hero-text h2 { font-size: 1.75rem; margin: 0 0 0.35rem; }
263
+ .region-hero-buttons, .region-title-buttons, .region-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; }
264
+ @media (max-width: 640px) { .region-hero { grid-template-columns: 1fr; padding: 1.25rem 1rem; } }
265
+ .region-title-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; padding: 0.25rem 0; border-bottom: 1px solid var(--border); margin-bottom: 0.75rem; }
266
+ .region-title-bar h2 { margin: 0; font-size: 1.15rem; }
267
+ .region-buttons_container .region-buttons { justify-content: flex-end; }
268
+ .region-wizard { max-width: 640px; width: 100%; margin-left: auto; margin-right: auto; }
269
+ dialog[data-inline-region] { padding: 0; max-width: min(640px, calc(100vw - 2rem)); width: 100%; color: var(--text); }
270
+ dialog[data-inline-region]::backdrop { background: rgb(0 0 0 / 45%); }
271
+ dialog[data-inline-region] .region-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
272
+ dialog.region-inline_drawer { margin: 0 0 0 auto; height: 100vh; max-height: 100vh; max-width: min(480px, 100vw); border-radius: 0; }
273
+ /* without JavaScript: a link to #R<id> shows it */
274
+ dialog[data-inline-region]:target { display: block; position: fixed; inset: 2rem 0 auto; margin: 0 auto; z-index: 1000; }
275
+ dialog.region-inline_drawer:target { inset: 0 0 0 auto; }
276
+ /* (088) carousel (display selector style) and wizard progress (list template) */
277
+ .rds-carousel { display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
278
+ .rds-carousel .rds-list { gap: 0.4rem; }
279
+ .rds-carousel .rds-tab { font-size: 0; width: 0.75rem; height: 0.75rem; padding: 0; border-radius: 50%; background: var(--border); min-height: 0; }
280
+ .rds-carousel .rds-tab.is-current { background: var(--accent); }
281
+ .rds-carousel .rds-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
282
+ .wizard-progress ol { list-style: none; margin: 0 0 1rem; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem 0; counter-reset: none; }
283
+ .wizard-step { flex: 1 1 0; min-width: 6rem; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 0.3rem; position: relative; color: var(--muted); font-size: 0.85rem; }
284
+ .wizard-step::before { content: ""; position: absolute; top: 0.95rem; left: -50%; right: 50%; border-top: 2px solid var(--border); z-index: 0; }
285
+ .wizard-step:first-child::before { display: none; }
286
+ .wizard-step a { color: inherit; text-decoration: none; display: contents; }
287
+ .wizard-step-no { position: relative; z-index: 1; display: grid; place-items: center; width: 1.9rem; height: 1.9rem; border-radius: 50%; border: 2px solid var(--border); background: var(--surface); font-weight: 600; }
288
+ .wizard-step-no .icon { width: 1rem; height: 1rem; }
289
+ .wizard-step.is-done, .wizard-step.is-current { color: var(--text); }
290
+ .wizard-step.is-done .wizard-step-no { border-color: var(--accent); color: var(--accent); }
291
+ .wizard-step.is-done::before, .wizard-step.is-current::before { border-top-color: var(--accent); }
292
+ .wizard-step.is-current .wizard-step-no { background: var(--accent); border-color: var(--accent); color: var(--accent-text, #fff); }
293
+ .wizard-step.is-current .wizard-step-label { font-weight: 600; }
258
294
  .region-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; min-height: 50px; padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); }
259
295
  .region-collapsible summary.region-header { cursor: pointer; list-style: none; }
260
296
  .region-collapsible summary.region-header::-webkit-details-marker { display: none; }
@@ -810,6 +846,14 @@ fieldset.prop-group > legend { font-size: 0.75rem; text-transform: uppercase; le
810
846
  .grid-table tr.deleted td { opacity: 0.5; text-decoration: line-through; }
811
847
  .grid-table tr.grid-new { background: color-mix(in srgb, var(--accent-soft) 45%, transparent); }
812
848
  .grid-table tr.dirty td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
849
+ /* the single row view: one row as a form */
850
+ .grid-record { padding: 0.9rem 1rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); display: grid; gap: 0.9rem; }
851
+ .grid-record .grid-field { display: grid; gap: 0.3rem; align-content: start; }
852
+ .grid-record .grid-value { min-height: 1.5rem; overflow-wrap: anywhere; }
853
+ .grid-record.grid-new { background: color-mix(in srgb, var(--accent-soft) 45%, transparent); }
854
+ .grid-record.dirty { box-shadow: inset 3px 0 0 var(--accent); }
855
+ .grid-record.deleted .form-grid { opacity: 0.5; text-decoration: line-through; }
856
+ .grid-record-nav { justify-content: center; }
813
857
  .js .grid-template { display: none; }
814
858
  .grid-table .icon { width: 14px; height: 14px; }
815
859
  /* columns a user hid stay in the form (their values still post) */
@@ -1006,9 +1050,19 @@ input[type=color] { width: 4rem; min-height: 2.4rem; padding: 0.15rem; }
1006
1050
  /* single sign-on buttons on the login page */
1007
1051
  .sso-buttons { display: flex; flex-direction: column; gap: 0.5rem; }
1008
1052
  .sso-buttons .btn { padding: 0.6rem; }
1009
- .login-card .or { display: flex; align-items: center; gap: 0.75rem; color: var(--muted); font-size: 0.85rem; }
1010
- .login-card .or::before, .login-card .or::after { content: ""; flex: 1; border-top: 1px solid var(--border); }
1053
+ .login-card .or, .login-region .or { display: flex; align-items: center; gap: 0.75rem; color: var(--muted); font-size: 0.85rem; }
1054
+ .login-card .or::before, .login-card .or::after, .login-region .or::before, .login-region .or::after { content: ""; flex: 1; border-top: 1px solid var(--border); }
1011
1055
  .login-form { display: flex; flex-direction: column; gap: 1rem; }
1056
+ .login-forgot { align-self: center; font-size: 0.9rem; }
1057
+ /* (087) minimal pages (no header and navigation) and an application's own login page */
1058
+ .t-minimal-main { width: 100%; max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem; }
1059
+ .login-body .t-minimal-main { max-width: 420px; padding: 1rem; }
1060
+ .login-region { display: flex; flex-direction: column; gap: 1rem; }
1061
+ .login-region .btn { padding: 0.6rem; }
1062
+ .t-minimal .t-titlebar { background: none; border: 0; padding: 0 0 1rem; }
1063
+ .t-minimal .t-content { padding: 0; max-width: none; }
1064
+ /* on the sign-in backdrop the title sits on the header colour, like the built-in page's card title */
1065
+ .login-body .t-titlebar h1 { color: var(--header-text); text-align: center; }
1012
1066
 
1013
1067
  /* ---------- preferences: theme switch, language picker ---------- */
1014
1068
  .segmented { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; margin-top: 0.35rem; }
@@ -1612,3 +1666,6 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
1612
1666
  .session-dialog { max-width: min(28rem, calc(100vw - 2rem)); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); padding: 1.25rem; }
1613
1667
  .session-dialog::backdrop { background: rgb(0 0 0 / 40%); }
1614
1668
  .session-dialog p { margin: 0 0 1rem; }
1669
+ /* (sprint 59) Quick edit from the developer toolbar */
1670
+ .quick-edit .qe-target { outline: 2px dashed var(--accent); outline-offset: 2px; cursor: pointer; }
1671
+ .devbar-quick[aria-pressed="true"] { background: var(--accent); color: var(--accent-text); }
package/public/app.js CHANGED
@@ -539,7 +539,11 @@ const pgkilnUnsaved = (() => {
539
539
  case 'close_region':
540
540
  for (const el of targets(da)) {
541
541
  const details = el.querySelector(':scope > details');
542
- if (details) details.open = da.action === 'open_region';
542
+ // (088) an inline dialog or drawer region
543
+ if (el.matches('dialog[data-inline-region]')) {
544
+ if (da.action === 'open_region' && !el.open) el.showModal();
545
+ else if (da.action === 'close_region' && el.open) el.close();
546
+ } else if (details) details.open = da.action === 'open_region';
543
547
  else el.hidden = da.action === 'close_region';
544
548
  }
545
549
  return;
@@ -1157,14 +1161,15 @@ const pgkilnUnsaved = (() => {
1157
1161
  if (leave && dirty && !window.confirm(pgkilnUnsaved.text)) e.preventDefault();
1158
1162
  });
1159
1163
  document.addEventListener('input', (e) => {
1160
- const row = e.target.closest?.('[data-grid] table.grid-table > tbody > tr');
1164
+ // a grid row, or the single row view's form
1165
+ const row = e.target.closest?.('[data-grid] table.grid-table > tbody > tr, [data-grid] .grid-record');
1161
1166
  if (!row) return;
1162
1167
  row.classList.add('dirty');
1163
1168
  dirty = true;
1164
1169
  });
1165
1170
  document.addEventListener('change', (e) => {
1166
1171
  const del = e.target.closest?.('[data-grid] .grid-sel input');
1167
- if (del) del.closest('tr').classList.toggle('deleted', del.checked);
1172
+ if (del) del.closest('tr, .grid-record').classList.toggle('deleted', del.checked);
1168
1173
  });
1169
1174
  document.addEventListener('submit', () => (dirty = false));
1170
1175
  window.addEventListener('beforeunload', (e) => {
@@ -1544,6 +1549,30 @@ document.addEventListener('input', (e) => {
1544
1549
  if (custom) custom.checked = true;
1545
1550
  });
1546
1551
 
1552
+ // (088) Inline dialog and drawer regions: a link to #R<id> opens one as a modal dialog (without
1553
+ // JavaScript CSS shows it as the :target), its close button and Esc close it, a click on the
1554
+ // backdrop too.
1555
+ document.addEventListener('click', (e) => {
1556
+ const close = e.target.closest?.('[data-region-close]');
1557
+ if (close) {
1558
+ e.preventDefault();
1559
+ close.closest('dialog')?.close();
1560
+ return;
1561
+ }
1562
+ const a = e.target.closest?.('a[href^="#R"]');
1563
+ const d = a && document.getElementById(a.getAttribute('href').slice(1));
1564
+ if (d?.matches('dialog[data-inline-region]')) {
1565
+ e.preventDefault();
1566
+ if (!d.open) d.showModal();
1567
+ return;
1568
+ }
1569
+ if (e.target.matches?.('dialog[data-inline-region]')) e.target.close();
1570
+ });
1571
+ if (location.hash) {
1572
+ const d = document.getElementById(location.hash.slice(1));
1573
+ if (d?.matches('dialog[data-inline-region]')) d.showModal();
1574
+ }
1575
+
1547
1576
  // Region display selector: the links to the regions become ARIA tabs (or a
1548
1577
  // select list) that show one tab's regions at a time; "Show all" shows them
1549
1578
  // all. The choice follows #R<id> in the URL and is remembered for the session.
@@ -1617,6 +1646,23 @@ document.querySelectorAll('[data-rds]').forEach((nav) => {
1617
1646
  const tab = byHash(location.hash.slice(1));
1618
1647
  if (tab) show(tab, false);
1619
1648
  });
1649
+ // (088) carousel: Previous and Next, and turning by itself every data-rds-interval seconds
1650
+ if (nav.classList.contains('rds-carousel')) {
1651
+ const step = (n) => {
1652
+ const i = tabs.findIndex((t) => t.classList.contains('is-current'));
1653
+ show(tabs[(i + n + tabs.length) % tabs.length], false);
1654
+ };
1655
+ nav.querySelectorAll('[data-rds-step]').forEach((b) => {
1656
+ b.hidden = false;
1657
+ b.addEventListener('click', () => step(Number(b.dataset.rdsStep)));
1658
+ });
1659
+ const seconds = Number(nav.dataset.rdsInterval) || 0;
1660
+ if (seconds && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
1661
+ const area = [nav, ...panels];
1662
+ const busy = () => area.some((el) => el.matches(':hover') || el.contains(document.activeElement));
1663
+ setInterval(() => { if (!busy() && !document.hidden) step(1); }, seconds * 1000);
1664
+ }
1665
+ }
1620
1666
  });
1621
1667
 
1622
1668
  // Popup list of values: the select (which works without JavaScript) is hidden
@@ -3433,3 +3479,43 @@ function areaFilter(map, f) {
3433
3479
  }
3434
3480
  check();
3435
3481
  })();
3482
+
3483
+ // (sprint 59) Quick edit from the developer toolbar (APEX: Quick Edit): while it is on, pointing at a
3484
+ // region, item or button outlines it and a click opens it in the Page Designer (in the builder's tab);
3485
+ // Esc or the button again turns it off. The toolbar is only there for developers of the application.
3486
+ (() => {
3487
+ const toggle = document.querySelector('[data-quick-edit]');
3488
+ if (!toggle) return;
3489
+ toggle.hidden = false;
3490
+ const base = toggle.dataset.quickEdit;
3491
+ const pick = (el) => el?.closest?.('[data-item], [data-button], .region[id^="R"]');
3492
+ let on = false;
3493
+ let marked = null;
3494
+ const mark = (el) => {
3495
+ marked?.classList.remove('qe-target');
3496
+ marked = el;
3497
+ el?.classList.add('qe-target');
3498
+ };
3499
+ const set = (v) => {
3500
+ on = v;
3501
+ toggle.setAttribute('aria-pressed', String(v));
3502
+ document.body.classList.toggle('quick-edit', v);
3503
+ if (!v) mark(null);
3504
+ };
3505
+ const urlOf = (el) => {
3506
+ if (el.dataset.item) return `${base}/quick?item=${encodeURIComponent(el.dataset.item)}`;
3507
+ if (el.dataset.button) return `${base}/quick?button=${encodeURIComponent(el.dataset.button)}`;
3508
+ return `${base}?c=region-${el.id.slice(1)}`;
3509
+ };
3510
+ toggle.addEventListener('click', () => set(!on));
3511
+ document.addEventListener('pointerover', (e) => { if (on && !e.target.closest('.devbar')) mark(pick(e.target)); });
3512
+ document.addEventListener('click', (e) => {
3513
+ if (!on || e.target.closest('.devbar')) return;
3514
+ const el = pick(e.target);
3515
+ if (!el) return;
3516
+ e.preventDefault();
3517
+ e.stopPropagation();
3518
+ window.open(urlOf(el), 'pgkiln-builder');
3519
+ }, true);
3520
+ document.addEventListener('keydown', (e) => { if (on && e.key === 'Escape') set(false); });
3521
+ })();
@@ -676,6 +676,11 @@ details.support-script pre.source { margin-top: 0.4rem; white-space: pre-wrap; o
676
676
 
677
677
  /* (sprint 34) working copies: differences and conflicts */
678
678
  pre.diff { max-height: 32rem; overflow: auto; background: var(--code-bg); padding: 0.6rem; font-size: 0.82rem; white-space: pre; }
679
+ pre.diff .diff-add { color: var(--success, #1a7f37); }
680
+ pre.diff .diff-del { color: var(--danger, #cf222e); }
681
+ /* (sprint 56) SQL Workshop → Schema Comparison */
682
+ .compare-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.35rem; }
683
+ .compare-list summary { cursor: pointer; overflow-wrap: anywhere; }
679
684
  tr.row-conflict > td:first-child { border-left: 3px solid var(--warn); }
680
685
  .badge.badge-warn { background: var(--warning-soft); color: var(--text); }
681
686
  .publish-buttons .btn { white-space: normal; overflow-wrap: anywhere; max-width: 100%; text-align: left; }
@@ -770,3 +775,9 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
770
775
  .provider-switch .field { margin: 0; min-width: min(100%, 280px); }
771
776
  .mail-check-buttons { margin: 4px 0 14px; }
772
777
  .mail-check-result:not(:empty) { margin-top: 8px; }
778
+ /* (sprint 59) Page Designer: the help pane under the properties */
779
+ .pe-help { border-top: 1px solid var(--border); margin-top: 0.75rem; padding: 0.5rem 0.75rem; }
780
+ .pe-help summary { cursor: pointer; font-weight: 600; display: flex; align-items: center; gap: 0.35rem; }
781
+ .pe-help-body { padding-top: 0.4rem; overflow-wrap: anywhere; }
782
+ .pe-help-body h3 { font-size: 0.95rem; margin: 0 0 0.3rem; }
783
+ .pe-help-body p { margin: 0 0 0.4rem; }
package/public/builder.js CHANGED
@@ -809,3 +809,53 @@ document.addEventListener('click', async (e) => {
809
809
  buttons.forEach((b) => (b.disabled = false));
810
810
  }
811
811
  });
812
+
813
+ // (sprint 59) The Page Designer's help pane (APEX: the Help tab of the property editor): the property
814
+ // that has the focus, its help, the column it is stored in and the guide section of the component type.
815
+ (() => {
816
+ const pane = document.querySelector('[data-pe-help]');
817
+ if (!pane) return;
818
+ const guide = pane.closest('.pe-help')?.dataset.guide;
819
+ let current = null;
820
+ const show = (field) => {
821
+ if (!field || field === current) return;
822
+ current = field;
823
+ const label = (field.querySelector('label.label, label.check, .label')?.textContent || field.dataset.prop).trim();
824
+ const help = field.querySelector('.help');
825
+ pane.replaceChildren();
826
+ const h = document.createElement('h3');
827
+ h.textContent = label;
828
+ pane.append(h);
829
+ if (help) {
830
+ const p = document.createElement('p');
831
+ // the help is the server's own (escaped) markup: copy its nodes, links included
832
+ p.append(...[...help.childNodes].map((n) => n.cloneNode(true)));
833
+ pane.append(p);
834
+ } else {
835
+ const p = document.createElement('p');
836
+ p.className = 'muted';
837
+ p.textContent = 'This property has no help of its own: see the guide.';
838
+ pane.append(p);
839
+ }
840
+ const meta = document.createElement('p');
841
+ meta.className = 'small muted';
842
+ const code = document.createElement('code');
843
+ code.textContent = field.dataset.prop;
844
+ meta.append('Stored as ', code, ' (in exports and application files).');
845
+ pane.append(meta);
846
+ if (guide) {
847
+ const a = document.createElement('a');
848
+ a.href = guide;
849
+ a.target = '_blank';
850
+ a.rel = 'noopener';
851
+ a.textContent = 'This component in the guide ▸';
852
+ pane.append(a);
853
+ }
854
+ };
855
+ document.addEventListener('focusin', (e) => show(e.target.closest?.('.pe [data-prop]')));
856
+ document.addEventListener('pointerover', (e) => {
857
+ // pointing at a property shows its help too, unless another one has the focus
858
+ if (document.activeElement?.closest?.('.pe [data-prop]')) return;
859
+ show(e.target.closest?.('.pe [data-prop]'));
860
+ });
861
+ })();
@@ -4,7 +4,7 @@
4
4
  :root {
5
5
  --ce-bg: #fbfcfd;
6
6
  --ce-gutter: #f1f3f6;
7
- --ce-gutter-text: #8a94a3;
7
+ --ce-gutter-text: #5d6876;
8
8
  --ce-text: #1b2330;
9
9
  --ce-kw: #8b2bb9;
10
10
  --ce-type: #0b6e8a;
@@ -26,7 +26,7 @@
26
26
  :root:not([data-theme="light"]) {
27
27
  --ce-bg: #12161d;
28
28
  --ce-gutter: #171c24;
29
- --ce-gutter-text: #6c7686;
29
+ --ce-gutter-text: #8f99a8;
30
30
  --ce-text: #e3e7ed;
31
31
  --ce-kw: #d39bff;
32
32
  --ce-type: #5fd0e6;
@@ -46,7 +46,7 @@
46
46
  :root[data-theme="dark"] {
47
47
  --ce-bg: #12161d;
48
48
  --ce-gutter: #171c24;
49
- --ce-gutter-text: #6c7686;
49
+ --ce-gutter-text: #8f99a8;
50
50
  --ce-text: #e3e7ed;
51
51
  --ce-kw: #d39bff;
52
52
  --ce-type: #5fd0e6;
package/public/sw.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // pgkiln service worker for one application (served as /a/<alias>/sw.js with PGKILN = {base, offlinePages, offlineSubmit, version}).
2
- // static files (app.css, app.js, icons) and the offline page: cached at install, served from the cache
2
+ // static files (app.css, app.js, icons) and the offline page: cached at install, served from the cache by their
3
+ // exact URL; pages ask for them with ?v=<version> (static-version.ts), so a changed file is fetched anew
3
4
  // pages (navigations): network first; offline, the cached copy (only when the app keeps pages) or the offline page
4
5
  // signing in or out empties the page cache, so the next person on the device doesn't see them
5
6
  // push notifications (migration 074): shown as they arrive, a click opens their page of the app;
@@ -7,7 +8,7 @@
7
8
  /* global PGKILN */
8
9
  const STATIC = `pgkiln-static-${PGKILN.version}`;
9
10
  const PAGES = `pgkiln-pages-${PGKILN.base}`;
10
- const SHELL = ['/static/app.css', '/static/app.js', '/static/icons.svg', `${PGKILN.base}/offline`];
11
+ const SHELL = [...['app.css', 'app.js', 'icons.svg'].map((f) => `/static/${f}?v=${PGKILN.version}`), `${PGKILN.base}/offline`];
11
12
 
12
13
  self.addEventListener('install', (event) => {
13
14
  event.waitUntil(caches.open(STATIC).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
@@ -39,7 +40,8 @@ self.addEventListener('fetch', (event) => {
39
40
  if (req.method !== 'GET') return;
40
41
 
41
42
  if (url.pathname.startsWith('/static/')) {
42
- event.respondWith(caches.match(req, { ignoreSearch: true }).then((hit) => hit || fetch(req)));
43
+ // the exact URL: another ?v= (a changed file) is not the cached copy
44
+ event.respondWith(caches.match(req, { cacheName: STATIC }).then((hit) => hit || fetch(req)));
43
45
  return;
44
46
  }
45
47
 
package/src/app.ts CHANGED
@@ -7,6 +7,7 @@ import fastifyStatic from '@fastify/static';
7
7
  import Fastify from 'fastify';
8
8
  import { apiRoutes } from './builder/api.ts';
9
9
  import { globalizationRoutes } from './builder/globalization.ts';
10
+ import { loginPageRoutes } from './builder/login-page.ts';
10
11
  import { designerRoutes } from './builder/designer.ts';
11
12
  import { lockRoutes } from './builder/locks.ts';
12
13
  import { supportingRoutes } from './builder/supporting.ts';
@@ -186,6 +187,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
186
187
  await app.register(oauthRoutes);
187
188
  await app.register(apiRoutes);
188
189
  await app.register(globalizationRoutes);
190
+ await app.register(loginPageRoutes);
189
191
  await app.register(codeEditorRoutes);
190
192
  await app.register(dataReporterRoutes);
191
193
  await app.register(reporterBuilderRoutes);
package/src/appfiles.ts CHANGED
@@ -190,6 +190,7 @@ const NAMED: [section: string, dir: string, table: string][] = [
190
190
  ['email_templates', 'shared/email-templates', 'email_template'],
191
191
  // (086) application settings
192
192
  ['app_settings', 'shared/app-settings', 'app_setting'],
193
+ ['page_groups', 'shared/page-groups', 'page_group'],
193
194
  ];
194
195
 
195
196
  /** Components of a page: [array in the document, directory, table, key source]. */
@@ -173,6 +173,20 @@ export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findi
173
173
  ).rows.map((row) => ({ kind, row }));
174
174
  const all = (await Promise.all(Object.entries(COMPONENTS).map(([kind, spec]) => rowsOf(kind, spec.table, spec.scope)))).flat();
175
175
 
176
+ // metadata checks, as the owner (before the role switch below: the application's role can't read meta)
177
+ // (087) the application's login page: a page that needs no sign-in, with a login region
178
+ if (app.login_page) {
179
+ const page = await one('select id, requires_auth from meta.page where app_id = $1 and page_no = $2', [appId, app.login_page]);
180
+ const field = 'Application settings → Login page';
181
+ if (!page) findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} doesn't exist: the built-in sign-in page is shown instead.` });
182
+ else if (page.requires_auth) findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} requires authentication: the built-in sign-in page is shown instead.` });
183
+ else if (!all.some((x) => x.kind === 'region' && x.row.type === 'login' && Number(x.row.page_no) === Number(app.login_page)))
184
+ findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} has no Login region: nobody can sign in.` });
185
+ }
186
+ // (089) a page's group exists
187
+ for (const p of (await query(`select p.page_no, p.page_group from meta.page p where p.app_id = $1 and p.page_group is not null
188
+ and not exists (select 1 from meta.page_group g where g.app_id = p.app_id and g.name = p.page_group) order by 1`, [appId])).rows)
189
+ findings.push({ severity: 'warning', entry: null, field: `Page ${p.page_no} → Page group`, message: `Page group ${p.page_group} doesn't exist (Shared Components → Page groups).` });
176
190
  // ---- SQL, as the application's role, rolled back (a savepoint in the caller's transaction)
177
191
  const c = (tx ?? (await owner.pool.connect())) as pg.PoolClient;
178
192
  try {
@@ -104,7 +104,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
104
104
  { name: 'title', label: 'Title', kind: 'text', group: 'Identification' },
105
105
  { name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
106
106
  help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
107
- { name: 'type', label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
107
+ { name: 'type', label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
108
108
  { name: 'source', label: 'Source', kind: 'code', wide: true, group: 'Source',
109
109
  help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
110
110
  { name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
@@ -114,7 +114,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
114
114
  { name: 'pk_item', label: 'Primary key item (form)', kind: 'upper', group: 'Source' },
115
115
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
116
116
  { name: 'columns', label: 'Column span (1-12)', kind: 'int', group: 'Layout' },
117
- { name: 'template', label: 'Template', kind: 'select', options: ['standard', 'plain', 'collapsible'], group: 'Layout' },
117
+ { name: 'template', label: 'Template', kind: 'select', options: ['standard', 'plain', 'collapsible', 'hero', 'title_bar', 'buttons_container', 'inline_dialog', 'inline_drawer', 'wizard'], group: 'Layout', help: 'hero: a large banner (title, text, {"icon":"…"} in the attributes, buttons). title_bar: title and buttons on one line. buttons_container: the buttons only. inline_dialog, inline_drawer: hidden until a dynamic action Open region (or a link to #R<id>) opens it over the page. wizard: a narrow card for one step (a list region with template wizard_progress shows the steps).' },
118
118
  { name: 'template_options', label: 'Template options', kind: 'options', choices: REGION_OPTIONS, group: 'Appearance',
119
119
  help: 'CSS classes from a fixed list, added to the region (APEX: Template Options). "Hide the header" applies to the standard template.' },
120
120
  { name: 'condition', label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'Boolean expression; the region renders only when true.' },
@@ -448,6 +448,19 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
448
448
  buildOption(),
449
449
  ],
450
450
  },
451
+ page_group: {
452
+ table: 'meta.page_group',
453
+ scope: 'app',
454
+ label: 'Page group',
455
+ plural: 'Page groups',
456
+ icon: 'layers',
457
+ summary: (o) => o.name,
458
+ defaults: {},
459
+ fields: [
460
+ { name: 'name', label: 'Name', kind: 'text', help: 'e.g. Administration. Pages join it in their Page group property; renaming the group renames it on its pages.' },
461
+ { name: 'description', label: 'Description', kind: 'text', wide: true },
462
+ ],
463
+ },
451
464
  build_option: {
452
465
  table: 'meta.build_option',
453
466
  scope: 'app',
@@ -0,0 +1,114 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { unifiedDiff } from '../cli/diff.ts';
3
+ import { owner } from '../db.ts';
4
+ import { changeScript, compare, ddlScript, OBJECT_TYPES, pick, readSchema, userSchemas, type Difference } from '../ddl.ts';
5
+ import { html, raw, type Raw } from '../html.ts';
6
+ import { icon } from '../icons.ts';
7
+ import { clientIp, logActivity } from '../session.ts';
8
+ import { BASE, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
9
+
10
+ // SQL Workshop → Generate DDL and Schema Comparison (APEX: SQL Workshop →
11
+ // Utilities). Both only read the catalog (src/ddl.ts); the scripts they
12
+ // write are shown and downloaded, never run.
13
+
14
+ const TYPE_LABELS: Record<(typeof OBJECT_TYPES)[number], string> = {
15
+ types: 'Types and domains', sequences: 'Sequences', tables: 'Tables (constraints, indexes)', views: 'Views and materialized views',
16
+ functions: 'Functions and procedures', triggers: 'Triggers', policies: 'Row level security and policies',
17
+ };
18
+ const STATE_LABELS: Record<Difference['state'], string> = { source: 'Only in the source', target: 'Only in the target', different: 'Different' };
19
+
20
+ const many = (v: unknown) => ([] as string[]).concat((v as string | string[] | undefined) ?? []);
21
+ const schemaSelect = (name: string, label: string, schemas: string[], current: string) =>
22
+ html`<div class="field"><label class="label" for="f_${name}">${label}</label>
23
+ <select id="f_${name}" name="${name}" required><option value="">- choose a schema -</option>${schemas.map((x) => html`<option value="${x}"${x === current ? raw(' selected') : ''}>${x}</option>`)}</select></div>`;
24
+
25
+ /** A unified diff with added and removed lines marked. */
26
+ const diffBlock = (d: Difference, source: string, target: string) => {
27
+ const text = unifiedDiff(d.name, Buffer.from(`${d.target ?? ''}\n`), Buffer.from(`${d.source ?? ''}\n`), [target, source]);
28
+ return html`<pre class="diff">${text.trimEnd().split('\n').map((l, i) => html`${i ? '\n' : ''}<span class="${l.startsWith('+') && !l.startsWith('+++') ? 'diff-add' : l.startsWith('-') && !l.startsWith('---') ? 'diff-del' : null}">${l}</span>`)}</pre>`;
29
+ };
30
+
31
+ export async function ddlToolRoutes(app: FastifyInstance) {
32
+ app.get(`${BASE}/sql/ddl`, async (req: Req, reply) => {
33
+ const s = await developer(req, reply);
34
+ if (!s) return;
35
+ const schemas = await userSchemas(owner);
36
+ const q = req.query ?? {};
37
+ const schema = schemas.includes(q.schema ?? '') ? q.schema! : '';
38
+ // the first visit: every type, comments and grants
39
+ const fresh = !q.schema;
40
+ const types = new Set(fresh ? OBJECT_TYPES : many(q.t).filter((x) => (OBJECT_TYPES as readonly string[]).includes(x)));
41
+ const opts = { comments: fresh || q.comments === '1', grants: fresh || q.grants === '1' };
42
+ let result: Raw | '' = '';
43
+ if (schema) {
44
+ try {
45
+ const objects = pick(await readSchema(owner.pool, schema), types, opts);
46
+ const script = ddlScript(schema, objects);
47
+ await logActivity({ username: s.username, event: 'generate_ddl', ip: clientIp(req), detail: `${schema}: ${objects.length} objects` });
48
+ if (q.download === '1')
49
+ return reply.header('content-disposition', `attachment; filename="${schema}.sql"`).type('application/sql; charset=utf-8').send(script);
50
+ const qs = new URLSearchParams([['schema', schema], ...[...types].map((x) => ['t', x]), ...(opts.comments ? [['comments', '1']] : []), ...(opts.grants ? [['grants', '1']] : []), ['download', '1']]);
51
+ result = region(`DDL of ${schema}`, html`<p class="muted">${objects.length} object(s). Review the script before you run it (SQL Workshop → SQL Scripts).</p>
52
+ <textarea class="code" rows="24" spellcheck="false" readonly aria-label="DDL of ${schema}" data-code="plpgsql">${script}</textarea>`,
53
+ html`<a class="btn" href="${BASE}/sql/ddl?${qs.toString()}">${icon('download')} Download ${schema}.sql</a>`);
54
+ } catch (e) {
55
+ result = html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>`;
56
+ }
57
+ }
58
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ddl')}
59
+ <p class="muted">The DDL of a schema's objects, from the database catalog, in an order that runs: types, sequences, tables, functions, views, constraints, indexes, triggers, row level security, comments and grants (APEX: Generate DDL). Objects of extensions are left out.</p>
60
+ <form method="get" class="u-mb1">
61
+ <div class="form-grid">${schemaSelect('schema', 'Schema', schemas, schema)}</div>
62
+ <fieldset class="prop-group"><legend>Objects</legend><div class="form-grid">
63
+ ${OBJECT_TYPES.map((x) => html`<label class="check"><input type="checkbox" name="t" value="${x}"${types.has(x) ? raw(' checked') : ''}> ${TYPE_LABELS[x]}</label>`)}
64
+ <label class="check"><input type="checkbox" name="comments" value="1"${opts.comments ? raw(' checked') : ''}> Comments</label>
65
+ <label class="check"><input type="checkbox" name="grants" value="1"${opts.grants ? raw(' checked') : ''}> Grants</label>
66
+ </div></fieldset>
67
+ <div class="buttons"><button class="btn btn-hot">${icon('code')} Generate</button></div>
68
+ </form>
69
+ ${result}`;
70
+ return send(reply, s, shell(s, 'Generate DDL', [['SQL Workshop', `${BASE}/sql`], ['Generate DDL']], main, 'sql'));
71
+ });
72
+
73
+ app.get(`${BASE}/sql/compare`, async (req: Req, reply) => {
74
+ const s = await developer(req, reply);
75
+ if (!s) return;
76
+ const schemas = await userSchemas(owner);
77
+ const q = req.query ?? {};
78
+ const source = schemas.includes(q.source ?? '') ? q.source! : '';
79
+ const target = schemas.includes(q.target ?? '') ? q.target! : '';
80
+ let result: Raw | '' = '';
81
+ if (source && target && source === target) result = html`<div class="alert alert-error" role="alert">Choose two different schemas.</div>`;
82
+ else if (source && target) {
83
+ try {
84
+ const [a, b] = await Promise.all([readSchema(owner.pool, source), readSchema(owner.pool, target)]);
85
+ const { differences, same } = compare(a, b);
86
+ const script = changeScript(source, target, a, b);
87
+ await logActivity({ username: s.username, event: 'schema_compare', ip: clientIp(req), detail: `${source} → ${target}: ${differences.length} differences` });
88
+ if (q.download === '1')
89
+ return reply.header('content-disposition', `attachment; filename="${target}-like-${source}.sql"`).type('application/sql; charset=utf-8').send(script);
90
+ const groups = (['source', 'target', 'different'] as const).map((st) => [st, differences.filter((d) => d.state === st)] as const).filter(([, list]) => list.length);
91
+ result = html`${region('Summary', html`<p>${differences.length
92
+ ? `${differences.length} difference(s): ${groups.map(([st, list]) => `${list.length} ${STATE_LABELS[st].toLowerCase()}`).join(', ')}; ${same} object(s) the same.`
93
+ : `The schemas are the same (${same} objects).`}</p>`)}
94
+ ${groups.map(([st, list]) => html`<div class="u-spacer"></div>${region(`${STATE_LABELS[st]} (${st === 'source' ? source : st === 'target' ? target : `${source} and ${target}`})`,
95
+ html`<ul class="compare-list">${list.map((d) => html`<li><details><summary><span class="tag">${d.kind}</span> ${d.name}</summary>
96
+ ${st === 'different' ? diffBlock(d, source, target) : html`<pre class="diff">${d.source ?? d.target}</pre>`}</details></li>`)}</ul>`)}`)}
97
+ <div class="u-spacer"></div>
98
+ ${region(`Change script: make ${target} like ${source}`, html`<p class="muted">Creates what only ${source} has, changes what differs, and lists what only ${target} has as commented-out drops; columns that change identity or generation, and types, domains and sequences that differ, are left for review. Nothing is run here.</p>
99
+ <textarea class="code" rows="20" spellcheck="false" readonly aria-label="Change script" data-code="plpgsql">${script}</textarea>`,
100
+ html`<a class="btn" href="${BASE}/sql/compare?${new URLSearchParams({ source, target, download: '1' }).toString()}">${icon('download')} Download</a>`)}`;
101
+ } catch (e) {
102
+ result = html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>`;
103
+ }
104
+ }
105
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('compare')}
106
+ <p class="muted">Compares the objects of two schemas of this database by their DDL (APEX: Schema Comparison), e.g. a development copy with the schema it came from, and writes a script that makes the target like the source. The order of a table's columns is not compared.</p>
107
+ <form method="get" class="u-mb1">
108
+ <div class="form-grid">${schemaSelect('source', 'Source', schemas, source)}${schemaSelect('target', 'Target (to make like the source)', schemas, target)}</div>
109
+ <div class="buttons"><button class="btn btn-hot">${icon('layers')} Compare</button></div>
110
+ </form>
111
+ ${result}`;
112
+ return send(reply, s, shell(s, 'Schema Comparison', [['SQL Workshop', `${BASE}/sql`], ['Schema Comparison']], main, 'sql'));
113
+ });
114
+ }