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/ai/embeddings.ts
CHANGED
|
@@ -18,8 +18,13 @@ import { AiError } from './types.ts';
|
|
|
18
18
|
// The dimensions are part of the definition: every embedding is checked against them, and the builder checks
|
|
19
19
|
// them against the vector column (vector(n)) a search configuration or a vector column helper uses.
|
|
20
20
|
|
|
21
|
-
export type VectorProviderType = 'ai_service' | 'function';
|
|
22
|
-
export const VECTOR_PROVIDER_TYPES: VectorProviderType[] = ['ai_service', 'function'];
|
|
21
|
+
export type VectorProviderType = 'ai_service' | 'function' | 'database_model';
|
|
22
|
+
export const VECTOR_PROVIDER_TYPES: VectorProviderType[] = ['ai_service', 'function', 'database_model'];
|
|
23
|
+
/** (sprint 151, migration 146) the extensions that run a model in the database: pgml = PostgresML's pgml.embed(model, text) */
|
|
24
|
+
export const MODEL_ENGINES = ['pgml'] as const;
|
|
25
|
+
export type ModelEngine = (typeof MODEL_ENGINES)[number];
|
|
26
|
+
/** A provider that embeds in the database (meta.embed, as the caller): a function, or a model in the database. */
|
|
27
|
+
export const embedsInDatabase = (p: { type?: unknown }) => p.type === 'function' || p.type === 'database_model';
|
|
23
28
|
|
|
24
29
|
export interface VectorProvider {
|
|
25
30
|
name: string;
|
|
@@ -32,6 +37,8 @@ export interface VectorProvider {
|
|
|
32
37
|
dimensions: number;
|
|
33
38
|
/** function: schema.name */
|
|
34
39
|
function?: string;
|
|
40
|
+
/** (151) database_model: the extension that runs the model (model: its name, e.g. intfloat/e5-small-v2) */
|
|
41
|
+
engine?: ModelEngine;
|
|
35
42
|
/** ai_service: texts per request (background embedding; default 64) */
|
|
36
43
|
batch_size?: number;
|
|
37
44
|
/** ai_service: requests per minute (background embedding; default 60) */
|
|
@@ -59,7 +66,7 @@ export function vectorProviderProblems(p: unknown): string[] {
|
|
|
59
66
|
const what = `Vector provider ${typeof p.name === 'string' ? p.name : '?'}`;
|
|
60
67
|
const out: string[] = [];
|
|
61
68
|
if (typeof p.name !== 'string' || !PROVIDER_NAME.test(p.name)) out.push(`${what}: the name (static ID) is upper case letters, digits and _ (e.g. DOC_EMBEDDINGS).`);
|
|
62
|
-
if (!VECTOR_PROVIDER_TYPES.includes(p.type)) out.push(`${what}: the type is ai_service or
|
|
69
|
+
if (!VECTOR_PROVIDER_TYPES.includes(p.type)) out.push(`${what}: the type is ai_service, function or database_model.`);
|
|
63
70
|
if (p.description !== undefined && p.description !== null && (typeof p.description !== 'string' || p.description.length > 500)) out.push(`${what}: the description is text (at most 500 characters).`);
|
|
64
71
|
if (!intIn(p.dimensions, 1, MAX_DIMENSIONS)) out.push(`${what}: the dimensions are a whole number from 1 to ${MAX_DIMENSIONS}.`);
|
|
65
72
|
if (p.type === 'ai_service') {
|
|
@@ -67,6 +74,12 @@ export function vectorProviderProblems(p: unknown): string[] {
|
|
|
67
74
|
if (typeof p.model !== 'string' || !MODEL.test(p.model)) out.push(`${what}: the embedding model is its name, e.g. text-embedding-3-small or nomic-embed-text.`);
|
|
68
75
|
if (p.function !== undefined) out.push(`${what}: an AI service provider has no function.`);
|
|
69
76
|
}
|
|
77
|
+
if (p.type === 'database_model') {
|
|
78
|
+
if (!MODEL_ENGINES.includes(p.engine)) out.push(`${what}: the engine is ${MODEL_ENGINES.join(' or ')} (the extension that runs the model in the database).`);
|
|
79
|
+
if (typeof p.model !== 'string' || !MODEL.test(p.model)) out.push(`${what}: the model is its name, e.g. intfloat/e5-small-v2.`);
|
|
80
|
+
if (p.service !== undefined || p.function !== undefined) out.push(`${what}: a database model provider has no AI service or function.`);
|
|
81
|
+
}
|
|
82
|
+
if (p.type !== 'database_model' && p.engine !== undefined) out.push(`${what}: only a database model provider has an engine.`);
|
|
70
83
|
if (p.type === 'function') {
|
|
71
84
|
if (typeof p.function !== 'string' || !FUNCTION_NAME.test(p.function)) out.push(`${what}: the function is schema.name, e.g. sales.embed.`);
|
|
72
85
|
if (p.service !== undefined || p.model !== undefined) out.push(`${what}: a function provider has no AI service or model.`);
|
|
@@ -126,11 +139,11 @@ export interface EmbedInfo extends Pick<CallInfo, 'appId' | 'pageNo' | 'user' |
|
|
|
126
139
|
export async function embedTexts(p: VectorProvider, texts: string[], info: EmbedInfo, client?: Queryable): Promise<number[][]> {
|
|
127
140
|
if (!texts.length) return [];
|
|
128
141
|
const input = texts.map((t) => String(t ?? '').slice(0, MAX_TEXT_CHARS));
|
|
129
|
-
if (p
|
|
130
|
-
if (!client) throw new AiError('config', `Vector provider ${p.name}: a function provider needs a database connection.`);
|
|
142
|
+
if (embedsInDatabase(p)) {
|
|
143
|
+
if (!client) throw new AiError('config', `Vector provider ${p.name}: a ${p.type === 'function' ? 'function' : 'database model'} provider needs a database connection.`);
|
|
131
144
|
const rows = (await client.query('select meta.embed(t, $2, $3) as v from unnest($1::text[]) with ordinality u(t, n) order by n', [input, p.name, info.appId])).rows;
|
|
132
145
|
return rows.map((r: { v: number[] | null }) => {
|
|
133
|
-
if (!Array.isArray(r.v)) throw new AiError('output', `Vector provider ${p.name}: the function returned no embedding.`);
|
|
146
|
+
if (!Array.isArray(r.v)) throw new AiError('output', `Vector provider ${p.name}: the ${p.type === 'function' ? 'function' : 'model'} returned no embedding.`);
|
|
134
147
|
return r.v.map(Number);
|
|
135
148
|
});
|
|
136
149
|
}
|
|
@@ -183,7 +196,7 @@ const RECENT_MS = 10 * 60_000;
|
|
|
183
196
|
|
|
184
197
|
/** The embedding of one search text (AI service providers: kept a few minutes in this process's memory). */
|
|
185
198
|
export async function embedQuery(p: VectorProvider, text: string, info: EmbedInfo, client?: Queryable): Promise<number[]> {
|
|
186
|
-
if (p
|
|
199
|
+
if (embedsInDatabase(p)) return (await embedTexts(p, [text], info, client))[0];
|
|
187
200
|
const key = JSON.stringify([info.appId, p.name, p.service, p.model, p.dimensions, text]);
|
|
188
201
|
const hit = recent.get(key);
|
|
189
202
|
if (hit && Date.now() - hit.at < RECENT_MS) return hit.v;
|
package/src/appfiles.ts
CHANGED
|
@@ -108,11 +108,11 @@ function sniff(b: Buffer) {
|
|
|
108
108
|
// Code columns that move to a sibling file when they are long or multi-line.
|
|
109
109
|
const CODE: Record<string, Record<string, string>> = {
|
|
110
110
|
region: { source: 'sql', condition: 'sql' },
|
|
111
|
-
item: { default_value: 'sql', readonly_condition: 'sql' },
|
|
111
|
+
item: { default_value: 'sql', readonly_condition: 'sql', condition_expr: 'sql' },
|
|
112
112
|
button: { condition: 'sql', badge_query: 'sql' },
|
|
113
|
-
dynamic_action: { code: 'sql' },
|
|
114
|
-
validation: { expression: 'sql' },
|
|
115
|
-
process: { code: 'sql' },
|
|
113
|
+
dynamic_action: { code: 'sql', server_condition_expr: 'sql' },
|
|
114
|
+
validation: { expression: 'sql', condition_expr: 'sql' },
|
|
115
|
+
process: { code: 'sql', condition_expr: 'sql' },
|
|
116
116
|
computation: { expression: 'sql', condition_expr: 'sql' },
|
|
117
117
|
branch: { condition_expr: 'sql' },
|
|
118
118
|
app_process: { code: 'sql' },
|
package/src/builder/advisor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { declarativeCondition } from './conditions.ts';
|
|
1
2
|
import type { FastifyInstance } from 'fastify';
|
|
2
3
|
import pg from 'pg';
|
|
3
4
|
import { applyBinds, splitStatements } from '../binds.ts';
|
|
@@ -52,15 +53,16 @@ export function sqlFields(kind: string, row: any): { name: string; shape: SqlSha
|
|
|
52
53
|
case 'region':
|
|
53
54
|
return [
|
|
54
55
|
...(['report', 'classic_report', 'grid', 'chart', 'cards', 'calendar', 'map', 'tree', 'dynamic'].includes(row.type) && row.source?.trim() && !row.rest_source ? [{ name: 'source', shape: 'select' as const }] : []),
|
|
55
|
-
|
|
56
|
+
...conditionSql(row, 'condition'),
|
|
56
57
|
];
|
|
57
58
|
case 'item':
|
|
58
59
|
return [
|
|
59
60
|
...(row.lov?.trim() && !/^(STATIC|LOV):/i.test(row.lov.trim()) ? [{ name: 'lov', shape: 'select' as const }] : []),
|
|
60
61
|
{ name: 'readonly_condition', shape: 'boolean' },
|
|
62
|
+
...conditionSql(row),
|
|
61
63
|
];
|
|
62
64
|
case 'button':
|
|
63
|
-
return [
|
|
65
|
+
return [...conditionSql(row, 'condition'), { name: 'badge_query', shape: 'select' }];
|
|
64
66
|
case 'computation':
|
|
65
67
|
case 'app_computation':
|
|
66
68
|
return [
|
|
@@ -70,9 +72,16 @@ export function sqlFields(kind: string, row: any): { name: string; shape: SqlSha
|
|
|
70
72
|
case 'branch':
|
|
71
73
|
return conditionSql(row);
|
|
72
74
|
case 'dynamic_action':
|
|
73
|
-
return
|
|
75
|
+
return [
|
|
76
|
+
...(row.action === 'set_value' ? [{ name: 'code', shape: 'select' as const }] : row.action === 'execute_sql' ? [{ name: 'code', shape: 'statements' as const }] : []),
|
|
77
|
+
...conditionSql({ condition_type: row.server_condition_type }, 'server_condition_expr'),
|
|
78
|
+
];
|
|
74
79
|
case 'validation':
|
|
75
|
-
return
|
|
80
|
+
return [
|
|
81
|
+
...(row.type === 'sql' ? [{ name: 'expression', shape: 'boolean' as const }] : row.type === 'regex' ? [{ name: 'expression', shape: 'regex' as const }]
|
|
82
|
+
: row.type === 'exists' || row.type === 'not_exists' ? [{ name: 'expression', shape: 'select' as const }] : []),
|
|
83
|
+
...conditionSql(row),
|
|
84
|
+
];
|
|
76
85
|
case 'process':
|
|
77
86
|
return [
|
|
78
87
|
...(row.type === 'sql' ? [{ name: 'code', shape: 'statements' as const }] : row.type === 'download' ? [{ name: 'code', shape: 'select' as const }] : []),
|
|
@@ -104,9 +113,14 @@ export function sqlFields(kind: string, row: any): { name: string; shape: SqlSha
|
|
|
104
113
|
}
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
/**
|
|
117
|
+
* The SQL of a server-side condition (migrations 029, 147). `field` holds the expression; for regions and
|
|
118
|
+
* buttons (`condition`) no type means SQL.
|
|
119
|
+
*/
|
|
120
|
+
const conditionSql = (row: any, field = 'condition_expr'): { name: string; shape: SqlShape }[] => {
|
|
121
|
+
const type = row.condition_type || (field === 'condition' && row.condition?.trim() ? 'sql' : null);
|
|
122
|
+
return type === 'sql' ? [{ name: field, shape: 'boolean' }] : type === 'exists' || type === 'not_exists' ? [{ name: field, shape: 'select' }] : [];
|
|
123
|
+
};
|
|
110
124
|
|
|
111
125
|
export const EXPLAINABLE = /^\s*(\(|select|with|values|table|insert|update|delete|merge)\b/i;
|
|
112
126
|
|
|
@@ -273,6 +287,28 @@ function structureFindings(x: AppRows): Finding[] {
|
|
|
273
287
|
else if (!all.some((r) => r.kind === 'region' && r.row.type === 'login' && r.row.page_id === page.id))
|
|
274
288
|
out.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} has no Login region: nobody can sign in.` });
|
|
275
289
|
}
|
|
290
|
+
// (154, CD-2) a SQL condition one declarative type can say: no query, and it reads in words in the tree
|
|
291
|
+
for (const r of all) {
|
|
292
|
+
const field = r.kind === 'region' || r.kind === 'button' ? 'condition'
|
|
293
|
+
: ['item', 'validation', 'process', 'computation', 'app_computation', 'branch'].includes(r.kind) ? 'condition_expr' : null;
|
|
294
|
+
if (!field || (r.row.build_option ?? '') === 'COMMENTED_OUT') continue;
|
|
295
|
+
const sql = (r.row.condition_type ?? (field === 'condition' ? 'sql' : null)) === 'sql' ? r.row[field] : null;
|
|
296
|
+
const d = sql ? declarativeCondition(sql) : null;
|
|
297
|
+
if (!d) continue;
|
|
298
|
+
const what = [d.condition_expr && `expression ${d.condition_expr}`, d.condition_value && `value ${d.condition_value}`].filter(Boolean).join(', ');
|
|
299
|
+
out.push({ severity: 'info', entry: x.byKey.get(`${r.kind}-${r.row.id}`) ?? null, field: 'Server-side condition',
|
|
300
|
+
message: `The SQL condition ${String(sql).trim()} can be the condition type ${d.condition_type}${what ? ` (${what})` : ''}: decided without a query, and shown in words.` });
|
|
301
|
+
}
|
|
302
|
+
// (155, CD-1) a SQL validation one declarative type says exactly
|
|
303
|
+
const VALIDATION_OF: Record<string, string> = { item_not_null: 'not_null', item_equals: 'equals', item_in_list: 'in_list' };
|
|
304
|
+
for (const r of all) {
|
|
305
|
+
if (r.kind !== 'validation' || r.row.type !== 'sql' || (r.row.build_option ?? '') === 'COMMENTED_OUT') continue;
|
|
306
|
+
const d = declarativeCondition(r.row.expression);
|
|
307
|
+
const type = d && VALIDATION_OF[d.condition_type ?? ''];
|
|
308
|
+
if (!d || !type) continue;
|
|
309
|
+
out.push({ severity: 'info', entry: x.byKey.get(`validation-${r.row.id}`) ?? null, field: 'Type',
|
|
310
|
+
message: `The SQL validation ${String(r.row.expression).trim()} can be the type ${type} with associated item ${d.condition_expr}${d.condition_value ? ` and value ${d.condition_value}` : ''}: no query.` });
|
|
311
|
+
}
|
|
276
312
|
const groups = new Set(all.filter((r) => r.kind === 'page_group').map((r) => r.row.name));
|
|
277
313
|
for (const p of [...pages].sort((a, b) => a.page_no - b.page_no))
|
|
278
314
|
if (p.page_group != null && !groups.has(p.page_group))
|
|
@@ -29,12 +29,14 @@ export type CodeLang = 'sql' | 'plpgsql' | 'json' | 'html' | 'text';
|
|
|
29
29
|
const STATEMENTS = new Set(['supporting_script.script', 'process.code', 'app_process.code', 'automation_action.code', 'task_definition.action_code', 'dynamic_action.code']);
|
|
30
30
|
/** The Advisor check per field (none: no check). */
|
|
31
31
|
const CHECKS: Record<string, SqlShape> = {
|
|
32
|
-
'region.source': 'select', '
|
|
33
|
-
'
|
|
32
|
+
'region.source': 'select', 'item.lov': 'select', 'item.readonly_condition': 'boolean',
|
|
33
|
+
'process.code': 'statements', 'authz_scheme.value': 'boolean',
|
|
34
34
|
'lov.query': 'select', 'app_process.code': 'statements', 'automation.query': 'select', 'automation_action.code': 'statements', 'automation_action.condition': 'boolean',
|
|
35
35
|
'document_template.query': 'select', 'report_query.query': 'select', 'task_definition.action_code': 'statements', 'dynamic_action.code': 'statements',
|
|
36
36
|
'button.badge_query': 'select', 'list.query': 'select', 'list_entry.condition': 'boolean',
|
|
37
37
|
};
|
|
38
|
+
/** (147) The expression fields of server-side conditions (their type select names how to read them). */
|
|
39
|
+
const CONDITION_EXPRS = ['computation', 'app_computation', 'branch', 'process', 'item', 'validation'].map((k) => `${k}.condition_expr`).concat('dynamic_action.server_condition_expr');
|
|
38
40
|
/** Fields whose check follows the component's type select, like SWITCH ("none": no check). */
|
|
39
41
|
const CHECK_SWITCH: Record<string, string> = {
|
|
40
42
|
'dynamic_action.code': 'action:set_value=select,*=statements',
|
|
@@ -42,22 +44,24 @@ const CHECK_SWITCH: Record<string, string> = {
|
|
|
42
44
|
'region.source': 'type:static=none,form=none,facets=none,smart_filters=none,display_selector=none,tasks=none,workflows=none,list=none,data_reporter=none,form_designer=none,ai_assistant=none,*=select',
|
|
43
45
|
'computation.expression': 'type:sql_query=select,*=none',
|
|
44
46
|
'app_computation.expression': 'type:sql_query=select,*=none',
|
|
45
|
-
'
|
|
46
|
-
|
|
47
|
-
'
|
|
47
|
+
// (147) every component's server-side condition: SQL types are checked, the others are names and values
|
|
48
|
+
...Object.fromEntries(CONDITION_EXPRS.map((k) => [k, `${k.endsWith('server_condition_expr') ? 'server_condition_type' : 'condition_type'}:sql=boolean,exists=select,not_exists=select,*=none`])),
|
|
49
|
+
'validation.expression': 'type:sql=boolean,exists=select,not_exists=select,*=none',
|
|
50
|
+
'region.condition': 'condition_type:=boolean,sql=boolean,exists=select,not_exists=select,*=none',
|
|
51
|
+
'button.condition': 'condition_type:=boolean,sql=boolean,exists=select,not_exists=select,*=none',
|
|
48
52
|
};
|
|
49
53
|
/** Fields whose language follows the component's type select: "type:value=lang,…" (other values: SQL). */
|
|
50
54
|
const SWITCH: Record<string, string> = {
|
|
51
55
|
'region.source': 'type:static=html,form=text',
|
|
52
|
-
'validation.expression': 'type:
|
|
56
|
+
'validation.expression': 'type:sql=sql,exists=sql,not_exists=sql,function_body=plpgsql,function_error=plpgsql,*=text',
|
|
53
57
|
'authz_scheme.value': 'type:role=text',
|
|
54
58
|
'dynamic_action.code': 'action:set_value=sql',
|
|
55
59
|
'computation.expression': 'type:static=text,item=text,function_body=plpgsql',
|
|
56
60
|
'app_computation.expression': 'type:static=text,item=text,function_body=plpgsql',
|
|
57
61
|
'shortcut.value': 'type:text=text,html=html,function_body=plpgsql',
|
|
58
|
-
|
|
59
|
-
'
|
|
60
|
-
'
|
|
62
|
+
...Object.fromEntries(CONDITION_EXPRS.map((k) => [k, `${k.endsWith('server_condition_expr') ? 'server_condition_type' : 'condition_type'}:sql=sql,exists=sql,not_exists=sql,function_body=plpgsql,=sql,*=text`])),
|
|
63
|
+
'region.condition': 'condition_type:=sql,sql=sql,exists=sql,not_exists=sql,function_body=plpgsql,*=text',
|
|
64
|
+
'button.condition': 'condition_type:=sql,sql=sql,exists=sql,not_exists=sql,function_body=plpgsql,*=text',
|
|
61
65
|
};
|
|
62
66
|
/** Language when no SWITCH value matches. */
|
|
63
67
|
const fallback = (key: string): CodeLang => (STATEMENTS.has(key) ? 'plpgsql' : 'sql');
|
|
@@ -66,7 +70,7 @@ const fallback = (key: string): CodeLang => (STATEMENTS.has(key) ? 'plpgsql' : '
|
|
|
66
70
|
function pick(sw: string, row: any): string | undefined {
|
|
67
71
|
const [col, list] = sw.split(':');
|
|
68
72
|
const pairs = list.split(',').map((p) => p.split('='));
|
|
69
|
-
return (pairs.find(([val]) => val === row?.[col]) ?? pairs.find(([val]) => val === '*'))?.[1];
|
|
73
|
+
return (pairs.find(([val]) => val === (row?.[col] ?? '')) ?? pairs.find(([val]) => val === '*'))?.[1];
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
/** Language of a component field in the property editor, or null for a plain field. */
|
|
@@ -22,6 +22,8 @@ import { DATA_LOAD_DEF_SPEC } from './dataload.ts';
|
|
|
22
22
|
import { processProblems } from '../runtime/processes.ts';
|
|
23
23
|
import { BUTTON_OPTIONS, ITEM_OPTIONS, REGION_OPTIONS } from '../runtime/template-options.ts';
|
|
24
24
|
import { checkGroupMembers } from '../subscriptions.ts';
|
|
25
|
+
import { CONDITION_TYPES } from '../metadata.ts';
|
|
26
|
+
import { styleProblem } from '../runtime/da-styles.ts';
|
|
25
27
|
|
|
26
28
|
export type FieldKind =
|
|
27
29
|
| 'text' | 'int' | 'bool' | 'code' | 'json' | 'select' | 'upper'
|
|
@@ -78,14 +80,27 @@ const AUTHZ_HELP = 'Authorization scheme; prefix with ! to negate. MUST_NOT_BE_P
|
|
|
78
80
|
const BUILD_HELP = 'Build option (Shared Components): the component exists only while the option is included; "Not" only while it is excluded.';
|
|
79
81
|
const buildOption = (group?: string): Field => ({ name: 'build_option', label: 'Build option', kind: 'build_option', help: BUILD_HELP, ...(group ? { group } : {}) });
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
const CONDITION_HELP = 'sql: a boolean expression · exists / not_exists: a query
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
// (147) one catalogue of server-side condition types for every component (meta.condition_ok)
|
|
84
|
+
const CONDITION_HELP = 'sql: a boolean expression · exists / not_exists: a query that returns a row / no row · function_body: PL/pgSQL returning boolean · '
|
|
85
|
+
+ 'item_*: the item\'s name in Expression · request_in / request_not_in: the button(s) pressed · page_in / page_not_in: page numbers · '
|
|
86
|
+
+ 'page_is_dialog: the page is a modal dialog · user_signed_in / user_public · language_in / language_not_in: language codes · always / never. '
|
|
87
|
+
+ 'Only the SQL types run a query (as the application\'s role); the others are decided by the server.';
|
|
88
|
+
const VALUE_HELP = 'item_equals / item_not_equals: the value · item_equals_item: the other item\'s name · item_in_list: values separated by colons (CLERK:ANALYST) · '
|
|
89
|
+
+ 'request_in: e.g. SAVE,CREATE · page_in: e.g. 1,3 · language_in: e.g. de,pt-br';
|
|
90
|
+
/** The type, expression and value of a server-side condition; `expr` is the expression column, `type` the type column. */
|
|
91
|
+
const conditionFields = (group: string, expr = 'condition_expr', type = 'condition_type', value = 'condition_value', common = false): Field[] => [
|
|
92
|
+
{ name: type, label: 'Condition type', kind: 'select', options: ['', ...CONDITION_TYPES], group, help: CONDITION_HELP, ...(common ? { common } : {}) },
|
|
93
|
+
{ name: expr, label: 'Condition expression / item', kind: 'code', group, help: 'e.g. :P3_STATUS = \'OPEN\' (sql) · select 1 from sales.orders where id = :P3_ID (exists) · return :P3_QTY > 0; (function_body) · P3_ID (item_*)', ...(common ? { common } : {}) },
|
|
94
|
+
{ name: value, label: 'Condition value', kind: 'text', group, help: VALUE_HELP, ...(common ? { common } : {}) },
|
|
87
95
|
];
|
|
88
96
|
|
|
97
|
+
// (148, CD-1) APEX's validation types
|
|
98
|
+
const VALIDATION_TYPES = ['sql', 'exists', 'not_exists', 'function_body', 'function_error', 'not_null', 'not_null_or_zero', 'not_zero', 'no_spaces',
|
|
99
|
+
'alphanumeric', 'numeric', 'valid_date', 'valid_timestamp', 'equals', 'not_equals', 'in_list', 'not_in_list', 'chars_only', 'chars_any', 'chars_none', 'regex'];
|
|
100
|
+
const VALIDATION_HELP = 'APEX: Expression (sql), Rows returned / No rows returned (exists / not_exists), Function Body returning Boolean / Error Text (function_body / function_error), '
|
|
101
|
+
+ 'Item is NOT NULL / NOT NULL or zero / NOT zero / contains no spaces / alphanumeric / numeric / a valid date (in the application\'s date format) / a valid timestamp, '
|
|
102
|
+
+ 'Item = / != Value, Item is (NOT) contained in Value, contains only / at least one / none of the characters, matches Regular Expression. Format checks pass an empty item: add not_null for a required one.';
|
|
103
|
+
|
|
89
104
|
/** JSON "menu" field: empty means no menu (null), not {}. */
|
|
90
105
|
const menuBeforeSave = async (v: Record<string, unknown>) => {
|
|
91
106
|
if (v.menu === '{}' || v.menu === '[]') v.menu = null;
|
|
@@ -116,7 +131,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
116
131
|
{ name: 'title', common: true, label: 'Title', kind: 'text', group: 'Identification' },
|
|
117
132
|
{ name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
|
|
118
133
|
help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
|
|
119
|
-
{ name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search', 'classic_report', 'breadcrumb', 'help_text'], group: 'Identification' },
|
|
134
|
+
{ name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search', 'classic_report', 'breadcrumb', 'help_text', 'workflow_diagram'], group: 'Identification' },
|
|
120
135
|
{ name: 'source', common: true, label: 'Source', kind: 'code', wide: true, group: 'Source',
|
|
121
136
|
help: 'report/grid/classic_report: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
|
|
122
137
|
{ name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
|
|
@@ -131,7 +146,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
131
146
|
help: 'Where the region shows (APEX: Layout → Position). banner: above everything else in the page; breadcrumb: in the bar under the title; left / right: in the side column of a page with template Left or Right side column (on other pages in the body); footer: after the body. Empty is body.' },
|
|
132
147
|
{ name: 'template_options', label: 'Template options', kind: 'options', choices: REGION_OPTIONS, group: 'Appearance',
|
|
133
148
|
help: 'CSS classes from a fixed list, added to the region (APEX: Template Options). "Hide the header" applies to the standard template.' },
|
|
134
|
-
|
|
149
|
+
...conditionFields('Server-side condition', 'condition', 'condition_type', 'condition_value', true),
|
|
135
150
|
{ name: 'authz', common: true, label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
|
|
136
151
|
buildOption('Security'),
|
|
137
152
|
{ name: 'config', label: 'Attributes (JSON)', kind: 'json', wide: true, group: 'Attributes',
|
|
@@ -149,7 +164,8 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
149
164
|
validate: (v) => formatSettingsProblem(v.config),
|
|
150
165
|
fields: [
|
|
151
166
|
{ name: 'name', common: true, label: 'Name', kind: 'upper', help: 'Referenced in SQL as :NAME, e.g. P3_NAME', group: 'Identification' },
|
|
152
|
-
{ name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin', 'shuttle', 'select_one', 'select_many'], group: 'Identification'
|
|
167
|
+
{ name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin', 'shuttle', 'select_one', 'select_many', 'image_upload', 'autocomplete', 'percent_graph', 'display_map'], group: 'Identification',
|
|
168
|
+
help: '(sprint 158) image_upload: a file item for pictures (image/*, cropping {"crop": "free" | "1:1" | "4:3" …}, {"max_px": 1600} before upload; file column settings as a file item) · autocomplete: a text field with suggestions from the list of values, any text allowed · percent_graph: a 0–100 value as a bar · display_map: a small map of "lat,lng" (the value, or {"coordinates_item": "P3_LOCATION"}; {"zoom": 15}).' },
|
|
153
169
|
{ name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
|
|
154
170
|
{ name: 'region_id', common: true, label: 'Region', kind: 'region', group: 'Layout' },
|
|
155
171
|
{ name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
|
|
@@ -160,6 +176,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
160
176
|
help: 'CSS classes from a fixed list, added to the item (APEX: Template Options).' },
|
|
161
177
|
{ name: 'required', common: true, label: 'Value required', kind: 'bool', group: 'Validation' },
|
|
162
178
|
{ name: 'help', label: 'Help text', kind: 'text', group: 'Validation' },
|
|
179
|
+
...conditionFields('Server-side condition'),
|
|
163
180
|
{ name: 'readonly_condition', label: 'Read-only condition (SQL)', kind: 'code', group: 'Security', help: 'When true the item is shown read-only and ignored on submit.' },
|
|
164
181
|
{ name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
|
|
165
182
|
buildOption('Security'),
|
|
@@ -194,7 +211,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
194
211
|
{ name: 'slot', common: true, label: 'Button position', kind: 'select', options: ['', 'region', 'create', 'edit', 'change', 'copy', 'help', 'up', 'previous', 'close', 'delete', 'next'], group: 'Layout',
|
|
195
212
|
help: 'A region button\'s place (APEX: Button Position): create, edit, change, copy, help and up in the region\'s header; previous, close and delete at the left of its bottom bar, next at the right. Empty or region: with its other buttons (the header of reports and charts, the bottom of forms and static content).' },
|
|
196
213
|
{ name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
|
|
197
|
-
|
|
214
|
+
...conditionFields('Server-side condition', 'condition'),
|
|
198
215
|
{ name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
|
|
199
216
|
buildOption('Security'),
|
|
200
217
|
],
|
|
@@ -212,16 +229,21 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
212
229
|
? 'CSS classes: up to five names of lower case letters, digits, - and _, separated by spaces.'
|
|
213
230
|
: v.action === 'execute_javascript' && !/^[A-Za-z_$][\w$.-]{0,99}$/.test(String(v.code ?? '').trim())
|
|
214
231
|
? 'Execute JavaScript: Code is the name of a function a static file registers with pgkiln.actions.register(name, fn), e.g. highlightLate.'
|
|
215
|
-
:
|
|
232
|
+
: v.action === 'set_style'
|
|
233
|
+
? styleProblem((typeof v.config === 'string' ? (() => { try { return JSON.parse(v.config as string); } catch { return null; } })() : (v.config as any))?.styles)
|
|
234
|
+
: null,
|
|
216
235
|
fields: [
|
|
217
236
|
{ name: 'name', label: 'Name', kind: 'text', group: 'When' },
|
|
218
|
-
{ name: 'event', label: 'Event', kind: 'select', options: ['change', 'click', 'load', 'dialog_closed', 'focus', 'blur', 'key_release', 'after_refresh', 'selection_change', 'before_submit', 'custom', 'menu_entry'
|
|
219
|
-
|
|
237
|
+
{ name: 'event', label: 'Event', kind: 'select', options: ['change', 'click', 'load', 'dialog_closed', 'focus', 'blur', 'key_release', 'after_refresh', 'selection_change', 'before_submit', 'custom', 'menu_entry',
|
|
238
|
+
'double_click', 'key_down', 'key_press', 'mouse_enter', 'mouse_leave', 'select', 'resize', 'scroll', 'page_unload', 'before_refresh', 'facets_change'], group: 'When',
|
|
239
|
+
help: '(sprint 156) double_click, mouse_enter, mouse_leave: items, buttons or regions. key_down, key_press (a key that types a character), select (text selected): items. resize, scroll: the window (no trigger). page_unload: the page is left (only quick, local actions finish). before_refresh: a region is about to be refreshed by a Refresh action. facets_change: a faceted search or smart filters region\'s facets were applied (trigger: that region). ' + 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region. focus / blur / key_release: an item gets or loses the focus, a key is released in it. after_refresh: a region was refreshed or loaded. selection_change: rows were selected in a report (row selection) or a grid\'s master row. before_submit: the page is about to be submitted (a declined confirm keeps it). custom: JavaScript raised it with pgkiln.trigger(\'NAME\', value). menu_entry (Menu entry chosen): an entry {"label": …, "action": "NAME"} of a menu button was chosen. The actions of one event and trigger run in sequence order; a declined confirm stops the ones after it.' },
|
|
220
240
|
{ name: 'trigger_element', label: 'Item(s) / button / region / page / event', kind: 'upper', group: 'When', help: 'Comma separated: item names (change, focus, blur, key_release), a button name (click), region ids or static ids (after_refresh, selection_change), dialog page numbers (dialog_closed; empty: any dialog), event names (custom), menu entry action names or menu button names (menu_entry: any of its entries; a client-side condition tests the entry\'s name). Empty for load and before_submit.' },
|
|
221
|
-
{ name: 'condition_type', label: 'Client-side condition', kind: 'select', options: ['', 'equals', 'not_equals', 'in_list', 'is_null', 'is_not_null'], group: 'When'
|
|
241
|
+
{ name: 'condition_type', label: 'Client-side condition', kind: 'select', options: ['', 'equals', 'not_equals', 'greater', 'greater_equal', 'less', 'less_equal', 'in_list', 'not_in_list', 'is_null', 'is_not_null'], group: 'When',
|
|
242
|
+
help: 'On the (first) trigger item\'s value. greater / less: numbers as numbers, other values (ISO dates) as text; an empty value fails them. in_list / not_in_list: comma-separated values.' },
|
|
222
243
|
{ name: 'condition_value', label: 'Condition value', kind: 'text', group: 'When' },
|
|
223
|
-
{ name: 'action', label: 'Action', kind: 'select', options: ['show', 'hide', 'enable', 'disable', 'set_value', 'execute_sql', 'refresh_region', 'refresh_item', 'alert', 'submit', 'set_focus', 'add_class', 'remove_class', 'show_success', 'show_error', 'clear_errors', 'ai_generate', 'execute_javascript', 'plugin', 'push_subscribe', 'confirm', 'close_dialog', 'cancel_dialog', 'clear', 'open_region', 'close_region', 'download', 'print', 'current_position', 'share', 'wait_popup', 'remove_wait_popup'
|
|
224
|
-
|
|
244
|
+
{ name: 'action', label: 'Action', kind: 'select', options: ['show', 'hide', 'enable', 'disable', 'set_value', 'execute_sql', 'refresh_region', 'refresh_item', 'alert', 'submit', 'set_focus', 'add_class', 'remove_class', 'show_success', 'show_error', 'clear_errors', 'ai_generate', 'execute_javascript', 'plugin', 'push_subscribe', 'confirm', 'close_dialog', 'cancel_dialog', 'clear', 'open_region', 'close_region', 'download', 'print', 'current_position', 'share', 'wait_popup', 'remove_wait_popup',
|
|
245
|
+
'cancel_event', 'set_style', 'collapse_tree', 'expand_tree', 'show_ai_assistant', 'trigger_geocoding', 'open_report_dialog'], group: 'Action',
|
|
246
|
+
help: '(sprint 156) cancel_event: stops the actions after it and the event\'s own effect (a button\'s submit or link, a typed key). set_style: {"styles": {"color": "#b42318", "font-weight": "bold"}} in the attributes, on the affected items or region. collapse_tree / expand_tree: every node of the affected tree region. show_ai_assistant: opens the affected AI assistant region (or its inline dialog) and puts the cursor in it. trigger_geocoding: the affected geocoded address item looks its text up. open_report_dialog: Code names the dialog of the affected interactive report (filter, columns, sort, highlight, compute, aggregate, chart, group, pivot, break, rows, saved). ' + 'show/hide/enable/disable reverse automatically when the condition is false. set_focus: the first affected item (or the region). show_error: on the affected items, or at the top. clear_errors: of the affected items, or all. ai_generate: runs the "Generate text with AI" process named in Code through AJAX, without submitting the page (on a submit button, the button submits instead when JavaScript is off). execute_javascript: calls the function named in Code, registered by a static application file (Shared Components → Static Application Files). push_subscribe: turns on push notifications on the user\'s device (use it on click: browsers ask only after one; the app needs push notifications on in Settings → Progressive Web App); Message is shown when it worked. confirm: asks Message; No stops the actions after it. close_dialog / cancel_dialog: close the modal page this runs on (close_dialog refreshes the page below and runs its dialog_closed actions). clear: empties the affected items. open_region / close_region: a collapsible region opens or closes (another region shows or hides). download: the file of the download process named in Code (Items to submit are sent first). print: the browser\'s print dialog. current_position: the device\'s position as latitude,longitude into the first affected item. share: the device\'s share sheet for this page (Message as the text), or the link copied. wait_popup: a modal "please wait" overlay (Message as its text) until remove_wait_popup or the next page, e.g. on before_submit around a long process, or wait_popup, execute_sql, remove_wait_popup around an Ajax call.' },
|
|
225
247
|
{ name: 'affected_items', label: 'Affected items', kind: 'upper', group: 'Action' },
|
|
226
248
|
{ name: 'affected_region_id', label: 'Affected region', kind: 'region', group: 'Action' },
|
|
227
249
|
{ name: 'code', label: 'SQL', kind: 'code', wide: true, group: 'Action', help: 'set_value: a SELECT whose columns set the affected items · execute_sql: any SQL; returned columns named like items set them · ai_generate: the name of an ai_generate process on this page (its output items are updated; items to submit default to the items its prompts use) · execute_javascript: the name of a function registered with pgkiln.actions.register(name, fn) in a static file the page loads.' },
|
|
@@ -229,6 +251,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
229
251
|
{ name: 'message', label: 'Message (alert, confirm, show_success, show_error, wait_popup)', kind: 'text', group: 'Action' },
|
|
230
252
|
{ name: 'config', label: 'Plug-in attributes (JSON)', kind: 'json', group: 'Action', help: 'plugin: {"attributes": {"NAME": "value or &ITEM."}} (Shared Components → Plug-ins lists each plug-in\'s attributes).' },
|
|
231
253
|
{ name: 'css_classes', label: 'CSS classes (add_class, remove_class)', kind: 'text', group: 'Action', help: 'Up to five class names, e.g. is-highlight is-muted (lower case letters, digits, - and _).' },
|
|
254
|
+
...conditionFields('Server-side condition', 'server_condition_expr', 'server_condition_type', 'server_condition_value'),
|
|
232
255
|
{ name: 'seq', label: 'Sequence', kind: 'int', group: 'Security' },
|
|
233
256
|
{ name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
|
|
234
257
|
buildOption('Security'),
|
|
@@ -244,11 +267,17 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
244
267
|
defaults: { type: 'sql' },
|
|
245
268
|
fields: [
|
|
246
269
|
{ name: 'name', label: 'Name', kind: 'text' },
|
|
247
|
-
{ name: 'type', label: 'Type', kind: 'select', options:
|
|
248
|
-
{ name: 'item_name', label: 'Associated item', kind: 'upper' },
|
|
249
|
-
{ name: 'expression', label: 'Expression', kind: 'code', wide: true,
|
|
270
|
+
{ name: 'type', label: 'Type', kind: 'select', options: VALIDATION_TYPES, help: VALIDATION_HELP },
|
|
271
|
+
{ name: 'item_name', label: 'Associated item', kind: 'upper', help: 'The item the error shows next to; the item types test its value.' },
|
|
272
|
+
{ name: 'expression', label: 'Expression / value', kind: 'code', wide: true,
|
|
273
|
+
help: 'sql: a boolean expression that must be true · exists / not_exists: a query · function_body: PL/pgSQL returning boolean · function_error: PL/pgSQL returning the error text (null: valid) · equals / not_equals: the value · in_list / not_in_list: values separated by colons (CLERK:ANALYST) · chars_*: the characters · regex: a POSIX pattern' },
|
|
250
274
|
{ name: 'message', label: 'Error message', kind: 'text' },
|
|
275
|
+
{ name: 'display_location', label: 'Display location', kind: 'select', options: ['', 'inline_and_notification', 'inline', 'notification'],
|
|
276
|
+
help: 'Where the error shows: inline with the item and in the notification at the top, inline only (empty: inline, with "correct the errors below" at the top), or in the notification only. Without an associated item it is always in the notification.' },
|
|
251
277
|
{ name: 'when_button', label: 'When button pressed', kind: 'upper' },
|
|
278
|
+
{ name: 'always_execute', label: 'Always execute', kind: 'bool', help: 'Also for a DELETE request (other validations without When button pressed are skipped for DELETE).' },
|
|
279
|
+
{ name: 'authz', label: 'Authorization', kind: 'authz', help: AUTHZ_HELP },
|
|
280
|
+
...conditionFields('Server-side condition'),
|
|
252
281
|
{ name: 'seq', label: 'Sequence', kind: 'int' },
|
|
253
282
|
buildOption(),
|
|
254
283
|
],
|
|
@@ -269,8 +298,9 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
269
298
|
},
|
|
270
299
|
fields: [
|
|
271
300
|
{ name: 'name', label: 'Name', kind: 'text' },
|
|
272
|
-
{ name: 'type', label: 'Type', kind: 'select', options: ['sql', 'form_dml', 'grid_dml', 'data_load', 'invoke_api', 'download', 'chain', 'workflow', 'ai_generate', 'plugin', 'send_push', 'send_email'
|
|
273
|
-
|
|
301
|
+
{ name: 'type', label: 'Type', kind: 'select', options: ['sql', 'form_dml', 'grid_dml', 'data_load', 'invoke_api', 'download', 'chain', 'workflow', 'ai_generate', 'plugin', 'send_push', 'send_email',
|
|
302
|
+
'close_dialog', 'clear_session_state', 'reset_pagination', 'user_preferences', 'server_geocoding', 'print_report', 'human_task_create', 'human_task_manage', 'form_init'],
|
|
303
|
+
help: '(sprint 157, settings in Attributes) close_dialog: {"items": ["P5_ID"]} closes the modal page, handing these items to the page below · clear_session_state: {"items": [...], "pages": [3], "all": false} · reset_pagination: reports and grids start on their first page · user_preferences: {"action": "set", "preference": "DEFAULT_DEPT", "item": "P1_DEPT"} or {"action": "reset", …} · server_geocoding: {"address_item": "P3_ADDRESS", "coordinates_item": "P3_LOCATION"} · print_report: {"document": "EMPLOYEE_SHEET"} (a document template or report query) · human_task_create: {"definition": "LEAVE_APPROVAL", "detail_pk": "&P7_ID.", "parameters": {"DAYS": "&P7_DAYS."}, "id_item": "P7_TASK_ID"} · human_task_manage: {"action": "approve", "task": "&P8_TASK_ID.", "comment": "&P8_COMMENT."} (approve, reject, complete with "outcome", claim, release, delegate with "to", cancel) · form_init: APEX\'s Form – Initialization (the form region fetches its row by itself) · download: a file from the query in Code · chain: runs the processes that name it as their chain, in sequence (optionally in the background) · workflow: start, terminate or retry a workflow · ai_generate: Generate text with AI (an AI service the application may use) · send_push: a push notification to a user\'s devices (Settings → Progressive Web App → push notifications)' },
|
|
274
304
|
{ name: 'point', label: 'Point', kind: 'select', options: ['submit', 'load', 'ajax'], help: 'A chain\'s processes run when the chain does (their own point is not used). ajax: an Ajax callback (APEX: On Demand) that JavaScript calls by name with pgkiln.server.process(\'NAME\', {items: [\'P1_X\'], x01: …}); SQL processes only; the last statement\'s rows come back as JSON, and the items it sets are set on the page.' },
|
|
275
305
|
{ name: 'parent_process', label: 'Chain (parent process)', kind: 'text', help: 'The name of a chain process on this page: this process then runs only inside that chain, in its sequence.' },
|
|
276
306
|
{ name: 'code', label: 'Code (SQL / PL/pgSQL call)', kind: 'code', wide: true,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// (sprint 154, CD-2) A component's server-side condition in words, in the builder's language: the Page
|
|
2
|
+
// Designer's tree shows it on the condition badge ("When the request is SAVE, CREATE").
|
|
3
|
+
import { CONDITION_TYPES, type Condition } from '../metadata.ts';
|
|
4
|
+
import type { BuilderKey, BuilderTranslate } from './i18n.ts';
|
|
5
|
+
|
|
6
|
+
const TYPES = new Set<string>(CONDITION_TYPES);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A row's server-side condition: a dynamic action's server_condition_*, a condition_type / condition_expr /
|
|
10
|
+
* condition_value (processes, items, validations…), or a region's or button's `condition` (SQL without a type).
|
|
11
|
+
* Null when there is none. A dynamic action's client-side condition_type isn't one.
|
|
12
|
+
*/
|
|
13
|
+
export function serverCondition(row: any): Condition | null {
|
|
14
|
+
if (!row) return null;
|
|
15
|
+
if (row.server_condition_type) return { condition_type: row.server_condition_type, condition_expr: row.server_condition_expr ?? null, condition_value: row.server_condition_value ?? null };
|
|
16
|
+
if (row.condition_type && TYPES.has(row.condition_type))
|
|
17
|
+
return { condition_type: row.condition_type, condition_expr: row.condition_expr ?? row.condition ?? null, condition_value: row.condition_value ?? null };
|
|
18
|
+
if (typeof row.condition === 'string' && row.condition.trim()) return { condition_type: 'sql', condition_expr: row.condition, condition_value: null };
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const listOf = (v: string | null, sep: string) => (v ?? '').split(sep).map((x) => x.trim()).filter(Boolean).join(', ');
|
|
23
|
+
|
|
24
|
+
/** The condition in words, e.g. "When P3_JOB is one of CLERK, ANALYST". */
|
|
25
|
+
export function conditionWords(tx: BuilderTranslate, c: Condition): string {
|
|
26
|
+
const type = c.condition_type ?? 'always';
|
|
27
|
+
const item = (c.condition_expr ?? '').trim();
|
|
28
|
+
const value = type === 'item_in_list' || type === 'item_not_in_list' ? listOf(c.condition_value, ':')
|
|
29
|
+
: /^(request|page|language)_/.test(type) && !/dialog/.test(type) ? listOf(c.condition_value, ',')
|
|
30
|
+
: (c.condition_value ?? '').trim();
|
|
31
|
+
return tx(`cond.${type}` as BuilderKey, { item, value });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NAME = '([A-Z][A-Z0-9_]*)';
|
|
35
|
+
const LIT = "'((?:[^']|'')*)'";
|
|
36
|
+
const unq = (s: string) => s.replace(/''/g, "'");
|
|
37
|
+
const lits = (s: string) => [...s.matchAll(/'((?:[^']|'')*)'/g)].map((m) => unq(m[1]));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* (154) The declarative type a SQL condition can be, or null: `:P3_X is not null` → item_not_null,
|
|
41
|
+
* `:REQUEST in ('SAVE', 'CREATE')` → request_in, `:P3_JOB = 'CLERK'` → item_equals… Only whole conditions of one
|
|
42
|
+
* bind and literals; anything else stays SQL. (The Advisor suggests it.) Negations (<>, not in) aren't offered: in SQL
|
|
43
|
+
* an empty item makes them null (false), while the declarative types count an empty item as different.
|
|
44
|
+
*/
|
|
45
|
+
export function declarativeCondition(sql: string | null | undefined): Condition | null {
|
|
46
|
+
const s = (sql ?? '').trim().replace(/;+$/, '').trim().replace(/\s+/g, ' ');
|
|
47
|
+
let m: RegExpExecArray | null;
|
|
48
|
+
const c = (condition_type: Condition['condition_type'], condition_expr: string | null, condition_value: string | null): Condition => ({ condition_type, condition_expr, condition_value });
|
|
49
|
+
if ((m = new RegExp(`^:${NAME} is (not )?null$`, 'i').exec(s))) {
|
|
50
|
+
if (m[1].toUpperCase() !== m[1]) return null;
|
|
51
|
+
return c(m[2] ? 'item_not_null' : 'item_null', m[1], null);
|
|
52
|
+
}
|
|
53
|
+
if ((m = new RegExp(`^:${NAME} = ${LIT}$`).exec(s))) {
|
|
54
|
+
if (m[1] === 'REQUEST') return /^[A-Z][A-Z0-9_]*$/.test(m[2]) ? c('request_in', null, m[2]) : null;
|
|
55
|
+
return m[2] === '' ? null : c('item_equals', m[1], unq(m[2]));
|
|
56
|
+
}
|
|
57
|
+
if ((m = new RegExp(`^:${NAME} in \\((${LIT}(?: ?, ?${LIT})*)\\)$`, 'i').exec(s))) {
|
|
58
|
+
if (m[1].toUpperCase() !== m[1]) return null;
|
|
59
|
+
const values = lits(m[2]);
|
|
60
|
+
if (m[1] === 'REQUEST') return values.every((v) => /^[A-Z][A-Z0-9_]*$/.test(v)) ? c('request_in', null, values.join(',')) : null;
|
|
61
|
+
return values.some((v) => v.includes(':') || v === '') ? null : c('item_in_list', m[1], values.join(':'));
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|