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.
Files changed (109) hide show
  1. package/db/migrations/147_condition_types.sql +80 -0
  2. package/db/migrations/148_validation_types.sql +44 -0
  3. package/db/migrations/149_dynamic_action_events.sql +45 -0
  4. package/db/migrations/150_process_types.sql +23 -0
  5. package/db/migrations/151_item_types.sql +16 -0
  6. package/db/migrations/152_workflow_diagram_region.sql +10 -0
  7. package/db/migrations/153_component_events.sql +21 -0
  8. package/docs/apex-feature-parity.md +11 -9
  9. package/docs/guide/02-concepts.md +3 -3
  10. package/docs/guide/03-builder.md +8 -1
  11. package/docs/guide/04-pages-and-regions.md +19 -4
  12. package/docs/guide/05-items.md +5 -0
  13. package/docs/guide/06-processing.md +104 -17
  14. package/docs/guide/07-dynamic-actions.md +43 -1
  15. package/docs/guide/11-from-apex.md +8 -0
  16. package/docs/guide/12-development.md +9 -1
  17. package/examples/hr/hr_79_condition_types.sql +17 -0
  18. package/package.json +1 -1
  19. package/public/app.css +14 -0
  20. package/public/app.js +242 -13
  21. package/public/builder.css +25 -2
  22. package/public/data-modeler.js +8 -5
  23. package/public/sql-monitor.js +5 -2
  24. package/src/appfiles.ts +4 -4
  25. package/src/builder/advisor.ts +43 -7
  26. package/src/builder/ai-builder.ts +50 -46
  27. package/src/builder/api.ts +49 -49
  28. package/src/builder/autorest.ts +92 -87
  29. package/src/builder/changelog.ts +39 -37
  30. package/src/builder/code-editor.ts +14 -10
  31. package/src/builder/components.ts +54 -23
  32. package/src/builder/conditions.ts +64 -0
  33. package/src/builder/data-modeler.ts +89 -84
  34. package/src/builder/data-security.ts +39 -37
  35. package/src/builder/dataload.ts +69 -65
  36. package/src/builder/datapump.ts +108 -97
  37. package/src/builder/ddl-tools.ts +38 -34
  38. package/src/builder/designer.ts +9 -1
  39. package/src/builder/document-view.ts +11 -11
  40. package/src/builder/i18n/de.ts +1662 -0
  41. package/src/builder/i18n/en.ts +1662 -0
  42. package/src/builder/i18n/es.ts +1662 -0
  43. package/src/builder/i18n/fr.ts +1662 -0
  44. package/src/builder/i18n/it.ts +1662 -0
  45. package/src/builder/i18n/ja.ts +1662 -0
  46. package/src/builder/i18n/ko.ts +1662 -0
  47. package/src/builder/i18n/nl.ts +1662 -0
  48. package/src/builder/i18n/pt.ts +1662 -0
  49. package/src/builder/i18n/zh-cn.ts +1662 -0
  50. package/src/builder/i18n/zh-tw.ts +1662 -0
  51. package/src/builder/i18n.ts +18 -0
  52. package/src/builder/object-actions.ts +18 -16
  53. package/src/builder/object-browser.ts +2 -2
  54. package/src/builder/querybuilder.ts +42 -36
  55. package/src/builder/quicksql.ts +16 -18
  56. package/src/builder/region-help.ts +3 -1
  57. package/src/builder/region-settings.ts +225 -198
  58. package/src/builder/report-settings.ts +64 -60
  59. package/src/builder/rest-modules.ts +145 -143
  60. package/src/builder/sampledata.ts +59 -52
  61. package/src/builder/scheduling.ts +180 -159
  62. package/src/builder/scripts.ts +71 -65
  63. package/src/builder/sql-charts.ts +36 -30
  64. package/src/builder/sql-monitor.ts +95 -89
  65. package/src/builder/sql-utilities.ts +47 -40
  66. package/src/builder/top-sql.ts +21 -17
  67. package/src/builder/unload.ts +48 -44
  68. package/src/builder/worksheet.ts +3 -3
  69. package/src/cron-schedule.ts +47 -17
  70. package/src/i18n/ar.ts +5 -0
  71. package/src/i18n/cs.ts +5 -0
  72. package/src/i18n/da.ts +5 -0
  73. package/src/i18n/de.ts +5 -0
  74. package/src/i18n/el.ts +5 -0
  75. package/src/i18n/es.ts +5 -0
  76. package/src/i18n/fi.ts +5 -0
  77. package/src/i18n/fr.ts +5 -0
  78. package/src/i18n/he.ts +5 -0
  79. package/src/i18n/it.ts +5 -0
  80. package/src/i18n/ja.ts +5 -0
  81. package/src/i18n/ko.ts +5 -0
  82. package/src/i18n/nb.ts +5 -0
  83. package/src/i18n/pl.ts +5 -0
  84. package/src/i18n/pt.ts +5 -0
  85. package/src/i18n/ru.ts +5 -0
  86. package/src/i18n/sv.ts +5 -0
  87. package/src/i18n/tr.ts +5 -0
  88. package/src/i18n/uk.ts +5 -0
  89. package/src/i18n/zh.ts +5 -0
  90. package/src/i18n.ts +10 -0
  91. package/src/metadata.ts +74 -9
  92. package/src/runtime/authz.ts +9 -5
  93. package/src/runtime/calendar.ts +3 -2
  94. package/src/runtime/context.ts +6 -0
  95. package/src/runtime/da-styles.ts +37 -0
  96. package/src/runtime/documents.ts +3 -2
  97. package/src/runtime/engine.ts +95 -13
  98. package/src/runtime/grid.ts +1 -0
  99. package/src/runtime/items.ts +33 -0
  100. package/src/runtime/logic.ts +79 -8
  101. package/src/runtime/maps.ts +56 -7
  102. package/src/runtime/process-types.ts +139 -0
  103. package/src/runtime/processes.ts +2 -0
  104. package/src/runtime/regions.ts +4 -0
  105. package/src/runtime/render.ts +19 -3
  106. package/src/runtime/routes.ts +6 -2
  107. package/src/runtime/tree.ts +4 -3
  108. package/src/runtime/workflow-diagram.ts +45 -0
  109. 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
