pgkiln 0.50.0 → 0.52.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 (104) hide show
  1. package/SECURITY.md +7 -0
  2. package/db/migrations/119_workspace_home.sql +30 -0
  3. package/db/migrations/120_datasets.sql +87 -0
  4. package/db/migrations/121_ai_agents.sql +94 -0
  5. package/db/migrations/122_team_development.sql +186 -0
  6. package/db/migrations/123_workflow_outcomes.sql +407 -0
  7. package/db/migrations/124_component_details.sql +51 -0
  8. package/docs/apex-feature-parity.md +15 -12
  9. package/docs/guide/01-installation.md +9 -3
  10. package/docs/guide/03-builder.md +211 -11
  11. package/docs/guide/04-pages-and-regions.md +96 -22
  12. package/docs/guide/05-items.md +7 -1
  13. package/docs/guide/06-processing.md +123 -8
  14. package/docs/guide/07-dynamic-actions.md +34 -0
  15. package/docs/guide/09-reference.md +2 -1
  16. package/docs/guide/11-from-apex.md +2 -1
  17. package/docs/guide/12-development.md +70 -11
  18. package/docs/guide/20-ai-agents.md +4 -2
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_63_datasets.sql +29 -0
  21. package/examples/hr/hr_64_ai_agent.sql +97 -0
  22. package/examples/hr/hr_65_ask_reports.sql +21 -0
  23. package/examples/hr/hr_66_team_development.sql +58 -0
  24. package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
  25. package/examples/hr/hr_68_component_details.sql +96 -0
  26. package/package.json +1 -1
  27. package/public/app.css +46 -0
  28. package/public/app.js +134 -20
  29. package/public/builder.css +116 -0
  30. package/public/builder.js +47 -0
  31. package/src/ai/chat.ts +18 -18
  32. package/src/ai/service.ts +35 -11
  33. package/src/ai/types.ts +7 -2
  34. package/src/app.ts +11 -1
  35. package/src/builder/advisor.ts +6 -3
  36. package/src/builder/ai-agents.ts +372 -0
  37. package/src/builder/ai-builder.ts +3 -3
  38. package/src/builder/ai.ts +13 -9
  39. package/src/builder/assistant.ts +22 -7
  40. package/src/builder/components.ts +38 -9
  41. package/src/builder/datasets.ts +472 -0
  42. package/src/builder/designer.ts +8 -3
  43. package/src/builder/feedback.ts +6 -1
  44. package/src/builder/home.ts +12 -7
  45. package/src/builder/instance.ts +2 -0
  46. package/src/builder/locks.ts +7 -1
  47. package/src/builder/region-help.ts +2 -2
  48. package/src/builder/reporter.ts +52 -3
  49. package/src/builder/routes.ts +7 -4
  50. package/src/builder/shared.ts +1 -1
  51. package/src/builder/subscriptions.ts +1 -0
  52. package/src/builder/team.ts +991 -0
  53. package/src/builder/ui.ts +8 -5
  54. package/src/builder/workflow-migrate.ts +210 -0
  55. package/src/builder/workflows.ts +29 -6
  56. package/src/builder/workspace-home.ts +285 -0
  57. package/src/builder/workspace-transfer.ts +1 -1
  58. package/src/builder/workspaces.ts +7 -4
  59. package/src/cli/replace.ts +7 -1
  60. package/src/datasets.ts +367 -0
  61. package/src/i18n/ar.ts +33 -0
  62. package/src/i18n/cs.ts +33 -0
  63. package/src/i18n/da.ts +33 -0
  64. package/src/i18n/de.ts +33 -0
  65. package/src/i18n/el.ts +33 -0
  66. package/src/i18n/es.ts +33 -0
  67. package/src/i18n/fi.ts +33 -0
  68. package/src/i18n/fr.ts +33 -0
  69. package/src/i18n/he.ts +33 -0
  70. package/src/i18n/it.ts +33 -0
  71. package/src/i18n/ja.ts +33 -0
  72. package/src/i18n/ko.ts +33 -0
  73. package/src/i18n/nb.ts +33 -0
  74. package/src/i18n/pl.ts +33 -0
  75. package/src/i18n/pt.ts +33 -0
  76. package/src/i18n/ru.ts +33 -0
  77. package/src/i18n/sv.ts +33 -0
  78. package/src/i18n/tr.ts +33 -0
  79. package/src/i18n/uk.ts +33 -0
  80. package/src/i18n/zh.ts +33 -0
  81. package/src/i18n.ts +66 -0
  82. package/src/instance.ts +3 -0
  83. package/src/metadata.ts +9 -3
  84. package/src/release-check.ts +139 -0
  85. package/src/reporting-app.ts +101 -0
  86. package/src/runtime/ai-agents.ts +365 -0
  87. package/src/runtime/ai-filter.ts +385 -107
  88. package/src/runtime/assistant.ts +64 -11
  89. package/src/runtime/context.ts +2 -0
  90. package/src/runtime/data-reporter.ts +199 -32
  91. package/src/runtime/ir-phrases.ts +812 -0
  92. package/src/runtime/ir-plan.ts +449 -0
  93. package/src/runtime/items.ts +1 -1
  94. package/src/runtime/regions.ts +6 -2
  95. package/src/runtime/render.ts +1 -1
  96. package/src/runtime/report.ts +1 -1
  97. package/src/runtime/tasks.ts +63 -12
  98. package/src/session.ts +2 -0
  99. package/src/subscriptions.ts +2 -1
  100. package/src/task-events.ts +232 -0
  101. package/src/team-samples.ts +74 -0
  102. package/src/team.ts +342 -0
  103. package/src/workflow.ts +135 -18
  104. package/src/workspace-export.ts +39 -4
