pgkiln 0.40.0 → 0.42.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/095_rest_catalogs.sql +59 -0
- package/db/migrations/096_developer_email.sql +15 -0
- package/db/migrations/097_generated_app_quality.sql +911 -0
- package/db/migrations/098_app_activity.sql +37 -0
- package/docs/apex-feature-parity.md +15 -12
- package/docs/guide/03-builder.md +103 -19
- package/docs/guide/04-pages-and-regions.md +37 -4
- package/docs/guide/09-reference.md +6 -2
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/12-development.md +19 -2
- package/docs/guide/14-globalization.md +7 -3
- package/docs/guide/17-mobile.md +1 -1
- package/docs/guide/19-rest-data-sources.md +39 -1
- package/examples/hr/hr_59_gantt_editing.sql +21 -0
- package/examples/hr/hr_60_tree_charts.sql +46 -0
- package/package.json +1 -1
- package/public/app.css +219 -33
- package/public/app.js +206 -0
- package/public/builder.css +31 -3
- package/public/builder.js +31 -0
- package/public/sw.js +5 -2
- package/src/app.ts +6 -0
- package/src/builder/appfromfile.ts +5 -4
- package/src/builder/appsheets.ts +23 -9
- package/src/builder/appwizard.ts +215 -70
- package/src/builder/blueprints.ts +2 -1
- package/src/builder/components.ts +1 -1
- package/src/builder/find-workspace.ts +120 -0
- package/src/builder/home.ts +4 -32
- package/src/builder/mail.ts +39 -0
- package/src/builder/newapp.ts +182 -5
- package/src/builder/region-settings.ts +19 -1
- package/src/builder/rest-catalogs.ts +424 -0
- package/src/builder/routes.ts +36 -34
- package/src/builder/shared.ts +2 -0
- package/src/builder/workspace-transfer.ts +136 -0
- package/src/i18n/ar.ts +15 -0
- package/src/i18n/cs.ts +15 -0
- package/src/i18n/da.ts +15 -0
- package/src/i18n/de.ts +15 -0
- package/src/i18n/el.ts +15 -0
- package/src/i18n/es.ts +15 -0
- package/src/i18n/fi.ts +15 -0
- package/src/i18n/fr.ts +15 -0
- package/src/i18n/he.ts +15 -0
- package/src/i18n/it.ts +15 -0
- package/src/i18n/ja.ts +15 -0
- package/src/i18n/ko.ts +15 -0
- package/src/i18n/nb.ts +15 -0
- package/src/i18n/pl.ts +15 -0
- package/src/i18n/pt.ts +15 -0
- package/src/i18n/ru.ts +15 -0
- package/src/i18n/sv.ts +15 -0
- package/src/i18n/tr.ts +15 -0
- package/src/i18n/uk.ts +15 -0
- package/src/i18n/zh.ts +15 -0
- package/src/i18n.ts +30 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +2 -2
- package/src/restcatalog.ts +605 -0
- package/src/runtime/charts.ts +304 -15
- package/src/runtime/data-reporter.ts +48 -16
- package/src/runtime/devbar.ts +35 -14
- package/src/runtime/engine.ts +7 -4
- package/src/runtime/gantt-move.ts +63 -0
- package/src/runtime/locale.ts +2 -2
- package/src/runtime/regions.ts +28 -2
- package/src/runtime/render.ts +18 -4
- package/src/runtime/report.ts +15 -9
- package/src/runtime/routes.ts +39 -0
- package/src/runtime/styles.ts +5 -2
- package/src/websources.ts +11 -3
- package/src/workspace-export.ts +240 -0
package/public/app.js
CHANGED
|
@@ -828,6 +828,15 @@ const pgkilnUnsaved = (() => {
|
|
|
828
828
|
});
|
|
829
829
|
drawer.addEventListener('change', () => toggle.setAttribute('aria-expanded', String(navOpen())));
|
|
830
830
|
}
|
|
831
|
+
// (097) a long menu shows the current page's entry: the menu scrolls, not the page
|
|
832
|
+
{
|
|
833
|
+
const nav = document.getElementById('t-nav');
|
|
834
|
+
const current = nav?.querySelector('a[aria-current="page"]');
|
|
835
|
+
if (nav && current && nav.scrollHeight > nav.clientHeight) {
|
|
836
|
+
const top = current.getBoundingClientRect().top - nav.getBoundingClientRect().top;
|
|
837
|
+
if (top < 0 || top + current.offsetHeight > nav.clientHeight) nav.scrollTop += top - nav.clientHeight / 3;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
831
840
|
|
|
832
841
|
// ------------------------------------------------------------ modal dialogs
|
|
833
842
|
const POSITIONS = ['center', 'left', 'right', 'top', 'bottom'];
|
|
@@ -972,6 +981,132 @@ const pgkilnUnsaved = (() => {
|
|
|
972
981
|
}
|
|
973
982
|
});
|
|
974
983
|
|
|
984
|
+
// ------------------------------------------------------------ Gantt chart drag and drop (sprint 69)
|
|
985
|
+
// An editable Gantt chart (.gantt-editable, data-gantt = its region) has tasks with
|
|
986
|
+
// data-task, data-start and data-end (wall clock ms, read as UTC). Dragging a task moves
|
|
987
|
+
// it, dragging a bar's end handle changes its end, in whole days or hours (data-snap);
|
|
988
|
+
// from the keyboard the arrow keys do the same (Shift: the end), Enter saves, Escape
|
|
989
|
+
// cancels. The server checks and saves the new dates and sends the chart back.
|
|
990
|
+
const GANTT_STEP = { day: 86400000, hour: 3600000 };
|
|
991
|
+
const ganttWhen = (ms, snap) => new Date(ms).toISOString().slice(0, snap === 'hour' ? 16 : 10);
|
|
992
|
+
const ganttState = new WeakMap();
|
|
993
|
+
function ganttPreview(task, steps, mode) {
|
|
994
|
+
const chart = task.closest('.gantt-editable');
|
|
995
|
+
const plot = chart.querySelector('.gantt-plot');
|
|
996
|
+
const step = GANTT_STEP[chart.dataset.snap] || GANTT_STEP.day;
|
|
997
|
+
const span = Number(chart.dataset.hi) - Number(chart.dataset.lo);
|
|
998
|
+
const start = Number(task.dataset.start);
|
|
999
|
+
const end = Number(task.dataset.end);
|
|
1000
|
+
const prev = ganttState.get(task);
|
|
1001
|
+
// the bar's width before this edit (the preview changes it)
|
|
1002
|
+
const orig = prev ? prev.orig : task.getBoundingClientRect().width;
|
|
1003
|
+
// a bar's end never goes before its start; a milestone only moves
|
|
1004
|
+
const resize = mode === 'end' && task.classList.contains('gantt-bar');
|
|
1005
|
+
if (resize) steps = Math.max(steps, Math.round((start - end) / step));
|
|
1006
|
+
const next = resize ? { start, end: end + steps * step } : { start: start + steps * step, end: end + steps * step };
|
|
1007
|
+
const px = (ms) => (ms / span) * plot.getBoundingClientRect().width;
|
|
1008
|
+
// (translate, not transform: a milestone is a rotated square)
|
|
1009
|
+
task.style.translate = !resize && steps ? `${px(steps * step)}px 0` : '';
|
|
1010
|
+
task.style.width = resize && steps ? `${Math.max(orig + px(steps * step), 2)}px` : '';
|
|
1011
|
+
ganttState.set(task, { steps, mode: resize ? 'end' : 'move', next, orig, keyboard: prev?.keyboard });
|
|
1012
|
+
task.classList.toggle('gantt-moving', !!steps);
|
|
1013
|
+
const status = chart.closest('.region')?.querySelector('.gantt-status');
|
|
1014
|
+
if (status) status.textContent = steps ? `${task.dataset.label}: ${ganttWhen(next.start, chart.dataset.snap).replace('T', ' ')} – ${ganttWhen(next.end, chart.dataset.snap).replace('T', ' ')}` : '';
|
|
1015
|
+
return next;
|
|
1016
|
+
}
|
|
1017
|
+
function ganttReset(task) {
|
|
1018
|
+
task.style.translate = '';
|
|
1019
|
+
task.style.width = '';
|
|
1020
|
+
const status = task.closest('.region')?.querySelector('.gantt-status');
|
|
1021
|
+
if (status) status.textContent = '';
|
|
1022
|
+
task.classList.remove('gantt-moving', 'gantt-dragging');
|
|
1023
|
+
ganttState.delete(task);
|
|
1024
|
+
}
|
|
1025
|
+
async function ganttSave(task) {
|
|
1026
|
+
const st = ganttState.get(task);
|
|
1027
|
+
if (!st || !st.steps) return ganttReset(task);
|
|
1028
|
+
const chart = task.closest('.gantt-editable');
|
|
1029
|
+
const region = chart.closest('.region');
|
|
1030
|
+
const id = task.dataset.task;
|
|
1031
|
+
const snap = chart.dataset.snap;
|
|
1032
|
+
chart.setAttribute('aria-busy', 'true');
|
|
1033
|
+
try {
|
|
1034
|
+
const res = await post(`/chart/${chart.dataset.gantt}/move`, { task: id, start: ganttWhen(st.next.start, snap), end: ganttWhen(st.next.end, snap) });
|
|
1035
|
+
const sheet = document.getElementById('pgkiln-css')?.sheet;
|
|
1036
|
+
if (sheet && res.css) for (const rule of res.css.split('\n')) if (rule) sheet.insertRule(rule, sheet.cssRules.length);
|
|
1037
|
+
const node = region ? replaceHtml(region, res.region) : null;
|
|
1038
|
+
const status = node?.querySelector('.gantt-status');
|
|
1039
|
+
if (status) status.textContent = res.message;
|
|
1040
|
+
// keyboard users keep their place
|
|
1041
|
+
if (st.keyboard) node?.querySelector(`.gantt-editable [data-task="${CSS.escape(id)}"]`)?.focus();
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
chart.removeAttribute('aria-busy');
|
|
1044
|
+
ganttReset(task);
|
|
1045
|
+
showError(err.message);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
let ganttDrag = null;
|
|
1049
|
+
let ganttSwallowClick = false;
|
|
1050
|
+
document.addEventListener('pointerdown', (e) => {
|
|
1051
|
+
const task = e.button === 0 && e.target.closest?.('.gantt-editable [data-task]');
|
|
1052
|
+
if (!task || task.closest('[aria-busy="true"]')) return;
|
|
1053
|
+
ganttDrag = { task, x: e.clientX, mode: e.target.closest('.gantt-handle') ? 'end' : 'move', id: e.pointerId, moved: false };
|
|
1054
|
+
task.setPointerCapture?.(e.pointerId);
|
|
1055
|
+
});
|
|
1056
|
+
document.addEventListener('pointermove', (e) => {
|
|
1057
|
+
if (!ganttDrag || e.pointerId !== ganttDrag.id) return;
|
|
1058
|
+
const { task } = ganttDrag;
|
|
1059
|
+
const chart = task.closest('.gantt-editable');
|
|
1060
|
+
const width = chart.querySelector('.gantt-plot').getBoundingClientRect().width || 1;
|
|
1061
|
+
const step = GANTT_STEP[chart.dataset.snap] || GANTT_STEP.day;
|
|
1062
|
+
const dx = e.clientX - ganttDrag.x;
|
|
1063
|
+
if (!ganttDrag.moved && Math.abs(dx) < 4) return;
|
|
1064
|
+
ganttDrag.moved = true;
|
|
1065
|
+
task.classList.add('gantt-dragging');
|
|
1066
|
+
const steps = Math.round(((dx / width) * (Number(chart.dataset.hi) - Number(chart.dataset.lo))) / step);
|
|
1067
|
+
if (steps !== ganttState.get(task)?.steps) ganttPreview(task, steps, ganttDrag.mode);
|
|
1068
|
+
});
|
|
1069
|
+
const ganttEnd = (e, cancel) => {
|
|
1070
|
+
if (!ganttDrag || e.pointerId !== ganttDrag.id) return;
|
|
1071
|
+
const { task, moved } = ganttDrag;
|
|
1072
|
+
ganttDrag = null;
|
|
1073
|
+
task.classList.remove('gantt-dragging');
|
|
1074
|
+
if (!moved) return;
|
|
1075
|
+
// a drag is not a click on the task's link
|
|
1076
|
+
ganttSwallowClick = true;
|
|
1077
|
+
setTimeout(() => (ganttSwallowClick = false), 0);
|
|
1078
|
+
if (cancel) ganttReset(task);
|
|
1079
|
+
else ganttSave(task);
|
|
1080
|
+
};
|
|
1081
|
+
document.addEventListener('pointerup', (e) => ganttEnd(e, false));
|
|
1082
|
+
document.addEventListener('pointercancel', (e) => ganttEnd(e, true));
|
|
1083
|
+
document.addEventListener('click', (e) => {
|
|
1084
|
+
if (ganttSwallowClick && e.target.closest?.('.gantt-editable [data-task]')) {
|
|
1085
|
+
e.preventDefault();
|
|
1086
|
+
e.stopPropagation();
|
|
1087
|
+
}
|
|
1088
|
+
}, true);
|
|
1089
|
+
document.addEventListener('keydown', (e) => {
|
|
1090
|
+
const task = e.target.closest?.('.gantt-editable [data-task]');
|
|
1091
|
+
if (!task || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
1092
|
+
const st = ganttState.get(task);
|
|
1093
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
1094
|
+
e.preventDefault();
|
|
1095
|
+
const mode = e.shiftKey && task.classList.contains('gantt-bar') ? 'end' : 'move';
|
|
1096
|
+
// switching between moving and changing the end starts over
|
|
1097
|
+
if (st && st.mode !== mode) ganttReset(task);
|
|
1098
|
+
const same = ganttState.get(task);
|
|
1099
|
+
ganttPreview(task, (same ? same.steps : 0) + (e.key === 'ArrowRight' ? 1 : -1), mode);
|
|
1100
|
+
ganttState.get(task).keyboard = true;
|
|
1101
|
+
} else if (e.key === 'Enter' && st?.steps) {
|
|
1102
|
+
e.preventDefault();
|
|
1103
|
+
ganttSave(task);
|
|
1104
|
+
} else if (e.key === 'Escape' && st) {
|
|
1105
|
+
e.preventDefault();
|
|
1106
|
+
ganttReset(task);
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
|
|
975
1110
|
// ------------------------------------------------------------ lazy regions
|
|
976
1111
|
// A lazy region arrives as a placeholder with a "Show" link (which works
|
|
977
1112
|
// without JavaScript); fetch the region now and put it in place, with its
|
|
@@ -3480,6 +3615,77 @@ function areaFilter(map, f) {
|
|
|
3480
3615
|
check();
|
|
3481
3616
|
})();
|
|
3482
3617
|
|
|
3618
|
+
// (sprint 72) Install app (PWA): the header's button appears when the browser offers the installation.
|
|
3619
|
+
(() => {
|
|
3620
|
+
const button = document.querySelector('[data-pwa-install]');
|
|
3621
|
+
if (!button) return;
|
|
3622
|
+
let offer = null;
|
|
3623
|
+
window.addEventListener('beforeinstallprompt', (e) => {
|
|
3624
|
+
e.preventDefault();
|
|
3625
|
+
offer = e;
|
|
3626
|
+
button.hidden = false;
|
|
3627
|
+
});
|
|
3628
|
+
button.addEventListener('click', async () => {
|
|
3629
|
+
if (!offer) return;
|
|
3630
|
+
offer.prompt();
|
|
3631
|
+
await offer.userChoice.catch(() => null);
|
|
3632
|
+
offer = null;
|
|
3633
|
+
button.hidden = true;
|
|
3634
|
+
});
|
|
3635
|
+
window.addEventListener('appinstalled', () => { button.hidden = true; });
|
|
3636
|
+
})();
|
|
3637
|
+
|
|
3638
|
+
// (sprint 72) The developer toolbar: docked where the developer chose (bottom, top, left, right;
|
|
3639
|
+
// kept in this browser), shrunk to a slim tab after a few seconds without use (unless turned off),
|
|
3640
|
+
// back when pointed at or focused. Its panels close when another opens or on Escape.
|
|
3641
|
+
(() => {
|
|
3642
|
+
const bar = document.querySelector('.devbar');
|
|
3643
|
+
if (!bar) return;
|
|
3644
|
+
const KEY = 'pgkiln.devbar';
|
|
3645
|
+
let prefs = { dock: 'bottom', autohide: true };
|
|
3646
|
+
try { prefs = { ...prefs, ...JSON.parse(localStorage.getItem(KEY) || '{}') }; } catch {}
|
|
3647
|
+
if (!['bottom', 'top', 'left', 'right'].includes(prefs.dock)) prefs.dock = 'bottom';
|
|
3648
|
+
const save = () => { try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch {} };
|
|
3649
|
+
const settings = bar.querySelector('.devbar-settings');
|
|
3650
|
+
settings?.closest('li')?.removeAttribute('hidden');
|
|
3651
|
+
const apply = () => {
|
|
3652
|
+
bar.dataset.dock = prefs.dock;
|
|
3653
|
+
bar.querySelectorAll('input[name="devbar-dock"]').forEach((r) => { r.checked = r.value === prefs.dock; });
|
|
3654
|
+
const auto = bar.querySelector('input[name="devbar-autohide"]');
|
|
3655
|
+
if (auto) auto.checked = !!prefs.autohide;
|
|
3656
|
+
};
|
|
3657
|
+
apply();
|
|
3658
|
+
bar.addEventListener('change', (e) => {
|
|
3659
|
+
if (e.target.name === 'devbar-dock') prefs.dock = e.target.value;
|
|
3660
|
+
else if (e.target.name === 'devbar-autohide') prefs.autohide = e.target.checked;
|
|
3661
|
+
else return;
|
|
3662
|
+
save();
|
|
3663
|
+
apply();
|
|
3664
|
+
});
|
|
3665
|
+
const menus = [...bar.querySelectorAll('details.devbar-menu')];
|
|
3666
|
+
menus.forEach((m) => m.addEventListener('toggle', () => { if (m.open) menus.forEach((o) => { if (o !== m) o.open = false; }); }));
|
|
3667
|
+
document.addEventListener('keydown', (e) => {
|
|
3668
|
+
if (e.key === 'Escape' && menus.some((m) => m.open)) {
|
|
3669
|
+
const open = menus.find((m) => m.open);
|
|
3670
|
+
open.open = false;
|
|
3671
|
+
open.querySelector('summary')?.focus();
|
|
3672
|
+
}
|
|
3673
|
+
});
|
|
3674
|
+
document.addEventListener('click', (e) => { if (!bar.contains(e.target)) menus.forEach((m) => { m.open = false; }); });
|
|
3675
|
+
let timer = 0;
|
|
3676
|
+
const busy = () => bar.matches(':hover') || bar.contains(document.activeElement) || menus.some((m) => m.open) || !!bar.querySelector('[aria-pressed="true"]');
|
|
3677
|
+
const later = () => {
|
|
3678
|
+
clearTimeout(timer);
|
|
3679
|
+
if (prefs.autohide) timer = setTimeout(() => { if (!busy()) bar.classList.add('is-idle'); else later(); }, 3000);
|
|
3680
|
+
};
|
|
3681
|
+
const wake = () => { bar.classList.remove('is-idle'); later(); };
|
|
3682
|
+
bar.addEventListener('pointerenter', wake);
|
|
3683
|
+
bar.addEventListener('focusin', wake);
|
|
3684
|
+
bar.addEventListener('pointerleave', later);
|
|
3685
|
+
bar.addEventListener('change', wake);
|
|
3686
|
+
later();
|
|
3687
|
+
})();
|
|
3688
|
+
|
|
3483
3689
|
// (sprint 59) Quick edit from the developer toolbar (APEX: Quick Edit): while it is on, pointing at a
|
|
3484
3690
|
// region, item or button outlines it and a click opens it in the Page Designer (in the builder's tab);
|
|
3485
3691
|
// Esc or the button again turns it off. The toolbar is only there for developers of the application.
|
package/public/builder.css
CHANGED
|
@@ -168,7 +168,7 @@ body.ide { margin: 0; min-height: 100vh; min-height: 100dvh; padding-left: var(-
|
|
|
168
168
|
.ide-page { max-width: 1680px; margin: 0 auto; padding: 14px 18px 48px; }
|
|
169
169
|
.ide .region-standard, .ide .region-collapsible { border-radius: var(--radius); box-shadow: none; }
|
|
170
170
|
.ide .region-header { min-height: 38px; padding: 0.35rem 0.85rem; background: var(--surface-2); border-radius: var(--radius) var(--radius) 0 0; }
|
|
171
|
-
.ide .region-header h2 { font-size: 13px; font-weight: 650; }
|
|
171
|
+
.ide .region-header h2 { font-size: 13px; font-weight: 650; min-width: 0; overflow-wrap: anywhere; }
|
|
172
172
|
.ide .region-body { padding: 0.85rem; }
|
|
173
173
|
.ide .btn { font-size: 13px; padding: 0.34rem 0.8rem; border-radius: 4px; }
|
|
174
174
|
.ide input:not([type=checkbox]):not([type=radio]), .ide select, .ide textarea { padding: 0.32rem 0.5rem; border-radius: 3px; font-size: 13px; }
|
|
@@ -768,7 +768,12 @@ button.is-busy { opacity: 0.7; cursor: progress; }
|
|
|
768
768
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --font-size: 15px; --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 6%);
|
|
769
769
|
color-scheme: light; margin-bottom: 1rem; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
|
770
770
|
background: var(--bg); color: var(--text); font: var(--font-size)/1.5 var(--font); }
|
|
771
|
-
.tr-preview-iris { --bg: #
|
|
771
|
+
.tr-preview-iris { --bg: #ffffff; --surface-2: #f7f6f5; --surface-3: #efedeb; --text: #1f1c1a; --muted: #645e59; --border: #e2dfdc;
|
|
772
|
+
--accent: #2b62c4; --accent-soft: #e9f0fb; --header: #3a3632; --header-text: #f7f5f3; --radius: 4px; --shadow: none;
|
|
773
|
+
--font: "Inter", "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; }
|
|
774
|
+
.tr-preview-iris .tr-preview-header { box-shadow: inset 0 3px 0 var(--accent); }
|
|
775
|
+
.tr-preview-iris .btn-hot { background: #2f2b28; border-color: #2f2b28; color: #fff; }
|
|
776
|
+
.tr-preview-lavender { --bg: #f6f6fb; --surface-2: #f8f8fc; --surface-3: #eeeef6; --text: #1d1b2e; --muted: #5f5d75; --border: #e3e2ef;
|
|
772
777
|
--accent: #5146d8; --accent-soft: #eeedfd; --header: #1e1a4d; --header-text: #f1f0ff; --radius: 12px;
|
|
773
778
|
--font: "Inter", "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; --shadow: 0 1px 2px rgb(30 27 75 / 5%), 0 2px 8px rgb(30 27 75 / 6%); }
|
|
774
779
|
.tr-preview-header { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 1rem; background: var(--header); color: var(--header-text); font-weight: 700; }
|
|
@@ -779,7 +784,7 @@ button.is-busy { opacity: 0.7; cursor: progress; }
|
|
|
779
784
|
.tr-preview input { background: var(--surface); color: var(--text); border-color: var(--border); }
|
|
780
785
|
.tr-preview .btn { background: var(--surface); color: var(--text); border-color: var(--border); border-radius: calc(var(--radius) * 0.75); font: inherit; }
|
|
781
786
|
.tr-preview .btn-hot { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
|
782
|
-
.tr-preview-
|
|
787
|
+
.tr-preview-lavender .btn-hot { border-radius: 999px; }
|
|
783
788
|
|
|
784
789
|
/* icon picker (forms.ts iconPicker) */
|
|
785
790
|
.icon-picker > summary { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 34px; padding: 0.3rem 0.6rem; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; list-style: none; background: var(--surface); }
|
|
@@ -872,3 +877,26 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
|
|
|
872
877
|
.gal-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
|
|
873
878
|
.gal-foot form { margin: 0; }
|
|
874
879
|
.ab-group select { min-width: 140px; }
|
|
880
|
+
|
|
881
|
+
/* (sprint 73) the Create Application wizard on one page */
|
|
882
|
+
.create-sources { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 0.5rem; margin: 0.75rem 0 1rem; }
|
|
883
|
+
.create-source { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); text-decoration: none; }
|
|
884
|
+
.create-source:hover, .create-source:focus-visible { border-color: var(--accent); }
|
|
885
|
+
.create-source .icon { color: var(--accent); margin-top: 0.15rem; }
|
|
886
|
+
.create-source span { display: grid; gap: 0.1rem; min-width: 0; overflow-wrap: anywhere; }
|
|
887
|
+
.create-app .region + form, .create-app form .region { margin-top: 1rem; }
|
|
888
|
+
.aw-pages { list-style: none; margin: 0 0 0.5rem; padding: 0; display: grid; gap: 0.4rem; }
|
|
889
|
+
.aw-pages li { display: grid; grid-template-columns: minmax(10rem, 1fr) minmax(12rem, 16rem); gap: 0.15rem 0.75rem; align-items: center; padding: 0.35rem 0; border-bottom: 1px solid var(--border); }
|
|
890
|
+
.aw-pages li > small, .aw-pages li > .muted:last-child { grid-column: 1 / -1; }
|
|
891
|
+
.aw-page-kind { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 600; overflow-wrap: anywhere; min-width: 0; }
|
|
892
|
+
@media (max-width: 560px) { .aw-pages li { grid-template-columns: 1fr; } }
|
|
893
|
+
.aw-features { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0 1rem; }
|
|
894
|
+
.aw-advanced > summary { cursor: pointer; list-style: none; }
|
|
895
|
+
.aw-advanced > summary::-webkit-details-marker { display: none; }
|
|
896
|
+
.aw-advanced .region-body { padding: 1rem; }
|
|
897
|
+
/* the overlay while a long form (Create application) is being processed */
|
|
898
|
+
.progress-overlay { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; background: rgb(0 0 0 / 35%); }
|
|
899
|
+
.progress-box { display: flex; align-items: center; gap: 0.75rem; padding: 1rem 1.25rem; border-radius: var(--radius); background: var(--surface); color: var(--text); box-shadow: 0 8px 24px rgb(0 0 0 / 25%); }
|
|
900
|
+
.progress-spinner { width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); animation: progress-spin 0.8s linear infinite; }
|
|
901
|
+
@keyframes progress-spin { to { transform: rotate(360deg); } }
|
|
902
|
+
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
package/public/builder.js
CHANGED
|
@@ -859,3 +859,34 @@ document.addEventListener('click', async (e) => {
|
|
|
859
859
|
show(e.target.closest?.('.pe [data-prop]'));
|
|
860
860
|
});
|
|
861
861
|
})();
|
|
862
|
+
|
|
863
|
+
// (sprint 73) a proposed value is selected on the first focus, so typing replaces it (data-select-once)
|
|
864
|
+
document.querySelectorAll('input[data-select-once]').forEach((el) => {
|
|
865
|
+
el.addEventListener('focus', () => {
|
|
866
|
+
el.select();
|
|
867
|
+
// the click that focused it would put the caret back: keep the selection
|
|
868
|
+
el.addEventListener('mouseup', (e) => e.preventDefault(), { once: true });
|
|
869
|
+
}, { once: true });
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
// (sprint 73) a form that takes a while (data-progress="Creating …"): an overlay with the message while the server works
|
|
873
|
+
document.addEventListener('submit', (e) => {
|
|
874
|
+
const form = e.target.closest?.('form[data-progress]');
|
|
875
|
+
if (!form || e.defaultPrevented || !form.checkValidity()) return;
|
|
876
|
+
const overlay = document.createElement('div');
|
|
877
|
+
overlay.className = 'progress-overlay';
|
|
878
|
+
overlay.setAttribute('role', 'status');
|
|
879
|
+
overlay.setAttribute('aria-live', 'polite');
|
|
880
|
+
const box = document.createElement('div');
|
|
881
|
+
box.className = 'progress-box';
|
|
882
|
+
const spin = document.createElement('span');
|
|
883
|
+
spin.className = 'progress-spinner';
|
|
884
|
+
spin.setAttribute('aria-hidden', 'true');
|
|
885
|
+
const text = document.createElement('span');
|
|
886
|
+
text.textContent = form.dataset.progress;
|
|
887
|
+
box.append(spin, text);
|
|
888
|
+
overlay.append(box);
|
|
889
|
+
document.body.append(overlay);
|
|
890
|
+
// back from the browser's cache (the back button): no overlay
|
|
891
|
+
window.addEventListener('pageshow', (ev) => { if (ev.persisted) overlay.remove(); }, { once: true });
|
|
892
|
+
});
|
package/public/sw.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// pgkiln service worker for one application (served as /a/<alias>/sw.js with PGKILN = {base, offlinePages, offlineSubmit, version}).
|
|
2
2
|
// static files (app.css, app.js, icons) and the offline page: cached at install, served from the cache by their
|
|
3
3
|
// exact URL; pages ask for them with ?v=<version> (static-version.ts), so a changed file is fetched anew
|
|
4
|
+
// (offline, the cached copy of another version)
|
|
4
5
|
// pages (navigations): network first; offline, the cached copy (only when the app keeps pages) or the offline page
|
|
5
6
|
// signing in or out empties the page cache, so the next person on the device doesn't see them
|
|
6
7
|
// push notifications (migration 074): shown as they arrive, a click opens their page of the app;
|
|
@@ -40,8 +41,10 @@ self.addEventListener('fetch', (event) => {
|
|
|
40
41
|
if (req.method !== 'GET') return;
|
|
41
42
|
|
|
42
43
|
if (url.pathname.startsWith('/static/')) {
|
|
43
|
-
// the exact URL: another ?v= (a changed file) is not the cached copy
|
|
44
|
-
|
|
44
|
+
// the exact URL: another ?v= (a changed file) is not the cached copy. Offline, the copy under another ?v= is
|
|
45
|
+
// better than none: a page kept on the device under an older version still gets its script and styles
|
|
46
|
+
const anyVersion = async () => (await caches.match(req, { cacheName: STATIC, ignoreSearch: true })) || Response.error();
|
|
47
|
+
event.respondWith(caches.match(req, { cacheName: STATIC }).then((hit) => hit || fetch(req).catch(anyVersion)));
|
|
45
48
|
return;
|
|
46
49
|
}
|
|
47
50
|
|
package/src/app.ts
CHANGED
|
@@ -70,6 +70,9 @@ import { galleryRoutes } from './builder/gallery.ts';
|
|
|
70
70
|
import { appGroupRoutes } from './builder/app-groups.ts';
|
|
71
71
|
import { crossAppRoutes } from './builder/cross-app.ts';
|
|
72
72
|
import { workspaceBackupRoutes } from './builder/workspace-backups.ts';
|
|
73
|
+
import { restCatalogRoutes } from './builder/rest-catalogs.ts';
|
|
74
|
+
import { workspaceTransferRoutes } from './builder/workspace-transfer.ts';
|
|
75
|
+
import { findWorkspaceRoutes } from './builder/find-workspace.ts';
|
|
73
76
|
import { runtimeScope } from './db.ts';
|
|
74
77
|
|
|
75
78
|
export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
@@ -179,6 +182,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
179
182
|
await app.register(appGroupRoutes);
|
|
180
183
|
await app.register(crossAppRoutes);
|
|
181
184
|
await app.register(workspaceBackupRoutes);
|
|
185
|
+
await app.register(restCatalogRoutes);
|
|
186
|
+
await app.register(workspaceTransferRoutes);
|
|
187
|
+
await app.register(findWorkspaceRoutes);
|
|
182
188
|
await app.register(sharedRoutes);
|
|
183
189
|
await app.register(designerRoutes);
|
|
184
190
|
await app.register(lockRoutes);
|
|
@@ -21,7 +21,7 @@ import type { Session } from '../session.ts';
|
|
|
21
21
|
import { MAX_MB, preview, resultHtml } from './dataload.ts';
|
|
22
22
|
import { buildAppFromSheets, MAX_SHEETS, parseBook, sheetsForm, type BuiltBook, type NamedSheet } from './appsheets.ts';
|
|
23
23
|
import { appWizardRoutes, PASTED } from './appwizard.ts';
|
|
24
|
-
import { checkNewApp, createApp, createAppError, reservedSchema, type CheckedApp } from './newapp.ts';
|
|
24
|
+
import { finishGeneratedApp, checkNewApp, createApp, createAppError, reservedSchema, type CheckedApp } from './newapp.ts';
|
|
25
25
|
import { currentWorkspace } from './workspaces.ts';
|
|
26
26
|
import { BASE, csrf, developer, input, region, select, send, shell, type Body, type Req } from './ui.ts';
|
|
27
27
|
|
|
@@ -149,9 +149,9 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
|
|
|
149
149
|
const generate = async (kind: string, page: number, options: Record<string, unknown>) => {
|
|
150
150
|
await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, kind, T, page, JSON.stringify(options)]);
|
|
151
151
|
};
|
|
152
|
-
const label = (await c.query(
|
|
153
|
-
await generate('report_form', 2, { form_page: 3
|
|
154
|
-
pages.push({ page: 2, label }, { page: 3, label: `${
|
|
152
|
+
const { label, singular } = (await c.query('select meta.wizard_table_label($1::regclass) as label, meta.wizard_table_label($1::regclass, false) as singular', [T])).rows[0] as { label: string; singular: string };
|
|
153
|
+
await generate('report_form', 2, { form_page: 3 });
|
|
154
|
+
pages.push({ page: 2, label }, { page: 3, label: `${singular} form` });
|
|
155
155
|
let next = 4;
|
|
156
156
|
// the statistics tell which columns group the rows: a few values, repeated (not a name per row)
|
|
157
157
|
const rows = result.inserted;
|
|
@@ -178,6 +178,7 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
|
|
|
178
178
|
pages.push({ page: next++, label: `Faceted search (${facets.join(', ')})` });
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
+
await finishGeneratedApp(c, app.id, { description: checked.description });
|
|
181
182
|
return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, table: T, result, pages, notes };
|
|
182
183
|
});
|
|
183
184
|
}
|
package/src/builder/appsheets.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type Sheet,
|
|
20
20
|
} from '../dataload.ts';
|
|
21
21
|
import { preview } from './dataload.ts';
|
|
22
|
-
import { createApp, createAppError, type CheckedApp } from './newapp.ts';
|
|
22
|
+
import { finishGeneratedApp, createApp, createAppError, type CheckedApp } from './newapp.ts';
|
|
23
23
|
import { groupColumn } from './appfromfile.ts';
|
|
24
24
|
import { region, type Body } from './ui.ts';
|
|
25
25
|
|
|
@@ -278,14 +278,21 @@ interface Catalog {
|
|
|
278
278
|
* A dashboard page: a chart per table (at most six) with the number of rows per
|
|
279
279
|
* parent row (the first foreign key), or per value of a text, yes/no or date
|
|
280
280
|
* column whose values repeat (from the statistics). Null when no table has one.
|
|
281
|
+
* (097) Tables with row level security are left out (the application's role may
|
|
282
|
+
* see none of their rows: an empty chart); the charts are titled "Employees by
|
|
283
|
+
* Manager", and the Dashboard comes right after Home in the navigation menu.
|
|
281
284
|
*/
|
|
282
|
-
export async function addDashboard(c: Client, alias: string, tables: string[], page: number): Promise<{ page: number; label: string } | null> {
|
|
285
|
+
export async function addDashboard(c: Client, alias: string, tables: string[], page: number, skipped: string[] = []): Promise<{ page: number; label: string } | null> {
|
|
283
286
|
const picks: { table: string; group: string }[] = [];
|
|
284
287
|
for (const t of tables) {
|
|
285
288
|
if (picks.length >= MAX_CHARTS) break;
|
|
289
|
+
const rel = (await c.query('select relrowsecurity as rls, greatest(reltuples, 0) as n from pg_class where oid = $1::regclass', [t])).rows[0];
|
|
290
|
+
if (rel.rls) {
|
|
291
|
+
skipped.push(t);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
286
294
|
const catalog = (await c.query<Catalog>('select column_name, kind, is_pk, distinct_values, fk_table::text from meta.wizard_catalog($1::regclass)', [t])).rows;
|
|
287
|
-
const
|
|
288
|
-
const group = catalog.find((x) => x.fk_table && !x.is_pk)?.column_name ?? groupColumn(catalog, rows);
|
|
295
|
+
const group = catalog.find((x) => x.fk_table && !x.is_pk)?.column_name ?? groupColumn(catalog, Number(rel.n));
|
|
289
296
|
if (group) picks.push({ table: t, group });
|
|
290
297
|
}
|
|
291
298
|
if (!picks.length) return null;
|
|
@@ -295,7 +302,7 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
|
|
|
295
302
|
JSON.stringify({ label: 'Dashboard', icon: 'chart', nav, label_column: group, function: 'count', value_column: null }),
|
|
296
303
|
]);
|
|
297
304
|
const title = async (table: string, group: string) =>
|
|
298
|
-
(await c.query(`select meta.
|
|
305
|
+
(await c.query(`select meta.wizard_table_label($1::regclass) || ' by ' || meta.wizard_column_label($1::regclass, $2) as t`, [table, group])).rows[0].t as string;
|
|
299
306
|
const dash = (await generate(picks[0].table, picks[0].group, page, true)).rows[0].id;
|
|
300
307
|
await c.query('update meta.region set title = $2 where page_id = $1', [dash, await title(picks[0].table, picks[0].group)]);
|
|
301
308
|
for (const [n, p] of picks.slice(1).entries()) {
|
|
@@ -306,7 +313,13 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
|
|
|
306
313
|
await c.query('delete from meta.page where id = $1', [tmp]);
|
|
307
314
|
}
|
|
308
315
|
if (picks.length > 1) await c.query('update meta.region set columns = 6 where page_id = $1', [dash]);
|
|
309
|
-
|
|
316
|
+
// right after Home (the first top-level entry)
|
|
317
|
+
await c.query(
|
|
318
|
+
`update meta.nav_entry e set seq = coalesce((select min(seq) from meta.nav_entry h where h.app_id = e.app_id and h.parent_id is null and h.target_page = a.home_page), 0) + 1
|
|
319
|
+
from meta.app a where a.alias = $1 and e.app_id = a.id and e.parent_id is null and e.target_page = $2`,
|
|
320
|
+
[alias, page],
|
|
321
|
+
);
|
|
322
|
+
return { page, label: `Dashboard (${(await Promise.all(picks.map((p) => title(p.table, p.group)))).join(', ')})` };
|
|
310
323
|
}
|
|
311
324
|
|
|
312
325
|
/** Create the application, the tables, the rows, the foreign keys and the pages, in one transaction. */
|
|
@@ -371,9 +384,9 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
|
|
|
371
384
|
let next = 2;
|
|
372
385
|
for (const p of plans) {
|
|
373
386
|
const T = qualified.get(p.k)!;
|
|
374
|
-
const label = (await c.query('select meta.
|
|
375
|
-
await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, 'report_form', T, next, JSON.stringify({ form_page: next + 1
|
|
376
|
-
pages.push({ page: next, label }, { page: next + 1, label: `${
|
|
387
|
+
const { label, singular } = (await c.query('select meta.wizard_table_label($1::regclass) as label, meta.wizard_table_label($1::regclass, false) as singular', [T])).rows[0] as { label: string; singular: string };
|
|
388
|
+
await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, 'report_form', T, next, JSON.stringify({ form_page: next + 1 })]);
|
|
389
|
+
pages.push({ page: next, label }, { page: next + 1, label: `${singular} form` });
|
|
377
390
|
next += 2;
|
|
378
391
|
}
|
|
379
392
|
if (b.chart === 'true') {
|
|
@@ -381,6 +394,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
|
|
|
381
394
|
if (dash) pages.push(dash);
|
|
382
395
|
else notes.push('No dashboard: no table has a foreign key or a text, yes/no or date column with a few values that repeat, to count the rows by.');
|
|
383
396
|
}
|
|
397
|
+
await finishGeneratedApp(c, app.id, { description: checked.description });
|
|
384
398
|
return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, tables, foreignKeys, pages, notes };
|
|
385
399
|
});
|
|
386
400
|
}
|