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.
- package/db/migrations/087_login_page.sql +175 -0
- package/db/migrations/088_region_templates.sql +18 -0
- package/db/migrations/089_page_groups.sql +330 -0
- package/docs/apex-feature-parity.md +22 -20
- package/docs/guide/03-builder.md +36 -2
- package/docs/guide/04-pages-and-regions.md +42 -3
- package/docs/guide/08-security.md +46 -5
- package/docs/guide/11-from-apex.md +4 -1
- package/docs/guide/12-development.md +7 -1
- package/docs/guide/17-mobile.md +3 -1
- package/docs/guide/18-cli.md +2 -0
- package/docs/guide/20-ai-agents.md +2 -0
- package/examples/hr/hr_54_region_templates.sql +67 -0
- package/examples/hr/hr_55_page_groups.sql +24 -0
- package/package.json +1 -1
- package/public/app.css +60 -3
- package/public/app.js +89 -3
- package/public/builder.css +11 -0
- package/public/builder.js +50 -0
- package/public/code-editor.css +3 -3
- package/public/sw.js +5 -3
- package/src/app.ts +2 -0
- package/src/appfiles.ts +1 -0
- package/src/builder/advisor.ts +14 -0
- package/src/builder/components.ts +15 -2
- package/src/builder/ddl-tools.ts +114 -0
- package/src/builder/designer.ts +29 -3
- package/src/builder/forms.ts +6 -4
- package/src/builder/login-page.ts +82 -0
- package/src/builder/region-help.ts +4 -2
- package/src/builder/region-settings.ts +19 -3
- package/src/builder/routes.ts +16 -4
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +3 -0
- package/src/builder/ui.ts +2 -2
- package/src/cli/main.ts +45 -0
- package/src/cli/mcp.ts +42 -0
- package/src/cli/replace.ts +4 -0
- package/src/ddl.ts +509 -0
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +3 -2
- package/src/metadata.ts +10 -4
- package/src/runtime/devbar.ts +3 -1
- package/src/runtime/display-selector.ts +12 -4
- package/src/runtime/grid.ts +170 -26
- package/src/runtime/lists.ts +11 -1
- package/src/runtime/password-reset.ts +171 -0
- package/src/runtime/pwa.ts +7 -10
- package/src/runtime/regions.ts +33 -0
- package/src/runtime/render.ts +15 -0
- package/src/runtime/report-views.ts +4 -2
- package/src/runtime/report.ts +36 -22
- package/src/runtime/routes.ts +35 -32
- package/src/runtime/signin.ts +80 -0
- 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
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
|
-
|
|
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
|
-
|
|
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
|
+
})();
|
package/public/builder.css
CHANGED
|
@@ -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
|
+
})();
|
package/public/code-editor.css
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
:root {
|
|
5
5
|
--ce-bg: #fbfcfd;
|
|
6
6
|
--ce-gutter: #f1f3f6;
|
|
7
|
-
--ce-gutter-text: #
|
|
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: #
|
|
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: #
|
|
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 = ['
|
|
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
|
-
|
|
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]. */
|
package/src/builder/advisor.ts
CHANGED
|
@@ -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
|
+
}
|