- // region events (trigger: region ids or static ids, case-insensitive)
807
- const regionRun = (event, region) => {
808
- if (!region || !/^R\d+$/.test(region.id || '')) return [];
809
- const keys = [region.id.slice(1), (region.dataset.staticId || '').toLowerCase()].filter(Boolean);
810
- return das.filter((da) => da.event === event && da.trigger.some((t) => keys.includes(String(t).toLowerCase())));
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
- const cell = e.target.closest?.('.calendar [data-add]');
1033
- if (!cell || e.target.closest('a, button')) return;
1034
- cell.querySelector(':scope > .cal-add')?.click();
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
- const tell = (reload) => window.parent.postMessage({ type: 'pgkiln:close', reload, page: body.dataset.dialogPage || '' }, location.origin);
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
- e.target.form?.requestSubmit();
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
- style: () => (several ? { className: `map-c${l.color}` } : {}),
3174
- onEachFeature: (f, layer) => layer.bindPopup(() => popup(f.properties)),
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
  }
@@ -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, #b42318); }
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, #b42318); }
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; }
@@ -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 ? 'Unsaved changes' : '';
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 = 'Saving…';
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 = 'Saved';
148
+ status.textContent = msg('js.dm.saved');
146
149
  } catch (e) {
147
- status.textContent = `Not saved (${e.message}): try again`;
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 = 'The PNG could not be made: export SVG instead');
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', () => {
@@ -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 ? 'Resume' : 'Pause';
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 = 'Refresh stopped: the monitor could not be reached (signed out?). Reload the page.';
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' },
@@ -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
- { name: 'condition', shape: 'boolean' },
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 [{ name: 'condition', shape: 'boolean' }, { name: 'badge_query', shape: 'select' }];
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 row.action === 'set_value' ? [{ name: 'code', shape: 'select' }] : row.action === 'execute_sql' ? [{ name: 'code', shape: 'statements' }] : [];
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 row.type === 'sql' ? [{ name: 'expression', shape: 'boolean' }] : row.type === 'regex' ? [{ name: 'expression', shape: 'regex' }] : [];
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
- /** The SQL of a computation's or branch's condition (migration 029). */
108
- const conditionSql = (row: any): { name: string; shape: SqlShape }[] =>
109
- row.condition_type === 'sql' ? [{ name: 'condition_expr', shape: 'boolean' }] : row.condition_type === 'exists' || row.condition_type === 'not_exists' ? [{ name: 'condition_expr', shape: 'select' }] : [];
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))