pgkiln 0.80.0 → 0.82.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/146_database_model_providers.sql +53 -0
- 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/docs/apex-feature-parity.md +12 -10
- package/docs/guide/02-concepts.md +3 -3
- package/docs/guide/03-builder.md +13 -3
- package/docs/guide/04-pages-and-regions.md +17 -3
- package/docs/guide/05-items.md +5 -0
- package/docs/guide/06-processing.md +104 -17
- package/docs/guide/07-dynamic-actions.md +16 -1
- package/docs/guide/11-from-apex.md +7 -0
- package/examples/hr/hr_79_condition_types.sql +17 -0
- package/package.json +1 -1
- package/public/app.css +12 -0
- package/public/app.js +157 -9
- package/src/ai/embeddings.ts +20 -7
- package/src/appfiles.ts +4 -4
- package/src/builder/advisor.ts +43 -7
- package/src/builder/code-editor.ts +14 -10
- package/src/builder/components.ts +51 -21
- package/src/builder/conditions.ts +64 -0
- package/src/builder/db-users.ts +124 -118
- package/src/builder/designer.ts +9 -1
- package/src/builder/home.ts +42 -40
- package/src/builder/i18n/de.ts +464 -0
- package/src/builder/i18n/en.ts +464 -0
- package/src/builder/i18n/es.ts +464 -0
- package/src/builder/i18n/fr.ts +464 -0
- package/src/builder/i18n/it.ts +464 -0
- package/src/builder/i18n/ja.ts +464 -0
- package/src/builder/i18n/ko.ts +464 -0
- package/src/builder/i18n/nl.ts +464 -0
- package/src/builder/i18n/pt.ts +464 -0
- package/src/builder/i18n/zh-cn.ts +464 -0
- package/src/builder/i18n/zh-tw.ts +464 -0
- package/src/builder/instance.ts +27 -23
- package/src/builder/ldap.ts +50 -47
- package/src/builder/mail-queue.ts +50 -45
- package/src/builder/network-access.ts +46 -43
- package/src/builder/region-help.ts +3 -1
- package/src/builder/region-settings.ts +30 -5
- package/src/builder/vector-column.ts +3 -3
- package/src/builder/vector-providers.ts +16 -5
- 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 +72 -9
- package/src/runtime/authz.ts +9 -5
- package/src/runtime/context.ts +4 -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/items.ts +33 -0
- package/src/runtime/logic.ts +79 -8
- package/src/runtime/maps.ts +50 -4
- 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 +13 -2
- package/src/runtime/routes.ts +2 -2
- package/src/runtime/workflow-diagram.ts +45 -0
- package/src/vector.ts +3 -3
- package/src/workflow.ts +10 -2
package/src/runtime/maps.ts
CHANGED
|
@@ -60,10 +60,35 @@ interface Point {
|
|
|
60
60
|
href: string | null;
|
|
61
61
|
weight: number;
|
|
62
62
|
}
|
|
63
|
+
/** (159, CD-6) a shape layer's look: fixed in the layer's settings, or per row from columns of the same names */
|
|
64
|
+
export interface ShapeStyle {
|
|
65
|
+
fill_color?: string;
|
|
66
|
+
stroke_color?: string;
|
|
67
|
+
stroke_width?: number;
|
|
68
|
+
opacity?: number;
|
|
69
|
+
}
|
|
70
|
+
const COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
71
|
+
/** The checked parts of a style (anything else is left out). */
|
|
72
|
+
export function shapeStyle(x: unknown): ShapeStyle {
|
|
73
|
+
const s = (x && typeof x === 'object' ? x : {}) as Record<string, unknown>;
|
|
74
|
+
const out: ShapeStyle = {};
|
|
75
|
+
if (typeof s.fill_color === 'string' && COLOR.test(s.fill_color)) out.fill_color = s.fill_color;
|
|
76
|
+
if (typeof s.stroke_color === 'string' && COLOR.test(s.stroke_color)) out.stroke_color = s.stroke_color;
|
|
77
|
+
const w = Number(s.stroke_width);
|
|
78
|
+
if (s.stroke_width !== undefined && s.stroke_width !== null && Number.isFinite(w) && w >= 0 && w <= 20) out.stroke_width = w;
|
|
79
|
+
const o = Number(s.opacity);
|
|
80
|
+
if (s.opacity !== undefined && s.opacity !== null && Number.isFinite(o) && o >= 0 && o <= 1) out.opacity = o;
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
const SHAPE_KINDS = new Set(['lines', 'polygons', 'extruded']);
|
|
84
|
+
const kindOf = (v: unknown): LayerDef['kind'] => (v === 'heat' || SHAPE_KINDS.has(v as string) ? (v as LayerDef['kind']) : 'markers');
|
|
85
|
+
|
|
63
86
|
export interface LayerDef {
|
|
64
87
|
name: string;
|
|
65
88
|
source: string;
|
|
66
|
-
|
|
89
|
+
/** (159) lines, polygons: GeoJSON / PostGIS shapes with a style; extruded: polygons shaded by a height column */
|
|
90
|
+
kind: 'markers' | 'heat' | 'lines' | 'polygons' | 'extruded';
|
|
91
|
+
style: ShapeStyle;
|
|
67
92
|
cluster: boolean;
|
|
68
93
|
link: Link | undefined;
|
|
69
94
|
hidden: boolean;
|
|
@@ -78,7 +103,8 @@ export function layerDefs(r: Region): LayerDef[] {
|
|
|
78
103
|
const first: LayerDef = {
|
|
79
104
|
name: typeof r.config.name === 'string' && r.config.name.trim() ? r.config.name.trim() : (r.title ?? 'Map'),
|
|
80
105
|
source: r.source ?? '',
|
|
81
|
-
kind: r.config.layer
|
|
106
|
+
kind: kindOf(r.config.layer),
|
|
107
|
+
style: shapeStyle(r.config.style),
|
|
82
108
|
cluster: r.config.cluster === true,
|
|
83
109
|
link: r.config.link,
|
|
84
110
|
hidden: false,
|
|
@@ -91,7 +117,8 @@ export function layerDefs(r: Region): LayerDef[] {
|
|
|
91
117
|
.map((l: any, i: number): LayerDef => ({
|
|
92
118
|
name: typeof l.name === 'string' && l.name.trim() ? l.name.trim() : `Layer ${i + 2}`,
|
|
93
119
|
source: l.source,
|
|
94
|
-
kind: l.layer
|
|
120
|
+
kind: kindOf(l.layer),
|
|
121
|
+
style: shapeStyle(l.style),
|
|
95
122
|
cluster: l.cluster === true,
|
|
96
123
|
link: l.link && typeof l.link === 'object' && Number.isInteger(l.link.page) ? l.link : undefined,
|
|
97
124
|
hidden: l.hidden === true,
|
|
@@ -151,12 +178,31 @@ async function loadLayer(ctx: PageContext, def: LayerDef, g: PostGis | null, opt
|
|
|
151
178
|
if (geometry?.type === 'Point' && Array.isArray(geometry.coordinates) && (Number.isNaN(lat) || Number.isNaN(lng)))
|
|
152
179
|
// a point is a place (a marker, a heat spot, part of a cluster)
|
|
153
180
|
[lng, lat] = geometry.coordinates.map(Number);
|
|
154
|
-
else if (geometry && typeof geometry === 'object')
|
|
181
|
+
else if (geometry && typeof geometry === 'object') {
|
|
182
|
+
// (159) a shape layer's style: the layer's, then the row's own columns
|
|
183
|
+
const style = SHAPE_KINDS.has(def.kind)
|
|
184
|
+
? { ...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') }) }
|
|
185
|
+
: undefined;
|
|
186
|
+
const h = num(col(row, 'height'));
|
|
187
|
+
shapes.push({ type: 'Feature', geometry, properties: { title, body, href, ...(style ? { style } : {}), ...(def.kind === 'extruded' && Number.isFinite(h) ? { height: h } : {}) } });
|
|
188
|
+
}
|
|
155
189
|
}
|
|
190
|
+
// a shape layer draws only shapes
|
|
191
|
+
if (SHAPE_KINDS.has(def.kind)) continue;
|
|
156
192
|
const w = num(col(row, 'weight'));
|
|
157
193
|
const weight = Number.isFinite(w) && w > 0 ? w : Number.isNaN(w) ? 1 : 0;
|
|
158
194
|
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) points.push({ lat, lng, title, body, href, weight });
|
|
159
195
|
}
|
|
196
|
+
// (159) extruded: each polygon shaded by its height (from light to strong; no 3D view)
|
|
197
|
+
if (def.kind === 'extruded') {
|
|
198
|
+
const hs = shapes.map((f: any) => f.properties.height).filter((h): h is number => typeof h === 'number');
|
|
199
|
+
const [lo, hi] = hs.length ? [Math.min(...hs), Math.max(...hs)] : [0, 0];
|
|
200
|
+
for (const f of shapes as any[]) {
|
|
201
|
+
const h = f.properties.height;
|
|
202
|
+
const share = typeof h === 'number' && hi > lo ? (h - lo) / (hi - lo) : 0.5;
|
|
203
|
+
f.properties.style = { ...f.properties.style, opacity: Math.round((0.2 + 0.7 * share) * 100) / 100 };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
160
206
|
return { points, shapes, truncated };
|
|
161
207
|
}
|
|
162
208
|
|
|
@@ -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';
|
|
@@ -50,6 +51,9 @@ function dynamicActionsJson(ctx: PageContext) {
|
|
|
50
51
|
fn: d.action === 'execute_javascript' && /^[A-Za-z_$][\w$.-]{0,99}$/.test(d.code?.trim() ?? '') ? d.code!.trim() : null,
|
|
51
52
|
// plugin: the plug-in's name and attribute values (only a plug-in of the application)
|
|
52
53
|
...(d.action === 'plugin' ? pluginAction(ctx, d.code, d.config) : {}),
|
|
54
|
+
// (156) set_style: checked properties and values; open_report_dialog: the dialog's key (checked by the database)
|
|
55
|
+
...(d.action === 'set_style' ? { styles: safeStyles(d.config) } : {}),
|
|
56
|
+
...(d.action === 'open_report_dialog' ? { dialog: /^[a-z]{3,10}$/.test(d.code?.trim() ?? '') ? d.code!.trim() : null } : {}),
|
|
53
57
|
}));
|
|
54
58
|
|
|
55
59
|
function pluginAction(ctx: PageContext, name: string | null, config: Record<string, any> | null | undefined) {
|
|
@@ -65,6 +69,12 @@ function conditionHolds(type: string | null, expected: string | null, value: str
|
|
|
65
69
|
case 'in_list': return (expected ?? '').split(',').map((x) => x.trim()).includes(value);
|
|
66
70
|
case 'is_null': return value === '';
|
|
67
71
|
case 'is_not_null': return value !== '';
|
|
72
|
+
// (156) greater / less: numbers as numbers, else text (ISO dates sort as text); not_in_list
|
|
73
|
+
case 'greater': return value !== '' && compareValues(value, expected ?? '') > 0;
|
|
74
|
+
case 'greater_equal': return value !== '' && compareValues(value, expected ?? '') >= 0;
|
|
75
|
+
case 'less': return value !== '' && compareValues(value, expected ?? '') < 0;
|
|
76
|
+
case 'less_equal': return value !== '' && compareValues(value, expected ?? '') <= 0;
|
|
77
|
+
case 'not_in_list': return !(expected ?? '').split(',').map((x) => x.trim()).includes(value);
|
|
68
78
|
default: return true;
|
|
69
79
|
}
|
|
70
80
|
}
|
|
@@ -418,12 +428,13 @@ export async function renderPage(ctx: PageContext) {
|
|
|
418
428
|
}
|
|
419
429
|
|
|
420
430
|
/** Response to a successful submit inside a dialog: tells the opener to close it. */
|
|
421
|
-
|
|
431
|
+
/** (157) `items`: the values a close_dialog process hands to the page below (items of the same name there are set). */
|
|
432
|
+
export function dialogClosePage(ctx: PageContext, items?: Record<string, string>) {
|
|
422
433
|
return documentShell(
|
|
423
434
|
ctx.app.name,
|
|
424
435
|
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
436
|
't-dialog-page',
|
|
426
|
-
{ 'data-dialog-close': '1', 'data-dialog-page': String(ctx.page.page_no) },
|
|
437
|
+
{ 'data-dialog-close': '1', 'data-dialog-page': String(ctx.page.page_no), ...(items && Object.keys(items).length ? { 'data-dialog-items': JSON.stringify(items) } : {}) },
|
|
427
438
|
'',
|
|
428
439
|
{ lang: ctx.locale.lang, dir: ctx.locale.dir, theme: ctx.locale.theme, style: ctx.locale.style },
|
|
429
440
|
);
|
package/src/runtime/routes.ts
CHANGED
|
@@ -630,9 +630,9 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
630
630
|
}
|
|
631
631
|
if (messages.length) ctx.session.state.__FLASH = messages.join(' ');
|
|
632
632
|
await saveState(ctx.session);
|
|
633
|
-
if (ctx.dialog) return reply.type('text/html').send(dialogClosePage(ctx));
|
|
633
|
+
if (ctx.dialog) return reply.type('text/html').send(dialogClosePage(ctx, ctx.closeDialog));
|
|
634
634
|
// (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);
|
|
635
|
+
const back = branchTo || button.target_page || ctx.resetPagination ? '' : gridReturn(ctx, button.name);
|
|
636
636
|
return reply.redirect(branchTo ?? (button.target_page ? `${ctx.base}/${button.target_page}` : back ? `${self}${self.includes('?') ? '&' : '?'}${back}` : self), 303);
|
|
637
637
|
});
|
|
638
638
|
|
|
@@ -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/vector.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
|
-
import { embedTexts, loadVectorProviders, vectorLiteral, vectorProviderProblems, DEFAULT_BATCH, DEFAULT_PER_MINUTE, type VectorProvider } from './ai/embeddings.ts';
|
|
2
|
+
import { embedsInDatabase, embedTexts, loadVectorProviders, vectorLiteral, vectorProviderProblems, DEFAULT_BATCH, DEFAULT_PER_MINUTE, type VectorProvider } from './ai/embeddings.ts';
|
|
3
3
|
import { isAiError } from './ai/types.ts';
|
|
4
4
|
import { owner } from './db.ts';
|
|
5
5
|
import { devDbForApp } from './workspace-db.ts';
|
|
@@ -127,8 +127,8 @@ async function runBatch(g: Group, p: VectorProvider): Promise<number> {
|
|
|
127
127
|
const text = `meta.embedding_text(to_jsonb(t), $2::text[])`;
|
|
128
128
|
try {
|
|
129
129
|
let written = 0;
|
|
130
|
-
if (p
|
|
131
|
-
// a function provider: embedded in the database, as the developer connection (the trigger's own function)
|
|
130
|
+
if (embedsInDatabase(p)) {
|
|
131
|
+
// a function or database model provider: embedded in the database, as the developer connection (the trigger's own function)
|
|
132
132
|
await db.query(
|
|
133
133
|
`update ${table} t set ${id(g.vector_column)} = translate(meta.embed(${text}, $3, $4)::text, '{}', '[]')::text::${await vectorTypeOf(db)}
|
|
134
134
|
where t.${id(g.key_column)}::text = any($1::text[])`, [rows.map((r) => r.key_value), g.source_columns, p.name, g.app_id]);
|
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; }
|