@@ -0,0 +1,96 @@
1
+ -- =====================================================================
2
+ -- HR example, part 68: component details of APEX 26.1 (migration 124;
3
+ -- docs/guide/05-items.md "Popup LOV", docs/guide/07-dynamic-actions.md
4
+ -- "Wait popup" and "Menu entry chosen")
5
+ --
6
+ -- Page 47 "Component details":
7
+ -- * P47_REF, a popup LOV over 6,000 reference numbers: the dialog loads
8
+ -- the next 100 as the list scrolls (no More button).
9
+ -- * RECALC, a submit button whose process takes two seconds: a dynamic
10
+ -- action shows the wait popup on its click; the next page removes it.
11
+ -- * CHECK ("Check in the background"): wait popup, an Ajax call (execute_sql,
12
+ -- a second and a half) that fills P47_RESULT, remove wait popup.
13
+ -- * ACTIONS, a menu button: a link, and two entries that run dynamic
14
+ -- actions (event menu_entry): COUNT (the same Ajax call with a wait
15
+ -- popup) and HELLO (a success message).
16
+ -- =====================================================================
17
+
18
+ insert into meta.page (app_id, page_no, name, title, parent_page)
19
+ select id, 47, 'Component details', 'Component details', 1 from meta.app where alias = 'hr';
20
+
21
+ insert into meta.region (page_id, seq, title, type, columns, template, source)
22
+ select p.id, 10, 'Component details', 'static', 12, 'standard',
23
+ '<p>A popup list of values that loads more rows as you scroll, a wait popup around a long process and an Ajax call, and a menu button whose entries run dynamic actions.</p>'
24
+ from meta.page p join meta.app a on a.id = p.app_id
25
+ where a.alias = 'hr' and p.page_no = 47;
26
+
27
+ insert into meta.item (page_id, region_id, seq, name, label, type, lov, config, help)
28
+ select p.id, r.id, i.seq, i.name, i.label, i.type, i.lov, i.config::jsonb, i.help
29
+ from meta.page p join meta.app a on a.id = p.app_id
30
+ join meta.region r on r.page_id = p.id and r.title = 'Component details', (values
31
+ (10, 'P47_REF', 'Reference', 'popup_lov',
32
+ 'select ''REF-'' || lpad(n::text, 5, ''0'') || '' · '' || (array[''Accounting'', ''Research'', ''Sales'', ''Operations''])[1 + n % 4] as name, n as ref
33
+ from generate_series(1, 6000) n
34
+ order by n',
35
+ '{"page_size": 100}', '6,000 reference numbers: scroll the dialog to load more.'),
36
+ (20, 'P47_RESULT', 'Result', 'text', null, '{}', 'Filled by the Ajax call behind Check in the background, or Actions → Count employees.')
37
+ ) as i (seq, name, label, type, lov, config, help)
38
+ where a.alias = 'hr' and p.page_no = 47;
39
+
40
+ insert into meta.button (page_id, region_id, seq, name, label, action, hot, menu)
41
+ select p.id, r.id, b.seq, b.name, b.label, b.action, b.hot, b.menu::jsonb
42
+ from meta.page p join meta.app a on a.id = p.app_id
43
+ join meta.region r on r.page_id = p.id and r.title = 'Component details', (values
44
+ (10, 'RECALC', 'Recalculate', 'submit', true, null),
45
+ (20, 'CHECK', 'Check in the background', 'da', false, null),
46
+ (30, 'ACTIONS', 'Actions', 'menu', false,
47
+ '[{"label": "Count employees", "action": "COUNT", "icon": "users"},
48
+ {"label": "Say hello", "action": "HELLO", "icon": "bell"},
49
+ {"label": "Pick an employee", "page": 26, "icon": "search"}]')
50
+ ) as b (seq, name, label, action, hot, menu)
51
+ where a.alias = 'hr' and p.page_no = 47;
52
+
53
+ -- the long process: two seconds on RECALC
54
+ insert into meta.process (page_id, seq, name, type, point, code, when_button, success_message)
55
+ select p.id, 10, 'Recalculate (slow)', 'sql', 'submit', 'select pg_sleep(2)', 'RECALC', 'Recalculated.'
56
+ from meta.page p join meta.app a on a.id = p.app_id
57
+ where a.alias = 'hr' and p.page_no = 47;
58
+
59
+ insert into meta.dynamic_action (page_id, seq, name, event, trigger_element, action, affected_items, code, message)
60
+ select p.id, d.seq, d.name, d.event, d.trig, d.action, d.affected, d.code, d.message
61
+ from meta.page p join meta.app a on a.id = p.app_id, (values
62
+ (10, 'Wait while recalculating', 'click', 'RECALC', 'wait_popup', null, null, 'Recalculating…'),
63
+ (20, 'Wait for the check', 'click', 'CHECK', 'wait_popup', null, null, 'Checking…'),
64
+ (30, 'The check', 'click', 'CHECK', 'execute_sql', 'P47_RESULT',
65
+ 'select (select count(*) from hr.emp) || '' employees, checked at '' || to_char(clock_timestamp(), ''HH24:MI:SS'') as p47_result from pg_sleep(1.5)', null),
66
+ (40, 'Check done', 'click', 'CHECK', 'remove_wait_popup', null, null, null),
67
+ (50, 'Wait for the count', 'menu_entry', 'COUNT', 'wait_popup', null, null, 'Counting employees…'),
68
+ (60, 'The count', 'menu_entry', 'COUNT', 'execute_sql', 'P47_RESULT',
69
+ 'select (select count(*) from hr.emp) || '' employees, counted at '' || to_char(clock_timestamp(), ''HH24:MI:SS'') as p47_result from pg_sleep(1.5)', null),
70
+ (70, 'Count done', 'menu_entry', 'COUNT', 'remove_wait_popup', null, null, null),
71
+ (80, 'Hello', 'menu_entry', 'HELLO', 'show_success', null, null, 'Hello from a menu entry.')
72
+ ) as d (seq, name, event, trig, action, affected, code, message)
73
+ where a.alias = 'hr' and p.page_no = 47;
74
+
75
+ insert into meta.nav_entry (app_id, seq, label, icon, target_page)
76
+ select id, 34, 'Component details', 'list', 47 from meta.app where alias = 'hr';
77
+
78
+ insert into meta.translation (app_id, language, source, target)
79
+ select a.id, 'nl', t.source, t.target
80
+ from meta.app a, (values
81
+ ('Component details', 'Componentdetails'),
82
+ ('Reference', 'Referentie'),
83
+ ('Result', 'Resultaat'),
84
+ ('Recalculate', 'Herberekenen'),
85
+ ('Check in the background', 'Op de achtergrond controleren'),
86
+ ('Actions', 'Acties'),
87
+ ('Count employees', 'Medewerkers tellen'),
88
+ ('Say hello', 'Hallo zeggen'),
89
+ ('Recalculating…', 'Herberekenen…'),
90
+ ('Checking…', 'Controleren…'),
91
+ ('Counting employees…', 'Medewerkers tellen…'),
92
+ ('Recalculated.', 'Herberekend.'),
93
+ ('Hello from a menu entry.', 'Hallo vanuit een menu-item.')
94
+ ) as t(source, target)
95
+ where a.alias = 'hr'
96
+ on conflict do nothing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.css CHANGED
@@ -2042,3 +2042,49 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
2042
2042
  .t-footer .icon { width: 1rem; height: 1rem; }
