pgkiln 0.31.0 → 0.35.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/README.md +2 -2
- package/SECURITY.md +10 -0
- package/db/migrations/076_form_lost_update.sql +50 -0
- package/db/migrations/077_warn_unsaved.sql +41 -0
- package/db/migrations/078_url_region.sql +12 -0
- package/db/migrations/079_developer_toolbar.sql +53 -0
- package/db/migrations/080_search.sql +326 -0
- package/db/migrations/081_mail.sql +621 -0
- package/db/migrations/082_app_backups.sql +73 -0
- package/db/migrations/083_availability.sql +156 -0
- package/db/migrations/084_feedback.sql +46 -0
- package/db/migrations/085_page_behaviour.sql +65 -0
- package/db/migrations/086_data_settings.sql +599 -0
- package/docs/apex-feature-parity.md +55 -35
- package/docs/guide/01-installation.md +1 -0
- package/docs/guide/03-builder.md +118 -2
- package/docs/guide/04-pages-and-regions.md +115 -0
- package/docs/guide/05-items.md +47 -0
- package/docs/guide/06-processing.md +210 -2
- package/docs/guide/07-dynamic-actions.md +60 -2
- package/docs/guide/08-security.md +13 -2
- package/docs/guide/09-reference.md +21 -0
- package/docs/guide/11-from-apex.md +5 -1
- package/docs/guide/12-development.md +33 -1
- package/docs/guide/14-globalization.md +15 -0
- package/docs/guide/15-extensions.md +1 -1
- package/docs/guide/18-cli.md +30 -1
- package/docs/guide/20-ai-agents.md +3 -2
- package/examples/hr/hr_50_search.sql +47 -0
- package/examples/hr/hr_51_mail.sql +70 -0
- package/examples/hr/hr_52_feedback.sql +15 -0
- package/examples/hr/hr_53_data_settings.sql +108 -0
- package/package.json +3 -1
- package/public/app.css +62 -0
- package/public/app.js +408 -11
- package/public/builder.css +11 -0
- package/public/builder.js +27 -0
- package/src/app.ts +8 -0
- package/src/appfiles.ts +12 -0
- package/src/automations.ts +12 -0
- package/src/builder/advisor.ts +54 -14
- package/src/builder/arrange.ts +2 -2
- package/src/builder/availability.ts +74 -0
- package/src/builder/backups.ts +134 -0
- package/src/builder/components.ts +109 -15
- package/src/builder/designer.ts +8 -3
- package/src/builder/feedback.ts +82 -0
- package/src/builder/forms.ts +4 -1
- package/src/builder/home.ts +3 -0
- package/src/builder/locks.ts +15 -5
- package/src/builder/mail.ts +425 -0
- package/src/builder/newapp.ts +1 -1
- package/src/builder/region-help.ts +71 -0
- package/src/builder/routes.ts +44 -1
- package/src/builder/search.ts +4 -4
- package/src/builder/shared.ts +3 -2
- package/src/builder/ui.ts +7 -1
- package/src/cli/apps.ts +46 -5
- package/src/cli/main.ts +21 -5
- package/src/cli/mcp.ts +16 -4
- package/src/cli/replace.ts +36 -4
- package/src/geocode.ts +44 -0
- package/src/i18n/ar.ts +39 -0
- package/src/i18n/cs.ts +39 -0
- package/src/i18n/da.ts +39 -0
- package/src/i18n/de.ts +39 -0
- package/src/i18n/el.ts +39 -0
- package/src/i18n/es.ts +39 -0
- package/src/i18n/fi.ts +39 -0
- package/src/i18n/fr.ts +39 -0
- package/src/i18n/he.ts +39 -0
- package/src/i18n/it.ts +39 -0
- package/src/i18n/ja.ts +39 -0
- package/src/i18n/ko.ts +39 -0
- package/src/i18n/nb.ts +39 -0
- package/src/i18n/pl.ts +39 -0
- package/src/i18n/pt.ts +39 -0
- package/src/i18n/ru.ts +39 -0
- package/src/i18n/sv.ts +39 -0
- package/src/i18n/tr.ts +39 -0
- package/src/i18n/uk.ts +39 -0
- package/src/i18n/zh.ts +39 -0
- package/src/i18n.ts +78 -0
- package/src/mail/mail.ts +148 -0
- package/src/mail/providers.ts +444 -0
- package/src/metadata.ts +26 -8
- package/src/runtime/ajax.ts +97 -0
- package/src/runtime/authz.ts +1 -1
- package/src/runtime/availability.ts +37 -0
- package/src/runtime/context.ts +46 -6
- package/src/runtime/data-export.ts +94 -0
- package/src/runtime/data-items.ts +194 -0
- package/src/runtime/devbar.ts +182 -0
- package/src/runtime/engine.ts +26 -1
- package/src/runtime/feedback.ts +128 -0
- package/src/runtime/items.ts +8 -0
- package/src/runtime/processes.ts +83 -2
- package/src/runtime/regions.ts +8 -0
- package/src/runtime/render.ts +33 -7
- package/src/runtime/rest.ts +4 -0
- package/src/runtime/routes.ts +59 -5
- package/src/runtime/search.ts +252 -0
- package/src/runtime/url-region.ts +93 -0
- package/src/security.ts +11 -1
- package/src/server.ts +2 -0
- package/src/session.ts +8 -2
- package/src/workingcopy.ts +2 -2
package/public/app.js
CHANGED
|
@@ -140,6 +140,68 @@ const pgkilnPush = (() => {
|
|
|
140
140
|
return { supported, subscribe, unsubscribe, isOn, t };
|
|
141
141
|
})();
|
|
142
142
|
|
|
143
|
+
// Warn on unsaved changes (migration 077): a page form with data-warn-unsaved asks
|
|
144
|
+
// before the user leaves the page (a link, a redirect button, another form, closing
|
|
145
|
+
// or reloading the tab, closing a dialog) while one of its items differs from the
|
|
146
|
+
// value the server rendered. Submitting the page doesn't ask. Items inside an
|
|
147
|
+
// element with data-no-warn, and the interactive grid (it has its own check), are
|
|
148
|
+
// left out. data-warn-unsaved="changed": the page came back from a failed submit,
|
|
149
|
+
// so its values are not saved yet.
|
|
150
|
+
const pgkilnUnsaved = (() => {
|
|
151
|
+
const form = document.querySelector('form.page-form[data-warn-unsaved]');
|
|
152
|
+
const metaEl = document.getElementById('pgkiln-meta');
|
|
153
|
+
const meta = (metaEl ? JSON.parse(metaEl.textContent) : null) || {};
|
|
154
|
+
const text = (meta.texts && meta.texts['page.unsaved_changes']) || 'You have unsaved changes. Leave this page anyway?';
|
|
155
|
+
let leaving = false;
|
|
156
|
+
const tracked = (el) =>
|
|
157
|
+
el.name && !el.name.startsWith('__') && (el.type !== 'hidden' || 'initial' in el.dataset) && el.type !== 'submit' && el.type !== 'button' && !el.disabled &&
|
|
158
|
+
!el.closest('[data-no-warn], [data-grid], .grid-template');
|
|
159
|
+
function differs(el) {
|
|
160
|
+
if (el.type === 'hidden') return el.value !== el.dataset.initial;
|
|
161
|
+
if (el.type === 'checkbox' || el.type === 'radio') return el.checked !== el.defaultChecked;
|
|
162
|
+
if (el.type === 'file') return !!(el.files && el.files.length);
|
|
163
|
+
if (el.tagName === 'SELECT') {
|
|
164
|
+
const opts = [...el.options];
|
|
165
|
+
if (el.multiple) return opts.some((o) => o.selected !== o.defaultSelected);
|
|
166
|
+
const def = opts.findIndex((o) => o.defaultSelected);
|
|
167
|
+
return el.selectedIndex !== (def >= 0 ? def : el.size > 1 ? -1 : 0);
|
|
168
|
+
}
|
|
169
|
+
return 'defaultValue' in el && el.value !== el.defaultValue;
|
|
170
|
+
}
|
|
171
|
+
const changed = () => !!form && (form.dataset.warnUnsaved === 'changed' || [...form.elements].some((el) => tracked(el) && differs(el)));
|
|
172
|
+
/** True when the user may leave: nothing changed, or they said so. */
|
|
173
|
+
function mayLeave() {
|
|
174
|
+
if (leaving || !changed()) return true;
|
|
175
|
+
if (!window.confirm(text)) return false;
|
|
176
|
+
leaving = true;
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (form) {
|
|
180
|
+
document.addEventListener('click', (e) => {
|
|
181
|
+
const a = e.target.closest?.('a[href]');
|
|
182
|
+
if (!a || e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
|
|
183
|
+
const href = a.getAttribute('href') || '';
|
|
184
|
+
// a dialog, a new tab, a download or a place on this page doesn't leave it
|
|
185
|
+
if (href.startsWith('#') || /^javascript:/i.test(href) || a.hasAttribute('download') || a.target === '_blank' || a.hasAttribute('data-dialog') || a.hasAttribute('data-dialog-cancel')) return;
|
|
186
|
+
if (!mayLeave()) e.preventDefault();
|
|
187
|
+
});
|
|
188
|
+
// submitting the page saves; another form (a menu, a switch) leaves it
|
|
189
|
+
document.addEventListener('submit', (e) => {
|
|
190
|
+
if (e.target === form) leaving = true;
|
|
191
|
+
else if (!e.defaultPrevented && (e.target.method || '').toLowerCase() === 'post' && !mayLeave()) e.preventDefault();
|
|
192
|
+
}, true);
|
|
193
|
+
window.addEventListener('beforeunload', (e) => {
|
|
194
|
+
if (leaving || !changed()) return;
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
e.returnValue = '';
|
|
197
|
+
});
|
|
198
|
+
// a navigation that was answered "leave" and then cancelled (download, same page) arms the check again
|
|
199
|
+
window.addEventListener('pageshow', () => (leaving = false));
|
|
200
|
+
}
|
|
201
|
+
return { changed, mayLeave, text };
|
|
202
|
+
})();
|
|
203
|
+
|
|
204
|
+
|
|
143
205
|
(() => {
|
|
144
206
|
const body = document.body;
|
|
145
207
|
const base = body.dataset.base;
|
|
@@ -383,6 +445,26 @@ const pgkilnPush = (() => {
|
|
|
383
445
|
},
|
|
384
446
|
}),
|
|
385
447
|
getValue: (name) => itemValue(name),
|
|
448
|
+
// (085) raise a custom event: dynamic actions with event "custom" and this name run
|
|
449
|
+
trigger: (name, value) => runChain(das.filter((da) => da.event === 'custom' && da.trigger.some((t) => String(t).toLowerCase() === String(name).toLowerCase())), value),
|
|
450
|
+
// (085) APEX: apex.server.process. An Ajax callback (a process with point "ajax") by name: posts the items given
|
|
451
|
+
// (a list of names, or {name: value}) and x01 … x10; answers { data, rows, items } and sets the items it changed.
|
|
452
|
+
server: Object.freeze({
|
|
453
|
+
async process(name, opts = {}) {
|
|
454
|
+
const data = {};
|
|
455
|
+
if (Array.isArray(opts.items)) for (const n of opts.items) data[n] = itemValue(n);
|
|
456
|
+
else if (opts.items && typeof opts.items === 'object') Object.assign(data, opts.items);
|
|
457
|
+
for (let i = 1; i <= 10; i++) {
|
|
458
|
+
const k = `x${String(i).padStart(2, '0')}`;
|
|
459
|
+
if (opts[k] !== undefined && opts[k] !== null) data[k] = String(opts[k]);
|
|
460
|
+
}
|
|
461
|
+
const res = await post(`/ajax/${encodeURIComponent(name)}`, data);
|
|
462
|
+
for (const [n, v] of Object.entries(res.items || {})) setItemValue(n, v);
|
|
463
|
+
return res;
|
|
464
|
+
},
|
|
465
|
+
}),
|
|
466
|
+
// (077) APEX: apex.page.isChanged()
|
|
467
|
+
isChanged: () => pgkilnUnsaved.changed(),
|
|
386
468
|
setValue: (name, value) => setItemValue(name, value),
|
|
387
469
|
showSuccess: (message) => showMessage(String(message), 'success'),
|
|
388
470
|
showError: (message, item) => (item ? itemError(item, String(message)) : showMessage(String(message), 'error')),
|
|
@@ -390,6 +472,38 @@ const pgkilnPush = (() => {
|
|
|
390
472
|
page: pageNo,
|
|
391
473
|
});
|
|
392
474
|
|
|
475
|
+
/** (085) what runDa returns to stop the actions after it (a declined confirm) */
|
|
476
|
+
const STOP = Symbol('stop');
|
|
477
|
+
const text = (k) => (meta.texts && meta.texts[k]) || k;
|
|
478
|
+
|
|
479
|
+
/** The actions of one event and trigger, one after the other in sequence order; false when one stopped them. */
|
|
480
|
+
async function runChain(list, value, extra) {
|
|
481
|
+
for (const da of list) if ((await runDa(da, false, value, extra)) === STOP) return false;
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** (085) The download dynamic action: the file of the download process it names, saved by the browser. */
|
|
486
|
+
async function download(da) {
|
|
487
|
+
const params = new URLSearchParams();
|
|
488
|
+
for (const n of da.submit) params.set(n, itemValue(n));
|
|
489
|
+
params.set('__csrf', meta.csrf);
|
|
490
|
+
try {
|
|
491
|
+
const res = await fetch(`${base}/${pageNo}/da/${da.id}/download`, { method: 'POST', body: params, credentials: 'same-origin' });
|
|
492
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
|
|
493
|
+
const name = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(res.headers.get('content-disposition') || '')?.[1];
|
|
494
|
+
const url = URL.createObjectURL(await res.blob());
|
|
495
|
+
const a = document.createElement('a');
|
|
496
|
+
a.href = url;
|
|
497
|
+
a.download = name ? decodeURIComponent(name) : 'download';
|
|
498
|
+
document.body.appendChild(a);
|
|
499
|
+
a.click();
|
|
500
|
+
a.remove();
|
|
501
|
+
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
502
|
+
} catch (e) {
|
|
503
|
+
showError(e.message);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
393
507
|
async function runDa(da, initial, value, extra) {
|
|
394
508
|
const holds = conditionHolds(da, value);
|
|
395
509
|
switch (da.action) {
|
|
@@ -411,6 +525,54 @@ const pgkilnPush = (() => {
|
|
|
411
525
|
case 'alert':
|
|
412
526
|
window.alert(da.message || '');
|
|
413
527
|
return;
|
|
528
|
+
// (085) a question: No stops the actions after it (and a page submit for before_submit)
|
|
529
|
+
case 'confirm':
|
|
530
|
+
return window.confirm(da.message || '') ? undefined : STOP;
|
|
531
|
+
case 'close_dialog':
|
|
532
|
+
case 'cancel_dialog':
|
|
533
|
+
if (window.parent !== window) window.parent.postMessage({ type: 'pgkiln:close', reload: da.action === 'close_dialog', page: body.dataset.dialogPage || String(pageNo) }, location.origin);
|
|
534
|
+
return;
|
|
535
|
+
case 'clear':
|
|
536
|
+
for (const n of da.items) setItemValue(n, '');
|
|
537
|
+
return;
|
|
538
|
+
case 'open_region':
|
|
539
|
+
case 'close_region':
|
|
540
|
+
for (const el of targets(da)) {
|
|
541
|
+
const details = el.querySelector(':scope > details');
|
|
542
|
+
if (details) details.open = da.action === 'open_region';
|
|
543
|
+
else el.hidden = da.action === 'close_region';
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
case 'print':
|
|
547
|
+
window.print();
|
|
548
|
+
return;
|
|
549
|
+
case 'current_position':
|
|
550
|
+
return new Promise((resolve) => {
|
|
551
|
+
if (!navigator.geolocation) return showError(text('item.locate_error')), resolve();
|
|
552
|
+
navigator.geolocation.getCurrentPosition(
|
|
553
|
+
(pos) => {
|
|
554
|
+
if (da.items[0]) setItemValue(da.items[0], `${pos.coords.latitude.toFixed(5)},${pos.coords.longitude.toFixed(5)}`);
|
|
555
|
+
resolve();
|
|
556
|
+
},
|
|
557
|
+
() => (showError(text('item.locate_error')), resolve()),
|
|
558
|
+
{ enableHighAccuracy: true, timeout: 15000 },
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
case 'share': {
|
|
562
|
+
const data = { title: document.title, text: da.message || undefined, url: location.href };
|
|
563
|
+
try {
|
|
564
|
+
if (navigator.share) await navigator.share(data);
|
|
565
|
+
else {
|
|
566
|
+
await navigator.clipboard.writeText(location.href);
|
|
567
|
+
showMessage(text('page.link_copied'), 'success');
|
|
568
|
+
}
|
|
569
|
+
} catch {
|
|
570
|
+
// the user closed the share sheet
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
case 'download':
|
|
575
|
+
return download(da);
|
|
414
576
|
case 'set_focus':
|
|
415
577
|
focusTarget(targets(da)[0]);
|
|
416
578
|
return;
|
|
@@ -499,7 +661,7 @@ const pgkilnPush = (() => {
|
|
|
499
661
|
document.addEventListener('change', (e) => {
|
|
500
662
|
const name = e.target.name;
|
|
501
663
|
if (!name) return;
|
|
502
|
-
|
|
664
|
+
runChain(das.filter((da) => da.event === 'change' && da.trigger.includes(name)));
|
|
503
665
|
cascade(name);
|
|
504
666
|
if (e.target.closest('[data-submit-on-change]') && e.target.form === form && depth === 0) form.requestSubmit();
|
|
505
667
|
});
|
|
@@ -511,21 +673,69 @@ const pgkilnPush = (() => {
|
|
|
511
673
|
// (without JavaScript the button submits and the page process runs instead)
|
|
512
674
|
const ai = run.some((da) => da.action === 'ai_generate');
|
|
513
675
|
if (ai && btn.type === 'submit') e.preventDefault();
|
|
514
|
-
const pending = run
|
|
676
|
+
const pending = runChain(run);
|
|
515
677
|
if (ai && btn.tagName === 'BUTTON') {
|
|
516
678
|
// one request at a time: the button waits for the answer
|
|
517
679
|
btn.disabled = true;
|
|
518
680
|
btn.setAttribute('aria-busy', 'true');
|
|
519
|
-
Promise.allSettled(pending).then(() => {
|
|
681
|
+
Promise.allSettled([pending]).then(() => {
|
|
520
682
|
btn.disabled = false;
|
|
521
683
|
btn.removeAttribute('aria-busy');
|
|
522
684
|
});
|
|
523
685
|
}
|
|
524
686
|
});
|
|
525
|
-
for (const da of das)
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
687
|
+
for (const da of das) if (da.event !== 'dialog_closed' && ['show', 'hide', 'enable', 'disable'].includes(da.action)) runDa(da, true);
|
|
688
|
+
runChain(das.filter((da) => da.event === 'load'));
|
|
689
|
+
|
|
690
|
+
// (085) more events. Item events: focus, blur, key_release (trigger: item names).
|
|
691
|
+
const itemEvent = (event) => (e) => {
|
|
692
|
+
const name = e.target && e.target.name;
|
|
693
|
+
if (!name) return;
|
|
694
|
+
const run = das.filter((da) => da.event === event && da.trigger.includes(name));
|
|
695
|
+
if (run.length) runChain(run);
|
|
696
|
+
};
|
|
697
|
+
document.addEventListener('focusin', itemEvent('focus'));
|
|
698
|
+
document.addEventListener('focusout', itemEvent('blur'));
|
|
699
|
+
document.addEventListener('keyup', itemEvent('key_release'));
|
|
700
|
+
// region events (trigger: region ids or static ids, case-insensitive)
|
|
701
|
+
const regionRun = (event, region) => {
|
|
702
|
+
if (!region || !/^R\d+$/.test(region.id || '')) return [];
|
|
703
|
+
const keys = [region.id.slice(1), (region.dataset.staticId || '').toLowerCase()].filter(Boolean);
|
|
704
|
+
return das.filter((da) => da.event === event && da.trigger.some((t) => keys.includes(String(t).toLowerCase())));
|
|
705
|
+
};
|
|
706
|
+
// after_refresh: a region replaced by a refresh, a lazy load or a master row
|
|
707
|
+
document.addEventListener('pgkiln:replaced', (e) => {
|
|
708
|
+
const run = regionRun('after_refresh', e.detail);
|
|
709
|
+
if (run.length) runChain(run);
|
|
710
|
+
});
|
|
711
|
+
// selection_change: the row checkboxes of a report (and a grid's selected master row, see selectMaster)
|
|
712
|
+
const selectionChanged = (region) => {
|
|
713
|
+
const run = regionRun('selection_change', region);
|
|
714
|
+
if (!run.length) return;
|
|
715
|
+
const boxes = region.querySelectorAll('td.row-select input[type=checkbox]:checked');
|
|
716
|
+
runChain(run, [...boxes].map((b) => b.value).join(':'));
|
|
717
|
+
};
|
|
718
|
+
document.addEventListener('change', (e) => {
|
|
719
|
+
if (e.target.closest?.('.row-select')) selectionChanged(e.target.closest('section[id^="R"]'));
|
|
720
|
+
});
|
|
721
|
+
document.addEventListener('pgkiln:selection', (e) => selectionChanged(e.detail));
|
|
722
|
+
// before_submit: the page's form; a declined confirm keeps the page
|
|
723
|
+
let submitting = false;
|
|
724
|
+
document.addEventListener('submit', async (e) => {
|
|
725
|
+
if (e.target !== form || submitting) return;
|
|
726
|
+
const run = das.filter((da) => da.event === 'before_submit');
|
|
727
|
+
if (!run.length) return;
|
|
728
|
+
e.preventDefault();
|
|
729
|
+
e.stopImmediatePropagation();
|
|
730
|
+
const submitter = e.submitter;
|
|
731
|
+
if (!(await runChain(run))) return;
|
|
732
|
+
submitting = true;
|
|
733
|
+
try {
|
|
734
|
+
form.requestSubmit(submitter && submitter.form === form ? submitter : undefined);
|
|
735
|
+
} finally {
|
|
736
|
+
submitting = false;
|
|
737
|
+
}
|
|
738
|
+
}, true);
|
|
529
739
|
|
|
530
740
|
// ------------------------------------------------------------ cascading lists of values
|
|
531
741
|
async function cascade(parent) {
|
|
@@ -641,7 +851,19 @@ const pgkilnPush = (() => {
|
|
|
641
851
|
dlg.setAttribute('aria-labelledby', 't-dialog-title');
|
|
642
852
|
dlg.innerHTML = '<div class="t-dialog-head"><h2 id="t-dialog-title"></h2><button type="button" class="icon-button t-dialog-x" aria-label="Close">×</button></div><iframe title="Dialog"></iframe>';
|
|
643
853
|
document.body.appendChild(dlg);
|
|
644
|
-
|
|
854
|
+
// (077) the page in the dialog decides whether its unsaved changes may go
|
|
855
|
+
const mayClose = () => {
|
|
856
|
+
try {
|
|
857
|
+
const w = dlg.querySelector('iframe').contentWindow;
|
|
858
|
+
return !w.pgkiln?.isChanged?.() || window.confirm(pgkilnUnsaved.text);
|
|
859
|
+
} catch {
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
dlg.querySelector('.t-dialog-x').addEventListener('click', () => mayClose() && dlg.close());
|
|
864
|
+
dlg.addEventListener('cancel', (e) => {
|
|
865
|
+
if (!mayClose()) e.preventDefault();
|
|
866
|
+
});
|
|
645
867
|
dlg.addEventListener('close', () => (dlg.querySelector('iframe').src = 'about:blank'));
|
|
646
868
|
}
|
|
647
869
|
const frame = dlg.querySelector('iframe');
|
|
@@ -790,7 +1012,7 @@ const pgkilnPush = (() => {
|
|
|
790
1012
|
document.addEventListener('click', (e) => {
|
|
791
1013
|
if (!e.target.closest('[data-dialog-cancel]')) return;
|
|
792
1014
|
e.preventDefault();
|
|
793
|
-
tell(false);
|
|
1015
|
+
if (pgkilnUnsaved.mayLeave()) tell(false);
|
|
794
1016
|
});
|
|
795
1017
|
}
|
|
796
1018
|
})();
|
|
@@ -932,7 +1154,7 @@ const pgkilnPush = (() => {
|
|
|
932
1154
|
return;
|
|
933
1155
|
}
|
|
934
1156
|
const leave = e.target.closest('[data-grid-leave]');
|
|
935
|
-
if (leave && dirty && !window.confirm(
|
|
1157
|
+
if (leave && dirty && !window.confirm(pgkilnUnsaved.text)) e.preventDefault();
|
|
936
1158
|
});
|
|
937
1159
|
document.addEventListener('input', (e) => {
|
|
938
1160
|
const row = e.target.closest?.('[data-grid] table.grid-table > tbody > tr');
|
|
@@ -969,7 +1191,7 @@ const pgkilnPush = (() => {
|
|
|
969
1191
|
const master = link.closest('[data-grid]');
|
|
970
1192
|
const ids = link.dataset.gridSelect.split(',').filter(Boolean);
|
|
971
1193
|
const dirtyDetail = ids.some((id) => document.querySelector(`#R${id} [data-grid] tr.dirty, #R${id} [data-grid] tbody:not(.grid-template) tr.grid-new`));
|
|
972
|
-
if (dirtyDetail && !window.confirm(
|
|
1194
|
+
if (dirtyDetail && !window.confirm(pgkilnUnsaved.text)) return;
|
|
973
1195
|
const query = new URL(link.href, location.href).search.slice(1);
|
|
974
1196
|
for (const id of ids) {
|
|
975
1197
|
const region = document.getElementById(`R${id}`);
|
|
@@ -1003,6 +1225,8 @@ const pgkilnPush = (() => {
|
|
|
1003
1225
|
return;
|
|
1004
1226
|
}
|
|
1005
1227
|
}
|
|
1228
|
+
// (085) the dynamic actions on the master's "selection change"
|
|
1229
|
+
document.dispatchEvent(new CustomEvent('pgkiln:selection', { detail: master.closest('section[id^="R"]') }));
|
|
1006
1230
|
// mark the row, and make the page's other links and forms carry the new selection
|
|
1007
1231
|
for (const a of master.querySelectorAll('a[data-grid-select]')) {
|
|
1008
1232
|
const on = a === link;
|
|
@@ -1787,6 +2011,58 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1787
2011
|
);
|
|
1788
2012
|
});
|
|
1789
2013
|
|
|
2014
|
+
// (086) address items: the geocoder's addresses for the text; the one chosen replaces it, its position goes into the coordinates item
|
|
2015
|
+
document.addEventListener('click', async (e) => {
|
|
2016
|
+
const btn = e.target.closest && e.target.closest('[data-geocode]');
|
|
2017
|
+
if (!btn) return;
|
|
2018
|
+
const input = document.getElementById(btn.dataset.geocode);
|
|
2019
|
+
const list = document.getElementById(`${btn.dataset.geocode}_places`);
|
|
2020
|
+
if (!input || !list) return;
|
|
2021
|
+
const csrf = (metaEl ? JSON.parse(metaEl.textContent).csrf : '') || '';
|
|
2022
|
+
btn.setAttribute('aria-busy', 'true');
|
|
2023
|
+
let places = null;
|
|
2024
|
+
try {
|
|
2025
|
+
const res = await fetch(btn.dataset.url, { method: 'POST', body: new URLSearchParams({ q: input.value, __csrf: csrf }), headers: { accept: 'application/json' }, credentials: 'same-origin' });
|
|
2026
|
+
const data = await res.json().catch(() => ({}));
|
|
2027
|
+
if (!res.ok) note(data.error || t('item.address_error'), 'error');
|
|
2028
|
+
else places = data.places || [];
|
|
2029
|
+
} catch {
|
|
2030
|
+
note(t('item.address_error'), 'error');
|
|
2031
|
+
}
|
|
2032
|
+
btn.removeAttribute('aria-busy');
|
|
2033
|
+
list.replaceChildren();
|
|
2034
|
+
if (!places) return;
|
|
2035
|
+
if (!places.length) {
|
|
2036
|
+
list.hidden = true;
|
|
2037
|
+
note(t('item.address_none'), 'error');
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
for (const p of places) {
|
|
2041
|
+
const li = document.createElement('li');
|
|
2042
|
+
li.setAttribute('role', 'option');
|
|
2043
|
+
const b = document.createElement('button');
|
|
2044
|
+
b.type = 'button';
|
|
2045
|
+
b.className = 'link-button';
|
|
2046
|
+
b.textContent = p.label;
|
|
2047
|
+
b.addEventListener('click', () => {
|
|
2048
|
+
input.value = p.label;
|
|
2049
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2050
|
+
const target = btn.dataset.coords && document.getElementById(btn.dataset.coords);
|
|
2051
|
+
if (target) {
|
|
2052
|
+
target.value = `${Number(p.lat).toFixed(5)},${Number(p.lon).toFixed(5)}`;
|
|
2053
|
+
target.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2054
|
+
}
|
|
2055
|
+
list.hidden = true;
|
|
2056
|
+
list.replaceChildren();
|
|
2057
|
+
input.focus();
|
|
2058
|
+
});
|
|
2059
|
+
li.append(b);
|
|
2060
|
+
list.append(li);
|
|
2061
|
+
}
|
|
2062
|
+
list.hidden = false;
|
|
2063
|
+
list.querySelector('button').focus();
|
|
2064
|
+
});
|
|
2065
|
+
|
|
1790
2066
|
// scan buttons: only where the browser reads barcodes (BarcodeDetector, e.g. Chrome on Android)
|
|
1791
2067
|
if ('BarcodeDetector' in window && navigator.mediaDevices) {
|
|
1792
2068
|
for (const b of document.querySelectorAll('[data-scan]')) b.hidden = false;
|
|
@@ -2908,6 +3184,8 @@ function areaFilter(map, f) {
|
|
|
2908
3184
|
hidden.type = 'hidden';
|
|
2909
3185
|
hidden.name = input.name;
|
|
2910
3186
|
hidden.value = input.value;
|
|
3187
|
+
// (077) a hidden input's value is its default too: the value to compare with for "unsaved changes"
|
|
3188
|
+
hidden.dataset.initial = input.value;
|
|
2911
3189
|
input.removeAttribute('name');
|
|
2912
3190
|
input.value = '';
|
|
2913
3191
|
const list = document.createElement('ul');
|
|
@@ -3036,3 +3314,122 @@ function areaFilter(map, f) {
|
|
|
3036
3314
|
}
|
|
3037
3315
|
});
|
|
3038
3316
|
})();
|
|
3317
|
+
|
|
3318
|
+
// Search region (migration 080): search as the user types. The field is a GET form that works
|
|
3319
|
+
// without JavaScript; here a pause in typing fetches the region (GET …/region/<id>) and swaps in
|
|
3320
|
+
// its results only, so the field keeps its focus and cursor. Answers that arrive late are dropped.
|
|
3321
|
+
(() => {
|
|
3322
|
+
const base = document.body.dataset.base;
|
|
3323
|
+
const page = document.body.dataset.page;
|
|
3324
|
+
if (!base || !page) return;
|
|
3325
|
+
const timers = new WeakMap();
|
|
3326
|
+
const latest = new WeakMap();
|
|
3327
|
+
async function search(field) {
|
|
3328
|
+
const id = field.dataset.searchRegion;
|
|
3329
|
+
if (!/^\d+$/.test(id || '') || !field.form) return;
|
|
3330
|
+
// (the form keeps the page's own parameters, dialog=1 among them)
|
|
3331
|
+
const params = new URLSearchParams(new FormData(field.form));
|
|
3332
|
+
const n = (latest.get(field) || 0) + 1;
|
|
3333
|
+
latest.set(field, n);
|
|
3334
|
+
try {
|
|
3335
|
+
const res = await fetch(`${base}/${page}/region/${id}?${params}`, { headers: { accept: 'application/json' }, credentials: 'same-origin' });
|
|
3336
|
+
if (!res.ok || latest.get(field) !== n) return;
|
|
3337
|
+
const json = await res.json();
|
|
3338
|
+
const fresh = new DOMParser().parseFromString(json.html || '', 'text/html').getElementById(`R${id}_results`);
|
|
3339
|
+
const results = document.getElementById(`R${id}_results`);
|
|
3340
|
+
if (!fresh || !results) return;
|
|
3341
|
+
results.replaceChildren(...[...fresh.childNodes].map((node) => document.importNode(node, true)));
|
|
3342
|
+
history.replaceState(history.state, '', `${location.pathname}?${params}${location.hash}`);
|
|
3343
|
+
} catch {
|
|
3344
|
+
// offline or the session ended: Enter still submits the form
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
document.addEventListener('input', (e) => {
|
|
3348
|
+
const field = e.target.closest?.('input[data-search-region]');
|
|
3349
|
+
if (!field) return;
|
|
3350
|
+
clearTimeout(timers.get(field));
|
|
3351
|
+
timers.set(field, setTimeout(() => search(field), 250));
|
|
3352
|
+
});
|
|
3353
|
+
})();
|
|
3354
|
+
|
|
3355
|
+
// Session timeout warning (migration 085; APEX: Session timeout warning). The page knows when its
|
|
3356
|
+
// session ends: after the idle time since the page was loaded (or since "Stay signed in"), and at
|
|
3357
|
+
// the session's maximum length. A dialog warns `warn` seconds before; "Stay signed in" marks the
|
|
3358
|
+
// session active on the server. At the maximum length the user can only save their work.
|
|
3359
|
+
(() => {
|
|
3360
|
+
const metaEl = document.getElementById('pgkiln-meta');
|
|
3361
|
+
if (!metaEl) return;
|
|
3362
|
+
const meta = JSON.parse(metaEl.textContent);
|
|
3363
|
+
const s = meta.session;
|
|
3364
|
+
if (!s || !s.idle || !s.warn) return;
|
|
3365
|
+
const t = (k) => (meta.texts && meta.texts[k]) || k;
|
|
3366
|
+
let last = Date.now();
|
|
3367
|
+
let timer;
|
|
3368
|
+
let dlg;
|
|
3369
|
+
let shown = '';
|
|
3370
|
+
let dismissed = false;
|
|
3371
|
+
const end = () => Math.min(last + s.idle * 1000, s.maxEnd || Infinity);
|
|
3372
|
+
const atMax = () => s.maxEnd && end() === s.maxEnd;
|
|
3373
|
+
function schedule(ms) {
|
|
3374
|
+
clearTimeout(timer);
|
|
3375
|
+
timer = setTimeout(check, Math.max(500, Math.min(ms, 2 ** 31 - 1)));
|
|
3376
|
+
}
|
|
3377
|
+
function check() {
|
|
3378
|
+
const left = end() - Date.now();
|
|
3379
|
+
if (left <= 0) return show('ended');
|
|
3380
|
+
if (left > s.warn * 1000) return schedule(left - s.warn * 1000);
|
|
3381
|
+
// the idle warning counts down until it is dismissed; the maximum length is said once
|
|
3382
|
+
if (atMax() ? shown !== 'max' : !dismissed) show(atMax() ? 'max' : 'idle', left);
|
|
3383
|
+
schedule(Math.min(left, 30000));
|
|
3384
|
+
}
|
|
3385
|
+
function button(label, cls, onClick) {
|
|
3386
|
+
const b = document.createElement('button');
|
|
3387
|
+
b.type = 'button';
|
|
3388
|
+
b.className = `btn ${cls}`;
|
|
3389
|
+
b.textContent = label;
|
|
3390
|
+
b.addEventListener('click', onClick);
|
|
3391
|
+
return b;
|
|
3392
|
+
}
|
|
3393
|
+
function show(kind, left) {
|
|
3394
|
+
if (!dlg) {
|
|
3395
|
+
dlg = document.createElement('dialog');
|
|
3396
|
+
dlg.className = 'session-dialog';
|
|
3397
|
+
dlg.setAttribute('aria-labelledby', 'session-dialog-text');
|
|
3398
|
+
dlg.innerHTML = '<p id="session-dialog-text"></p><div class="buttons"></div>';
|
|
3399
|
+
document.body.appendChild(dlg);
|
|
3400
|
+
// Escape dismisses the warning like the button
|
|
3401
|
+
dlg.addEventListener('cancel', () => (dismissed = true));
|
|
3402
|
+
}
|
|
3403
|
+
if (kind === 'max' && shown === 'max' && dlg.open) return;
|
|
3404
|
+
shown = kind;
|
|
3405
|
+
const p = dlg.querySelector('p');
|
|
3406
|
+
const buttons = dlg.querySelector('.buttons');
|
|
3407
|
+
buttons.replaceChildren();
|
|
3408
|
+
if (kind === 'ended') {
|
|
3409
|
+
clearTimeout(timer);
|
|
3410
|
+
p.textContent = t('session.ended');
|
|
3411
|
+
buttons.append(button(t('login.sign_in_link'), 'btn-hot', () => (location.href = `${document.body.dataset.base}/login?next=${encodeURIComponent(location.pathname + location.search)}`)));
|
|
3412
|
+
} else if (kind === 'max') {
|
|
3413
|
+
p.textContent = t('session.max').replace('{time}', new Date(s.maxEnd).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
|
3414
|
+
buttons.append(button('OK', 'btn-hot', () => dlg.close()));
|
|
3415
|
+
} else {
|
|
3416
|
+
p.textContent = t('session.warning').replace('{minutes}', String(Math.max(1, Math.ceil(left / 60000))));
|
|
3417
|
+
buttons.append(button(t('session.stay'), 'btn-hot', stay), button(t('common.dismiss'), '', () => ((dismissed = true), dlg.close())));
|
|
3418
|
+
}
|
|
3419
|
+
if (!dlg.open) dlg.showModal();
|
|
3420
|
+
}
|
|
3421
|
+
async function stay() {
|
|
3422
|
+
try {
|
|
3423
|
+
const res = await fetch(s.extend, { method: 'POST', body: new URLSearchParams({ __csrf: meta.csrf }), headers: { accept: 'application/json' }, credentials: 'same-origin' });
|
|
3424
|
+
if (!res.ok) return show('ended');
|
|
3425
|
+
last = Date.now();
|
|
3426
|
+
shown = '';
|
|
3427
|
+
dismissed = false;
|
|
3428
|
+
dlg.close();
|
|
3429
|
+
check();
|
|
3430
|
+
} catch {
|
|
3431
|
+
// offline: try again with the next click
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
check();
|
|
3435
|
+
})();
|
package/public/builder.css
CHANGED
|
@@ -759,3 +759,14 @@ button.is-busy { opacity: 0.7; cursor: progress; }
|
|
|
759
759
|
.icon-choice .icon { width: 20px; height: 20px; }
|
|
760
760
|
.icon-none { font-size: 18px; line-height: 20px; color: var(--muted); }
|
|
761
761
|
.icon-name { font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; line-height: 1.2; }
|
|
762
|
+
|
|
763
|
+
/* Mail services: one provider's settings in groups, at a readable width */
|
|
764
|
+
.mail-form-page { max-width: 960px; }
|
|
765
|
+
.mail-service-form .prop-group { margin: 0 0 16px; }
|
|
766
|
+
details.prop-group-more { border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px; margin: 0 0 16px; }
|
|
767
|
+
details.prop-group-more > summary { cursor: pointer; font-weight: 600; }
|
|
768
|
+
details.prop-group-more[open] > summary { margin-bottom: 8px; }
|
|
769
|
+
.provider-switch { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin-bottom: 16px; }
|
|
770
|
+
.provider-switch .field { margin: 0; min-width: min(100%, 280px); }
|
|
771
|
+
.mail-check-buttons { margin: 4px 0 14px; }
|
|
772
|
+
.mail-check-result:not(:empty) { margin-top: 8px; }
|
package/public/builder.js
CHANGED
|
@@ -782,3 +782,30 @@
|
|
|
782
782
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
|
783
783
|
else init();
|
|
784
784
|
})();
|
|
785
|
+
|
|
786
|
+
// Mail services: Test connection and Send test mail answer in place, so the form keeps what was typed
|
|
787
|
+
// (the secret too). Without JavaScript the buttons post the form and the page comes back with the result.
|
|
788
|
+
document.addEventListener('click', async (e) => {
|
|
789
|
+
const btn = e.target.closest?.('button[name="check"][formaction$="/mail/check"]');
|
|
790
|
+
if (!btn || !btn.form) return;
|
|
791
|
+
e.preventDefault();
|
|
792
|
+
const form = btn.form;
|
|
793
|
+
const out = form.querySelector('.mail-check-result');
|
|
794
|
+
const data = new FormData(form);
|
|
795
|
+
data.set('check', btn.value);
|
|
796
|
+
const buttons = form.querySelectorAll('button[name="check"]');
|
|
797
|
+
buttons.forEach((b) => (b.disabled = true));
|
|
798
|
+
if (out) out.textContent = btn.value === 'send' ? 'Sending…' : 'Checking…';
|
|
799
|
+
try {
|
|
800
|
+
const res = await fetch(btn.formAction, { method: 'POST', body: new URLSearchParams(data), headers: { accept: 'application/json' }, credentials: 'same-origin' });
|
|
801
|
+
const json = await res.json();
|
|
802
|
+
const div = document.createElement('div');
|
|
803
|
+
div.className = `alert ${json.ok ? 'alert-success' : 'alert-error'}`;
|
|
804
|
+
div.textContent = json.text || (json.ok ? 'OK' : 'The check failed.');
|
|
805
|
+
out?.replaceChildren(div);
|
|
806
|
+
} catch {
|
|
807
|
+
if (out) out.textContent = 'The check could not be made (the session may have ended: reload the page).';
|
|
808
|
+
} finally {
|
|
809
|
+
buttons.forEach((b) => (b.disabled = false));
|
|
810
|
+
}
|
|
811
|
+
});
|
package/src/app.ts
CHANGED
|
@@ -23,6 +23,10 @@ import { topSqlRoutes } from './builder/top-sql.ts';
|
|
|
23
23
|
import { diagnosticsRoutes } from './builder/diagnostics.ts';
|
|
24
24
|
import { ldapRoutes } from './builder/ldap.ts';
|
|
25
25
|
import { aiRoutes } from './builder/ai.ts';
|
|
26
|
+
import { mailRoutes } from './builder/mail.ts';
|
|
27
|
+
import { backupRoutes } from './builder/backups.ts';
|
|
28
|
+
import { availabilityRoutes } from './builder/availability.ts';
|
|
29
|
+
import { feedbackRoutes as builderFeedbackRoutes } from './builder/feedback.ts';
|
|
26
30
|
import { documentRoutes } from './builder/documents.ts';
|
|
27
31
|
import { pwaBuilderRoutes } from './builder/pwa.ts';
|
|
28
32
|
import { themeRollerRoutes } from './builder/themeroller.ts';
|
|
@@ -170,6 +174,10 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
170
174
|
await app.register(diagnosticsRoutes);
|
|
171
175
|
await app.register(ldapRoutes);
|
|
172
176
|
await app.register(aiRoutes);
|
|
177
|
+
await app.register(mailRoutes);
|
|
178
|
+
await app.register(backupRoutes);
|
|
179
|
+
await app.register(availabilityRoutes);
|
|
180
|
+
await app.register(builderFeedbackRoutes);
|
|
173
181
|
await app.register(documentRoutes);
|
|
174
182
|
await app.register(pwaBuilderRoutes);
|
|
175
183
|
await app.register(themeRollerRoutes);
|
package/src/appfiles.ts
CHANGED
|
@@ -127,6 +127,10 @@ const CODE: Record<string, Record<string, string>> = {
|
|
|
127
127
|
list: { query: 'sql' },
|
|
128
128
|
supporting_script: { script: 'sql' },
|
|
129
129
|
plugin: { install_sql: 'sql' },
|
|
130
|
+
// (080) a search configuration on a SELECT
|
|
131
|
+
search_config: { source: 'sql' },
|
|
132
|
+
// (081) an e-mail template's bodies
|
|
133
|
+
email_template: { html_body: 'html', text_body: 'txt' },
|
|
130
134
|
};
|
|
131
135
|
const isLong = (v: unknown): v is string => typeof v === 'string' && (v.includes('\n') || v.length > 60);
|
|
132
136
|
const codeExt = (table: string, column: string, row: any) =>
|
|
@@ -180,6 +184,12 @@ const NAMED: [section: string, dir: string, table: string][] = [
|
|
|
180
184
|
['supporting_scripts', 'shared/supporting-objects', 'supporting_script'],
|
|
181
185
|
// (069) plug-ins: install SQL in <name>.install_sql.sql
|
|
182
186
|
['plugins', 'shared/plugins', 'plugin'],
|
|
187
|
+
// (080) search configurations
|
|
188
|
+
['search_configs', 'shared/search-configurations', 'search_config'],
|
|
189
|
+
// (081) e-mail templates
|
|
190
|
+
['email_templates', 'shared/email-templates', 'email_template'],
|
|
191
|
+
// (086) application settings
|
|
192
|
+
['app_settings', 'shared/app-settings', 'app_setting'],
|
|
183
193
|
];
|
|
184
194
|
|
|
185
195
|
/** Components of a page: [array in the document, directory, table, key source]. */
|
|
@@ -198,6 +208,8 @@ const PAGE_PARTS: [section: string, dir: string, table: string, key: (r: any) =>
|
|
|
198
208
|
const SINGLE: [section: string, path: string, sort: string[]][] = [
|
|
199
209
|
['group_roles', 'shared/group-roles.json', ['group_name', 'role']],
|
|
200
210
|
['text_messages', 'globalization/text-messages.json', ['name', 'language']],
|
|
211
|
+
// (086) dynamic translations (meta.lang)
|
|
212
|
+
['dynamic_translations', 'globalization/dynamic-translations.json', ['language', 'source']],
|
|
201
213
|
];
|
|
202
214
|
|
|
203
215
|
const ACTIONS_DIR = 'shared/automation-actions';
|
package/src/automations.ts
CHANGED
|
@@ -305,6 +305,18 @@ export function startScheduler() {
|
|
|
305
305
|
} catch (e) {
|
|
306
306
|
console.error('push notifications:', (e as Error).message);
|
|
307
307
|
}
|
|
308
|
+
try {
|
|
309
|
+
// (082) a daily backup of every application that changed (src/builder/backups.ts)
|
|
310
|
+
await (await import('./builder/backups.ts')).backupTick();
|
|
311
|
+
} catch (e) {
|
|
312
|
+
console.error('backups:', (e as Error).message);
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
// mail queued from SQL (meta.send_mail; src/mail/mail.ts), when the listener missed it, and retries
|
|
316
|
+
await (await import('./mail/mail.ts')).mailTick();
|
|
317
|
+
} catch (e) {
|
|
318
|
+
console.error('mail:', (e as Error).message);
|
|
319
|
+
}
|
|
308
320
|
try {
|
|
309
321
|
// AI requests queued from SQL (meta.ai_generate; src/ai/requests.ts)
|
|
310
322
|
await (await import('./ai/requests.ts')).aiRequestTick();
|