pgkiln 0.81.0 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/147_condition_types.sql +80 -0
- package/db/migrations/148_validation_types.sql +44 -0
- package/db/migrations/149_dynamic_action_events.sql +45 -0
- package/db/migrations/150_process_types.sql +23 -0
- package/db/migrations/151_item_types.sql +16 -0
- package/db/migrations/152_workflow_diagram_region.sql +10 -0
- package/db/migrations/153_component_events.sql +21 -0
- package/docs/apex-feature-parity.md +11 -9
- package/docs/guide/02-concepts.md +3 -3
- package/docs/guide/03-builder.md +8 -1
- package/docs/guide/04-pages-and-regions.md +19 -4
- package/docs/guide/05-items.md +5 -0
- package/docs/guide/06-processing.md +104 -17
- package/docs/guide/07-dynamic-actions.md +43 -1
- package/docs/guide/11-from-apex.md +8 -0
- package/docs/guide/12-development.md +9 -1
- package/examples/hr/hr_79_condition_types.sql +17 -0
- package/package.json +1 -1
- package/public/app.css +14 -0
- package/public/app.js +242 -13
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/src/appfiles.ts +4 -4
- package/src/builder/advisor.ts +43 -7
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/code-editor.ts +14 -10
- package/src/builder/components.ts +54 -23
- package/src/builder/conditions.ts +64 -0
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/designer.ts +9 -1
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1662 -0
- package/src/builder/i18n/en.ts +1662 -0
- package/src/builder/i18n/es.ts +1662 -0
- package/src/builder/i18n/fr.ts +1662 -0
- package/src/builder/i18n/it.ts +1662 -0
- package/src/builder/i18n/ja.ts +1662 -0
- package/src/builder/i18n/ko.ts +1662 -0
- package/src/builder/i18n/nl.ts +1662 -0
- package/src/builder/i18n/pt.ts +1662 -0
- package/src/builder/i18n/zh-cn.ts +1662 -0
- package/src/builder/i18n/zh-tw.ts +1662 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-help.ts +3 -1
- package/src/builder/region-settings.ts +225 -198
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cron-schedule.ts +47 -17
- package/src/i18n/ar.ts +5 -0
- package/src/i18n/cs.ts +5 -0
- package/src/i18n/da.ts +5 -0
- package/src/i18n/de.ts +5 -0
- package/src/i18n/el.ts +5 -0
- package/src/i18n/es.ts +5 -0
- package/src/i18n/fi.ts +5 -0
- package/src/i18n/fr.ts +5 -0
- package/src/i18n/he.ts +5 -0
- package/src/i18n/it.ts +5 -0
- package/src/i18n/ja.ts +5 -0
- package/src/i18n/ko.ts +5 -0
- package/src/i18n/nb.ts +5 -0
- package/src/i18n/pl.ts +5 -0
- package/src/i18n/pt.ts +5 -0
- package/src/i18n/ru.ts +5 -0
- package/src/i18n/sv.ts +5 -0
- package/src/i18n/tr.ts +5 -0
- package/src/i18n/uk.ts +5 -0
- package/src/i18n/zh.ts +5 -0
- package/src/i18n.ts +10 -0
- package/src/metadata.ts +74 -9
- package/src/runtime/authz.ts +9 -5
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +6 -0
- package/src/runtime/da-styles.ts +37 -0
- package/src/runtime/documents.ts +3 -2
- package/src/runtime/engine.ts +95 -13
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/items.ts +33 -0
- package/src/runtime/logic.ts +79 -8
- package/src/runtime/maps.ts +56 -7
- package/src/runtime/process-types.ts +139 -0
- package/src/runtime/processes.ts +2 -0
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +19 -3
- package/src/runtime/routes.ts +6 -2
- package/src/runtime/tree.ts +4 -3
- package/src/runtime/workflow-diagram.ts +45 -0
- package/src/workflow.ts +10 -2
package/src/runtime/logic.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
76
|
+
return item === '';
|
|
44
77
|
case 'item_not_null':
|
|
45
|
-
return
|
|
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
|
|
88
|
+
return item === (cond.condition_value ?? '');
|
|
48
89
|
case 'item_not_equals':
|
|
49
|
-
return
|
|
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`;
|
package/src/runtime/maps.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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')
|
|
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
|
+
}
|
package/src/runtime/processes.ts
CHANGED
|
@@ -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
|
|
package/src/runtime/regions.ts
CHANGED
|
@@ -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;
|
package/src/runtime/render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
);
|
package/src/runtime/routes.ts
CHANGED
|
@@ -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
|
|
package/src/runtime/tree.ts
CHANGED
|
@@ -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
|
-
|
|
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 }
|
|
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
|
-
|
|
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; }
|