pgkiln 0.81.0 → 0.83.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/147_condition_types.sql +80 -0
- package/db/migrations/148_validation_types.sql +44 -0
- package/db/migrations/149_dynamic_action_events.sql +45 -0
- package/db/migrations/150_process_types.sql +23 -0
- package/db/migrations/151_item_types.sql +16 -0
- package/db/migrations/152_workflow_diagram_region.sql +10 -0
- package/db/migrations/153_component_events.sql +21 -0
- package/docs/apex-feature-parity.md +11 -9
- package/docs/guide/02-concepts.md +3 -3
- package/docs/guide/03-builder.md +8 -1
- package/docs/guide/04-pages-and-regions.md +19 -4
- package/docs/guide/05-items.md +5 -0
- package/docs/guide/06-processing.md +104 -17
- package/docs/guide/07-dynamic-actions.md +43 -1
- package/docs/guide/11-from-apex.md +8 -0
- package/docs/guide/12-development.md +9 -1
- package/examples/hr/hr_79_condition_types.sql +17 -0
- package/package.json +1 -1
- package/public/app.css +14 -0
- package/public/app.js +242 -13
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/src/appfiles.ts +4 -4
- package/src/builder/advisor.ts +43 -7
- 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 +14 -10
- package/src/builder/components.ts +54 -23
- package/src/builder/conditions.ts +64 -0
- 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/designer.ts +9 -1
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1662 -0
- package/src/builder/i18n/en.ts +1662 -0
- package/src/builder/i18n/es.ts +1662 -0
- package/src/builder/i18n/fr.ts +1662 -0
- package/src/builder/i18n/it.ts +1662 -0
- package/src/builder/i18n/ja.ts +1662 -0
- package/src/builder/i18n/ko.ts +1662 -0
- package/src/builder/i18n/nl.ts +1662 -0
- package/src/builder/i18n/pt.ts +1662 -0
- package/src/builder/i18n/zh-cn.ts +1662 -0
- package/src/builder/i18n/zh-tw.ts +1662 -0
- package/src/builder/i18n.ts +18 -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-help.ts +3 -1
- package/src/builder/region-settings.ts +225 -198
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- 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/cron-schedule.ts +47 -17
- package/src/i18n/ar.ts +5 -0
- package/src/i18n/cs.ts +5 -0
- package/src/i18n/da.ts +5 -0
- package/src/i18n/de.ts +5 -0
- package/src/i18n/el.ts +5 -0
- package/src/i18n/es.ts +5 -0
- package/src/i18n/fi.ts +5 -0
- package/src/i18n/fr.ts +5 -0
- package/src/i18n/he.ts +5 -0
- package/src/i18n/it.ts +5 -0
- package/src/i18n/ja.ts +5 -0
- package/src/i18n/ko.ts +5 -0
- package/src/i18n/nb.ts +5 -0
- package/src/i18n/pl.ts +5 -0
- package/src/i18n/pt.ts +5 -0
- package/src/i18n/ru.ts +5 -0
- package/src/i18n/sv.ts +5 -0
- package/src/i18n/tr.ts +5 -0
- package/src/i18n/uk.ts +5 -0
- package/src/i18n/zh.ts +5 -0
- package/src/i18n.ts +10 -0
- package/src/metadata.ts +74 -9
- package/src/runtime/authz.ts +9 -5
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +6 -0
- package/src/runtime/da-styles.ts +37 -0
- package/src/runtime/documents.ts +3 -2
- package/src/runtime/engine.ts +95 -13
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/items.ts +33 -0
- package/src/runtime/logic.ts +79 -8
- package/src/runtime/maps.ts +56 -7
- package/src/runtime/process-types.ts +139 -0
- package/src/runtime/processes.ts +2 -0
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +19 -3
- package/src/runtime/routes.ts +6 -2
- package/src/runtime/tree.ts +4 -3
- package/src/runtime/workflow-diagram.ts +45 -0
- package/src/workflow.ts +10 -2
package/public/app.js
CHANGED
|
@@ -341,9 +341,17 @@ const pgkilnUnsaved = (() => {
|
|
|
341
341
|
case 'in_list': return da.cond.value.split(',').map((s) => s.trim()).includes(v);
|
|
342
342
|
case 'is_null': return v === '';
|
|
343
343
|
case 'is_not_null': return v !== '';
|
|
344
|
+
// (156) numbers compared as numbers, other values (ISO dates) as text
|
|
345
|
+
case 'greater': return v !== '' && compare(v, da.cond.value) > 0;
|
|
346
|
+
case 'greater_equal': return v !== '' && compare(v, da.cond.value) >= 0;
|
|
347
|
+
case 'less': return v !== '' && compare(v, da.cond.value) < 0;
|
|
348
|
+
case 'less_equal': return v !== '' && compare(v, da.cond.value) <= 0;
|
|
349
|
+
case 'not_in_list': return !da.cond.value.split(',').map((s) => s.trim()).includes(v);
|
|
344
350
|
default: return true;
|
|
345
351
|
}
|
|
346
352
|
}
|
|
353
|
+
const NUM = /^\s*-?\d+(\.\d+)?\s*$/;
|
|
354
|
+
const compare = (a, b) => (NUM.test(a) && NUM.test(b) ? Number(a) - Number(b) : a < b ? -1 : a > b ? 1 : 0);
|
|
347
355
|
|
|
348
356
|
// ------------------------------------------------------------ messages and errors (dynamic actions)
|
|
349
357
|
// class names a dynamic action may add or remove (the database checks the same pattern)
|
|
@@ -499,6 +507,10 @@ const pgkilnUnsaved = (() => {
|
|
|
499
507
|
|
|
500
508
|
/** (085) what runDa returns to stop the actions after it (a declined confirm) */
|
|
501
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;
|
|
512
|
+
/** (156) does a Cancel Event in these actions hold its condition (decided at once, so the event's effect can be stopped)? */
|
|
513
|
+
const cancels = (list) => list.some((da) => da.action === 'cancel_event' && conditionHolds(da));
|
|
502
514
|
const text = (k) => (meta.texts && meta.texts[k]) || k;
|
|
503
515
|
|
|
504
516
|
/** The actions of one event and trigger, one after the other in sequence order; false when one stopped them. */
|
|
@@ -580,6 +592,37 @@ const pgkilnUnsaved = (() => {
|
|
|
580
592
|
}
|
|
581
593
|
if (initial || !holds) return;
|
|
582
594
|
switch (da.action) {
|
|
595
|
+
// (156) stops the actions after it; the triggering event's own effect is stopped by its handler (cancels())
|
|
596
|
+
case 'cancel_event':
|
|
597
|
+
return STOP;
|
|
598
|
+
case 'set_style':
|
|
599
|
+
for (const el of targets(da)) for (const [k, v] of Object.entries(da.styles || {})) el.style.setProperty(k, v);
|
|
600
|
+
return;
|
|
601
|
+
case 'collapse_tree':
|
|
602
|
+
case 'expand_tree':
|
|
603
|
+
for (const el of targets(da)) el.querySelectorAll('.tree-view details').forEach((d) => (d.open = da.action === 'expand_tree'));
|
|
604
|
+
return;
|
|
605
|
+
case 'show_ai_assistant':
|
|
606
|
+
for (const el of targets(da)) {
|
|
607
|
+
const dlg = el.closest('dialog[data-inline-region]') || (el.matches('dialog[data-inline-region]') ? el : null);
|
|
608
|
+
if (dlg && !dlg.open) dlg.showModal();
|
|
609
|
+
el.hidden = false;
|
|
610
|
+
const details = el.querySelector(':scope > details');
|
|
611
|
+
if (details) details.open = true;
|
|
612
|
+
const input = el.querySelector('textarea, input[type=text]') || (el.id && document.querySelector(`form[id^="${el.id}"] textarea`));
|
|
613
|
+
if (input) input.focus();
|
|
614
|
+
else el.scrollIntoView({ block: 'nearest' });
|
|
615
|
+
}
|
|
616
|
+
return;
|
|
617
|
+
case 'trigger_geocoding':
|
|
618
|
+
for (const n of da.items) document.querySelector(`[data-geocode="${CSS.escape(n)}"]`)?.click();
|
|
619
|
+
return;
|
|
620
|
+
case 'open_report_dialog':
|
|
621
|
+
for (const el of targets(da)) {
|
|
622
|
+
const item = da.dialog && el.querySelector(`.ir-item[data-ir-dialog="${CSS.escape(da.dialog)}"]`);
|
|
623
|
+
if (item) item.open = true;
|
|
624
|
+
}
|
|
625
|
+
return;
|
|
583
626
|
case 'alert':
|
|
584
627
|
window.alert(da.message || '');
|
|
585
628
|
return;
|
|
@@ -593,6 +636,11 @@ const pgkilnUnsaved = (() => {
|
|
|
593
636
|
case 'clear':
|
|
594
637
|
for (const n of da.items) setItemValue(n, '');
|
|
595
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;
|
|
596
644
|
case 'open_region':
|
|
597
645
|
case 'close_region':
|
|
598
646
|
for (const el of targets(da)) {
|
|
@@ -690,7 +738,7 @@ const pgkilnUnsaved = (() => {
|
|
|
690
738
|
const fn = da.fn && actions.get(da.fn);
|
|
691
739
|
if (!fn) return console.warn(`pgkiln: no function "${da.fn}" registered (pgkiln.actions.register) for dynamic action ${da.id}`);
|
|
692
740
|
try {
|
|
693
|
-
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 });
|
|
694
742
|
} catch (e) {
|
|
695
743
|
console.error(e);
|
|
696
744
|
showError(e.message);
|
|
@@ -698,6 +746,12 @@ const pgkilnUnsaved = (() => {
|
|
|
698
746
|
return;
|
|
699
747
|
}
|
|
700
748
|
}
|
|
749
|
+
// (156) before_refresh: the region's actions run before its refresh is asked for
|
|
750
|
+
if (da.action === 'refresh_region' && da.region) {
|
|
751
|
+
const r = document.getElementById(`R${da.region}`);
|
|
752
|
+
const before = regionRun('before_refresh', r);
|
|
753
|
+
if (before.length && !(await runChain(before))) return;
|
|
754
|
+
}
|
|
701
755
|
const data = { ...(extra || {}) };
|
|
702
756
|
for (const n of da.submit) data[n] = itemValue(n);
|
|
703
757
|
const busy = targets(da);
|
|
@@ -727,6 +781,12 @@ const pgkilnUnsaved = (() => {
|
|
|
727
781
|
}
|
|
728
782
|
|
|
729
783
|
const das = meta.das || [];
|
|
784
|
+
// region events (trigger: region ids or static ids, case-insensitive)
|
|
785
|
+
const regionRun = (event, region) => {
|
|
786
|
+
if (!region || !/^R\d+$/.test(region.id || '')) return [];
|
|
787
|
+
const keys = [region.id.slice(1), (region.dataset.staticId || '').toLowerCase()].filter(Boolean);
|
|
788
|
+
return das.filter((da) => da.event === event && da.trigger.some((t) => keys.includes(String(t).toLowerCase())));
|
|
789
|
+
};
|
|
730
790
|
// (121) an AI agent's javascript tools: after the answer, the registered function of the action
|
|
731
791
|
// (pgkiln.actions.register) and the dynamic actions of the custom event of that name, with the arguments
|
|
732
792
|
scriptsLoaded.then(async () => {
|
|
@@ -760,6 +820,8 @@ const pgkilnUnsaved = (() => {
|
|
|
760
820
|
const btn = e.target.closest('[data-button]');
|
|
761
821
|
if (!btn || e.target.closest('[data-menu-entry]')) return;
|
|
762
822
|
const run = das.filter((da) => da.event === 'click' && da.trigger.includes(btn.dataset.button));
|
|
823
|
+
// (156) a Cancel Event whose condition holds: the button doesn't submit or follow its link
|
|
824
|
+
if (cancels(run)) e.preventDefault();
|
|
763
825
|
// "Generate text with AI" on a submit button: the AJAX call replaces the submit
|
|
764
826
|
// (without JavaScript the button submits and the page process runs instead)
|
|
765
827
|
const ai = run.some((da) => da.action === 'ai_generate');
|
|
@@ -803,12 +865,120 @@ const pgkilnUnsaved = (() => {
|
|
|
803
865
|
document.addEventListener('focusin', itemEvent('focus'));
|
|
804
866
|
document.addEventListener('focusout', itemEvent('blur'));
|
|
805
867
|
document.addEventListener('keyup', itemEvent('key_release'));
|
|
806
|
-
//
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
868
|
+
// (156) key_down, key_press (a key that types a character), select (text selected in an item)
|
|
869
|
+
document.addEventListener('keydown', (e) => {
|
|
870
|
+
const name = e.target && e.target.name;
|
|
871
|
+
if (!name) return;
|
|
872
|
+
const down = das.filter((da) => da.event === 'key_down' && da.trigger.includes(name));
|
|
873
|
+
const press = e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey ? das.filter((da) => da.event === 'key_press' && da.trigger.includes(name)) : [];
|
|
874
|
+
// a Cancel Event on key_down / key_press keeps the key out of the field
|
|
875
|
+
if (cancels([...down, ...press])) e.preventDefault();
|
|
876
|
+
if (down.length) runChain(down);
|
|
877
|
+
if (press.length) runChain(press);
|
|
878
|
+
});
|
|
879
|
+
document.addEventListener('select', itemEvent('select'), true);
|
|
880
|
+
// double_click, mouse_enter, mouse_leave: an item (its name), a button (its name) or a region (id or static id)
|
|
881
|
+
const elementRun = (event, target) => {
|
|
882
|
+
if (!target || !target.closest) return [];
|
|
883
|
+
const item = target.closest('[name]');
|
|
884
|
+
const btn = target.closest('[data-button]');
|
|
885
|
+
const region = target.closest('section[id^="R"], div[id^="R"], dialog[id^="R"]');
|
|
886
|
+
const names = [item && item.name, btn && btn.dataset.button].filter(Boolean);
|
|
887
|
+
return [...new Set([...das.filter((da) => da.event === event && da.trigger.some((t) => names.includes(t))), ...regionRun(event, region)])];
|
|
811
888
|
};
|
|
889
|
+
document.addEventListener('dblclick', (e) => {
|
|
890
|
+
const run = elementRun('double_click', e.target);
|
|
891
|
+
if (run.length) runChain(run);
|
|
892
|
+
});
|
|
893
|
+
// enter / leave: once per element, not again while moving inside it
|
|
894
|
+
const hovered = new Set();
|
|
895
|
+
document.addEventListener('mouseover', (e) => {
|
|
896
|
+
for (const da of elementRun('mouse_enter', e.target)) if (!hovered.has(da)) { hovered.add(da); runChain([da]); }
|
|
897
|
+
});
|
|
898
|
+
document.addEventListener('mouseout', (e) => {
|
|
899
|
+
const leaving = elementRun('mouse_enter', e.target).concat(elementRun('mouse_leave', e.target));
|
|
900
|
+
const still = new Set([...elementRun('mouse_enter', e.relatedTarget), ...elementRun('mouse_leave', e.relatedTarget)]);
|
|
901
|
+
for (const da of leaving) {
|
|
902
|
+
if (still.has(da)) continue;
|
|
903
|
+
hovered.delete(da);
|
|
904
|
+
if (da.event === 'mouse_leave') runChain([da]);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
// resize, scroll: the window (at most one run per animation frame)
|
|
908
|
+
for (const [event, dom] of [['resize', 'resize'], ['scroll', 'scroll']]) {
|
|
909
|
+
const run = das.filter((da) => da.event === event);
|
|
910
|
+
if (!run.length) continue;
|
|
911
|
+
let queued = false;
|
|
912
|
+
window.addEventListener(dom, () => {
|
|
913
|
+
if (queued) return;
|
|
914
|
+
queued = true;
|
|
915
|
+
requestAnimationFrame(() => { queued = false; runChain(run); });
|
|
916
|
+
}, { passive: true });
|
|
917
|
+
}
|
|
918
|
+
// page_unload: the page is left (only quick, local actions finish)
|
|
919
|
+
window.addEventListener('pagehide', () => runChain(das.filter((da) => da.event === 'page_unload')));
|
|
920
|
+
// facets_change: a faceted search region's facets were applied (the page came back from its form)
|
|
921
|
+
{
|
|
922
|
+
const params = new URLSearchParams(location.search);
|
|
923
|
+
const changed = params.get('facets_changed');
|
|
924
|
+
if (changed) {
|
|
925
|
+
params.delete('facets_changed');
|
|
926
|
+
history.replaceState(history.state, '', `${location.pathname}${params.size ? `?${params}` : ''}${location.hash}`);
|
|
927
|
+
runChain(regionRun('facets_change', document.getElementById(`R${changed.replace(/\D/g, '')}`)));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// (161) component events of grid, calendar, map and tree regions (trigger: the region). The component
|
|
931
|
+
// raises pgkiln:component {event, element, value}; detail.cancelled tells it a cancel_event holds
|
|
932
|
+
// (the calendar's Create link, the tree node's link) and detail.ran how many actions run.
|
|
933
|
+
document.addEventListener('pgkiln:component', (e) => {
|
|
934
|
+
const d = e.detail || {};
|
|
935
|
+
const region = d.element && d.element.closest && d.element.closest('section[id^="R"], div[id^="R"], dialog[id^="R"]');
|
|
936
|
+
const run = regionRun(d.event, region);
|
|
937
|
+
d.ran = run.length;
|
|
938
|
+
if (!run.length) return;
|
|
939
|
+
const value = d.value == null ? '' : String(d.value);
|
|
940
|
+
if (run.some((da) => da.action === 'cancel_event' && conditionHolds(da, value))) d.cancelled = true;
|
|
941
|
+
componentElement = d.element;
|
|
942
|
+
runChain(run, value).finally(() => (componentElement = null));
|
|
943
|
+
});
|
|
944
|
+
// grid_save: the page that comes back after a grid's Save
|
|
945
|
+
for (const id of meta.savedGrids || []) {
|
|
946
|
+
const run = regionRun('grid_save', document.getElementById(`R${id}`));
|
|
947
|
+
if (run.length) runChain(run);
|
|
948
|
+
}
|
|
949
|
+
// calendar_view_change: the page that comes back from a calendar's view or period link (cal_changed=<region>)
|
|
950
|
+
{
|
|
951
|
+
const params = new URLSearchParams(location.search);
|
|
952
|
+
const changed = params.get('cal_changed');
|
|
953
|
+
if (changed) {
|
|
954
|
+
params.delete('cal_changed');
|
|
955
|
+
history.replaceState(history.state, '', `${location.pathname}${params.size ? `?${params}` : ''}${location.hash}`);
|
|
956
|
+
const region = document.getElementById(`R${changed.replace(/\D/g, '')}`);
|
|
957
|
+
const run = regionRun('calendar_view_change', region);
|
|
958
|
+
if (run.length) runChain(run, region.querySelector('.calendar')?.dataset.view || '');
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
document.addEventListener('click', (e) => {
|
|
962
|
+
const a = e.target.closest?.('.calendar .cal-toolbar a[href]');
|
|
963
|
+
const region = a && a.closest('section[id^="R"], div[id^="R"]');
|
|
964
|
+
if (!region || !regionRun('calendar_view_change', region).length) return;
|
|
965
|
+
const url = new URL(a.href, location.href);
|
|
966
|
+
url.searchParams.set('cal_changed', region.id.slice(1));
|
|
967
|
+
a.href = url.pathname + url.search + url.hash;
|
|
968
|
+
}, true);
|
|
969
|
+
// tree_selection_change: a node's label chosen (not the disclosure of a linked branch); the node is marked
|
|
970
|
+
document.addEventListener('click', (e) => {
|
|
971
|
+
const node = e.target.closest?.('.tree-view li[data-node]');
|
|
972
|
+
if (!node) return;
|
|
973
|
+
const summary = e.target.closest('summary');
|
|
974
|
+
if (summary && !summary.querySelector(':scope > span:not(.sr-only), :scope > a')) return;
|
|
975
|
+
const d = { event: 'tree_selection_change', element: node, value: node.dataset.node };
|
|
976
|
+
document.dispatchEvent(new CustomEvent('pgkiln:component', { detail: d }));
|
|
977
|
+
if (!d.ran) return;
|
|
978
|
+
node.closest('.tree-view').querySelectorAll('li.tree-selected').forEach((li) => li.classList.remove('tree-selected'));
|
|
979
|
+
node.classList.add('tree-selected');
|
|
980
|
+
if (d.cancelled) e.preventDefault();
|
|
981
|
+
});
|
|
812
982
|
// after_refresh: a region replaced by a refresh, a lazy load or a master row
|
|
813
983
|
document.addEventListener('pgkiln:replaced', (e) => {
|
|
814
984
|
const run = regionRun('after_refresh', e.detail);
|
|
@@ -1011,6 +1181,10 @@ const pgkilnUnsaved = (() => {
|
|
|
1011
1181
|
const dlg = document.getElementById('t-dialog');
|
|
1012
1182
|
if (dlg) dlg.close();
|
|
1013
1183
|
if (!e.data.reload) return;
|
|
1184
|
+
// (157) items a close_dialog process returned: items of the same name on this page take their values
|
|
1185
|
+
if (e.data.items && typeof e.data.items === 'object')
|
|
1186
|
+
for (const [name, value] of Object.entries(e.data.items))
|
|
1187
|
+
if (/^[A-Z][A-Z0-9_]{0,99}$/.test(name) && typeof value === 'string' && wrapperOf(name)) setItemValue(name, value);
|
|
1014
1188
|
const page = /^\d{1,9}$/.test(String(e.data.page || '')) ? String(e.data.page) : '';
|
|
1015
1189
|
const handlers = das.filter((da) => da.event === 'dialog_closed' && (!da.trigger.length || da.trigger.includes(page)));
|
|
1016
1190
|
if (!handlers.length) return location.reload();
|
|
@@ -1028,10 +1202,23 @@ const pgkilnUnsaved = (() => {
|
|
|
1028
1202
|
|
|
1029
1203
|
// ------------------------------------------------------------ calendar
|
|
1030
1204
|
// Create on click: a click on an empty day or hour slot follows its "+" link.
|
|
1205
|
+
// (161) a day or hour chosen (a click on the cell or its "+"): calendar_date_select; its cancel_event keeps the link closed
|
|
1206
|
+
let addClicked = false;
|
|
1031
1207
|
document.addEventListener('click', (e) => {
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1208
|
+
if (addClicked) return void (addClicked = false);
|
|
1209
|
+
const cell = e.target.closest?.('.calendar [data-date]');
|
|
1210
|
+
if (!cell) return;
|
|
1211
|
+
const add = e.target.closest('a.cal-add');
|
|
1212
|
+
if (!add && e.target.closest('a, button')) return;
|
|
1213
|
+
const d = { event: 'calendar_date_select', element: cell, value: cell.dataset.date };
|
|
1214
|
+
document.dispatchEvent(new CustomEvent('pgkiln:component', { detail: d }));
|
|
1215
|
+
if (d.cancelled) return void e.preventDefault();
|
|
1216
|
+
const link = !add && cell.hasAttribute('data-add') && cell.querySelector(':scope > .cal-add');
|
|
1217
|
+
if (link) {
|
|
1218
|
+
addClicked = true;
|
|
1219
|
+
link.click();
|
|
1220
|
+
addClicked = false;
|
|
1221
|
+
}
|
|
1035
1222
|
});
|
|
1036
1223
|
// Drag and drop (mouse): drop an event on a day or hour slot; the server moves it
|
|
1037
1224
|
// and sends the calendar back. Without JS (and from the keyboard) the event's
|
|
@@ -1248,7 +1435,10 @@ const pgkilnUnsaved = (() => {
|
|
|
1248
1435
|
|
|
1249
1436
|
// Inside a dialog: close on success or cancel.
|
|
1250
1437
|
if (window.parent !== window) {
|
|
1251
|
-
|
|
1438
|
+
// (157) a close_dialog process's items go along to the page below
|
|
1439
|
+
let items = null;
|
|
1440
|
+
try { items = body.dataset.dialogItems ? JSON.parse(body.dataset.dialogItems) : null; } catch { items = null; }
|
|
1441
|
+
const tell = (reload) => window.parent.postMessage({ type: 'pgkiln:close', reload, page: body.dataset.dialogPage || '', items }, location.origin);
|
|
1252
1442
|
if (body.dataset.dialogClose) tell(true);
|
|
1253
1443
|
document.addEventListener('click', (e) => {
|
|
1254
1444
|
if (!e.target.closest('[data-dialog-cancel]')) return;
|
|
@@ -1324,6 +1514,9 @@ const pgkilnUnsaved = (() => {
|
|
|
1324
1514
|
return row;
|
|
1325
1515
|
}
|
|
1326
1516
|
|
|
1517
|
+
/** (161) grid_row_init: a new row (Add row, Duplicate); the condition sees the row's number among the new rows */
|
|
1518
|
+
const rowInit = (row) => document.dispatchEvent(new CustomEvent('pgkiln:component', { detail: { event: 'grid_row_init', element: row, value: row.dataset.newRow } }));
|
|
1519
|
+
|
|
1327
1520
|
/** The editable control of a cell, if any. */
|
|
1328
1521
|
const control = (td) => td?.querySelector('input:not([type=hidden]), select');
|
|
1329
1522
|
|
|
@@ -1364,6 +1557,7 @@ const pgkilnUnsaved = (() => {
|
|
|
1364
1557
|
if (add) {
|
|
1365
1558
|
const row = addRow(add.closest('[data-grid]'));
|
|
1366
1559
|
row?.querySelector('input:not([type=hidden]), select')?.focus();
|
|
1560
|
+
if (row) rowInit(row);
|
|
1367
1561
|
return;
|
|
1368
1562
|
}
|
|
1369
1563
|
// Duplicate: the row's values in a new row (without JS a link does it on the server)
|
|
@@ -1380,6 +1574,7 @@ const pgkilnUnsaved = (() => {
|
|
|
1380
1574
|
if (from && control(td) && control(from)) setCell(td, control(from).tagName === 'SELECT' ? control(from).value : cellText(from));
|
|
1381
1575
|
}
|
|
1382
1576
|
control(row.querySelector('td[data-col]:not([hidden])'))?.focus();
|
|
1577
|
+
rowInit(row);
|
|
1383
1578
|
return;
|
|
1384
1579
|
}
|
|
1385
1580
|
if (e.target.closest('[data-grid-del]')) {
|
|
@@ -1841,7 +2036,20 @@ document.addEventListener('change', (e) => {
|
|
|
1841
2036
|
const custom = e.target.closest('fieldset')?.querySelector('[data-facet-range]');
|
|
1842
2037
|
custom?.querySelectorAll('input').forEach((i) => (i.value = ''));
|
|
1843
2038
|
}
|
|
1844
|
-
|
|
2039
|
+
// (156) the page that comes back runs the region's facets_change dynamic actions
|
|
2040
|
+
const f = e.target.form;
|
|
2041
|
+
const region = e.target.closest('[id^="R"]');
|
|
2042
|
+
if (f && region && /^R\d+$/.test(region.id)) {
|
|
2043
|
+
let mark = f.querySelector('input[name="facets_changed"]');
|
|
2044
|
+
if (!mark) {
|
|
2045
|
+
mark = document.createElement('input');
|
|
2046
|
+
mark.type = 'hidden';
|
|
2047
|
+
mark.name = 'facets_changed';
|
|
2048
|
+
f.appendChild(mark);
|
|
2049
|
+
}
|
|
2050
|
+
mark.value = region.id.slice(1);
|
|
2051
|
+
}
|
|
2052
|
+
f?.requestSubmit();
|
|
1845
2053
|
});
|
|
1846
2054
|
document.addEventListener('input', (e) => {
|
|
1847
2055
|
const box = e.target.closest?.('[data-facet-range]');
|
|
@@ -3116,6 +3324,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
3116
3324
|
// without a Referer: the tiles get the site's origin only (no paths or item values)
|
|
3117
3325
|
window.L.tileLayer(data.tiles, { attribution: data.attribution, maxZoom: 19, referrerPolicy: 'strict-origin-when-cross-origin' }).addTo(map);
|
|
3118
3326
|
const popup = (p) => {
|
|
3327
|
+
// (161) a marker or shape clicked (its popup opens): map_object_click sees its id, else its title
|
|
3328
|
+
el.dispatchEvent(new CustomEvent('pgkiln:component', { bubbles: true, detail: { event: 'map_object_click', element: el, value: p.id ?? p.title ?? '' } }));
|
|
3119
3329
|
// built with the DOM, not HTML strings: titles and texts are data
|
|
3120
3330
|
const box = document.createElement('div');
|
|
3121
3331
|
const b = document.createElement('strong');
|
|
@@ -3159,9 +3369,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
3159
3369
|
if (data.layers.some((l) => l.kind === 'heat')) heatLegend(map, data.legend);
|
|
3160
3370
|
if (several) layerSwitch(map, group, built, data.layersLabel);
|
|
3161
3371
|
if (f) areaFilter(map, f);
|
|
3372
|
+
// (161) map_change: moved or zoomed by the user (after the first view); the condition sees the zoom level
|
|
3373
|
+
map.on('moveend', () => el.dispatchEvent(new CustomEvent('pgkiln:component', { bubbles: true, detail: { event: 'map_change', element: el, value: map.getZoom() } })));
|
|
3162
3374
|
}
|
|
3163
3375
|
});
|
|
3164
3376
|
|
|
3377
|
+
// (159) Leaflet path options from a shape layer's checked style
|
|
3378
|
+
function shapeStyle(st, kind) {
|
|
3379
|
+
if (!st) return {};
|
|
3380
|
+
const o = {};
|
|
3381
|
+
if (st.stroke_color) o.color = st.stroke_color;
|
|
3382
|
+
if (typeof st.stroke_width === 'number') o.weight = st.stroke_width;
|
|
3383
|
+
if (st.fill_color) { o.fillColor = st.fill_color; o.fill = kind !== 'lines'; }
|
|
3384
|
+
if (typeof st.opacity === 'number') { if (kind === 'lines') o.opacity = st.opacity; else o.fillOpacity = st.opacity; }
|
|
3385
|
+
return o;
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3165
3388
|
// One layer's Leaflet parts: markers (clustered or not) or a heat map, with its lines and areas.
|
|
3166
3389
|
function layerParts(l, popup, data, several) {
|
|
3167
3390
|
const parts = [];
|
|
@@ -3170,8 +3393,14 @@ function layerParts(l, popup, data, several) {
|
|
|
3170
3393
|
else for (const p of l.points) parts.push(placeMarker(p, l, popup, several));
|
|
3171
3394
|
if (l.shapes.length)
|
|
3172
3395
|
parts.push(window.L.geoJSON({ type: 'FeatureCollection', features: l.shapes }, {
|
|
3173
|
-
|
|
3174
|
-
|
|
3396
|
+
// (159) a lines / polygons / extruded layer's colours, width and opacity (checked on the server: #rrggbb, numbers)
|
|
3397
|
+
style: (f) => ({ ...(several ? { className: `map-c${l.color}` } : {}), ...shapeStyle(f.properties && f.properties.style, l.kind) }),
|
|
3398
|
+
onEachFeature: (f, layer) => {
|
|
3399
|
+
layer.bindPopup(() => popup(f.properties));
|
|
3400
|
+
// a shape's title as its tooltip (and its height on an extruded layer)
|
|
3401
|
+
const tip = [f.properties && f.properties.title, f.properties && typeof f.properties.height === 'number' ? String(f.properties.height) : ''].filter(Boolean).join(': ');
|
|
3402
|
+
if (tip && layer.bindTooltip) layer.bindTooltip(tip, { sticky: true });
|
|
3403
|
+
},
|
|
3175
3404
|
}));
|
|
3176
3405
|
return parts;
|
|
3177
3406
|
}
|
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/src/appfiles.ts
CHANGED
|
@@ -108,11 +108,11 @@ function sniff(b: Buffer) {
|
|
|
108
108
|
// Code columns that move to a sibling file when they are long or multi-line.
|
|
109
109
|
const CODE: Record<string, Record<string, string>> = {
|
|
110
110
|
region: { source: 'sql', condition: 'sql' },
|
|
111
|
-
item: { default_value: 'sql', readonly_condition: 'sql' },
|
|
111
|
+
item: { default_value: 'sql', readonly_condition: 'sql', condition_expr: 'sql' },
|
|
112
112
|
button: { condition: 'sql', badge_query: 'sql' },
|
|
113
|
-
dynamic_action: { code: 'sql' },
|
|
114
|
-
validation: { expression: 'sql' },
|
|
115
|
-
process: { code: 'sql' },
|
|
113
|
+
dynamic_action: { code: 'sql', server_condition_expr: 'sql' },
|
|
114
|
+
validation: { expression: 'sql', condition_expr: 'sql' },
|
|
115
|
+
process: { code: 'sql', condition_expr: 'sql' },
|
|
116
116
|
computation: { expression: 'sql', condition_expr: 'sql' },
|
|
117
117
|
branch: { condition_expr: 'sql' },
|
|
118
118
|
app_process: { code: 'sql' },
|
package/src/builder/advisor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { declarativeCondition } from './conditions.ts';
|
|
1
2
|
import type { FastifyInstance } from 'fastify';
|
|
2
3
|
import pg from 'pg';
|
|
3
4
|
import { applyBinds, splitStatements } from '../binds.ts';
|
|
@@ -52,15 +53,16 @@ export function sqlFields(kind: string, row: any): { name: string; shape: SqlSha
|
|
|
52
53
|
case 'region':
|
|
53
54
|
return [
|
|
54
55
|
...(['report', 'classic_report', 'grid', 'chart', 'cards', 'calendar', 'map', 'tree', 'dynamic'].includes(row.type) && row.source?.trim() && !row.rest_source ? [{ name: 'source', shape: 'select' as const }] : []),
|
|
55
|
-
|
|
56
|
+
...conditionSql(row, 'condition'),
|
|
56
57
|
];
|
|
57
58
|
case 'item':
|
|
58
59
|
return [
|
|
59
60
|
...(row.lov?.trim() && !/^(STATIC|LOV):/i.test(row.lov.trim()) ? [{ name: 'lov', shape: 'select' as const }] : []),
|
|
60
61
|
{ name: 'readonly_condition', shape: 'boolean' },
|
|
62
|
+
...conditionSql(row),
|
|
61
63
|
];
|
|
62
64
|
case 'button':
|
|
63
|
-
return [
|
|
65
|
+
return [...conditionSql(row, 'condition'), { name: 'badge_query', shape: 'select' }];
|
|
64
66
|
case 'computation':
|
|
65
67
|
case 'app_computation':
|
|
66
68
|
return [
|
|
@@ -70,9 +72,16 @@ export function sqlFields(kind: string, row: any): { name: string; shape: SqlSha
|
|
|
70
72
|
case 'branch':
|
|
71
73
|
return conditionSql(row);
|
|
72
74
|
case 'dynamic_action':
|
|
73
|
-
return
|
|
75
|
+
return [
|
|
76
|
+
...(row.action === 'set_value' ? [{ name: 'code', shape: 'select' as const }] : row.action === 'execute_sql' ? [{ name: 'code', shape: 'statements' as const }] : []),
|
|
77
|
+
...conditionSql({ condition_type: row.server_condition_type }, 'server_condition_expr'),
|
|
78
|
+
];
|
|
74
79
|
case 'validation':
|
|
75
|
-
return
|
|
80
|
+
return [
|
|
81
|
+
...(row.type === 'sql' ? [{ name: 'expression', shape: 'boolean' as const }] : row.type === 'regex' ? [{ name: 'expression', shape: 'regex' as const }]
|
|
82
|
+
: row.type === 'exists' || row.type === 'not_exists' ? [{ name: 'expression', shape: 'select' as const }] : []),
|
|
83
|
+
...conditionSql(row),
|
|
84
|
+
];
|
|
76
85
|
case 'process':
|
|
77
86
|
return [
|
|
78
87
|
...(row.type === 'sql' ? [{ name: 'code', shape: 'statements' as const }] : row.type === 'download' ? [{ name: 'code', shape: 'select' as const }] : []),
|
|
@@ -104,9 +113,14 @@ export function sqlFields(kind: string, row: any): { name: string; shape: SqlSha
|
|
|
104
113
|
}
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
/**
|
|
117
|
+
* The SQL of a server-side condition (migrations 029, 147). `field` holds the expression; for regions and
|
|
118
|
+
* buttons (`condition`) no type means SQL.
|
|
119
|
+
*/
|
|
120
|
+
const conditionSql = (row: any, field = 'condition_expr'): { name: string; shape: SqlShape }[] => {
|
|
121
|
+
const type = row.condition_type || (field === 'condition' && row.condition?.trim() ? 'sql' : null);
|
|
122
|
+
return type === 'sql' ? [{ name: field, shape: 'boolean' }] : type === 'exists' || type === 'not_exists' ? [{ name: field, shape: 'select' }] : [];
|
|
123
|
+
};
|
|
110
124
|
|
|
111
125
|
export const EXPLAINABLE = /^\s*(\(|select|with|values|table|insert|update|delete|merge)\b/i;
|
|
112
126
|
|
|
@@ -273,6 +287,28 @@ function structureFindings(x: AppRows): Finding[] {
|
|
|
273
287
|
else if (!all.some((r) => r.kind === 'region' && r.row.type === 'login' && r.row.page_id === page.id))
|
|
274
288
|
out.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} has no Login region: nobody can sign in.` });
|
|
275
289
|
}
|
|
290
|
+
// (154, CD-2) a SQL condition one declarative type can say: no query, and it reads in words in the tree
|
|
291
|
+
for (const r of all) {
|
|
292
|
+
const field = r.kind === 'region' || r.kind === 'button' ? 'condition'
|
|
293
|
+
: ['item', 'validation', 'process', 'computation', 'app_computation', 'branch'].includes(r.kind) ? 'condition_expr' : null;
|
|
294
|
+
if (!field || (r.row.build_option ?? '') === 'COMMENTED_OUT') continue;
|
|
295
|
+
const sql = (r.row.condition_type ?? (field === 'condition' ? 'sql' : null)) === 'sql' ? r.row[field] : null;
|
|
296
|
+
const d = sql ? declarativeCondition(sql) : null;
|
|
297
|
+
if (!d) continue;
|
|
298
|
+
const what = [d.condition_expr && `expression ${d.condition_expr}`, d.condition_value && `value ${d.condition_value}`].filter(Boolean).join(', ');
|
|
299
|
+
out.push({ severity: 'info', entry: x.byKey.get(`${r.kind}-${r.row.id}`) ?? null, field: 'Server-side condition',
|
|
300
|
+
message: `The SQL condition ${String(sql).trim()} can be the condition type ${d.condition_type}${what ? ` (${what})` : ''}: decided without a query, and shown in words.` });
|
|
301
|
+
}
|
|
302
|
+
// (155, CD-1) a SQL validation one declarative type says exactly
|
|
303
|
+
const VALIDATION_OF: Record<string, string> = { item_not_null: 'not_null', item_equals: 'equals', item_in_list: 'in_list' };
|
|
304
|
+
for (const r of all) {
|
|
305
|
+
if (r.kind !== 'validation' || r.row.type !== 'sql' || (r.row.build_option ?? '') === 'COMMENTED_OUT') continue;
|
|
306
|
+
const d = declarativeCondition(r.row.expression);
|
|
307
|
+
const type = d && VALIDATION_OF[d.condition_type ?? ''];
|
|
308
|
+
if (!d || !type) continue;
|
|
309
|
+
out.push({ severity: 'info', entry: x.byKey.get(`validation-${r.row.id}`) ?? null, field: 'Type',
|
|
310
|
+
message: `The SQL validation ${String(r.row.expression).trim()} can be the type ${type} with associated item ${d.condition_expr}${d.condition_value ? ` and value ${d.condition_value}` : ''}: no query.` });
|
|
311
|
+
}
|
|
276
312
|
const groups = new Set(all.filter((r) => r.kind === 'page_group').map((r) => r.row.name));
|
|
277
313
|
for (const p of [...pages].sort((a, b) => a.page_no - b.page_no))
|
|
278
314
|
if (p.page_group != null && !groups.has(p.page_group))
|