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
package/src/metadata.ts CHANGED
@@ -164,7 +164,7 @@ export interface Region {
164
164
  title: string | null;
165
165
  /** (0.31, 073) optional, unique on the page */
166
166
  static_id?: string | null;
167
- type: 'login' | 'report' | 'form' | 'chart' | 'cards' | 'static' | 'grid' | 'calendar' | 'dynamic' | 'facets' | 'tasks' | 'workflows' | 'map' | 'tree' | 'template_component' | 'smart_filters' | 'display_selector' | 'list' | 'data_reporter' | 'ai_assistant' | 'plugin' | 'url' | 'search' | 'form_designer' | 'classic_report' | 'breadcrumb' | 'help_text';
167
+ type: 'login' | 'report' | 'form' | 'chart' | 'cards' | 'static' | 'grid' | 'calendar' | 'dynamic' | 'facets' | 'tasks' | 'workflows' | 'map' | 'tree' | 'template_component' | 'smart_filters' | 'display_selector' | 'list' | 'data_reporter' | 'ai_assistant' | 'plugin' | 'url' | 'search' | 'form_designer' | 'classic_report' | 'breadcrumb' | 'help_text' | 'workflow_diagram';
168
168
  source: string | null;
169
169
  table_name: string | null;
170
170
  pk_column: string | null;
@@ -172,6 +172,9 @@ export interface Region {
172
172
  columns: number;
173
173
  template: 'standard' | 'plain' | 'collapsible' | 'hero' | 'title_bar' | 'buttons_container' | 'inline_dialog' | 'inline_drawer' | 'wizard';
174
174
  condition: string | null;
175
+ /** (147) the condition's type (null: `condition` is SQL) and value */
176
+ condition_type?: ConditionType | null;
177
+ condition_value?: string | null;
175
178
  authz: string | null;
176
179
  config: Record<string, any>;
177
180
  /** a REST data source the region reads (its source, if any, is SQL over the CTE "rest") */
@@ -189,7 +192,9 @@ export type ItemType =
189
192
  | 'richtext' | 'markdown' | 'rating' | 'combobox' | 'daterange' | 'qrcode' | 'plugin'
190
193
  | 'shuttle' | 'select_one' | 'select_many'
191
194
  // (086) display image, geocoded address
192
- | 'image' | 'address';
195
+ | 'image' | 'address'
196
+ // (151) image upload and autocomplete (presets of file and combobox, see presetItem), percent graph, display map
197
+ | 'image_upload' | 'autocomplete' | 'percent_graph' | 'display_map';
193
198
 
194
199
  export interface Item {
195
200
  id: number;
@@ -206,6 +211,10 @@ export interface Item {
206
211
  required: boolean;
207
212
  help: string | null;
208
213
  readonly_condition: string | null;
214
+ /** (147) a server-side condition: when false the item is not rendered and not accepted */
215
+ condition_type?: ConditionType | null;
216
+ condition_expr?: string | null;
217
+ condition_value?: string | null;
209
218
  authz: string | null;
210
219
  config: Record<string, any>;
211
220
  }
@@ -222,6 +231,9 @@ export interface Button {
222
231
  document?: string | null;
223
232
  target_items: Record<string, string>;
224
233
  condition: string | null;
234
+ /** (147) the condition's type (null: `condition` is SQL) and value */
235
+ condition_type?: ConditionType | null;
236
+ condition_value?: string | null;
225
237
  authz: string | null;
226
238
  hot: boolean;
227
239
  confirm: string | null;
@@ -248,9 +260,20 @@ export interface MenuEntry {
248
260
  icon?: string;
249
261
  }
250
262
 
251
- /** A condition of a computation or branch (see migration 029). */
263
+ /** (147) The server-side condition types every component shares (meta.condition_ok). */
264
+ export const CONDITION_TYPES = [
265
+ 'sql', 'exists', 'not_exists', 'function_body',
266
+ 'request_in', 'request_not_in',
267
+ 'item_null', 'item_not_null', 'item_zero', 'item_not_zero', 'item_null_or_zero', 'item_not_null_or_zero',
268
+ 'item_equals', 'item_not_equals', 'item_equals_item', 'item_not_equals_item', 'item_in_list', 'item_not_in_list',
269
+ 'page_in', 'page_not_in', 'page_is_dialog', 'page_is_not_dialog',
270
+ 'user_signed_in', 'user_public', 'language_in', 'language_not_in', 'always', 'never',
271
+ ] as const;
272
+ export type ConditionType = (typeof CONDITION_TYPES)[number];
273
+
274
+ /** A server-side condition (migrations 029, 147). */
252
275
  export interface Condition {
253
- condition_type: 'sql' | 'exists' | 'not_exists' | 'item_null' | 'item_not_null' | 'item_equals' | 'item_not_equals' | 'request_in' | null;
276
+ condition_type: ConditionType | null;
254
277
  condition_expr: string | null;
255
278
  condition_value: string | null;
256
279
  }
@@ -295,9 +318,11 @@ export interface DynamicAction {
295
318
  /** (085) */
296
319
  | 'after_refresh' | 'selection_change' | 'focus' | 'blur' | 'key_release' | 'before_submit' | 'custom'
297
320
  /** (124) a menu button's entry {"action": NAME} was chosen (trigger: entry or menu button names) */
298
- | 'menu_entry';
321
+ | 'menu_entry'
322
+ /** (149) */
323
+ | 'double_click' | 'key_down' | 'key_press' | 'mouse_enter' | 'mouse_leave' | 'select' | 'resize' | 'scroll' | 'page_unload' | 'before_refresh' | 'facets_change';
299
324
  trigger_element: string | null;
300
- condition_type: 'equals' | 'not_equals' | 'in_list' | 'is_null' | 'is_not_null' | null;
325
+ condition_type: 'equals' | 'not_equals' | 'in_list' | 'is_null' | 'is_not_null' | 'greater' | 'greater_equal' | 'less' | 'less_equal' | 'not_in_list' | null;
301
326
  condition_value: string | null;
302
327
  action: 'show' | 'hide' | 'enable' | 'disable' | 'set_value' | 'execute_sql' | 'refresh_region' | 'refresh_item' | 'alert' | 'submit'
303
328
  | 'set_focus' | 'add_class' | 'remove_class' | 'show_success' | 'show_error' | 'clear_errors' | 'ai_generate'
@@ -310,7 +335,9 @@ export interface DynamicAction {
310
335
  /** (085) */
311
336
  | 'confirm' | 'close_dialog' | 'cancel_dialog' | 'clear' | 'open_region' | 'close_region' | 'download' | 'print' | 'current_position' | 'share'
312
337
  /** (124) a modal "please wait" overlay (message: its text) until remove_wait_popup or the next page */
313
- | 'wait_popup' | 'remove_wait_popup';
338
+ | 'wait_popup' | 'remove_wait_popup'
339
+ /** (149) */
340
+ | 'cancel_event' | 'set_style' | 'collapse_tree' | 'expand_tree' | 'show_ai_assistant' | 'trigger_geocoding' | 'open_report_dialog';
314
341
  affected_items: string | null;
315
342
  affected_region_id: number | null;
316
343
  code: string | null;
@@ -321,22 +348,40 @@ export interface DynamicAction {
321
348
  /** (069) a plug-in action's attribute values: {"attributes": {…}} */
322
349
  config?: Record<string, any> | null;
323
350
  authz: string | null;
351
+ /** (147) a server-side condition: when false the dynamic action isn't on the page */
352
+ server_condition_type?: ConditionType | null;
353
+ server_condition_expr?: string | null;
354
+ server_condition_value?: string | null;
324
355
  }
325
356
 
326
357
  export interface Validation {
327
358
  id: number;
328
359
  name: string;
329
360
  item_name: string | null;
330
- type: 'not_null' | 'sql' | 'regex';
361
+ /** (148) APEX's validation types (migration 148) */
362
+ type: 'sql' | 'exists' | 'not_exists' | 'function_body' | 'function_error'
363
+ | 'not_null' | 'not_null_or_zero' | 'not_zero' | 'no_spaces' | 'alphanumeric' | 'numeric' | 'valid_date' | 'valid_timestamp'
364
+ | 'equals' | 'not_equals' | 'in_list' | 'not_in_list' | 'chars_only' | 'chars_any' | 'chars_none' | 'regex';
331
365
  expression: string | null;
332
366
  message: string;
333
367
  when_button: string | null;
368
+ /** (148) an authorization scheme; Always execute (also for DELETE); where the error shows */
369
+ authz?: string | null;
370
+ always_execute?: boolean;
371
+ display_location?: 'inline_and_notification' | 'inline' | 'notification' | null;
372
+ /** (147) a server-side condition: when false the validation is skipped */
373
+ condition_type?: ConditionType | null;
374
+ condition_expr?: string | null;
375
+ condition_value?: string | null;
334
376
  }
335
377
 
336
378
  export interface Process {
337
379
  id: number;
338
380
  name: string;
339
- type: 'form_dml' | 'grid_dml' | 'sql' | 'data_load' | 'invoke_api' | 'download' | 'chain' | 'workflow' | 'ai_generate' | 'plugin' | 'send_push' | 'send_email';
381
+ type: 'form_dml' | 'grid_dml' | 'sql' | 'data_load' | 'invoke_api' | 'download' | 'chain' | 'workflow' | 'ai_generate' | 'plugin' | 'send_push' | 'send_email'
382
+ /** (150) */
383
+ | 'close_dialog' | 'clear_session_state' | 'reset_pagination' | 'user_preferences' | 'server_geocoding' | 'print_report'
384
+ | 'human_task_create' | 'human_task_manage' | 'form_init';
340
385
  region_id: number | null;
341
386
  code: string | null;
342
387
  config: Record<string, unknown> | null;
@@ -420,7 +465,25 @@ export async function loadApp(alias: string) {
420
465
  );
421
466
  }
422
467
 
468
+ /**
469
+ * (151, CD-5) APEX's item types that are presets of pgkiln's: an Image Upload is a file item for pictures (image/*,
470
+ * cropping, a maximum size before upload), a Text Field with autocomplete a one-value combobox. The runtime sees the
471
+ * preset (every file and list check applies); the builder and exports keep the type as chosen.
472
+ */
473
+ export function presetItem(i: Item): Item {
474
+ if (i.type === 'image_upload')
475
+ return { ...i, type: 'file', config: { accept: 'image/*', crop: 'free', max_px: 1600, ...(i.config ?? {}), preset: 'image_upload' } };
476
+ if (i.type === 'autocomplete') return { ...i, type: 'combobox', config: { ...(i.config ?? {}), multiple: false, preset: 'autocomplete' } };
477
+ return i;
478
+ }
479
+
423
480
  export async function loadPage(appId: number, pageNo: number) {
481
+ const page = await loadPageRows(appId, pageNo);
482
+ if (page) page.items = page.items.map(presetItem);
483
+ return page;
484
+ }
485
+
486
+ function loadPageRows(appId: number, pageNo: number) {
424
487
  return runtime.one<Page>(
425
488
  `select p.id, p.app_id, p.page_no, p.name, p.title, p.requires_auth, p.parent_page, p.mode,
426
489
  p.protection, p.authz, p.warn_unsaved, p.page_template, p.help,
@@ -1,10 +1,11 @@
1
1
  import { secondStepWithin } from '../account-mfa.ts';
2
2
  import { applyBinds } from '../binds.ts';
3
- import type { AuthzScheme } from '../metadata.ts';
3
+ import type { AuthzScheme, Condition } from '../metadata.ts';
4
4
  import { savepoint } from '../db.ts';
5
5
  import { bindValues, publicError, stripSemicolon, type PageContext, type Visibility } from './context.ts';
6
6
  import { saveButton, saveRequest } from './grid.ts';
7
7
  import { selectionOf } from './report.ts';
8
+ import { conditionHolds, conditionOf, serverConditionOf } from './logic.ts';
8
9
 
9
10
  export class Forbidden extends Error {}
10
11
  /** (sprint 131) The page needs a second step more recent than the session's: ask for one, then come back. */
@@ -90,14 +91,15 @@ export async function pageAllowed(ctx: PageContext, pageNo: number) {
90
91
  export async function computeVisibility(ctx: PageContext): Promise<Visibility> {
91
92
  const vis: Visibility = { regions: new Set(), items: new Set(), editable: new Set(), buttons: new Map(), dynamicActions: new Set() };
92
93
  for (const r of ctx.page.regions)
93
- if ((await isAuthorized(ctx, r.authz)) && (await sqlTrue(ctx, r.condition, `condition of region "${r.title ?? r.id}"`)))
94
+ if ((await isAuthorized(ctx, r.authz)) && (await conditionHolds(ctx, conditionOf(r), `condition of region "${r.title ?? r.id}"`)))
94
95
  vis.regions.add(r.id);
95
96
 
96
97
  for (const i of ctx.page.items) {
97
98
  if (i.region_id !== null && !vis.regions.has(i.region_id)) continue;
98
99
  if (!(await isAuthorized(ctx, i.authz))) continue;
100
+ if (i.condition_type && !(await conditionHolds(ctx, i as Condition, `condition of item ${i.name}`))) continue;
99
101
  vis.items.add(i.name);
100
- if (i.type === 'hidden' || i.type === 'display' || i.type === 'qrcode' || i.type === 'image') continue;
102
+ if (i.type === 'hidden' || i.type === 'display' || i.type === 'qrcode' || i.type === 'image' || i.type === 'percent_graph' || i.type === 'display_map') continue;
101
103
  // fail closed: a broken read-only condition makes the item read-only
102
104
  if (i.readonly_condition && (await sqlTrue(ctx, i.readonly_condition, `read-only condition of ${i.name}`, true))) continue;
103
105
  vis.editable.add(i.name);
@@ -112,7 +114,7 @@ export async function computeVisibility(ctx: PageContext): Promise<Visibility> {
112
114
  for (const b of ctx.page.buttons) {
113
115
  if (b.region_id !== null && !vis.regions.has(b.region_id)) continue;
114
116
  if (!(await isAuthorized(ctx, b.authz))) continue;
115
- if (!(await sqlTrue(ctx, b.condition, `condition of button ${b.name}`))) continue;
117
+ if (!(await conditionHolds(ctx, conditionOf(b), `condition of button ${b.name}`))) continue;
116
118
  vis.buttons.set(b.name, b);
117
119
  }
118
120
 
@@ -132,7 +134,9 @@ export async function computeVisibility(ctx: PageContext): Promise<Visibility> {
132
134
  if (r.type === 'grid' && vis.regions.has(r.id) && ctx.page.processes.some((p) => p.type === 'grid_dml' && p.region_id === r.id))
133
135
  vis.buttons.set(saveRequest(r), saveButton(r));
134
136
 
135
- for (const d of ctx.page.dynamic_actions) if (await isAuthorized(ctx, d.authz)) vis.dynamicActions.add(d.id);
137
+ for (const d of ctx.page.dynamic_actions)
138
+ if ((await isAuthorized(ctx, d.authz)) && (await conditionHolds(ctx, serverConditionOf(d), `server-side condition of dynamic action "${d.name}"`)))
139
+ vis.dynamicActions.add(d.id);
136
140
  ctx.vis = vis;
137
141
  return vis;
138
142
  }
@@ -74,6 +74,10 @@ export interface PageContext {
74
74
  wholePage?: boolean;
75
75
  /** a download process's file, sent instead of the page (processes.ts) */
76
76
  download?: Download;
77
+ /** (157) a close_dialog process ran: the items handed to the page below (closes the modal page after the submit) */
78
+ closeDialog?: Record<string, string>;
79
+ /** (157) a reset_pagination process ran: reports and grids start on their first page after the submit */
80
+ resetPagination?: boolean;
77
81
  /** running a background chain (process-jobs.ts): no request, no browser */
78
82
  background?: boolean;
79
83
  /** debug messages of this request, when the app's debug level is on (src/debug.ts) */
@@ -0,0 +1,37 @@
1
+ // (sprint 156, CD-3) The Set Style dynamic action: CSS properties from a fixed list, with plain values. The builder
2
+ // refuses others (styleProblem); the page only ever gets the ones that pass (safeStyles), set through the CSSOM
3
+ // (element.style), which the Content-Security-Policy allows without 'unsafe-inline'.
4
+
5
+ export const STYLE_PROPERTIES = new Set([
6
+ 'color', 'background-color', 'border-color', 'outline-color', 'font-weight', 'font-style', 'font-size', 'text-decoration', 'text-transform',
7
+ 'text-align', 'opacity', 'visibility', 'border-width', 'border-style', 'padding', 'margin', 'width', 'max-width', 'min-height',
8
+ ]);
9
+ /** colours, lengths, keywords, numbers: no url(), quotes, semicolons, braces, backslashes or expressions */
10
+ const VALUE = /^[#a-zA-Z0-9 .,%()+-]{1,60}$/;
11
+ const BAD = /url\s*\(|expression|var\s*\(|attr\s*\(|image|\/\*/i;
12
+
13
+ export function styleProblem(styles: unknown): string | null {
14
+ if (styles === undefined || styles === null) return 'Set style: {"styles": {"color": "#b42318", "font-weight": "bold"}} in the attributes.';
15
+ if (typeof styles !== 'object' || Array.isArray(styles)) return 'Set style: "styles" is an object of CSS properties and values.';
16
+ const entries = Object.entries(styles as Record<string, unknown>);
17
+ if (!entries.length || entries.length > 10) return 'Set style: 1 to 10 properties.';
18
+ for (const [k, v] of entries) {
19
+ if (!STYLE_PROPERTIES.has(k)) return `Set style: ${k} isn't one of ${[...STYLE_PROPERTIES].join(', ')}.`;
20
+ if (typeof v !== 'string' || !VALUE.test(v) || BAD.test(v)) return `Set style: the value of ${k} is a colour, length or keyword (letters, digits, # . , % ( ) + -), without url() or var().`;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /** The properties of a Set Style action that pass, as {property: value}. */
26
+ export function safeStyles(config: Record<string, any> | null | undefined): Record<string, string> {
27
+ const s = config?.styles;
28
+ if (!s || typeof s !== 'object' || Array.isArray(s)) return {};
29
+ return Object.fromEntries(Object.entries(s).filter(([k, v]) => STYLE_PROPERTIES.has(k) && typeof v === 'string' && VALUE.test(v) && !BAD.test(v)).slice(0, 10)) as Record<string, string>;
30
+ }
31
+
32
+ /** The client-side comparison of greater / less conditions: numbers as numbers, ISO dates as dates, else text. */
33
+ export function compareValues(a: string, b: string): number {
34
+ const num = /^\s*-?\d+(\.\d+)?\s*$/;
35
+ if (num.test(a) && num.test(b)) return Number(a) - Number(b);
36
+ return a < b ? -1 : a > b ? 1 : 0;
37
+ }
@@ -23,7 +23,8 @@ interface ReportQuery {
23
23
  authz: string | null;
24
24
  }
25
25
 
26
- export async function renderDocument(ctx: PageContext, name: string) {
26
+ /** (157) `offered`: a print_report process of the page asks for it (the page offers it). */
27
+ export async function renderDocument(ctx: PageContext, name: string, offered = false) {
27
28
  const c = ctx.client!;
28
29
  // metadata through the runtime connection: the page's transaction already runs as the app's role
29
30
  const tpl = await runtime.one<DocumentTemplate>('select * from meta.document_template where app_id = $1 and name = upper($2)', [ctx.app.id, name.slice(0, 100)]);
@@ -33,7 +34,7 @@ export async function renderDocument(ctx: PageContext, name: string) {
33
34
  if (!found || !(await isAuthorized(ctx, found.authz))) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
34
35
  // without signing in (a public page of an app that has sign-in) only the documents the page itself offers
35
36
  // with a visible document button: ?doc= on any public page would otherwise hand out every template
36
- if (ctx.app.authentication !== 'none' && !ctx.session.username) {
37
+ if (ctx.app.authentication !== 'none' && !ctx.session.username && !offered) {
37
38
  const offered = [...(ctx.vis?.buttons.values() ?? [])].some((b) => b.action === 'document' && (b.document ?? '').toUpperCase() === found.name);
38
39
  if (!offered) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
39
40
  }
@@ -1,7 +1,7 @@
1
1
  import pg from 'pg';
2
2
  import { applyBinds, literal } from '../binds.ts';
3
3
  import { runtime, savepoint, type Client } from '../db.ts';
4
- import type { Item, Process, Region } from '../metadata.ts';
4
+ import type { Condition, Item, Process, Region, Validation } from '../metadata.ts';
5
5
  import { isAuthorized } from './authz.ts';
6
6
  import { gridDml } from './grid.ts';
7
7
  import { dropObjects, dropObjectsSince, formRegion, isMultiple, isTempId, removals, REMOVE, saveFileLists, storedFiles, storeOf, storeTempFile, tempIds } from './files.ts';
@@ -14,8 +14,10 @@ import { runPendingAi } from '../ai/requests.ts';
14
14
  import { runAiProcess } from './ai.ts';
15
15
  import { itemMask, LOV_CHECKED, lovContains, MULTI_VALUE, ratingMax, splitValues } from './items.ts';
16
16
  import { formatNumber, isPlainNumber } from '../numformat.ts';
17
- import { conditionHolds } from './logic.ts';
17
+ import { parseDateMask } from './format.ts';
18
+ import { conditionHolds, functionBody } from './logic.ts';
18
19
  import { runPluginProcess } from './plugins.ts';
20
+ import { runDeclarative } from './process-types.ts';
19
21
  import { BACKGROUND_TYPES, downloadFile, sendEmailProcess, sendPushProcess, workflowProcess, type ChainConfig } from './processes.ts';
20
22
  import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, toState, type Errors, type PageContext } from './context.ts';
21
23
 
@@ -138,6 +140,76 @@ async function storedFileExists(ctx: PageContext, r: Region, column: string) {
138
140
  return res.rows[0]?.ok === true;
139
141
  }
140
142
 
143
+ const VALID_TIMESTAMP = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})(:(\d{2})(\.\d{1,6})?)?$/;
144
+ const isZero = (v: string) => isPlainNumber(v) && Number(v) === 0;
145
+
146
+ /**
147
+ * (148, CD-1) Does a validation pass? true, false (its message), or an error text (function_error). Item types
148
+ * test the item's posted value; format checks pass an empty one (as in APEX). SQL types run as the app's role.
149
+ */
150
+ async function validationPasses(ctx: PageContext, v: Validation, value: string | null): Promise<boolean | string> {
151
+ const c = ctx.client!;
152
+ const expr = v.expression ?? '';
153
+ const sql = async (text: string) => (await savepoint(c, () => c.query(text))).rows[0]?.ok === true;
154
+ switch (v.type) {
155
+ case 'sql':
156
+ return sql(`select (${stripSemicolon(applyBinds(v.expression ?? 'true', bindValues(ctx)))})::boolean as ok`);
157
+ case 'exists':
158
+ case 'not_exists': {
159
+ const found = await sql(`select exists (${stripSemicolon(applyBinds(expr, bindValues(ctx)))}) as ok`);
160
+ return v.type === 'exists' ? found : !found;
161
+ }
162
+ case 'function_body': {
163
+ const r = await savepoint(c, () => functionBody(ctx, expr));
164
+ return r === 'true' || r === 't';
165
+ }
166
+ case 'function_error': {
167
+ const r = await savepoint(c, () => functionBody(ctx, expr));
168
+ return r === null || r.trim() === '' ? true : r;
169
+ }
170
+ case 'not_null':
171
+ return value !== null;
172
+ case 'not_null_or_zero':
173
+ return value !== null && !isZero(value);
174
+ case 'not_zero':
175
+ return value === null || !isZero(value);
176
+ case 'equals':
177
+ return value === (v.expression ?? null) || (value === null && (v.expression ?? '') === '');
178
+ case 'not_equals':
179
+ return !(value === (v.expression ?? null) || (value === null && (v.expression ?? '') === ''));
180
+ case 'in_list':
181
+ return value !== null && expr.split(':').includes(value);
182
+ case 'not_in_list':
183
+ return value === null || !expr.split(':').includes(value);
184
+ }
185
+ // format checks: an empty item passes
186
+ if (value === null) return true;
187
+ switch (v.type) {
188
+ case 'no_spaces':
189
+ return !/\s/.test(value);
190
+ case 'alphanumeric':
191
+ return /^[\p{L}\p{N}]+$/u.test(value);
192
+ case 'numeric':
193
+ return isPlainNumber(value);
194
+ case 'valid_date':
195
+ return parseDateMask(value, ctx.app.date_format ?? 'YYYY-MM-DD', ctx.locale.lang) !== null;
196
+ case 'valid_timestamp': {
197
+ const m = VALID_TIMESTAMP.exec(value.trim());
198
+ return !!m && parseDateMask(m[1], 'YYYY-MM-DD', ctx.locale.lang) !== null && Number(m[2]) < 24 && Number(m[3]) < 60 && Number(m[5] ?? 0) < 60;
199
+ }
200
+ case 'chars_only':
201
+ return [...value].every((ch) => expr.includes(ch));
202
+ case 'chars_any':
203
+ return [...value].some((ch) => expr.includes(ch));
204
+ case 'chars_none':
205
+ return ![...value].some((ch) => expr.includes(ch));
206
+ case 'regex':
207
+ return (await savepoint(c, () => c.query(`select ${literal(value)} ~ ${literal(expr)} as ok`))).rows[0].ok === true;
208
+ default:
209
+ return false; // an unknown type fails closed
210
+ }
211
+ }
212
+
141
213
  /**
142
214
  * The page's checks before processing. A DELETE request skips the item checks (required, list,
143
215
  * number and format: the values don't matter for a delete) and the validations of every button,
@@ -212,21 +284,26 @@ export async function validate(ctx: PageContext) {
212
284
  }
213
285
 
214
286
  for (const v of ctx.page.validations) {
215
- if (v.when_button ? v.when_button !== ctx.request : deleting) continue;
287
+ // (148) Always execute: also for a DELETE request
288
+ if (v.when_button ? v.when_button !== ctx.request : deleting && !v.always_execute) continue;
216
289
  if (v.item_name && (!vis.items.has(v.item_name) || errors.items[v.item_name])) continue;
290
+ if (v.authz && !(await isAuthorized(ctx, v.authz))) continue;
291
+ if (v.condition_type && !(await conditionHolds(ctx, v as Condition, `condition of validation ${v.name}`))) continue;
217
292
  const value = v.item_name ? (state[v.item_name] ?? null) : null;
218
- try {
219
- let ok = true;
220
- if (v.type === 'not_null') ok = value !== null;
221
- else if (v.type === 'regex') {
222
- if (value !== null) ok = (await savepoint(c, () => c.query(`select ${literal(value)} ~ ${literal(v.expression ?? '')} as ok`))).rows[0].ok;
223
- } else {
224
- const sql = `select (${stripSemicolon(applyBinds(v.expression ?? 'true', bindValues(ctx)))})::boolean as ok`;
225
- ok = (await savepoint(c, () => c.query(sql))).rows[0].ok === true;
293
+ // (148) where the error shows: inline (as before), inline and in the notification, or the notification only
294
+ const report = (msg: string) => {
295
+ const inline = v.item_name && vis.items.has(v.item_name) && !errors.items[v.item_name];
296
+ if (v.display_location === 'notification' || !inline) errors.page.push(msg);
297
+ else {
298
+ errors.items[v.item_name!] = msg;
299
+ if (v.display_location === 'inline_and_notification') errors.page.push(msg);
226
300
  }
227
- if (!ok) fail(v.item_name, v.message);
301
+ };
302
+ try {
303
+ const outcome = await validationPasses(ctx, v, value);
304
+ if (outcome !== true) report(typeof outcome === 'string' ? outcome : v.message);
228
305
  } catch (e) {
229
- fail(v.item_name, `${v.message} (${await publicError(ctx, e, `validation ${v.name}`)})`);
306
+ report(`${v.message} (${await publicError(ctx, e, `validation ${v.name}`)})`);
230
307
  }
231
308
  }
232
309
 
@@ -547,6 +624,11 @@ async function runOneStep(ctx: PageContext, p: Process, names: Set<string>, dept
547
624
  case 'send_email': return await sendEmailProcess(ctx, p);
548
625
  case 'ai_generate': return (await runAiProcess(ctx, p, names)).message;
549
626
  case 'plugin': return await runPluginProcess(ctx, p.config, p.name);
627
+ // (157, CD-4) close_dialog, clear_session_state, reset_pagination, user_preferences, server_geocoding,
628
+ // print_report, human_task_create, human_task_manage, form_init
629
+ case 'close_dialog': case 'clear_session_state': case 'reset_pagination': case 'user_preferences': case 'server_geocoding':
630
+ case 'print_report': case 'human_task_create': case 'human_task_manage': case 'form_init':
631
+ return await runDeclarative(ctx, p, names);
550
632
  case 'download':
551
633
  // sent instead of the page (routes.ts); the first download of a request wins
552
634
  ctx.download ??= await downloadFile(ctx, p);
@@ -1,3 +1,4 @@
1
+ import { tileAttribution, tileUrl } from '../maptiles.ts';
1
2
  import { applyBinds } from '../binds.ts';
2
3
  import { icon } from '../icons.ts';
3
4
  import { pluginAttributes, pluginData, pluginOf } from './plugins.ts';
@@ -235,6 +236,30 @@ function maskedValue(ctx: PageContext, item: Item, value: string) {
235
236
  const LOCATION = /^\s*(-?\d{1,2}(?:\.\d+)?)\s*,\s*(-?\d{1,3}(?:\.\d+)?)\s*$/;
236
237
 
237
238
  /** Render one item (field wrapper included). Hidden items are never rendered. */
239
+ /**
240
+ * (151) A Display Map item: one map tile around "lat,lng" (the value, or config.coordinates_item's) with a pin, from the
241
+ * map regions' tile server; its position is a class of the page's stylesheet (the CSP allows no inline styles).
242
+ */
243
+ function displayMap(ctx: PageContext, item: Item, value: string): Raw {
244
+ const source = typeof item.config?.coordinates_item === 'string' ? (ctx.session.state[item.config.coordinates_item.toUpperCase()] ?? '') : value;
245
+ const at = LOCATION.exec(source);
246
+ const lat = at ? Number(at[1]) : NaN;
247
+ const lng = at ? Number(at[2]) : NaN;
248
+ if (!at || Math.abs(lat) > 85 || Math.abs(lng) > 180) return html`<div class="display-value" id="${item.name}"> </div>`;
249
+ const z = Number.isInteger(item.config?.zoom) && item.config.zoom >= 1 && item.config.zoom <= 19 ? item.config.zoom : 15;
250
+ const n = 2 ** z;
251
+ const fx = ((lng + 180) / 360) * n;
252
+ const r = (lat * Math.PI) / 180;
253
+ const fy = ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * n;
254
+ const [x, y] = [Math.floor(fx), Math.floor(fy)];
255
+ const src = tileUrl().replace('{s}', 'a').replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y));
256
+ const pin = ctx.css.cls(`left:${((fx - x) * 100).toFixed(2)}%;top:${((fy - y) * 100).toFixed(2)}%`);
257
+ return html`<figure class="display-map" id="${item.name}">
258
+ <div class="display-map-tile"><img src="${src}" alt="${ctx.locale.t('item.map_of', { position: source.trim() })}" width="256" height="256" loading="lazy"><span class="display-map-pin ${pin}" aria-hidden="true"></span></div>
259
+ <figcaption><small>${tileAttribution()}</small> · <a href="https://www.openstreetmap.org/?mlat=${String(lat)}&amp;mlon=${String(lng)}#map=${String(z)}/${String(lat)}/${String(lng)}" target="_blank" rel="noopener noreferrer">${ctx.locale.t('item.show_map')}</a></figcaption>
260
+ </figure>`;
261
+ }
262
+
238
263
  export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = false): Promise<Raw | ''> {
239
264
  if (item.type === 'hidden' || !ctx.vis!.items.has(item.name)) return '';
240
265
  const value = ctx.session.state[item.name] ?? item.default_value ?? '';
@@ -270,6 +295,14 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
270
295
  control = qrControl(ctx, item, value);
271
296
  } else if (item.type === 'image') {
272
297
  control = imageControl(ctx, item, value);
298
+ } else if (item.type === 'percent_graph') {
299
+ // (151) a 0–100 value as a bar; the number is its accessible text
300
+ const n = /^\s*-?\d+(\.\d+)?\s*$/.test(value) ? Math.max(0, Math.min(100, Number(value))) : null;
301
+ control = n === null
302
+ ? html`<div class="display-value" id="${id}"> </div>`
303
+ : html`<div class="percent-graph" id="${id}"><progress max="100" value="${String(n)}" aria-label="${label}">${String(n)}%</progress><span class="percent-graph-value">${String(Math.round(n))}%</span></div>`;
304
+ } else if (item.type === 'display_map') {
305
+ control = displayMap(ctx, item, value);
273
306
  } else if (!editable && (item.type === 'richtext' || item.type === 'markdown')) {
274
307
  // rebuilt from the allow-list every time: the value may come from the table, not from this form
275
308
  control = html`<div class="display-value rich-text" id="${id}">${raw((item.type === 'markdown' ? markdownHtml(value) : sanitizeHtml(value)) || ' ')}</div>`;
@@ -14,17 +14,42 @@ import { logActivity } from '../session.ts';
14
14
  // own savepoint.
15
15
 
16
16
  const list = (s: string | null | undefined) => (s ?? '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean);
17
+ const isZero = (v: string) => v.trim() !== '' && Number(v) === 0;
17
18
 
18
- /** Does a computation's or branch's condition hold? Broken SQL counts as false (and shows a message). */
19
+ /** The condition types that run SQL (as the application's role); all others are decided without a query. */
20
+ export const SQL_CONDITIONS = new Set(['sql', 'exists', 'not_exists', 'function_body']);
21
+
22
+ /** (147) A region's or button's condition: `condition` is the expression, SQL when no type is set. */
23
+ export const conditionOf = (x: { condition?: string | null; condition_type?: string | null; condition_value?: string | null }): Condition => ({
24
+ condition_type: (x.condition_type || (x.condition?.trim() ? 'sql' : null)) as Condition['condition_type'],
25
+ condition_expr: x.condition ?? null,
26
+ condition_value: x.condition_value ?? null,
27
+ });
28
+
29
+ /** (147) A dynamic action's server-side condition (its condition_type is the client-side one). */
30
+ export const serverConditionOf = (d: { server_condition_type?: string | null; server_condition_expr?: string | null; server_condition_value?: string | null }): Condition => ({
31
+ condition_type: (d.server_condition_type || null) as Condition['condition_type'],
32
+ condition_expr: d.server_condition_expr ?? null,
33
+ condition_value: d.server_condition_value ?? null,
34
+ });
35
+
36
+ /**
37
+ * Does a server-side condition hold (migrations 029, 147)? The same catalogue for every component. Broken SQL
38
+ * counts as false (and shows a message); an unknown type is false.
39
+ */
19
40
  export async function conditionHolds(ctx: PageContext, cond: Condition, where: string): Promise<boolean> {
20
- const value = (name: string | null) => {
21
- const v = name ? bindValues(ctx)[name.toUpperCase()] : null;
41
+ const value = (name: string | null | undefined) => {
42
+ const v = name ? bindValues(ctx)[name.trim().toUpperCase()] : null;
22
43
  return v === undefined || v === null ? '' : v;
23
44
  };
45
+ const item = value(cond.condition_expr);
24
46
  switch (cond.condition_type) {
25
47
  case null:
26
48
  case undefined:
49
+ case 'always':
27
50
  return true;
51
+ case 'never':
52
+ return false;
28
53
  case 'sql':
29
54
  return sqlTrue(ctx, cond.condition_expr, where);
30
55
  case 'exists':
@@ -39,16 +64,62 @@ export async function conditionHolds(ctx: PageContext, cond: Condition, where: s
39
64
  return false; // fail closed either way
40
65
  }
41
66
  }
67
+ case 'function_body':
68
+ try {
69
+ const v = await savepoint(ctx.client!, () => functionBody(ctx, cond.condition_expr ?? ''));
70
+ return v === 'true' || v === 't';
71
+ } catch (e) {
72
+ ctx.errors.page.push(await publicError(ctx, e, where));
73
+ return false;
74
+ }
42
75
  case 'item_null':
43
- return value(cond.condition_expr) === '';
76
+ return item === '';
44
77
  case 'item_not_null':
45
- return value(cond.condition_expr) !== '';
78
+ return item !== '';
79
+ case 'item_zero':
80
+ return isZero(item);
81
+ case 'item_not_zero':
82
+ return !isZero(item);
83
+ case 'item_null_or_zero':
84
+ return item === '' || isZero(item);
85
+ case 'item_not_null_or_zero':
86
+ return item !== '' && !isZero(item);
46
87
  case 'item_equals':
47
- return value(cond.condition_expr) === (cond.condition_value ?? '');
88
+ return item === (cond.condition_value ?? '');
48
89
  case 'item_not_equals':
49
- return value(cond.condition_expr) !== (cond.condition_value ?? '');
90
+ return item !== (cond.condition_value ?? '');
91
+ case 'item_equals_item':
92
+ return item === value(cond.condition_value);
93
+ case 'item_not_equals_item':
94
+ return item !== value(cond.condition_value);
95
+ case 'item_in_list':
96
+ case 'item_not_in_list': {
97
+ const found = (cond.condition_value ?? '').split(':').includes(item);
98
+ return cond.condition_type === 'item_in_list' ? found : !found;
99
+ }
50
100
  case 'request_in':
51
101
  return list(cond.condition_value).includes(ctx.request);
102
+ case 'request_not_in':
103
+ return !list(cond.condition_value).includes(ctx.request);
104
+ case 'page_in':
105
+ case 'page_not_in': {
106
+ const found = list(cond.condition_value).includes(String(ctx.page.page_no));
107
+ return cond.condition_type === 'page_in' ? found : !found;
108
+ }
109
+ case 'page_is_dialog':
110
+ return ctx.page.mode === 'modal';
111
+ case 'page_is_not_dialog':
112
+ return ctx.page.mode !== 'modal';
113
+ case 'user_signed_in':
114
+ return ctx.user !== 'nobody';
115
+ case 'user_public':
116
+ return ctx.user === 'nobody';
117
+ case 'language_in':
118
+ case 'language_not_in': {
119
+ const lang = ctx.locale.lang.toUpperCase();
120
+ const found = list(cond.condition_value).some((l) => l === lang || lang.startsWith(`${l}-`));
121
+ return cond.condition_type === 'language_in' ? found : !found;
122
+ }
52
123
  default:
53
124
  return false; // unknown type: fail closed
54
125
  }
@@ -61,7 +132,7 @@ export async function conditionHolds(ctx: PageContext, cond: Condition, where: s
61
132
  * savepoint, as the application's role (binds are substituted first, as
62
133
  * escaped literals). It never outlives the transaction's savepoint.
63
134
  */
64
- async function functionBody(ctx: PageContext, body: string): Promise<string | null> {
135
+ export async function functionBody(ctx: PageContext, body: string): Promise<string | null> {
65
136
  const c = ctx.client!;
66
137
  const code = applyBinds(body, bindValues(ctx)).trim();
67
138
  const block = /^(declare|begin)\b/i.test(code) ? code : `begin\n${code}\nend`;