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.
Files changed (85) hide show
  1. package/db/migrations/146_database_model_providers.sql +53 -0
  2. package/db/migrations/147_condition_types.sql +80 -0
  3. package/db/migrations/148_validation_types.sql +44 -0
  4. package/db/migrations/149_dynamic_action_events.sql +45 -0
  5. package/db/migrations/150_process_types.sql +23 -0
  6. package/db/migrations/151_item_types.sql +16 -0
  7. package/db/migrations/152_workflow_diagram_region.sql +10 -0
  8. package/docs/apex-feature-parity.md +12 -10
  9. package/docs/guide/02-concepts.md +3 -3
  10. package/docs/guide/03-builder.md +13 -3
  11. package/docs/guide/04-pages-and-regions.md +17 -3
  12. package/docs/guide/05-items.md +5 -0
  13. package/docs/guide/06-processing.md +104 -17
  14. package/docs/guide/07-dynamic-actions.md +16 -1
  15. package/docs/guide/11-from-apex.md +7 -0
  16. package/examples/hr/hr_79_condition_types.sql +17 -0
  17. package/package.json +1 -1
  18. package/public/app.css +12 -0
  19. package/public/app.js +157 -9
  20. package/src/ai/embeddings.ts +20 -7
  21. package/src/appfiles.ts +4 -4
  22. package/src/builder/advisor.ts +43 -7
  23. package/src/builder/code-editor.ts +14 -10
  24. package/src/builder/components.ts +51 -21
  25. package/src/builder/conditions.ts +64 -0
  26. package/src/builder/db-users.ts +124 -118
  27. package/src/builder/designer.ts +9 -1
  28. package/src/builder/home.ts +42 -40
  29. package/src/builder/i18n/de.ts +464 -0
  30. package/src/builder/i18n/en.ts +464 -0
  31. package/src/builder/i18n/es.ts +464 -0
  32. package/src/builder/i18n/fr.ts +464 -0
  33. package/src/builder/i18n/it.ts +464 -0
  34. package/src/builder/i18n/ja.ts +464 -0
  35. package/src/builder/i18n/ko.ts +464 -0
  36. package/src/builder/i18n/nl.ts +464 -0
  37. package/src/builder/i18n/pt.ts +464 -0
  38. package/src/builder/i18n/zh-cn.ts +464 -0
  39. package/src/builder/i18n/zh-tw.ts +464 -0
  40. package/src/builder/instance.ts +27 -23
  41. package/src/builder/ldap.ts +50 -47
  42. package/src/builder/mail-queue.ts +50 -45
  43. package/src/builder/network-access.ts +46 -43
  44. package/src/builder/region-help.ts +3 -1
  45. package/src/builder/region-settings.ts +30 -5
  46. package/src/builder/vector-column.ts +3 -3
  47. package/src/builder/vector-providers.ts +16 -5
  48. package/src/i18n/ar.ts +5 -0
  49. package/src/i18n/cs.ts +5 -0
  50. package/src/i18n/da.ts +5 -0
  51. package/src/i18n/de.ts +5 -0
  52. package/src/i18n/el.ts +5 -0
  53. package/src/i18n/es.ts +5 -0
  54. package/src/i18n/fi.ts +5 -0
  55. package/src/i18n/fr.ts +5 -0
  56. package/src/i18n/he.ts +5 -0
  57. package/src/i18n/it.ts +5 -0
  58. package/src/i18n/ja.ts +5 -0
  59. package/src/i18n/ko.ts +5 -0
  60. package/src/i18n/nb.ts +5 -0
  61. package/src/i18n/pl.ts +5 -0
  62. package/src/i18n/pt.ts +5 -0
  63. package/src/i18n/ru.ts +5 -0
  64. package/src/i18n/sv.ts +5 -0
  65. package/src/i18n/tr.ts +5 -0
  66. package/src/i18n/uk.ts +5 -0
  67. package/src/i18n/zh.ts +5 -0
  68. package/src/i18n.ts +10 -0
  69. package/src/metadata.ts +72 -9
  70. package/src/runtime/authz.ts +9 -5
  71. package/src/runtime/context.ts +4 -0
  72. package/src/runtime/da-styles.ts +37 -0
  73. package/src/runtime/documents.ts +3 -2
  74. package/src/runtime/engine.ts +95 -13
  75. package/src/runtime/items.ts +33 -0
  76. package/src/runtime/logic.ts +79 -8
  77. package/src/runtime/maps.ts +50 -4
  78. package/src/runtime/process-types.ts +139 -0
  79. package/src/runtime/processes.ts +2 -0
  80. package/src/runtime/regions.ts +4 -0
  81. package/src/runtime/render.ts +13 -2
  82. package/src/runtime/routes.ts +2 -2
  83. package/src/runtime/workflow-diagram.ts +45 -0
  84. package/src/vector.ts +3 -3
  85. package/src/workflow.ts +10 -2
