pgkiln 0.82.0 → 0.84.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/153_component_events.sql +21 -0
- package/db/migrations/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +4 -3
- package/docs/guide/03-builder.md +10 -1
- package/docs/guide/04-pages-and-regions.md +44 -1
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +28 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +3 -1
- package/docs/guide/12-development.md +16 -1
- package/package.json +1 -1
- package/public/app.css +31 -0
- package/public/app.js +163 -4
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/public/sw.js +5 -1
- package/src/app.ts +2 -0
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +13 -8
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1635 -0
- package/src/builder/i18n/en.ts +1635 -0
- package/src/builder/i18n/es.ts +1635 -0
- package/src/builder/i18n/fr.ts +1635 -0
- package/src/builder/i18n/it.ts +1635 -0
- package/src/builder/i18n/ja.ts +1635 -0
- package/src/builder/i18n/ko.ts +1635 -0
- package/src/builder/i18n/nl.ts +1635 -0
- package/src/builder/i18n/pt.ts +1635 -0
- package/src/builder/i18n/zh-cn.ts +1635 -0
- package/src/builder/i18n/zh-tw.ts +1635 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-settings.ts +201 -199
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/routes.ts +3 -0
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cli/replace.ts +2 -0
- package/src/cron-schedule.ts +47 -17
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +11 -3
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +2 -0
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/maps.ts +7 -4
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +18 -4
- package/src/runtime/routes.ts +16 -4
- package/src/runtime/signin.ts +2 -1
- package/src/runtime/tree.ts +4 -3
- package/src/session.ts +13 -4
|
@@ -328,6 +328,10 @@ src/
|
|
|
328
328
|
availability.ts availabilityRefusal(): who may use an application (migration 083); loadContext, the login page
|
|
329
329
|
and REST modules ask it (PostgREST: meta.api_check)
|
|
330
330
|
feedback.ts users' feedback page and the navigation bar entry (migration 084)
|
|
331
|
+
notifications.ts (sprint 165, migration 154) the notification bell (bellMarkup: unread count, newest 8, Mark all read),
|
|
332
|
+
/a/:alias/notifications, POST …/notifications/:id/open (the user's own only, marks read, its signed
|
|
333
|
+
link under the current alias) and …/notifications/read; GET …/:page/live (liveState: list entry
|
|
334
|
+
badges n:/b:<key>, buttons btn:<id>, the bell) with loadContext touch: false (no last_seen, no new session)
|
|
331
335
|
data-items.ts display image and address items (migration 086): imageUrl (https origins into the CSP's img-src),
|
|
332
336
|
GET …/image/:item (a BLOB from the item's query), POST …/geocode/:item, and items kept per user
|
|
333
337
|
(loadPreferences / savePreferences through meta.get_preference / set_preference)
|
|
@@ -338,7 +342,8 @@ src/
|
|
|
338
342
|
components.ts property spec of every component (drives the property editor)
|
|
339
343
|
ui.ts IDE shell (icon rail, toolbar, breadcrumb, status bar), builder theme, form helpers, CSRF check, app tabs
|
|
340
344
|
i18n.ts (sprint 121) the builder's own texts: BUILDER_LANGUAGES, tr(s) / translatorFor(lang) → tx(key, params),
|
|
341
|
-
tx.rich (markup in a sentence), tx.dyn (keys made at run time), tx.label (known breadcrumb labels)
|
|
345
|
+
tx.rich (markup in a sentence), tx.dyn (keys made at run time), tx.label (known breadcrumb labels),
|
|
346
|
+
(sprint 162) tx.code (`backticks` become <code>), withTranslator / scopedTx (a request-scoped translator)
|
|
342
347
|
i18n/ en.ts defines the builder's keys; de, es, fr, it, nl, pt, zh-cn, zh-tw, ja, ko (sprint 134) are Records of the same keys
|
|
343
348
|
language.ts (migration 127) the status bar's language menu (POST /builder/language), the sign-in page's
|
|
344
349
|
language links (?lang=, a cookie), meta.developer.language
|
|
@@ -610,6 +615,8 @@ test/
|
|
|
610
615
|
fixtures/ test files (employees.xlsx)
|
|
611
616
|
template-components.test.ts template language, escaping, plug-ins, regions and column templates
|
|
612
617
|
logic.test.ts computations, branches, menu buttons and badges, new dynamic actions, build options, export
|
|
618
|
+
notifications.test.ts (165) meta.notify and its checks, the bell, opening and Mark all read, list entry badge queries,
|
|
619
|
+
the live endpoint, send_notification, the timer event, the settings (security cases in security.test.ts)
|
|
613
620
|
page-logic.test.ts download, chain (background jobs) and workflow processes, function/app branches, dialog_closed (HR page 28)
|
|
614
621
|
code-editor.test.ts code editor: completions scoped to the app's role, the check, marked fields
|
|
615
622
|
builder-i18n.test.ts (sprint 121) builder languages: the coverage check of src/builder against i18n/en.ts, the message
|
|
@@ -683,6 +690,7 @@ test/
|
|
|
683
690
|
e2e/globalization.test.ts the browser's time zone (sign-in, app.js), no-JavaScript fallback, a masked number item
|
|
684
691
|
e2e/grid.test.ts interactive grid in a browser: master-detail refresh, move/resize columns, row menu, copy/paste; without JavaScript
|
|
685
692
|
e2e/page-logic.test.ts dialog_closed refreshes a region without a reload, download process in a browser, dialog link without JavaScript
|
|
693
|
+
e2e/notifications.test.ts (165) a notification reaches the open page (bell and navigation bar badge), opened from the bell; a timer action
|
|
686
694
|
e2e/ai-builder.test.ts App Builder AI in a browser (mock Claude): SQL from a question, a drafted description, proposed pages,
|
|
687
695
|
a blueprint drafted and reviewed
|
|
688
696
|
e2e/ai-assistant.test.ts the AI assistant and report questions on HR page 38 in a browser (mock Claude), with and without JavaScript
|
|
@@ -704,8 +712,15 @@ const tx = tr(s); // the developer's language (
|
|
|
704
712
|
html`<h2>${tx('wh.summary')}</h2><p>${tx('ab.count', { n: 3 })}</p>`; // plain text: html`` escapes it
|
|
705
713
|
tx.rich('ab.none_yet', { create: html`<a href="…">${tx('ab.create_one')}</a>` }); // markup inside a sentence
|
|
706
714
|
tx.dyn(`gallery.region.${type}`, REGION_LABELS[type][0]); // a key made at run time, with a fallback
|
|
715
|
+
tx.code('cr.schedule_help'); // (sprint 162) `backticked` parts become <code>: syntax help
|
|
707
716
|
```
|
|
708
717
|
|
|
718
|
+
(sprint 162) A long form built by many helpers (the region and report settings, RESTful Services' pages) doesn't
|
|
719
|
+
pass `tx` to each of them: its entry point runs inside `withTranslator(tr(s), () => …)`, and the helpers import
|
|
720
|
+
`scopedTx as tx`, which takes the translator of the request being rendered from an `AsyncLocalStorage` (so two
|
|
721
|
+
requests in different languages never mix; `test/builder-i18n.test.ts` renders German and French concurrently).
|
|
722
|
+
Outside `withTranslator` it is English.
|
|
723
|
+
|
|
709
724
|
A missing text falls back to English, never to the raw key. Shared helpers that render chrome take the session as
|
|
710
725
|
an optional last argument (`appHeader(a, 'pages', s)`, `workshopTabs('sql', s)`); `shell()` translates known English
|
|
711
726
|
breadcrumb labels (`tx.label`) so pages that aren't moved yet still get a translated trail.
|
package/package.json
CHANGED
package/public/app.css
CHANGED
|
@@ -280,6 +280,35 @@ h2 { font-size: 1rem; margin: 0; font-weight: 600; }
|
|
|
280
280
|
.t-user > summary::-webkit-details-marker { display: none; }
|
|
281
281
|
.t-user > summary:hover { background: rgb(255 255 255 / 10%); }
|
|
282
282
|
|
|
283
|
+
/* (165) the notification bell: the unread count on the icon, the newest notifications in its menu */
|
|
284
|
+
.t-bell > summary { position: relative; }
|
|
285
|
+
.t-bell-count {
|
|
286
|
+
position: absolute; top: 1px; inset-inline-end: 0; min-width: 1.15rem; height: 1.15rem; padding: 0 0.3rem; border-radius: 99px;
|
|
287
|
+
background: #d92d20; color: #fff; font-size: 0.68rem; font-weight: 700; line-height: 1.15rem; text-align: center;
|
|
288
|
+
box-shadow: 0 0 0 2px var(--header, #1d1b31);
|
|
289
|
+
}
|
|
290
|
+
.t-bell-panel { width: min(380px, calc(100vw - 1rem)); }
|
|
291
|
+
.t-bell-head { flex-direction: row; align-items: center; justify-content: space-between; }
|
|
292
|
+
.t-bell-read { margin: 0; }
|
|
293
|
+
.t-bell-read .link-button { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.85rem; }
|
|
294
|
+
.t-bell-none { padding: 0.5rem 0.9rem; margin: 0; }
|
|
295
|
+
.t-bell-list, .t-notification-list { list-style: none; margin: 0; padding: 0; }
|
|
296
|
+
.t-bell-list form, .t-notification-list form { margin: 0; }
|
|
297
|
+
.t-notification {
|
|
298
|
+
display: flex; gap: 0.6rem; align-items: flex-start; width: 100%; padding: 0.55rem 0.9rem; border: 0; background: none;
|
|
299
|
+
color: var(--text); font: inherit; text-align: start; cursor: pointer;
|
|
300
|
+
}
|
|
301
|
+
.t-notification:hover, .t-notification:focus-visible { background: var(--surface-2, rgb(0 0 0 / 4%)); }
|
|
302
|
+
.t-notification > .icon { flex: none; margin-top: 0.15rem; color: var(--muted); }
|
|
303
|
+
.t-notification-text { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
|
|
304
|
+
.t-notification-title { font-weight: 500; }
|
|
305
|
+
.t-notification-body { color: var(--muted); font-size: 0.88rem; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
306
|
+
.t-notification-time { color: var(--muted); font-size: 0.78rem; }
|
|
307
|
+
.is-unread > form > .t-notification { box-shadow: inset 3px 0 0 var(--accent); }
|
|
308
|
+
.is-unread .t-notification-title { font-weight: 700; }
|
|
309
|
+
.is-unread > form > .t-notification > .icon { color: var(--accent); }
|
|
310
|
+
.t-notification-list > li + li { border-top: 1px solid var(--border); }
|
|
311
|
+
|
|
283
312
|
/* ---------- menus (details dropdowns) ---------- */
|
|
284
313
|
details.menu { position: relative; }
|
|
285
314
|
details.menu > summary { list-style: none; }
|
|
@@ -1468,6 +1497,8 @@ path.map-near { stroke: #1f2733; stroke-dasharray: 6 4; fill: #1f2733; fill-opac
|
|
|
1468
1497
|
.tree-view a, .tree-view summary > span, .tree-leaf > span { display: inline-flex; align-items: center; gap: 0.35rem; }
|
|
1469
1498
|
.tree-leaf { padding-left: 1rem; }
|
|
1470
1499
|
.tree-count { font-size: 0.75rem; color: var(--muted); }
|
|
1500
|
+
/* (161) the node a tree_selection_change dynamic action chose */
|
|
1501
|
+
.tree-view li.tree-selected > :is(a, span:not(.tree-count), .tree-row), .tree-view li.tree-selected > details > summary > span { background: var(--accent-soft); border-radius: 3px; outline: 1px solid var(--accent); outline-offset: 1px; }
|
|
1471
1502
|
/* a linked branch: the disclosure triangle sits in front of the link's row */
|
|
1472
1503
|
.tree-branch { position: relative; }
|
|
1473
1504
|
.tree-branch > .tree-row { display: inline-flex; align-items: center; gap: 0.35rem; }
|
package/public/app.js
CHANGED
|
@@ -507,6 +507,8 @@ const pgkilnUnsaved = (() => {
|
|
|
507
507
|
|
|
508
508
|
/** (085) what runDa returns to stop the actions after it (a declined confirm) */
|
|
509
509
|
const STOP = Symbol('stop');
|
|
510
|
+
/** (161) the element of a component event (a grid row, calendar cell, map, tree node): execute_javascript's `element` */
|
|
511
|
+
let componentElement = null;
|
|
510
512
|
/** (156) does a Cancel Event in these actions hold its condition (decided at once, so the event's effect can be stopped)? */
|
|
511
513
|
const cancels = (list) => list.some((da) => da.action === 'cancel_event' && conditionHolds(da));
|
|
512
514
|
const text = (k) => (meta.texts && meta.texts[k]) || k;
|
|
@@ -634,6 +636,11 @@ const pgkilnUnsaved = (() => {
|
|
|
634
636
|
case 'clear':
|
|
635
637
|
for (const n of da.items) setItemValue(n, '');
|
|
636
638
|
return;
|
|
639
|
+
// (161) set_value without SQL on a component event: the event's value, set in the browser
|
|
640
|
+
case 'set_value':
|
|
641
|
+
if (!da.eventValue) break;
|
|
642
|
+
for (const n of da.items) setItemValue(n, value == null ? '' : String(value));
|
|
643
|
+
return;
|
|
637
644
|
case 'open_region':
|
|
638
645
|
case 'close_region':
|
|
639
646
|
for (const el of targets(da)) {
|
|
@@ -731,7 +738,7 @@ const pgkilnUnsaved = (() => {
|
|
|
731
738
|
const fn = da.fn && actions.get(da.fn);
|
|
732
739
|
if (!fn) return console.warn(`pgkiln: no function "${da.fn}" registered (pgkiln.actions.register) for dynamic action ${da.id}`);
|
|
733
740
|
try {
|
|
734
|
-
await fn({ value, items: da.items, region: da.region ? document.getElementById(`R${da.region}`) : null, elements: targets(da), message: da.message });
|
|
741
|
+
await fn({ value, items: da.items, region: da.region ? document.getElementById(`R${da.region}`) : null, elements: targets(da), message: da.message, element: componentElement });
|
|
735
742
|
} catch (e) {
|
|
736
743
|
console.error(e);
|
|
737
744
|
showError(e.message);
|
|
@@ -908,6 +915,16 @@ const pgkilnUnsaved = (() => {
|
|
|
908
915
|
requestAnimationFrame(() => { queued = false; runChain(run); });
|
|
909
916
|
}, { passive: true });
|
|
910
917
|
}
|
|
918
|
+
// (165) timer: every so many seconds (the trigger), while the page is visible
|
|
919
|
+
{
|
|
920
|
+
const bySeconds = new Map();
|
|
921
|
+
for (const da of das.filter((d) => d.event === 'timer')) {
|
|
922
|
+
const seconds = Number(da.trigger[0]);
|
|
923
|
+
if (!(seconds >= 5 && seconds <= 3600)) continue;
|
|
924
|
+
bySeconds.set(seconds, [...(bySeconds.get(seconds) || []), da]);
|
|
925
|
+
}
|
|
926
|
+
for (const [seconds, run] of bySeconds) setInterval(() => { if (!document.hidden) runChain(run); }, seconds * 1000);
|
|
927
|
+
}
|
|
911
928
|
// page_unload: the page is left (only quick, local actions finish)
|
|
912
929
|
window.addEventListener('pagehide', () => runChain(das.filter((da) => da.event === 'page_unload')));
|
|
913
930
|
// facets_change: a faceted search region's facets were applied (the page came back from its form)
|
|
@@ -920,6 +937,58 @@ const pgkilnUnsaved = (() => {
|
|
|
920
937
|
runChain(regionRun('facets_change', document.getElementById(`R${changed.replace(/\D/g, '')}`)));
|
|
921
938
|
}
|
|
922
939
|
}
|
|
940
|
+
// (161) component events of grid, calendar, map and tree regions (trigger: the region). The component
|
|
941
|
+
// raises pgkiln:component {event, element, value}; detail.cancelled tells it a cancel_event holds
|
|
942
|
+
// (the calendar's Create link, the tree node's link) and detail.ran how many actions run.
|
|
943
|
+
document.addEventListener('pgkiln:component', (e) => {
|
|
944
|
+
const d = e.detail || {};
|
|
945
|
+
const region = d.element && d.element.closest && d.element.closest('section[id^="R"], div[id^="R"], dialog[id^="R"]');
|
|
946
|
+
const run = regionRun(d.event, region);
|
|
947
|
+
d.ran = run.length;
|
|
948
|
+
if (!run.length) return;
|
|
949
|
+
const value = d.value == null ? '' : String(d.value);
|
|
950
|
+
if (run.some((da) => da.action === 'cancel_event' && conditionHolds(da, value))) d.cancelled = true;
|
|
951
|
+
componentElement = d.element;
|
|
952
|
+
runChain(run, value).finally(() => (componentElement = null));
|
|
953
|
+
});
|
|
954
|
+
// grid_save: the page that comes back after a grid's Save
|
|
955
|
+
for (const id of meta.savedGrids || []) {
|
|
956
|
+
const run = regionRun('grid_save', document.getElementById(`R${id}`));
|
|
957
|
+
if (run.length) runChain(run);
|
|
958
|
+
}
|
|
959
|
+
// calendar_view_change: the page that comes back from a calendar's view or period link (cal_changed=<region>)
|
|
960
|
+
{
|
|
961
|
+
const params = new URLSearchParams(location.search);
|
|
962
|
+
const changed = params.get('cal_changed');
|
|
963
|
+
if (changed) {
|
|
964
|
+
params.delete('cal_changed');
|
|
965
|
+
history.replaceState(history.state, '', `${location.pathname}${params.size ? `?${params}` : ''}${location.hash}`);
|
|
966
|
+
const region = document.getElementById(`R${changed.replace(/\D/g, '')}`);
|
|
967
|
+
const run = regionRun('calendar_view_change', region);
|
|
968
|
+
if (run.length) runChain(run, region.querySelector('.calendar')?.dataset.view || '');
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
document.addEventListener('click', (e) => {
|
|
972
|
+
const a = e.target.closest?.('.calendar .cal-toolbar a[href]');
|
|
973
|
+
const region = a && a.closest('section[id^="R"], div[id^="R"]');
|
|
974
|
+
if (!region || !regionRun('calendar_view_change', region).length) return;
|
|
975
|
+
const url = new URL(a.href, location.href);
|
|
976
|
+
url.searchParams.set('cal_changed', region.id.slice(1));
|
|
977
|
+
a.href = url.pathname + url.search + url.hash;
|
|
978
|
+
}, true);
|
|
979
|
+
// tree_selection_change: a node's label chosen (not the disclosure of a linked branch); the node is marked
|
|
980
|
+
document.addEventListener('click', (e) => {
|
|
981
|
+
const node = e.target.closest?.('.tree-view li[data-node]');
|
|
982
|
+
if (!node) return;
|
|
983
|
+
const summary = e.target.closest('summary');
|
|
984
|
+
if (summary && !summary.querySelector(':scope > span:not(.sr-only), :scope > a')) return;
|
|
985
|
+
const d = { event: 'tree_selection_change', element: node, value: node.dataset.node };
|
|
986
|
+
document.dispatchEvent(new CustomEvent('pgkiln:component', { detail: d }));
|
|
987
|
+
if (!d.ran) return;
|
|
988
|
+
node.closest('.tree-view').querySelectorAll('li.tree-selected').forEach((li) => li.classList.remove('tree-selected'));
|
|
989
|
+
node.classList.add('tree-selected');
|
|
990
|
+
if (d.cancelled) e.preventDefault();
|
|
991
|
+
});
|
|
923
992
|
// after_refresh: a region replaced by a refresh, a lazy load or a master row
|
|
924
993
|
document.addEventListener('pgkiln:replaced', (e) => {
|
|
925
994
|
const run = regionRun('after_refresh', e.detail);
|
|
@@ -1143,10 +1212,23 @@ const pgkilnUnsaved = (() => {
|
|
|
1143
1212
|
|
|
1144
1213
|
// ------------------------------------------------------------ calendar
|
|
1145
1214
|
// Create on click: a click on an empty day or hour slot follows its "+" link.
|
|
1215
|
+
// (161) a day or hour chosen (a click on the cell or its "+"): calendar_date_select; its cancel_event keeps the link closed
|
|
1216
|
+
let addClicked = false;
|
|
1146
1217
|
document.addEventListener('click', (e) => {
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1218
|
+
if (addClicked) return void (addClicked = false);
|
|
1219
|
+
const cell = e.target.closest?.('.calendar [data-date]');
|
|
1220
|
+
if (!cell) return;
|
|
1221
|
+
const add = e.target.closest('a.cal-add');
|
|
1222
|
+
if (!add && e.target.closest('a, button')) return;
|
|
1223
|
+
const d = { event: 'calendar_date_select', element: cell, value: cell.dataset.date };
|
|
1224
|
+
document.dispatchEvent(new CustomEvent('pgkiln:component', { detail: d }));
|
|
1225
|
+
if (d.cancelled) return void e.preventDefault();
|
|
1226
|
+
const link = !add && cell.hasAttribute('data-add') && cell.querySelector(':scope > .cal-add');
|
|
1227
|
+
if (link) {
|
|
1228
|
+
addClicked = true;
|
|
1229
|
+
link.click();
|
|
1230
|
+
addClicked = false;
|
|
1231
|
+
}
|
|
1150
1232
|
});
|
|
1151
1233
|
// Drag and drop (mouse): drop an event on a day or hour slot; the server moves it
|
|
1152
1234
|
// and sends the calendar back. Without JS (and from the keyboard) the event's
|
|
@@ -1442,6 +1524,9 @@ const pgkilnUnsaved = (() => {
|
|
|
1442
1524
|
return row;
|
|
1443
1525
|
}
|
|
1444
1526
|
|
|
1527
|
+
/** (161) grid_row_init: a new row (Add row, Duplicate); the condition sees the row's number among the new rows */
|
|
1528
|
+
const rowInit = (row) => document.dispatchEvent(new CustomEvent('pgkiln:component', { detail: { event: 'grid_row_init', element: row, value: row.dataset.newRow } }));
|
|
1529
|
+
|
|
1445
1530
|
/** The editable control of a cell, if any. */
|
|
1446
1531
|
const control = (td) => td?.querySelector('input:not([type=hidden]), select');
|
|
1447
1532
|
|
|
@@ -1482,6 +1567,7 @@ const pgkilnUnsaved = (() => {
|
|
|
1482
1567
|
if (add) {
|
|
1483
1568
|
const row = addRow(add.closest('[data-grid]'));
|
|
1484
1569
|
row?.querySelector('input:not([type=hidden]), select')?.focus();
|
|
1570
|
+
if (row) rowInit(row);
|
|
1485
1571
|
return;
|
|
1486
1572
|
}
|
|
1487
1573
|
// Duplicate: the row's values in a new row (without JS a link does it on the server)
|
|
@@ -1498,6 +1584,7 @@ const pgkilnUnsaved = (() => {
|
|
|
1498
1584
|
if (from && control(td) && control(from)) setCell(td, control(from).tagName === 'SELECT' ? control(from).value : cellText(from));
|
|
1499
1585
|
}
|
|
1500
1586
|
control(row.querySelector('td[data-col]:not([hidden])'))?.focus();
|
|
1587
|
+
rowInit(row);
|
|
1501
1588
|
return;
|
|
1502
1589
|
}
|
|
1503
1590
|
if (e.target.closest('[data-grid-del]')) {
|
|
@@ -2774,6 +2861,66 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
2774
2861
|
for (const form of document.querySelectorAll('form[data-autosubmit]')) form.submit();
|
|
2775
2862
|
});
|
|
2776
2863
|
|
|
2864
|
+
// (165) Live badges and the notification bell (body data-live: seconds; GET <base>/<page>/live).
|
|
2865
|
+
const liveBadges = (() => {
|
|
2866
|
+
let seconds = 0;
|
|
2867
|
+
let timer = 0;
|
|
2868
|
+
let last = 0;
|
|
2869
|
+
let busy = false;
|
|
2870
|
+
let stopped = false;
|
|
2871
|
+
const apply = (state) => {
|
|
2872
|
+
for (const el of document.querySelectorAll('[data-badge]')) {
|
|
2873
|
+
const key = el.dataset.badge;
|
|
2874
|
+
if (key === 'bell' || !(key in state.badges)) continue;
|
|
2875
|
+
const v = state.badges[key];
|
|
2876
|
+
el.textContent = v ?? '';
|
|
2877
|
+
el.hidden = v === null || v === '';
|
|
2878
|
+
}
|
|
2879
|
+
const bell = state.bell && document.querySelector('[data-bell]');
|
|
2880
|
+
if (!bell) return;
|
|
2881
|
+
const count = bell.querySelector('[data-badge="bell"]');
|
|
2882
|
+
if (count) {
|
|
2883
|
+
count.textContent = state.bell.unread ? (state.bell.unread > 99 ? '99+' : String(state.bell.unread)) : '';
|
|
2884
|
+
count.hidden = !state.bell.unread;
|
|
2885
|
+
}
|
|
2886
|
+
const summary = bell.querySelector('summary');
|
|
2887
|
+
if (summary) {
|
|
2888
|
+
summary.title = state.bell.label;
|
|
2889
|
+
summary.setAttribute('aria-label', state.bell.label);
|
|
2890
|
+
}
|
|
2891
|
+
// the list is replaced while the menu is closed only (not under the user's pointer or focus)
|
|
2892
|
+
const panel = bell.querySelector('[data-bell-panel]');
|
|
2893
|
+
if (panel && !bell.open && panel.innerHTML !== state.bell.panel) panel.innerHTML = state.bell.panel;
|
|
2894
|
+
};
|
|
2895
|
+
const poll = () => {
|
|
2896
|
+
const base = document.body.dataset.base;
|
|
2897
|
+
const page = document.body.dataset.page;
|
|
2898
|
+
if (stopped || busy || !base || !page) return;
|
|
2899
|
+
busy = true;
|
|
2900
|
+
last = Date.now();
|
|
2901
|
+
fetch(`${base}/${page}/live`, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
|
|
2902
|
+
.then((r) => {
|
|
2903
|
+
if (r.status === 401 || r.status === 403 || r.status === 404) stopped = true;
|
|
2904
|
+
return r.ok ? r.json() : null;
|
|
2905
|
+
})
|
|
2906
|
+
.then((state) => state && state.badges && apply(state))
|
|
2907
|
+
.catch(() => {})
|
|
2908
|
+
.finally(() => { busy = false; });
|
|
2909
|
+
};
|
|
2910
|
+
return {
|
|
2911
|
+
start(s) {
|
|
2912
|
+
if (!(s >= 10) || timer) return;
|
|
2913
|
+
seconds = s;
|
|
2914
|
+
timer = setInterval(() => { if (!document.hidden) poll(); }, seconds * 1000);
|
|
2915
|
+
// back to a tab that was hidden longer than the interval: at once
|
|
2916
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && Date.now() - last > seconds * 1000) poll(); });
|
|
2917
|
+
},
|
|
2918
|
+
now() {
|
|
2919
|
+
if (seconds) poll();
|
|
2920
|
+
},
|
|
2921
|
+
};
|
|
2922
|
+
})();
|
|
2923
|
+
|
|
2777
2924
|
// ------------------------------------------------------------------ Progressive Web App and field work
|
|
2778
2925
|
(() => {
|
|
2779
2926
|
const metaEl = document.getElementById('pgkiln-meta');
|
|
@@ -2802,10 +2949,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
2802
2949
|
}
|
|
2803
2950
|
navigator.serviceWorker.addEventListener('message', (e) => {
|
|
2804
2951
|
if (e.data && e.data.type === 'pgkiln:queue') showQueue(e.data.items, post);
|
|
2952
|
+
// (165) a push notification arrived: the badges and the bell now, not at the next refresh
|
|
2953
|
+
if (e.data && e.data.type === 'pgkiln:pushed') liveBadges.now();
|
|
2805
2954
|
});
|
|
2806
2955
|
if (new URLSearchParams(location.search).get('queued') === '1') note(t('pwa.queued'));
|
|
2807
2956
|
}
|
|
2808
2957
|
|
|
2958
|
+
// (165) live badges: every data-live seconds the page asks for its badges (navigation menu and bar,
|
|
2959
|
+
// buttons with a badge query) and the bell, and updates them in place; not while the tab is hidden.
|
|
2960
|
+
// A signed-out session (401) or a page without them (404, 403) stops it.
|
|
2961
|
+
liveBadges.start(Number(body.dataset.live) || 0);
|
|
2962
|
+
|
|
2809
2963
|
// offline: say so (the page may come from the device)
|
|
2810
2964
|
const banner = () => {
|
|
2811
2965
|
let b = document.querySelector('.offline-banner');
|
|
@@ -3247,6 +3401,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
3247
3401
|
// without a Referer: the tiles get the site's origin only (no paths or item values)
|
|
3248
3402
|
window.L.tileLayer(data.tiles, { attribution: data.attribution, maxZoom: 19, referrerPolicy: 'strict-origin-when-cross-origin' }).addTo(map);
|
|
3249
3403
|
const popup = (p) => {
|
|
3404
|
+
// (161) a marker or shape clicked (its popup opens): map_object_click sees its id, else its title
|
|
3405
|
+
el.dispatchEvent(new CustomEvent('pgkiln:component', { bubbles: true, detail: { event: 'map_object_click', element: el, value: p.id ?? p.title ?? '' } }));
|
|
3250
3406
|
// built with the DOM, not HTML strings: titles and texts are data
|
|
3251
3407
|
const box = document.createElement('div');
|
|
3252
3408
|
const b = document.createElement('strong');
|
|
@@ -3290,6 +3446,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
3290
3446
|
if (data.layers.some((l) => l.kind === 'heat')) heatLegend(map, data.legend);
|
|
3291
3447
|
if (several) layerSwitch(map, group, built, data.layersLabel);
|
|
3292
3448
|
if (f) areaFilter(map, f);
|
|
3449
|
+
// (161) map_change: moved or zoomed by the user (after the first view); the condition sees the zoom level
|
|
3450
|
+
map.on('moveend', () => el.dispatchEvent(new CustomEvent('pgkiln:component', { bubbles: true, detail: { event: 'map_change', element: el, value: map.getZoom() } })));
|
|
3293
3451
|
}
|
|
3294
3452
|
});
|
|
3295
3453
|
|
|
@@ -5160,3 +5318,4 @@ document.addEventListener('change', (e) => {
|
|
|
5160
5318
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') send(); });
|
|
5161
5319
|
window.addEventListener('pagehide', send);
|
|
5162
5320
|
})();
|
|
5321
|
+
|
package/public/builder.css
CHANGED
|
@@ -401,6 +401,10 @@ body.ide {
|
|
|
401
401
|
.ide-head-actions { margin-left: auto; }
|
|
402
402
|
.ide-tabs { display: flex; gap: 4px; margin-top: 12px; overflow-x: auto; scrollbar-width: thin; }
|
|
403
403
|
.ide-page > .ide-tabs { margin-top: 0; border-bottom: 1px solid var(--border); }
|
|
404
|
+
/* (sprint 163) regions and two-column rows stacked on a page always have a gap between them (pages used to rely on
|
|
405
|
+
an explicit .u-spacer, and the ones without one let a region touch the one above); not the regions side by side in a
|
|
406
|
+
grid, which have the grid's gap; :where() keeps it weaker than any page's own margin rule */
|
|
407
|
+
:where(.ide-page :is(.region, .columns, :has(> .region)) + :is(.region, .columns, :has(> .region)):not(:is(.columns, .qs-columns, .vec-columns, .ws-cols, .wh-top, [class*="grid"]) > *)) { margin-top: 1rem; }
|
|
404
408
|
.ide-tab {
|
|
405
409
|
position: relative; display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px 9px; color: var(--muted); text-decoration: none;
|
|
406
410
|
border-bottom: 2px solid transparent; border-radius: var(--radius-sm) var(--radius-sm) 0 0; white-space: nowrap; font-weight: 550;
|
|
@@ -1340,7 +1344,7 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
1340
1344
|
.cron-field-grid input { width: 100%; font-family: var(--mono, monospace); }
|
|
1341
1345
|
.cron-preview { border-left: 3px solid var(--accent); padding: 0.4rem 0.8rem; margin: 0.6rem 0; background: var(--accent-soft); border-radius: 0 4px 4px 0; }
|
|
1342
1346
|
.cron-runs { margin: 0.25rem 0 0; padding-left: 1.4rem; font-variant-numeric: tabular-nums; }
|
|
1343
|
-
.cron-error { color: var(--danger
|
|
1347
|
+
.cron-error { color: var(--danger); }
|
|
1344
1348
|
.cron-sql { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
1345
1349
|
/* (sprint 102) SQL Monitor: refresh controls, session actions, bars in Top SQL */
|
|
1346
1350
|
.sm-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
|
|
@@ -1526,7 +1530,7 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
1526
1530
|
.mail-queue-filters .field { min-width: 0; flex: 1 1 160px; max-width: 260px; }
|
|
1527
1531
|
.mail-queue-stats { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
|
1528
1532
|
.mail-queue-stats .stat b { font-size: 1.3rem; }
|
|
1529
|
-
.stat.stat-bad b { color: var(--danger
|
|
1533
|
+
.stat.stat-bad b { color: var(--danger); }
|
|
1530
1534
|
.inline-form { display: inline; margin: 0; }
|
|
1531
1535
|
.mail-failures .buttons { flex-wrap: wrap; gap: 6px; }
|
|
1532
1536
|
.mail-answer { overflow-wrap: anywhere; font-family: var(--font-mono, monospace); font-size: 0.85em; }
|
|
@@ -1648,3 +1652,22 @@ a.cp-type:hover, a.cp-type:focus-visible { border-color: var(--accent); }
|
|
|
1648
1652
|
|
|
1649
1653
|
/* (sprint 139, AP-6) OpenTelemetry */
|
|
1650
1654
|
.otel-problem { color: var(--danger); overflow-wrap: anywhere; }
|
|
1655
|
+
|
|
1656
|
+
/* (sprint 163) an application's REST API page: status pills under the intro, the New client form behind a button */
|
|
1657
|
+
.api-intro { max-width: 80ch; margin: 0 0 0.6rem; }
|
|
1658
|
+
.api-status { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 0; }
|
|
1659
|
+
.api-status li { display: inline-block; border: 1px solid var(--border); border-radius: 14px; padding: 3px 12px; font-size: 0.875rem; overflow-wrap: anywhere; }
|
|
1660
|
+
.api-status li.is-ok > span:first-child { color: var(--success); }
|
|
1661
|
+
.api-status li.is-bad { border-color: var(--danger); }
|
|
1662
|
+
.api-status li.is-bad > span:first-child { color: var(--danger); font-weight: 700; }
|
|
1663
|
+
.api-top { align-items: start; }
|
|
1664
|
+
.api-top form > .field + .field { margin-top: 0.9rem; }
|
|
1665
|
+
.api-sub { font-size: 0.95rem; margin: 1.25rem 0 0.5rem; }
|
|
1666
|
+
.api-new > summary { list-style: none; display: inline-flex; }
|
|
1667
|
+
.api-new > summary::-webkit-details-marker { display: none; }
|
|
1668
|
+
.api-new[open] > summary { margin-bottom: 0.75rem; }
|
|
1669
|
+
.api-new-body { border-top: 1px solid var(--border); padding-top: 0.75rem; }
|
|
1670
|
+
.api-client-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
|
|
1671
|
+
.api-client-actions select { width: auto; min-height: 0; padding-block: 0.2rem; font-size: 0.8125rem; }
|
|
1672
|
+
.api-client-actions .inline-form { display: flex; gap: 6px; align-items: center; }
|
|
1673
|
+
.api-clients td.actions { text-align: end; }
|
package/public/data-modeler.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// Strict CSP: no inline styles; geometry goes into SVG attributes, sizes through the CSSOM.
|
|
11
11
|
(() => {
|
|
12
12
|
'use strict';
|
|
13
|
+
// (sprint 162) texts in the builder's language: #builder-texts (shell(), keys js.*), English in en.ts
|
|
14
|
+
const TEXTS = (() => { try { return JSON.parse(document.getElementById('builder-texts')?.dataset.texts || '{}'); } catch { return {}; } })();
|
|
15
|
+
const msg = (key, params = {}) => (TEXTS[key] ?? key).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
|
|
13
16
|
// the data dictionary's Print button
|
|
14
17
|
for (const b of document.querySelectorAll('.dm-dict-actions [data-dm=print]')) {
|
|
15
18
|
b.hidden = false;
|
|
@@ -104,7 +107,7 @@
|
|
|
104
107
|
btn('redo').disabled = !redo.length;
|
|
105
108
|
const d = dirty();
|
|
106
109
|
btn('save').classList.toggle('btn-hot', d);
|
|
107
|
-
status.textContent = d ? '
|
|
110
|
+
status.textContent = d ? msg('js.dm.unsaved') : '';
|
|
108
111
|
}
|
|
109
112
|
function remember(before) {
|
|
110
113
|
undo.push(before);
|
|
@@ -136,15 +139,15 @@
|
|
|
136
139
|
format: 'json',
|
|
137
140
|
positions: JSON.stringify([...boxes].map(([name, b]) => ({ name, x: Math.round(b.x), y: Math.round(b.y) }))),
|
|
138
141
|
});
|
|
139
|
-
status.textContent = '
|
|
142
|
+
status.textContent = msg('js.dm.saving');
|
|
140
143
|
try {
|
|
141
144
|
const res = await fetch(saveForm.action, { method: 'POST', body, credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
142
145
|
if (!res.ok) throw new Error(String(res.status));
|
|
143
146
|
saved = JSON.stringify(snapshot());
|
|
144
147
|
changed();
|
|
145
|
-
status.textContent = '
|
|
148
|
+
status.textContent = msg('js.dm.saved');
|
|
146
149
|
} catch (e) {
|
|
147
|
-
status.textContent =
|
|
150
|
+
status.textContent = msg('js.dm.not_saved', { error: e.message });
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
btn('save').addEventListener('click', save);
|
|
@@ -333,7 +336,7 @@
|
|
|
333
336
|
ctx.drawImage(img, 0, 0, W, H);
|
|
334
337
|
c.toBlob((blob) => blob && download(blob, `${fileName}.png`), 'image/png');
|
|
335
338
|
};
|
|
336
|
-
img.onerror = () => (status.textContent = '
|
|
339
|
+
img.onerror = () => (status.textContent = msg('js.dm.no_png'));
|
|
337
340
|
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText())}`;
|
|
338
341
|
});
|
|
339
342
|
btn('print').addEventListener('click', () => {
|
package/public/sql-monitor.js
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
// JavaScript the page's Refresh link reloads it.
|
|
8
8
|
(() => {
|
|
9
9
|
'use strict';
|
|
10
|
+
// (sprint 162) texts in the builder's language: #builder-texts (shell(), keys js.*), English in en.ts
|
|
11
|
+
const TEXTS = (() => { try { return JSON.parse(document.getElementById('builder-texts')?.dataset.texts || '{}'); } catch { return {}; } })();
|
|
12
|
+
const msg = (key, params = {}) => (TEXTS[key] ?? key).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
|
|
10
13
|
const live = document.querySelector('[data-sm-live]');
|
|
11
14
|
const controls = document.querySelector('[data-sm-controls]');
|
|
12
15
|
if (!live || !controls) return;
|
|
@@ -26,7 +29,7 @@
|
|
|
26
29
|
|
|
27
30
|
const show = () => {
|
|
28
31
|
pause.setAttribute('aria-pressed', paused ? 'true' : 'false');
|
|
29
|
-
pause.textContent = paused ? '
|
|
32
|
+
pause.textContent = paused ? msg('js.sm.resume') : msg('js.sm.pause');
|
|
30
33
|
live.classList.toggle('is-paused', paused);
|
|
31
34
|
};
|
|
32
35
|
const schedule = () => {
|
|
@@ -52,7 +55,7 @@
|
|
|
52
55
|
paused = true;
|
|
53
56
|
show();
|
|
54
57
|
const note = live.querySelector('[data-sm-updated]');
|
|
55
|
-
if (note) note.textContent = '
|
|
58
|
+
if (note) note.textContent = msg('js.sm.stopped');
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
select.addEventListener('change', () => {
|
package/public/sw.js
CHANGED
|
@@ -210,7 +210,11 @@ self.addEventListener('push', (event) => {
|
|
|
210
210
|
data: { url: appUrl(m.url) || appUrl(`${PGKILN.base}/`) },
|
|
211
211
|
};
|
|
212
212
|
if (typeof m.tag === 'string' && m.tag) Object.assign(options, { tag: m.tag, renotify: true });
|
|
213
|
-
event.waitUntil(
|
|
213
|
+
event.waitUntil(Promise.all([
|
|
214
|
+
self.registration.showNotification(typeof m.title === 'string' && m.title ? m.title : 'Notification', options),
|
|
215
|
+
// (165) open pages refresh their badges and the bell now
|
|
216
|
+
self.clients.matchAll({ type: 'window' }).then((wins) => wins.forEach((w) => w.postMessage({ type: 'pgkiln:pushed' }))),
|
|
217
|
+
]));
|
|
214
218
|
});
|
|
215
219
|
|
|
216
220
|
self.addEventListener('notificationclick', (event) => {
|
package/src/app.ts
CHANGED
|
@@ -60,6 +60,7 @@ import { assistantBuilderRoutes } from './builder/assistant.ts';
|
|
|
60
60
|
import { aiBuilderRoutes } from './builder/ai-builder.ts';
|
|
61
61
|
import { blueprintRoutes } from './builder/blueprints.ts';
|
|
62
62
|
import { accountRoutes } from './runtime/account.ts';
|
|
63
|
+
import { notificationRoutes } from './runtime/notifications.ts';
|
|
63
64
|
import { taskRoutes } from './runtime/tasks.ts';
|
|
64
65
|
import { dataReporterRoutes } from './runtime/data-reporter.ts';
|
|
65
66
|
import { datasetRoutes } from './builder/datasets.ts';
|
|
@@ -210,6 +211,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
210
211
|
});
|
|
211
212
|
await app.register(runtimeRoutes);
|
|
212
213
|
await app.register(accountRoutes);
|
|
214
|
+
await app.register(notificationRoutes);
|
|
213
215
|
await app.register(taskRoutes);
|
|
214
216
|
await app.register(workflowRoutes);
|
|
215
217
|
await app.register(pwaRoutes);
|