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
@@ -14,17 +14,42 @@ import { logActivity } from '../session.ts';
14
14
  // own savepoint.
15
15
 
16
16
  const list = (s: string | null | undefined) => (s ?? '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean);
17
+ const isZero = (v: string) => v.trim() !== '' && Number(v) === 0;
17
18
 
18
- /** Does a computation's or branch's condition hold? Broken SQL counts as false (and shows a message). */
19
+ /** The condition types that run SQL (as the application's role); all others are decided without a query. */
20
+ export const SQL_CONDITIONS = new Set(['sql', 'exists', 'not_exists', 'function_body']);
21
+
22
+ /** (147) A region's or button's condition: `condition` is the expression, SQL when no type is set. */
23
+ export const conditionOf = (x: { condition?: string | null; condition_type?: string | null; condition_value?: string | null }): Condition => ({
24
+ condition_type: (x.condition_type || (x.condition?.trim() ? 'sql' : null)) as Condition['condition_type'],
25
+ condition_expr: x.condition ?? null,
26
+ condition_value: x.condition_value ?? null,
27
+ });
28
+
29
+ /** (147) A dynamic action's server-side condition (its condition_type is the client-side one). */
30
+ export const serverConditionOf = (d: { server_condition_type?: string | null; server_condition_expr?: string | null; server_condition_value?: string | null }): Condition => ({
31
+ condition_type: (d.server_condition_type || null) as Condition['condition_type'],
32
+ condition_expr: d.server_condition_expr ?? null,
33
+ condition_value: d.server_condition_value ?? null,
34
+ });
35
+
36
+ /**
37
+ * Does a server-side condition hold (migrations 029, 147)? The same catalogue for every component. Broken SQL
38
+ * counts as false (and shows a message); an unknown type is false.
39
+ */
19
40
  export async function conditionHolds(ctx: PageContext, cond: Condition, where: string): Promise<boolean> {
20
- const value = (name: string | null) => {
21
- const v = name ? bindValues(ctx)[name.toUpperCase()] : null;
41
+ const value = (name: string | null | undefined) => {
42
+ const v = name ? bindValues(ctx)[name.trim().toUpperCase()] : null;
22
43
  return v === undefined || v === null ? '' : v;
23
44
  };
45
+ const item = value(cond.condition_expr);
24
46
  switch (cond.condition_type) {
25
47
  case null:
26
48
  case undefined:
49
+ case 'always':
27
50
  return true;
51
+ case 'never':
52
+ return false;
28
53
  case 'sql':
29
54
  return sqlTrue(ctx, cond.condition_expr, where);
30
55
  case 'exists':
@@ -39,16 +64,62 @@ export async function conditionHolds(ctx: PageContext, cond: Condition, where: s
39
64
  return false; // fail closed either way
40
65
  }
41
66
  }
67
+ case 'function_body':
68
+ try {
69
+ const v = await savepoint(ctx.client!, () => functionBody(ctx, cond.condition_expr ?? ''));
70
+ return v === 'true' || v === 't';
71
+ } catch (e) {
72
+ ctx.errors.page.push(await publicError(ctx, e, where));
73
+ return false;
74
+ }
42
75
  case 'item_null':
43
- return value(cond.condition_expr) === '';
76
+ return item === '';
44
77
  case 'item_not_null':
45
- return value(cond.condition_expr) !== '';
78
+ return item !== '';
79
+ case 'item_zero':
80
+ return isZero(item);
81
+ case 'item_not_zero':
82
+ return !isZero(item);
83
+ case 'item_null_or_zero':
84
+ return item === '' || isZero(item);
85
+ case 'item_not_null_or_zero':
86
+ return item !== '' && !isZero(item);
46
87
  case 'item_equals':
47
- return value(cond.condition_expr) === (cond.condition_value ?? '');
88
+ return item === (cond.condition_value ?? '');
48
89
  case 'item_not_equals':
49
- return value(cond.condition_expr) !== (cond.condition_value ?? '');
90
+ return item !== (cond.condition_value ?? '');
91
+ case 'item_equals_item':
92
+ return item === value(cond.condition_value);
93
+ case 'item_not_equals_item':
94
+ return item !== value(cond.condition_value);
95
+ case 'item_in_list':
96
+ case 'item_not_in_list': {
97
+ const found = (cond.condition_value ?? '').split(':').includes(item);
98
+ return cond.condition_type === 'item_in_list' ? found : !found;
99
+ }
50
100
  case 'request_in':
51
101
  return list(cond.condition_value).includes(ctx.request);
102
+ case 'request_not_in':
103
+ return !list(cond.condition_value).includes(ctx.request);
104
+ case 'page_in':
105
+ case 'page_not_in': {
106
+ const found = list(cond.condition_value).includes(String(ctx.page.page_no));
107
+ return cond.condition_type === 'page_in' ? found : !found;
108
+ }
109
+ case 'page_is_dialog':
110
+ return ctx.page.mode === 'modal';
111
+ case 'page_is_not_dialog':
112
+ return ctx.page.mode !== 'modal';
113
+ case 'user_signed_in':
114
+ return ctx.user !== 'nobody';
115
+ case 'user_public':
116
+ return ctx.user === 'nobody';
117
+ case 'language_in':
118
+ case 'language_not_in': {
119
+ const lang = ctx.locale.lang.toUpperCase();
120
+ const found = list(cond.condition_value).some((l) => l === lang || lang.startsWith(`${l}-`));
121
+ return cond.condition_type === 'language_in' ? found : !found;
122
+ }
52
123
  default:
53
124
  return false; // unknown type: fail closed
54
125
  }
@@ -61,7 +132,7 @@ export async function conditionHolds(ctx: PageContext, cond: Condition, where: s
61
132
  * savepoint, as the application's role (binds are substituted first, as
62
133
  * escaped literals). It never outlives the transaction's savepoint.
63
134
  */
64
- async function functionBody(ctx: PageContext, body: string): Promise<string | null> {
135
+ export async function functionBody(ctx: PageContext, body: string): Promise<string | null> {
65
136
  const c = ctx.client!;
66
137
  const code = applyBinds(body, bindValues(ctx)).trim();
67
138
  const block = /^(declare|begin)\b/i.test(code) ? code : `begin\n${code}\nend`;
@@ -13,7 +13,7 @@ import { encodeTile, tileArea, type TileFeature } from '../mvt.ts';
13
13
 
14
14
  // Map region (APEX: Map region). The SELECT returns, per row, a position
15
15
  // (columns lat and lng, latitude and longitude, or location as "lat,lng")
16
- // and optionally title, body and geojson (a GeoJSON geometry or feature for
16
+ // and optionally id (what a map_object_click dynamic action sees), title, body and geojson (a GeoJSON geometry or feature for
17
17
  // lines and areas; a GeoJSON point is a place), and weight (for a heat map).
18
18
  // With PostGIS installed, a geometry or geography column (WGS 84) is turned
19
19
  // into GeoJSON on the server (ST_AsGeoJSON), so no geojson column is needed.
@@ -59,11 +59,38 @@ interface Point {
59
59
  body: string;
60
60
  href: string | null;
61
61
  weight: number;
62
+ /** (161) the row's id column: what a map_object_click dynamic action's condition sees */
63
+ id?: string;
62
64
  }
65
+ /** (159, CD-6) a shape layer's look: fixed in the layer's settings, or per row from columns of the same names */
66
+ export interface ShapeStyle {
67
+ fill_color?: string;
68
+ stroke_color?: string;
69
+ stroke_width?: number;
70
+ opacity?: number;
71
+ }
72
+ const COLOR = /^#[0-9a-fA-F]{6}$/;
73
+ /** The checked parts of a style (anything else is left out). */
74
+ export function shapeStyle(x: unknown): ShapeStyle {
75
+ const s = (x && typeof x === 'object' ? x : {}) as Record<string, unknown>;
76
+ const out: ShapeStyle = {};
77
+ if (typeof s.fill_color === 'string' && COLOR.test(s.fill_color)) out.fill_color = s.fill_color;
78
+ if (typeof s.stroke_color === 'string' && COLOR.test(s.stroke_color)) out.stroke_color = s.stroke_color;
79
+ const w = Number(s.stroke_width);
80
+ if (s.stroke_width !== undefined && s.stroke_width !== null && Number.isFinite(w) && w >= 0 && w <= 20) out.stroke_width = w;
81
+ const o = Number(s.opacity);
82
+ if (s.opacity !== undefined && s.opacity !== null && Number.isFinite(o) && o >= 0 && o <= 1) out.opacity = o;
83
+ return out;
84
+ }
85
+ const SHAPE_KINDS = new Set(['lines', 'polygons', 'extruded']);
86
+ const kindOf = (v: unknown): LayerDef['kind'] => (v === 'heat' || SHAPE_KINDS.has(v as string) ? (v as LayerDef['kind']) : 'markers');
87
+
63
88
  export interface LayerDef {
64
89
  name: string;
65
90
  source: string;
66
- kind: 'markers' | 'heat';
91
+ /** (159) lines, polygons: GeoJSON / PostGIS shapes with a style; extruded: polygons shaded by a height column */
92
+ kind: 'markers' | 'heat' | 'lines' | 'polygons' | 'extruded';
93
+ style: ShapeStyle;
67
94
  cluster: boolean;
68
95
  link: Link | undefined;
69
96
  hidden: boolean;
@@ -78,7 +105,8 @@ export function layerDefs(r: Region): LayerDef[] {
78
105
  const first: LayerDef = {
79
106
  name: typeof r.config.name === 'string' && r.config.name.trim() ? r.config.name.trim() : (r.title ?? 'Map'),
80
107
  source: r.source ?? '',
81
- kind: r.config.layer === 'heat' ? 'heat' : 'markers',
108
+ kind: kindOf(r.config.layer),
109
+ style: shapeStyle(r.config.style),
82
110
  cluster: r.config.cluster === true,
83
111
  link: r.config.link,
84
112
  hidden: false,
@@ -91,7 +119,8 @@ export function layerDefs(r: Region): LayerDef[] {
91
119
  .map((l: any, i: number): LayerDef => ({
92
120
  name: typeof l.name === 'string' && l.name.trim() ? l.name.trim() : `Layer ${i + 2}`,
93
121
  source: l.source,
94
- kind: l.layer === 'heat' ? 'heat' : 'markers',
122
+ kind: kindOf(l.layer),
123
+ style: shapeStyle(l.style),
95
124
  cluster: l.cluster === true,
96
125
  link: l.link && typeof l.link === 'object' && Number.isInteger(l.link.page) ? l.link : undefined,
97
126
  hidden: l.hidden === true,
@@ -137,6 +166,7 @@ async function loadLayer(ctx: PageContext, def: LayerDef, g: PostGis | null, opt
137
166
  if ((Number.isNaN(lat) || Number.isNaN(lng)) && loc) [lat, lng] = [Number(loc[1]), Number(loc[2])];
138
167
  const title = toState(col(row, 'title')) ?? '';
139
168
  const body = toState(col(row, 'body')) ?? '';
169
+ const id = toState(col(row, 'id')) ?? undefined;
140
170
  let href: string | null = null;
141
171
  if (linkOk && link) {
142
172
  const items: Record<string, string> = {};
@@ -151,11 +181,30 @@ async function loadLayer(ctx: PageContext, def: LayerDef, g: PostGis | null, opt
151
181
  if (geometry?.type === 'Point' && Array.isArray(geometry.coordinates) && (Number.isNaN(lat) || Number.isNaN(lng)))
152
182
  // a point is a place (a marker, a heat spot, part of a cluster)
153
183
  [lng, lat] = geometry.coordinates.map(Number);
154
- else if (geometry && typeof geometry === 'object') shapes.push({ type: 'Feature', geometry, properties: { title, body, href } });
184
+ else if (geometry && typeof geometry === 'object') {
185
+ // (159) a shape layer's style: the layer's, then the row's own columns
186
+ const style = SHAPE_KINDS.has(def.kind)
187
+ ? { ...def.style, ...shapeStyle({ fill_color: col(row, 'fill_color'), stroke_color: col(row, 'stroke_color'), stroke_width: col(row, 'stroke_width'), opacity: col(row, 'opacity') }) }
188
+ : undefined;
189
+ const h = num(col(row, 'height'));
190
+ shapes.push({ type: 'Feature', geometry, properties: { title, body, href, ...(id !== undefined ? { id } : {}), ...(style ? { style } : {}), ...(def.kind === 'extruded' && Number.isFinite(h) ? { height: h } : {}) } });
191
+ }
155
192
  }
193
+ // a shape layer draws only shapes
194
+ if (SHAPE_KINDS.has(def.kind)) continue;
156
195
  const w = num(col(row, 'weight'));
157
196
  const weight = Number.isFinite(w) && w > 0 ? w : Number.isNaN(w) ? 1 : 0;
158
- if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) points.push({ lat, lng, title, body, href, weight });
197
+ if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) points.push({ lat, lng, title, body, href, weight, ...(id !== undefined ? { id } : {}) });
198
+ }
199
+ // (159) extruded: each polygon shaded by its height (from light to strong; no 3D view)
200
+ if (def.kind === 'extruded') {
201
+ const hs = shapes.map((f: any) => f.properties.height).filter((h): h is number => typeof h === 'number');
202
+ const [lo, hi] = hs.length ? [Math.min(...hs), Math.max(...hs)] : [0, 0];
203
+ for (const f of shapes as any[]) {
204
+ const h = f.properties.height;
205
+ const share = typeof h === 'number' && hi > lo ? (h - lo) / (hi - lo) : 0.5;
206
+ f.properties.style = { ...f.properties.style, opacity: Math.round((0.2 + 0.7 * share) * 100) / 100 };
207
+ }
159
208
  }
160
209
  return { points, shapes, truncated };
161
210
  }
@@ -250,7 +299,7 @@ export function parseTile(z: string, x: string, y: string) {
250
299
  export async function layerTile(ctx: PageContext, def: LayerDef, t: { z: number; x: number; y: number }) {
251
300
  const { points, shapes, truncated } = await loadLayer(ctx, def, await postgis(), { area: tileArea(t.z, t.x, t.y, 1 / 16), limit: MAX_TILE_ROWS });
252
301
  const features: TileFeature[] = [
253
- ...points.map((p) => ({ geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, properties: { title: p.title, body: p.body, href: p.href, weight: p.weight } })),
302
+ ...points.map((p) => ({ geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, properties: { title: p.title, body: p.body, href: p.href, weight: p.weight, ...(p.id !== undefined ? { id: p.id } : {}) } })),
254
303
  ...(shapes as { geometry: TileFeature['geometry']; properties: TileFeature['properties'] }[]).map((s) => ({ geometry: s.geometry, properties: s.properties })),
255
304
  ];
256
305
  return { tile: encodeTile('places', t.z, t.x, t.y, features), truncated };
@@ -0,0 +1,139 @@
1
+ // (sprint 157, CD-4) Declarative process types APEX applications rely on (migration 150). Each one calls what pgkiln
2
+ // already has: session state, user preferences, the geocoder, document templates, human tasks.
3
+ import type { Process } from '../metadata.ts';
4
+ import { geocode, geocoderUrl } from '../geocode.ts';
5
+ import { substitute, toState, type PageContext } from './context.ts';
6
+ import { renderDocument } from './documents.ts';
7
+
8
+ const ITEM = /^[A-Z][A-Z0-9_]{0,99}$/;
9
+ const NAME = /^[A-Z][A-Z0-9_]{0,59}$/;
10
+ const isItem = (x: unknown) => typeof x === 'string' && ITEM.test(x);
11
+ const items = (x: unknown) => Array.isArray(x) && x.every(isItem);
12
+ const text = (x: unknown) => x === undefined || typeof x === 'string';
13
+
14
+ export const DECLARATIVE_PROCESS_TYPES = new Set(['close_dialog', 'clear_session_state', 'reset_pagination', 'user_preferences', 'server_geocoding',
15
+ 'print_report', 'human_task_create', 'human_task_manage', 'form_init']);
16
+ const TASK_ACTIONS = ['approve', 'reject', 'complete', 'claim', 'release', 'delegate', 'cancel'];
17
+
18
+ /** Problems with a declarative process's config (the builder shows them; running refuses them). */
19
+ export function declarativeProblems(type: string, conf: unknown): string[] {
20
+ const c = (conf ?? {}) as Record<string, any>;
21
+ const out: string[] = [];
22
+ switch (type) {
23
+ case 'close_dialog':
24
+ if (c.items !== undefined && !items(c.items)) out.push('"items" is a list of item names, e.g. ["P5_ID"].');
25
+ break;
26
+ case 'clear_session_state':
27
+ if (c.items !== undefined && !items(c.items)) out.push('"items" is a list of item names.');
28
+ if (c.pages !== undefined && !(Array.isArray(c.pages) && c.pages.every((n: unknown) => Number.isInteger(n) && (n as number) > 0))) out.push('"pages" is a list of page numbers.');
29
+ if (c.all !== undefined && typeof c.all !== 'boolean') out.push('"all" is true or false.');
30
+ if (!c.items && !c.pages && !c.all) out.push('Name "items", "pages" or "all": true.');
31
+ break;
32
+ case 'user_preferences':
33
+ if (!['set', 'reset'].includes(c.action ?? 'set')) out.push('"action" is set or reset.');
34
+ if (typeof c.preference !== 'string' || !NAME.test(c.preference.toUpperCase())) out.push('"preference" is a name, e.g. DEFAULT_DEPT.');
35
+ if ((c.action ?? 'set') === 'set' && !isItem(c.item)) out.push('"item" names the item whose value is stored.');
36
+ break;
37
+ case 'server_geocoding':
38
+ if (!isItem(c.address_item)) out.push('"address_item" is the item with the address.');
39
+ if (!isItem(c.coordinates_item)) out.push('"coordinates_item" is the item that gets "lat,lng".');
40
+ if (c.country !== undefined && !(typeof c.country === 'string' && /^[a-z]{2}(,[a-z]{2})*$/i.test(c.country))) out.push('"country" is a country code, e.g. nl.');
41
+ break;
42
+ case 'print_report':
43
+ if (typeof c.document !== 'string' || !NAME.test(c.document.toUpperCase())) out.push('"document" names a document template or report query.');
44
+ break;
45
+ case 'human_task_create':
46
+ if (typeof c.definition !== 'string' || !NAME.test(c.definition.toUpperCase())) out.push('"definition" names a task definition, e.g. LEAVE_APPROVAL.');
47
+ if (!text(c.detail_pk)) out.push('"detail_pk" is text, e.g. "&P7_ID.".');
48
+ if (c.parameters !== undefined && (typeof c.parameters !== 'object' || c.parameters === null || Array.isArray(c.parameters) || Object.values(c.parameters).some((v) => typeof v !== 'string')))
49
+ out.push('"parameters" maps parameter names to values, e.g. {"DAYS": "&P7_DAYS."}.');
50
+ if (c.id_item !== undefined && !isItem(c.id_item)) out.push('"id_item" is an item name.');
51
+ break;
52
+ case 'human_task_manage':
53
+ if (!TASK_ACTIONS.includes(c.action)) out.push(`"action" is one of ${TASK_ACTIONS.join(', ')}.`);
54
+ if (typeof c.task !== 'string' || !c.task) out.push('"task" is the task id, e.g. "&P8_TASK_ID.".');
55
+ if (c.action === 'complete' && (typeof c.outcome !== 'string' || !NAME.test(c.outcome.toUpperCase()))) out.push('"outcome" names the outcome, e.g. APPROVED.');
56
+ if (c.action === 'delegate' && (typeof c.to !== 'string' || !c.to)) out.push('"to" is the user it goes to, e.g. "&P8_TO.".');
57
+ if (!text(c.comment)) out.push('"comment" is text.');
58
+ break;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Run a declarative process; its success message (or a default) is the answer. */
64
+ export async function runDeclarative(ctx: PageContext, p: Process, assignable: Set<string>): Promise<string | null> {
65
+ const conf = (p.config ?? {}) as Record<string, any>;
66
+ const problems = declarativeProblems(p.type, conf);
67
+ if (problems.length) throw new Error(`Process "${p.name}": ${problems.join(' ')}`);
68
+ const c = ctx.client!;
69
+ const value = (s: unknown) => (typeof s === 'string' ? substitute(s, ctx, (x) => x) : null);
70
+ const state = ctx.session.state;
71
+ const assign = (name: string, v: string | null) => {
72
+ if (!assignable.has(name)) throw new Error(`Process "${p.name}": ${name} is not an item of this page or an application item.`);
73
+ state[name] = v;
74
+ };
75
+ switch (p.type) {
76
+ case 'form_init':
77
+ return null;
78
+ case 'close_dialog':
79
+ ctx.closeDialog = Object.fromEntries(((conf.items ?? []) as string[]).map((n) => [n, state[n] ?? '']));
80
+ return p.success_message;
81
+ case 'reset_pagination':
82
+ ctx.resetPagination = true;
83
+ return p.success_message;
84
+ case 'clear_session_state': {
85
+ const names = new Set<string>(conf.items ?? []);
86
+ const prefixes = ((conf.pages ?? []) as number[]).map((n) => `P${n}_`);
87
+ for (const k of Object.keys(state)) {
88
+ if (k.startsWith('__')) continue;
89
+ if (names.has(k) || prefixes.some((x) => k.startsWith(x)) || (conf.all === true && (/^P\d+_/.test(k) || ctx.app.app_items.includes(k)))) state[k] = null;
90
+ }
91
+ return p.success_message;
92
+ }
93
+ case 'user_preferences': {
94
+ // the public user has no preferences (as meta.get_preference): nothing to store
95
+ if (ctx.user === 'nobody') return null;
96
+ const name = String(conf.preference).toUpperCase();
97
+ if ((conf.action ?? 'set') === 'reset') await c.query('select meta.remove_preference($1)', [name]);
98
+ else await c.query('select meta.set_preference($1, $2)', [name, state[conf.item] ?? null]);
99
+ return p.success_message;
100
+ }
101
+ case 'server_geocoding': {
102
+ const address = (state[conf.address_item] ?? '').trim();
103
+ if (!address) return null;
104
+ if (!geocoderUrl()) throw new Error(ctx.locale.t('process.no_geocoder'));
105
+ const [first] = await geocode(address, { language: ctx.locale.lang, country: conf.country ?? null });
106
+ if (!first) throw new Error(ctx.locale.t('process.address_not_found', { address }));
107
+ assign(conf.coordinates_item, `${first.lat.toFixed(6)},${first.lon.toFixed(6)}`);
108
+ return p.success_message;
109
+ }
110
+ case 'print_report': {
111
+ const doc = await renderDocument(ctx, String(conf.document), true);
112
+ // the first file of a request wins (as for download processes)
113
+ ctx.download ??= 'download' in doc ? doc.download : { content: doc.file, name: doc.name, type: doc.type, inline: false };
114
+ return null;
115
+ }
116
+ case 'human_task_create': {
117
+ const params = Object.fromEntries(Object.entries((conf.parameters ?? {}) as Record<string, string>).map(([k, v]) => [k.toUpperCase(), value(v)]));
118
+ const id = (await c.query('select meta.create_task($1, $2, $3::jsonb)::text as id', [String(conf.definition).toUpperCase(), value(conf.detail_pk) || null, JSON.stringify(params)])).rows[0].id as string;
119
+ if (conf.id_item) assign(conf.id_item, toState(id));
120
+ return p.success_message ?? ctx.locale.t('process.task_created', { id });
121
+ }
122
+ case 'human_task_manage': {
123
+ const id = (value(conf.task) ?? '').trim();
124
+ if (!/^\d{1,18}$/.test(id)) throw new Error(ctx.locale.t('process.no_task'));
125
+ const comment = value(conf.comment) || null;
126
+ switch (conf.action) {
127
+ case 'approve': await c.query('select meta.complete_task($1::bigint, $2, $3)', [id, 'APPROVED', comment]); break;
128
+ case 'reject': await c.query('select meta.complete_task($1::bigint, $2, $3)', [id, 'REJECTED', comment]); break;
129
+ case 'complete': await c.query('select meta.complete_task($1::bigint, $2, $3)', [id, String(conf.outcome).toUpperCase(), comment]); break;
130
+ case 'claim': await c.query('select meta.claim_task($1::bigint)', [id]); break;
131
+ case 'release': await c.query('select meta.release_task($1::bigint)', [id]); break;
132
+ case 'delegate': await c.query('select meta.delegate_task($1::bigint, $2)', [id, value(conf.to)]); break;
133
+ case 'cancel': await c.query('select meta.cancel_task($1::bigint, $2)', [id, comment]); break;
134
+ }
135
+ return p.success_message;
136
+ }
137
+ }
138
+ return null;
139
+ }
@@ -1,3 +1,4 @@
1
+ import { declarativeProblems, DECLARATIVE_PROCESS_TYPES } from './process-types.ts';
1
2
  import { zipSync, type Zippable } from 'fflate';
2
3
  import { applyBinds } from '../binds.ts';
3
4
  import { esc } from '../html.ts';
@@ -248,6 +249,7 @@ export function processProblems(type: string, conf: unknown): string[] {
248
249
  if (type === 'workflow') return workflowProblems(conf);
249
250
  if (type === 'send_push') return sendPushProblems(conf);
250
251
  if (type === 'send_email') return sendEmailProblems(conf);
252
+ if (DECLARATIVE_PROCESS_TYPES.has(type)) return declarativeProblems(type, conf);
251
253
  return [];
252
254
  }
253
255
 
@@ -1,3 +1,4 @@
1
+ import { renderWorkflowDiagram } from './workflow-diagram.ts';
1
2
  import type { QueryResult } from 'pg';
2
3
  import { ganttEditable } from './gantt-move.ts';
3
4
  import { applyBinds } from '../binds.ts';
@@ -379,6 +380,9 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
379
380
  case 'workflows':
380
381
  body = await renderWorkflows(ctx, r);
381
382
  break;
383
+ case 'workflow_diagram':
384
+ body = await renderWorkflowDiagram(ctx, r);
385
+ break;
382
386
  case 'map':
383
387
  body = await renderMap(ctx, r);
384
388
  break;
@@ -1,3 +1,4 @@
1
+ import { compareValues, safeStyles } from './da-styles.ts';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { urlChecksum } from '../security.ts';
3
4
  import { pwaBody, pwaHead } from './pwa.ts';
@@ -29,6 +30,9 @@ import { sessionLimits } from '../session.ts';
29
30
 
30
31
  const list = (s: string | null) => (s ?? '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean);
31
32
 
33
+ /** (161) events of grid, calendar, map and tree regions (their value: see public/app.js, pgkiln:component) */
34
+ export const COMPONENT_EVENTS = new Set(['grid_row_init', 'grid_save', 'calendar_date_select', 'calendar_view_change', 'map_object_click', 'map_change', 'tree_selection_change']);
35
+
32
36
  /** Client-side definition of the page's dynamic actions (never includes SQL). */
33
37
  function dynamicActionsJson(ctx: PageContext) {
34
38
  return ctx.page.dynamic_actions
@@ -50,6 +54,11 @@ function dynamicActionsJson(ctx: PageContext) {
50
54
  fn: d.action === 'execute_javascript' && /^[A-Za-z_$][\w$.-]{0,99}$/.test(d.code?.trim() ?? '') ? d.code!.trim() : null,
51
55
  // plugin: the plug-in's name and attribute values (only a plug-in of the application)
52
56
  ...(d.action === 'plugin' ? pluginAction(ctx, d.code, d.config) : {}),
57
+ // (156) set_style: checked properties and values; open_report_dialog: the dialog's key (checked by the database)
58
+ ...(d.action === 'set_style' ? { styles: safeStyles(d.config) } : {}),
59
+ // (161) set_value without SQL on a component event: the event's value (a date, node id, …) into the affected items
60
+ ...(d.action === 'set_value' && !d.code?.trim() && COMPONENT_EVENTS.has(d.event) ? { eventValue: true } : {}),
61
+ ...(d.action === 'open_report_dialog' ? { dialog: /^[a-z]{3,10}$/.test(d.code?.trim() ?? '') ? d.code!.trim() : null } : {}),
53
62
  }));
54
63
 
55
64
  function pluginAction(ctx: PageContext, name: string | null, config: Record<string, any> | null | undefined) {
@@ -65,6 +74,12 @@ function conditionHolds(type: string | null, expected: string | null, value: str
65
74
  case 'in_list': return (expected ?? '').split(',').map((x) => x.trim()).includes(value);
66
75
  case 'is_null': return value === '';
67
76
  case 'is_not_null': return value !== '';
77
+ // (156) greater / less: numbers as numbers, else text (ISO dates sort as text); not_in_list
78
+ case 'greater': return value !== '' && compareValues(value, expected ?? '') > 0;
79
+ case 'greater_equal': return value !== '' && compareValues(value, expected ?? '') >= 0;
80
+ case 'less': return value !== '' && compareValues(value, expected ?? '') < 0;
81
+ case 'less_equal': return value !== '' && compareValues(value, expected ?? '') <= 0;
82
+ case 'not_in_list': return !(expected ?? '').split(',').map((x) => x.trim()).includes(value);
68
83
  default: return true;
69
84
  }
70
85
  }
@@ -412,18 +427,19 @@ export async function renderPage(ctx: PageContext) {
412
427
  ${ctx.detached}
413
428
  </div>
414
429
  <script type="application/json" id="pgkiln-meta">${raw(
415
- JSON.stringify({ csrf: ctx.session.csrf_token, das, texts: clientTexts(ctx), dialogs: dialogShapes(ctx), ...timeZoneMeta(ctx), session: sessionMeta(ctx) }).replace(/</g, '\\u003c'),
430
+ JSON.stringify({ csrf: ctx.session.csrf_token, das, ...(ctx.savedGrids?.length ? { savedGrids: ctx.savedGrids } : {}), texts: clientTexts(ctx), dialogs: dialogShapes(ctx), ...timeZoneMeta(ctx), session: sessionMeta(ctx) }).replace(/</g, '\\u003c'),
416
431
  )}</script>`;
417
432
  return chrome(ctx, main, title);
418
433
  }
419
434
 
420
435
  /** Response to a successful submit inside a dialog: tells the opener to close it. */
421
- export function dialogClosePage(ctx: PageContext) {
436
+ /** (157) `items`: the values a close_dialog process hands to the page below (items of the same name there are set). */
437
+ export function dialogClosePage(ctx: PageContext, items?: Record<string, string>) {
422
438
  return documentShell(
423
439
  ctx.app.name,
424
440
  html`<main class="t-dialog-main"><p>${ctx.locale.t('dialog.done')} <a href="${ctx.base}/${ctx.app.home_page}">${ctx.locale.t('dialog.continue')}</a></p></main>`,
425
441
  't-dialog-page',
426
- { 'data-dialog-close': '1', 'data-dialog-page': String(ctx.page.page_no) },
442
+ { 'data-dialog-close': '1', 'data-dialog-page': String(ctx.page.page_no), ...(items && Object.keys(items).length ? { 'data-dialog-items': JSON.stringify(items) } : {}) },
427
443
  '',
428
444
  { lang: ctx.locale.lang, dir: ctx.locale.dir, theme: ctx.locale.theme, style: ctx.locale.style },
429
445
  );
@@ -443,6 +443,8 @@ export async function runtimeRoutes(app: FastifyInstance) {
443
443
  if (flash) ctx.messages.push(flash);
444
444
  const flashError = takeFlash(ctx.session, '__FLASH_ERROR');
445
445
  if (flashError) ctx.errors.page.push(flashError);
446
+ const saved = takeFlash(ctx.session, '__GRID_SAVED');
447
+ if (typeof saved === 'string') ctx.savedGrids = saved.split(',').map(Number).filter(Number.isInteger);
446
448
  // Actions → Download CSV / Excel / PDF: r<region id>_csv=1, _xlsx=1 or _pdf=1
447
449
  const downloadKey = [...ctx.params.keys()].find((k) => /^r\d+_(csv|xlsx|pdf)$/.test(k));
448
450
  // a document template: ?doc=NAME (see documents.ts)
@@ -629,10 +631,12 @@ export async function runtimeRoutes(app: FastifyInstance) {
629
631
  return reply.headers(downloadHeaders(ctx.download)).send(ctx.download.content);
630
632
  }
631
633
  if (messages.length) ctx.session.state.__FLASH = messages.join(' ');
634
+ // (161) the page that comes back runs the saved grids' grid_save dynamic actions
635
+ if (ctx.savedGrids?.length && !ctx.dialog) ctx.session.state.__GRID_SAVED = ctx.savedGrids.join(',');
632
636
  await saveState(ctx.session);
633
- if (ctx.dialog) return reply.type('text/html').send(dialogClosePage(ctx));
637
+ if (ctx.dialog) return reply.type('text/html').send(dialogClosePage(ctx, ctx.closeDialog));
634
638
  // (sprint 54) a grid's Save comes back to the grid's search, page, sort and view
635
- const back = branchTo || button.target_page ? '' : gridReturn(ctx, button.name);
639
+ const back = branchTo || button.target_page || ctx.resetPagination ? '' : gridReturn(ctx, button.name);
636
640
  return reply.redirect(branchTo ?? (button.target_page ? `${ctx.base}/${button.target_page}` : back ? `${self}${self.includes('?') ? '&' : '?'}${back}` : self), 303);
637
641
  });
638
642
 
@@ -64,14 +64,15 @@ export async function renderTree(ctx: PageContext, r: Region): Promise<Raw> {
64
64
  });
65
65
  label = html`<a ${linkAttrs(ctx, link.page, items)}>${label}</a>`;
66
66
  }
67
- if (!n.children.length) return html`<li class="tree-leaf">${label}</li>`;
67
+ // (161) data-node: what a tree_selection_change dynamic action's condition sees
68
+ if (!n.children.length) return html`<li class="tree-leaf" data-node="${n.id}">${label}</li>`;
68
69
  const open = depth < expanded ? raw(' open') : '';
69
70
  const children = html`<ul>${n.children.map((ch) => draw(ch, depth + 1))}</ul>`;
70
71
  // a linked branch: the link is a row of its own and the disclosure only toggles (no link inside <summary>)
71
72
  if (linkOk && link)
72
- return html`<li class="tree-branch"><span class="tree-row">${label} <span class="tree-count" aria-hidden="true">${n.children.length}</span></span>
73
+ return html`<li class="tree-branch" data-node="${n.id}"><span class="tree-row">${label} <span class="tree-count" aria-hidden="true">${n.children.length}</span></span>
73
74
  <details${open}><summary><span class="sr-only">${n.label} (${n.children.length})</span></summary>${children}</details></li>`;
74
- return html`<li><details${open}><summary>${label} <span class="tree-count">${n.children.length}</span></summary>
75
+ return html`<li data-node="${n.id}"><details${open}><summary>${label} <span class="tree-count">${n.children.length}</span></summary>
75
76
  ${children}</details></li>`;
76
77
  };
77
78
  return html`<ul class="tree-view">${roots.map((n) => draw(n, 0))}</ul>`;
@@ -0,0 +1,45 @@
1
+ // (sprint 160, CD-7) Workflow Diagram region (APEX 24.2+): one workflow instance drawn as its steps, the ones it went
2
+ // through (green), where it is now (blue) and a failed step (red); each step's tooltip says when it was reached and
3
+ // what happened. config {"instance_item": "P9_WORKFLOW_ID"}. The instance comes through meta.workflows, as the
4
+ // application's role: only workflows the user started or administers (the Workflow Console's rule). Server-rendered SVG.
5
+ import { owner, savepoint } from '../db.ts';
6
+ import { html, type Raw } from '../html.ts';
7
+ import type { Region } from '../metadata.ts';
8
+ import { workflowDiagram, type Step } from '../workflow.ts';
9
+ import { publicError, type PageContext } from './context.ts';
10
+ import { cell } from './report.ts';
11
+
12
+ export async function renderWorkflowDiagram(ctx: PageContext, r: Region): Promise<Raw> {
13
+ const t = ctx.locale.t;
14
+ const itemName = typeof r.config.instance_item === 'string' ? r.config.instance_item.toUpperCase() : '';
15
+ const id = (itemName ? (ctx.session.state[itemName] ?? '') : '').trim();
16
+ if (!/^\d{1,18}$/.test(id)) return html`<p class="empty">${r.config.empty ?? t('workflows.none')}</p>`;
17
+ const c = ctx.client!;
18
+ let w: { id: string; title: string; state: string; current_step: string | null; error: string | null; active_steps: string[] } | undefined;
19
+ let events: { at: string; step: string | null; event: string; detail: string | null }[] = [];
20
+ try {
21
+ w = (await savepoint(c, () => c.query('select id::text, title, state, current_step, error, active_steps from meta.workflows where id = $1::bigint', [id]))).rows[0];
22
+ if (w) events = (await savepoint(c, () => c.query('select at::text, step, event, detail from meta.workflow_events where workflow_id = $1::bigint order by at, id', [id]))).rows;
23
+ } catch (e) {
24
+ return html`<div class="alert alert-error" role="alert">${await publicError(ctx, e, `workflow diagram "${r.title ?? r.id}"`)}</div>`;
25
+ }
26
+ // not one the user may see (or none): the same as no instance
27
+ if (!w) return html`<p class="empty">${r.config.empty ?? t('workflows.none')}</p>`;
28
+ // the steps of its version: only after the view let this user see the instance (the steps hold SQL)
29
+ const steps = (await owner.query<{ steps: Step[] }>('select steps from meta.workflow where id = $1::bigint and app_id = $2', [id, ctx.app.id])).rows[0]?.steps ?? [];
30
+ const reached = new Set(events.filter((e) => e.step).map((e) => e.step!));
31
+ const active = w.state === 'completed' || w.state === 'terminated' ? [] : w.active_steps.length ? w.active_steps : w.current_step ? [w.current_step] : [];
32
+ const failed = w.state === 'faulted' ? active : [];
33
+ const done = [...reached].filter((s) => !active.includes(s));
34
+ const notes = new Map<string, string>();
35
+ // events without a step (started, terminated) belong to the first step's history
36
+ for (const e of events) {
37
+ const step = e.step ?? steps[0]?.name;
38
+ if (step) notes.set(step, [notes.get(step), `${cell(e.at, 1184, ctx.locale.format)}: ${e.event}${e.detail ? ` (${e.detail})` : ''}`].filter(Boolean).join('\n'));
39
+ }
40
+ return html`<div class="wf-wrap wf-instance" data-state="${w.state}">
41
+ <p class="muted u-mt0">${w.title} · ${t(`workflows.state.${w.state}`)}</p>
42
+ ${w.error ? html`<div class="alert alert-error" role="alert">${w.error}</div>` : ''}
43
+ ${workflowDiagram(steps, { active: failed.length ? [] : active, failed, done, notes, id: `r${r.id}` })}
44
+ </div>`;
45
+ }
package/src/workflow.ts CHANGED
@@ -917,12 +917,16 @@ export async function startWorkflowRunner() {
917
917
  * marks the steps an instance is at (several with parallel branches); `id`
918
918
  * keeps the arrow marker unique when a page shows several diagrams.
919
919
  */
920
- export function workflowDiagram(steps: Step[], opts: { active?: string[]; id?: string; standalone?: { title: string } } = {}): Raw {
920
+ export function workflowDiagram(steps: Step[], opts: { active?: string[]; id?: string; standalone?: { title: string };
921
+ /** (160) the steps the instance went through, a step that failed, a tooltip per step (who and when) */
922
+ done?: string[]; failed?: string[]; notes?: Map<string, string> } = {}): Raw {
921
923
  if (!Array.isArray(steps) || !steps.length || steps.some((s) => !s || typeof s !== 'object' || typeof s.name !== 'string')) return html``;
922
924
  // (123) the export is an XML file: no control characters (the template escapes the rest)
923
925
  const txt = (v: unknown) => String(v ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, '');
924
926
  const marker = `wf-arrow${opts.id ? `-${opts.id}` : ''}`;
925
927
  const active = new Set(opts.active ?? []);
928
+ const done = new Set(opts.done ?? []);
929
+ const failed = new Set(opts.failed ?? []);
926
930
  // levels: the longest path from the first step (cycles are cut)
927
931
  const level = new Map<string, number>([[steps[0].name, 0]]);
928
932
  const edges: { from: string; to: string; label: string; branch?: boolean }[] = [];
@@ -977,7 +981,9 @@ export function workflowDiagram(steps: Step[], opts: { active?: string[]; id?: s
977
981
  })}
978
982
  ${steps.map((s) => {
979
983
  const p = pos.get(s.name)!;
980
- return html`<g class="wf-node wf-${TYPES.includes(s.type) ? s.type : 'unknown'}${active.has(s.name) ? ' wf-active' : ''}"><rect x="${p.x}" y="${p.y}" width="${W}" height="${H}" rx="${s.type === 'parallel' || s.type === 'join' ? 20 : 8}"/>
984
+ const note = opts.notes?.get(s.name);
985
+ const mark = failed.has(s.name) ? ' wf-failed' : active.has(s.name) ? ' wf-active' : done.has(s.name) ? ' wf-done' : '';
986
+ return html`<g class="wf-node wf-${TYPES.includes(s.type) ? s.type : 'unknown'}${mark}">${note ? html`<title>${txt(note)}</title>` : ''}<rect x="${p.x}" y="${p.y}" width="${W}" height="${H}" rx="${s.type === 'parallel' || s.type === 'join' ? 20 : 8}"/>
981
987
  <text x="${p.x + W / 2}" y="${p.y + 18}" class="wf-name">${txt(s.name)}</text>
982
988
  <text x="${p.x + W / 2}" y="${p.y + 34}" class="wf-type">${txt(typeText(s))}</text></g>`;
983
989
  })}
@@ -996,6 +1002,8 @@ const SVG_STYLE = `
996
1002
  .wf-send_mail rect { stroke: #17794a; stroke-width: 2; }
997
1003
  .wf-draft rect { fill: #fafafa; stroke: #8a8f98; stroke-width: 1.5; stroke-dasharray: 6 4; }
998
1004
  .wf-active rect { fill: #e6efff; stroke: #2f6fde; stroke-width: 3; }
1005
+ .wf-done rect { fill: #e7f5ed; stroke: #17794a; }
1006
+ .wf-failed rect { fill: #fde7e4; stroke: #b42318; stroke-width: 3; }
999
1007
  text { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
1000
1008
  .wf-name { fill: #1d2129; font-size: 12px; font-weight: 600; text-anchor: middle; }
1001
1009
  .wf-type { fill: #5d6470; font-size: 10px; text-anchor: middle; }