@@ -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 function.`);
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.type === 'function') {
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.type === 'function') return (await embedTexts(p, [text], info, client))[0];
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' },
@@ -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
- { name: 'condition', shape: 'boolean' },
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 [{ name: 'condition', shape: 'boolean' }, { name: 'badge_query', shape: 'select' }];
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 row.action === 'set_value' ? [{ name: 'code', shape: 'select' }] : row.action === 'execute_sql' ? [{ name: 'code', shape: 'statements' }] : [];
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 row.type === 'sql' ? [{ name: 'expression', shape: 'boolean' }] : row.type === 'regex' ? [{ name: 'expression', shape: 'regex' }] : [];
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
- /** The SQL of a computation's or branch's condition (migration 029). */
108
- const conditionSql = (row: any): { name: string; shape: SqlShape }[] =>
109
- row.condition_type === 'sql' ? [{ name: 'condition_expr', shape: 'boolean' }] : row.condition_type === 'exists' || row.condition_type === 'not_exists' ? [{ name: 'condition_expr', shape: 'select' }] : [];
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', 'region.condition': 'boolean', 'item.lov': 'select', 'item.readonly_condition': 'boolean',
33
- 'button.condition': 'boolean', 'validation.expression': 'boolean', 'process.code': 'statements', 'authz_scheme.value': 'boolean',
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
- 'computation.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
46
- 'app_computation.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
47
- 'branch.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
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:regex=text,not_null=text',
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
- 'computation.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
59
- 'app_computation.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
60
- 'branch.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
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
- const CONDITION_TYPES = ['', 'sql', 'exists', 'not_exists', 'item_null', 'item_not_null', 'item_equals', 'item_not_equals', 'request_in'];
82
- const CONDITION_HELP = 'sql: a boolean expression · exists / not_exists: a query · item_*: the item\'s name below · request_in: the button(s) pressed, in Value.';
83
- const conditionFields = (group: string): Field[] => [
84
- { name: 'condition_type', label: 'Condition', kind: 'select', options: CONDITION_TYPES, group, help: CONDITION_HELP },
85
- { name: 'condition_expr', label: 'Expression / item', kind: 'code', group, help: 'e.g. :P3_STATUS = \'OPEN\' (sql) · select 1 from sales.orders where id = :P3_ID (exists) · P3_ID (item_*)' },
86
- { name: 'condition_value', label: 'Value', kind: 'text', group, help: 'item_equals / item_not_equals: the value · request_in: e.g. SAVE,CREATE' },
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
- { name: 'condition', common: true, label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'Boolean expression; the region renders only when true.' },
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
- { name: 'condition', label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'e.g. :P3_ID is not null — also re-checked when the button is pressed.' },
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
- : null,
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'], group: 'When',
219
- help: '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.' },
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'], group: 'Action',
224
- help: '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.' },
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: ['sql', 'not_null', 'regex'] },
248
- { name: 'item_name', label: 'Associated item', kind: 'upper' },
249
- { name: 'expression', label: 'Expression', kind: 'code', wide: true, help: 'sql: boolean expression that must be true · regex: POSIX pattern' },
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
- help: '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)' },
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
+ }