2043
2043
  @media print { .t-footer { display: none; } }
2044
2044
  .list-card:hover { border-top-color: var(--card-color); }
2045
+
2046
+ /* (sprint 110) Data Reporter: a saved report's link and actions, column descriptions */
2047
+ .reporter-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; margin: 0 0 0.75rem; min-width: 0; }
2048
+ .reporter-actions .buttons { margin: 0; }
2049
+ .reporter-link { flex: 1 1 14rem; min-width: 0; font-family: var(--mono, monospace); font-size: 0.85rem; }
2050
+ .reporter-coldesc { display: block; font-size: 0.78rem; }
2051
+
2052
+ /* (121) AI agents: links a navigate tool built under an answer; tool arguments in the AI usage log */
2053
+ .assistant-links { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.4rem; }
2054
+ .assistant-links .btn { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
2055
+ .ai-args { overflow-wrap: anywhere; word-break: break-word; }
2056
+
2057
+ /* (sprint 112) the report's settings as chips under "Ask in your own words" (src/runtime/ai-filter.ts): Edit unfolds a small form */
2058
+ .ask-chips { margin: 0.6rem 0 0; padding: 0; list-style: none; }
2059
+ .ask-chip { flex-wrap: wrap; max-width: 100%; min-width: 0; overflow-wrap: anywhere; padding-right: 0.2rem; }
2060
+ .ask-chip-text { min-width: 0; }
2061
+ .ask-edit > summary { cursor: pointer; font-size: 0.78rem; text-decoration: underline; list-style: none; padding: 0 0.2rem; }
2062
+ .ask-edit > summary::-webkit-details-marker { display: none; }
2063
+ .ask-edit[open] { flex-basis: 100%; }
2064
+ .ask-edit-body { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.4rem; padding: 0.4rem 0.2rem 0.3rem; }
2065
+ .ask-field { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; flex: 1 1 8rem; max-width: 100%; }
2066
+ .ask-field label { font-size: 0.75rem; }
2067
+ .ask-field select, .ask-field input { min-width: 0; width: 100%; box-sizing: border-box; }
2068
+ .ask-chip-x { background: none; border: 0; color: inherit; font-size: 1rem; line-height: 1; padding: 0.15rem 0.4rem; border-radius: 99px; cursor: pointer; }
2069
+ .ask-chip-x:hover, .ask-chip-x:focus-visible { background: var(--surface); }
2070
+ /* (123) workflows: draft and send e-mail steps; custom task outcomes */
2071
+ .wf-send_mail rect { stroke: var(--success); stroke-width: 2; }
2072
+ .wf-draft rect { fill: var(--surface-2, var(--surface)); stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 6 4; }
2073
+ .wf-draft .wf-name, .wf-draft .wf-type { font-style: italic; }
2074
+ .btn.to-warning { background: var(--warning-soft); border-color: color-mix(in srgb, #b86e00 45%, var(--border)); color: var(--text); }
2075
+ .btn.to-warning:hover { background: color-mix(in srgb, var(--warning-soft) 80%, #b86e00); }
2076
+ .wf-export { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.5rem 0 0; }
2077
+ .wf-migrate-map { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); gap: 0.5rem 1rem; }
2078
+ .wf-migrate-map label { display: flex; flex-direction: column; gap: 0.2rem; overflow-wrap: anywhere; }
2079
+ .wf-migrate-map select { max-width: 100%; }
2080
+ .wf-problem { color: var(--danger); overflow-wrap: anywhere; }
2081
+
2082
+ /* (sprint 115) the popup LOV's end of list while the next page loads, and the wait popup (dynamic action wait_popup) */
2083
+ .popup-lov-sentinel { margin: 0.5rem 0 0; text-align: center; font-size: 0.875rem; }
2084
+ dialog.wait-popup { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); box-shadow: var(--shadow); padding: 1.25rem 1.5rem; max-width: min(360px, calc(100vw - 2rem)); display: none; }
2085
+ dialog.wait-popup[open] { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
2086
+ dialog.wait-popup:focus { outline: none; }
2087
+ dialog.wait-popup::backdrop { background: rgb(15 20 30 / 35%); cursor: wait; }
2088
+ .wait-popup-spinner { width: 2rem; height: 2rem; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); }
2089
+ .wait-popup-text { margin: 0; text-align: center; overflow-wrap: anywhere; }
2090
+ @media (prefers-reduced-motion: no-preference) { .wait-popup-spinner { animation: icon-spin 0.9s linear infinite; } }
package/public/app.js CHANGED
@@ -483,6 +483,9 @@ const pgkilnUnsaved = (() => {
483
483
  // (077) APEX: apex.page.isChanged()
484
484
  isChanged: () => pgkilnUnsaved.changed(),
485
485
  setValue: (name, value) => setItemValue(name, value),
486
+ // (124) APEX: apex.widget.waitPopup; the returned function removes it
487
+ waitPopup: (message) => (showWait(message == null ? '' : String(message)), removeWait),
488
+ removeWaitPopup: () => removeWait(),
486
489
  showSuccess: (message) => showMessage(String(message), 'success'),
487
490
  showError: (message, item) => (item ? itemError(item, String(message)) : showMessage(String(message), 'error')),
488
491
  clearErrors: (...items) => clearErrors(items),
@@ -521,6 +524,39 @@ const pgkilnUnsaved = (() => {
521
524
  }
522
525
  }
523
526
 
527
+ // (124) APEX: Show / Remove Wait Popup. A modal dialog (the page below is inert, Escape doesn't
528
+ // close it) with a spinner and a status text read out by screen readers; <main> is aria-busy.
529
+ // It goes with the page on a submit; a submit the browser refused (an invalid field) or a page
530
+ // restored from the back/forward cache removes it.
531
+ let waitDlg = null;
532
+ function showWait(message) {
533
+ if (!waitDlg) {
534
+ waitDlg = document.createElement('dialog');
535
+ waitDlg.className = 'wait-popup';
536
+ waitDlg.tabIndex = -1;
537
+ const spin = document.createElement('span');
538
+ spin.className = 'wait-popup-spinner';
539
+ spin.setAttribute('aria-hidden', 'true');
540
+ const p = document.createElement('p');
541
+ p.className = 'wait-popup-text';
542
+ p.id = 'pgkiln-wait-text';
543
+ p.setAttribute('role', 'status');
544
+ waitDlg.setAttribute('aria-labelledby', p.id);
545
+ waitDlg.append(spin, p);
546
+ waitDlg.addEventListener('cancel', (e) => e.preventDefault());
547
+ body.appendChild(waitDlg);
548
+ }
549
+ waitDlg.querySelector('.wait-popup-text').textContent = message || text('da.wait');
550
+ if (!waitDlg.open) waitDlg.showModal();
551
+ (document.getElementById('main') || body).setAttribute('aria-busy', 'true');
552
+ }
553
+ function removeWait() {
554
+ if (waitDlg && waitDlg.open) waitDlg.close();
555
+ (document.getElementById('main') || body).removeAttribute('aria-busy');
556
+ }
557
+ document.addEventListener('invalid', () => waitDlg && waitDlg.open && removeWait(), true);
558
+ window.addEventListener('pageshow', (e) => e.persisted && removeWait());
559
+
524
560
  async function runDa(da, initial, value, extra) {
525
561
  const holds = conditionHolds(da, value);
526
562
  switch (da.action) {
@@ -567,6 +603,13 @@ const pgkilnUnsaved = (() => {
567
603
  case 'print':
568
604
  window.print();
569
605
  return;
606
+ // (124) the wait popup: until remove_wait_popup, the next page or a refused submit
607
+ case 'wait_popup':
608
+ showWait(da.message);
609
+ return;
610
+ case 'remove_wait_popup':
611
+ removeWait();
612
+ return;
570
613
  case 'current_position':
571
614
  return new Promise((resolve) => {
572
615
  if (!navigator.geolocation) return showError(text('item.locate_error')), resolve();
@@ -679,6 +722,28 @@ const pgkilnUnsaved = (() => {
679
722
  }
680
723
 
681
724
  const das = meta.das || [];
725
+ // (121) an AI agent's javascript tools: after the answer, the registered function of the action
726
+ // (pgkiln.actions.register) and the dynamic actions of the custom event of that name, with the arguments
727
+ scriptsLoaded.then(async () => {
728
+ for (const el of document.querySelectorAll('.assistant-action[data-ai-action]')) {
729
+ const name = el.dataset.aiAction;
730
+ let args = {};
731
+ try {
732
+ args = JSON.parse(el.dataset.aiArgs || '{}');
733
+ } catch {
734
+ // ignored
735
+ }
736
+ const region = el.closest('[id^="R"]');
737
+ el.remove();
738
+ const fn = actions.get(name);
739
+ try {
740
+ if (fn) await fn({ value: args, items: [], region, elements: [], message: null });
741
+ await runChain(das.filter((da) => da.event === 'custom' && da.trigger.some((t) => String(t).toLowerCase() === name.toLowerCase())), JSON.stringify(args));
742
+ } catch (e) {
743
+ console.error(e);
744
+ }
745
+ }
746
+ });
682
747
  document.addEventListener('change', (e) => {
683
748
  const name = e.target.name;
684
749
  if (!name) return;
@@ -688,7 +753,7 @@ const pgkilnUnsaved = (() => {
688
753
  });
689
754
  document.addEventListener('click', (e) => {
690
755
  const btn = e.target.closest('[data-button]');
691
- if (!btn) return;
756
+ if (!btn || e.target.closest('[data-menu-entry]')) return;
692
757
  const run = das.filter((da) => da.event === 'click' && da.trigger.includes(btn.dataset.button));
693
758
  // "Generate text with AI" on a submit button: the AJAX call replaces the submit
694
759
  // (without JavaScript the button submits and the page process runs instead)
@@ -705,6 +770,21 @@ const pgkilnUnsaved = (() => {
705
770
  });
706
771
  }
707
772
  });
773
+ // (124) menu_entry: an entry {"action": NAME} of a menu button was chosen (a declined confirm
774
+ // stops it in the capture handler below). Trigger: the entry's name or the menu button's name;
775
+ // the value a client-side condition tests is the entry's name.
776
+ document.addEventListener('click', (e) => {
777
+ const entry = e.target.closest?.('[data-menu-entry]');
778
+ if (!entry) return;
779
+ const name = entry.dataset.menuEntry;
780
+ const menu = entry.closest('details.menu');
781
+ const owner = (menu && menu.dataset.button) || '';
782
+ if (menu) {
783
+ menu.open = false;
784
+ menu.querySelector('summary')?.focus();
785
+ }
786
+ runChain(das.filter((da) => da.event === 'menu_entry' && (da.trigger.includes(name) || (owner && da.trigger.includes(owner)))), name);
787
+ });
708
788
  for (const da of das) if (da.event !== 'dialog_closed' && ['show', 'hide', 'enable', 'disable'].includes(da.action)) runDa(da, true);
709
789
  runChain(das.filter((da) => da.event === 'load'));
710
790
 
@@ -1882,7 +1962,7 @@ document.querySelectorAll('[data-rds]').forEach((nav) => {
1882
1962
 
1883
1963
  // Popup list of values: the select (which works without JavaScript) is hidden
1884
1964
  // behind a read-only field and a button that opens a dialog searching the
1885
- // item's list of values on the server, page by page.
1965
+ // item's list of values on the server, page by page (loaded as the list scrolls).
1886
1966
  function enhancePopupLovs(root) {
1887
1967
  const selects = root.matches?.('select[data-popup-lov]') ? [root] : [...(root.querySelectorAll?.('select[data-popup-lov]') ?? [])];
1888
1968
  selects.forEach(popupLov);
@@ -1964,8 +2044,16 @@ function popupLov(sel) {
1964
2044
  sel.dispatchEvent(new Event('change', { bubbles: true }));
1965
2045
  dlg.close();
1966
2046
  };
2047
+ // (124) infinite scrolling: the next page loads when the end of the list scrolls into view
2048
+ // (APEX: lazy loading); without IntersectionObserver the dialog pages with ‹ and More
2049
+ const infinite = 'IntersectionObserver' in window;
2050
+ let observer = null;
2051
+ let seq = 0;
2052
+ let loading = false;
1967
2053
  async function load(p) {
1968
2054
  page = p;
2055
+ const my = ++seq;
2056
+ const append = infinite && p > 0;
1969
2057
  const results = dlg.querySelector('.popup-lov-results');
1970
2058
  const params = new URLSearchParams({ q: dlg.querySelector('.popup-lov-search').value, p: String(p), __csrf: csrf });
1971
2059
  // a cascading LOV sends its parents' current values
@@ -1975,31 +2063,45 @@ function popupLov(sel) {
1975
2063
  if (el) params.set(parent, el.value);
1976
2064
  }
1977
2065
  let json;
2066
+ loading = true;
1978
2067
  try {
1979
2068
  const res = await fetch(d.popupLov, { method: 'POST', body: params, headers: { accept: 'application/json' }, credentials: 'same-origin' });
1980
2069
  json = await res.json();
1981
2070
  if (!res.ok) throw new Error(json.error || res.statusText);
1982
2071
  } catch (e) {
1983
- results.textContent = e.message;
2072
+ if (my !== seq) return;
2073
+ loading = false;
2074
+ const end = results.querySelector('.popup-lov-sentinel');
2075
+ if (append && end) end.textContent = e.message;
2076
+ else results.textContent = e.message;
1984
2077
  return;
1985
2078
  }
1986
- results.replaceChildren();
1987
- if (!json.rows.length) {
1988
- const none = document.createElement('p');
1989
- none.textContent = d.noneLabel || 'Nothing found.';
1990
- results.append(none);
1991
- return;
1992
- }
1993
- const table = document.createElement('table');
1994
- table.className = 'popup-lov-table';
1995
- const tr = table.createTHead().insertRow();
1996
- for (const hd of json.headings) {
1997
- const th = document.createElement('th');
1998
- th.scope = 'col';
1999
- th.textContent = hd;
2000
- tr.append(th);
2079
+ // a newer search (or page) replaced this one
2080
+ if (my !== seq) return;
2081
+ loading = false;
2082
+ observer?.disconnect();
2083
+ results.querySelector('.popup-lov-pages, .popup-lov-sentinel')?.remove();
2084
+ let tb = append ? results.querySelector('.popup-lov-table tbody') : null;
2085
+ if (!tb) {
2086
+ results.replaceChildren();
2087
+ if (!json.rows.length) {
2088
+ const none = document.createElement('p');
2089
+ none.textContent = d.noneLabel || 'Nothing found.';
2090
+ results.append(none);
2091
+ return;
2092
+ }
2093
+ const table = document.createElement('table');
2094
+ table.className = 'popup-lov-table';
2095
+ const tr = table.createTHead().insertRow();
2096
+ for (const hd of json.headings) {
2097
+ const th = document.createElement('th');
2098
+ th.scope = 'col';
2099
+ th.textContent = hd;
2100
+ tr.append(th);
2101
+ }
2102
+ tb = table.createTBody();
2103
+ results.append(table);
2001
2104
  }
2002
- const tb = table.createTBody();
2003
2105
  for (const r of json.rows) {
2004
2106
  const row = tb.insertRow();
2005
2107
  r.columns.forEach((v, i) => {
@@ -2015,7 +2117,19 @@ function popupLov(sel) {
2015
2117
  });
2016
2118
  if (r.value === sel.value) row.className = 'is-current';
2017
2119
  }
2018
- results.append(table);
2120
+ if (infinite) {
2121
+ if (!json.more) return;
2122
+ const end = document.createElement('p');
2123
+ end.className = 'popup-lov-sentinel muted';
2124
+ end.textContent = d.loadingLabel || 'Loading more…';
2125
+ results.append(end);
2126
+ // the implicit root: whichever box scrolls the list (the dialog's body or the results), it clips
2127
+ observer = observer || new IntersectionObserver((entries) => {
2128
+ if (entries.some((x) => x.isIntersecting) && !loading) load(page + 1);
2129
+ });
2130
+ observer.observe(end);
2131
+ return;
2132
+ }
2019
2133
  if (json.more || page > 0) {
2020
2134
  const nav = document.createElement('div');
2021
2135
  nav.className = 'popup-lov-pages';
@@ -1359,3 +1359,119 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
1359
1359
  .sm-toolbar:not([hidden]) { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0.25rem 0 0.75rem; }
1360
1360
  .sm-toolbar select { width: auto; }
1361
1361
  .sm-toolbar [aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
1362
+
1363
+ /* (sprint 109, WS-1) the Workspace home (/builder/home) */
1364
+ .wh-title { margin: 0 0 14px; font-size: var(--fs-xl); overflow-wrap: anywhere; }
1365
+ .wh-summary h2, .wh-top-head h2 { margin: 0 0 10px; }
1366
+ .wh-stats a.stat { color: inherit; text-decoration: none; }
1367
+ .wh-stats a.stat:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
1368
+ .wh-stats a.stat span { color: var(--accent); }
1369
+ .wh-top-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
1370
+ .wh-top-head h2 { margin: 0; }
1371
+ .wh-timeframe { display: inline-flex; }
1372
+ .wh-top { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 16px; margin: 10px 0 20px; }
1373
+ .wh-top > .region { margin: 0; min-width: 0; }
1374
+ .wh-message { margin-bottom: 16px; }
1375
+ .wh-message-text { margin: 0 0 8px; white-space: pre-line; overflow-wrap: anywhere; }
1376
+ .wh-edit > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
1377
+ .wh-edit > summary::-webkit-details-marker { display: none; }
1378
+ .wh-edit[open] > summary { margin-bottom: 10px; }
1379
+ .wh-release { display: flex; gap: 0.6rem; align-items: flex-start; }
1380
+ .wh-release p { margin: 0.2rem 0 0; overflow-wrap: anywhere; }
1381
+ .wh-release-form { margin-top: 10px; }
1382
+ @media (max-width: 640px) {
1383
+ .wh-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
1384
+ .wh-stats .stat { padding: 0.6rem 0.75rem; }
1385
+ .wh-stats .stat b { font-size: 1.2rem; }
1386
+ }
1387
+
1388
+ /* (sprint 110) App Builder → Data Reporter: datasets */
1389
+ .dataset-checks { display: flex; flex-direction: column; gap: 0.35rem; margin: 0.25rem 0 0.5rem; overflow-wrap: anywhere; }
1390
+ .dataset-filter { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.4rem; }
1391
+ .dataset-filter > select, .dataset-filter > input { flex: 1 1 10rem; min-width: 0; }
1392
+ .dataset-columns input { min-width: 0; width: 100%; }
1393
+ .dataset-page pre.code { white-space: pre-wrap; overflow-wrap: anywhere; }
1394
+ .dataset-schema { flex-wrap: wrap; }
1395
+ .dataset-schema > select { flex: 1 1 10rem; min-width: 0; }
1396
+
1397
+ /* (sprint 113) Team Development: issues, board, labels, milestones, templates */
1398
+ .team-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; margin-bottom: 8px; }
1399
+ .team-head h1 { margin: 0; font-size: var(--fs-xl); }
1400
+ .team-head .team-counts { margin: 0; flex: 1 1 200px; }
1401
+ .team-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }
1402
+ @media (min-width: 1024px) { .team-layout { grid-template-columns: minmax(0, 1fr) 280px; } }
1403
+ .team-main { min-width: 0; }
1404
+ .team-side { min-width: 0; }
1405
+ .team-side section { padding: 10px 0; border-top: 1px solid var(--border); }
1406
+ .team-side section:first-child { border-top: 0; padding-top: 0; }
1407
+ .team-side h2 { margin: 0 0 6px; font-size: var(--fs-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
1408
+ .team-side p, .team-side li { overflow-wrap: anywhere; }
1409
+ .team-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); gap: 8px 12px; align-items: end; margin-bottom: 10px; }
1410
+ .team-filters .field { margin: 0; min-width: 0; }
1411
+ .team-filters select, .team-filters input { width: 100%; min-width: 0; }
1412
+ .team-filters .buttons { margin: 0; }
1413
+ .team-issues { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
1414
+ .team-issue { padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); min-width: 0; }
1415
+ .team-issue.is-closed { opacity: 0.8; }
1416
+ .team-issue-title { display: flex; gap: 6px; align-items: baseline; font-weight: 600; overflow-wrap: anywhere; min-width: 0; }
1417
+ .team-issue-title .icon { flex: none; width: 15px; height: 15px; color: var(--hot); align-self: center; }
1418
+ .team-issue.is-closed .team-issue-title .icon { color: var(--success, #2f7d32); }
1419
+ .team-issue-title a { min-width: 0; }
1420
+ .team-labels { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
1421
+ .team-issue-meta { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 6px; overflow-wrap: anywhere; }
1422
+ .team-issue-meta span { display: inline-flex; gap: 4px; align-items: center; min-width: 0; }
1423
+ .team-issue-meta .icon { width: 13px; height: 13px; flex: none; }
1424
+ .team-label { display: inline-block; max-width: 100%; padding: 1px 8px; border-radius: 999px; font-size: 0.75rem; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
1425
+ .team-label-red { background: #b42318; color: #fff; }
1426
+ .team-label-orange { background: #c4520f; color: #fff; }
1427
+ .team-label-yellow { background: #e6b400; color: #1e1d1b; }
1428
+ .team-label-green { background: #2f7d32; color: #fff; }
1429
+ .team-label-teal { background: #0e7a74; color: #fff; }
1430
+ .team-label-blue { background: #1a5f99; color: #fff; }
1431
+ .team-label-purple { background: #6941c6; color: #fff; }
1432
+ .team-label-gray { background: #5e5a54; color: #fff; }
1433
+ .team-board-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
1434
+ .team-board-filters { margin-bottom: 10px; }
1435
+ .team-board-filters > summary { cursor: pointer; display: inline-flex; gap: 6px; align-items: center; color: var(--muted); }
1436
+ .team-board-filters[open] > summary { margin-bottom: 8px; }
1437
+ .team-board { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 12px; align-items: start; }
1438
+ .team-col { min-width: 0; padding: 10px; border-radius: var(--radius-sm); background: var(--surface-2); border: 1px solid var(--border); }
1439
+ .team-col-head { display: flex; gap: 8px; align-items: center; margin: 0 0 8px; font-size: var(--fs-sm, 0.9rem); overflow-wrap: anywhere; }
1440
+ .team-move { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }
1441
+ .team-move select { flex: 1 1 120px; min-width: 0; }
1442
+ .team-status { display: inline-block; padding: 1px 10px; border-radius: 999px; font-size: 0.8rem; font-weight: 700; margin-right: 6px; }
1443
+ .team-status-open { background: var(--accent-soft); color: var(--text); }
1444
+ .team-status-closed { background: var(--surface-3); color: var(--muted); }
1445
+ .team-issue-h { margin: 0 0 4px; font-size: var(--fs-lg, 1.2rem); overflow-wrap: anywhere; }
1446
+ .team-md { overflow-wrap: anywhere; min-width: 0; }
1447
+ .team-md pre { overflow-x: auto; max-width: 100%; }
1448
+ .team-body { padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
1449
+ .mention { font-weight: 600; color: var(--accent); }
1450
+ .team-timeline { list-style: none; margin: 0 0 12px; padding: 0; display: grid; gap: 8px; }
1451
+ .team-comment { padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); min-width: 0; }
1452
+ .team-comment-head { margin-bottom: 4px; overflow-wrap: anywhere; }
1453
+ .team-event { display: flex; gap: 6px; align-items: flex-start; overflow-wrap: anywhere; min-width: 0; }
1454
+ .team-event .icon { flex: none; width: 13px; height: 13px; margin-top: 3px; }
1455
+ .team-response-pick, .team-template-pick, .team-save-view { display: flex; flex-wrap: wrap; gap: 6px; align-items: end; margin-bottom: 8px; }
1456
+ .team-response-pick .field, .team-template-pick .field { margin: 0; min-width: 0; flex: 1 1 200px; }
1457
+ .team-save-view input { flex: 1 1 140px; min-width: 0; }
1458
+ .team-edit { margin-top: 16px; }
1459
+ .team-edit > summary { list-style: none; }
1460
+ .team-edit[open] > summary { margin-bottom: 10px; }
1461
+ .team-fieldset { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 12px; margin: 8px 0; min-width: 0; }
1462
+ .team-check-group { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; margin-bottom: 4px; }
1463
+ .team-check-group > .small { flex-basis: 100%; }
1464
+ .team-label-list, .team-template-list, .team-milestones, .team-views, .team-mentions { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
1465
+ .team-label-list li, .team-template-list li, .team-views li, .team-mentions li { overflow-wrap: anywhere; min-width: 0; }
1466
+ .team-views li { display: flex; gap: 6px; justify-content: space-between; align-items: center; }
1467
+ .team-views a[aria-current] { font-weight: 700; }
1468
+ .team-inline-edit { margin-top: 4px; }
1469
+ .team-inline-edit[open] { padding: 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
1470
+ .team-milestone { padding: 8px 0; border-bottom: 1px solid var(--border); }
1471
+ .team-milestone-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; overflow-wrap: anywhere; }
1472
+ .team-milestone-head .icon { width: 14px; height: 14px; margin-right: 4px; }
1473
+ .team-progress { width: 100%; max-width: 360px; height: 8px; }
1474
+ .team-page-issues { display: flex; gap: 6px; align-items: flex-start; margin: 0 0 6px; padding: 6px 10px; border-radius: var(--radius-sm); background: var(--warning-soft); overflow-wrap: anywhere; }
1475
+ .team-page-issues .icon { flex: none; width: 14px; height: 14px; margin-top: 2px; }
1476
+ .wh-team-n { margin-left: auto; padding-left: 8px; }
1477
+ .feedback-issue { margin: 4px 0; }
package/public/builder.js CHANGED
@@ -1282,3 +1282,50 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
1282
1282
  });
1283
1283
  for (const b of form.querySelectorAll('[data-cron-nojs]')) b.hidden = true;
1284
1284
  })();
1285
+
1286
+ // (123) Workflow diagram as PNG: the server's SVG file drawn on a canvas. Without JavaScript the
1287
+ // button stays hidden and the SVG link is the download.
1288
+ (() => {
1289
+ for (const button of document.querySelectorAll('[data-wf-png]')) {
1290
+ const link = button.parentElement?.querySelector('[data-wf-svg]');
1291
+ if (!link || typeof HTMLCanvasElement === 'undefined') continue;
1292
+ button.hidden = false;
1293
+ button.addEventListener('click', async () => {
1294
+ button.disabled = true;
1295
+ try {
1296
+ const res = await fetch(link.href, { credentials: 'same-origin' });
1297
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1298
+ const svg = await res.text();
1299
+ const img = new Image();
1300
+ await new Promise((ok, fail) => {
1301
+ img.onload = ok;
1302
+ img.onerror = () => fail(new Error('The diagram could not be drawn.'));
1303
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
1304
+ });
1305
+ const scale = 2;
1306
+ const canvas = document.createElement('canvas');
1307
+ canvas.width = Math.max(1, Math.round((img.naturalWidth || 800) * scale));
1308
+ canvas.height = Math.max(1, Math.round((img.naturalHeight || 600) * scale));
1309
+ const g = canvas.getContext('2d');
1310
+ g.fillStyle = '#ffffff';
1311
+ g.fillRect(0, 0, canvas.width, canvas.height);
1312
+ g.drawImage(img, 0, 0, canvas.width, canvas.height);
1313
+ const blob = await new Promise((ok) => canvas.toBlob(ok, 'image/png'));
1314
+ if (!blob) throw new Error('The browser could not make a PNG.');
1315
+ const a = document.createElement('a');
1316
+ a.href = URL.createObjectURL(blob);
1317
+ a.download = 'workflow.png';
1318
+ const name = /([^/?]+)\.svg/.exec(res.headers.get('content-disposition') || '');
1319
+ if (name) a.download = `${name[1]}.png`;
1320
+ document.body.append(a);
1321
+ a.click();
1322
+ a.remove();
1323
+ setTimeout(() => URL.revokeObjectURL(a.href), 10000);
1324
+ } catch (e) {
1325
+ window.alert(e.message);
1326
+ } finally {
1327
+ button.disabled = false;
1328
+ }
1329
+ });
1330
+ }
1331
+ })();