pgkiln 0.81.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 (73) hide show
  1. package/db/migrations/147_condition_types.sql +80 -0
  2. package/db/migrations/148_validation_types.sql +44 -0
  3. package/db/migrations/149_dynamic_action_events.sql +45 -0
  4. package/db/migrations/150_process_types.sql +23 -0
  5. package/db/migrations/151_item_types.sql +16 -0
  6. package/db/migrations/152_workflow_diagram_region.sql +10 -0
  7. package/docs/apex-feature-parity.md +10 -8
  8. package/docs/guide/02-concepts.md +3 -3
  9. package/docs/guide/04-pages-and-regions.md +17 -3
  10. package/docs/guide/05-items.md +5 -0
  11. package/docs/guide/06-processing.md +104 -17
  12. package/docs/guide/07-dynamic-actions.md +16 -1
  13. package/docs/guide/11-from-apex.md +7 -0
  14. package/examples/hr/hr_79_condition_types.sql +17 -0
  15. package/package.json +1 -1
  16. package/public/app.css +12 -0
  17. package/public/app.js +157 -9
  18. package/src/appfiles.ts +4 -4
  19. package/src/builder/advisor.ts +43 -7
  20. package/src/builder/code-editor.ts +14 -10
  21. package/src/builder/components.ts +51 -21
  22. package/src/builder/conditions.ts +64 -0
  23. package/src/builder/designer.ts +9 -1
  24. package/src/builder/i18n/de.ts +35 -0
  25. package/src/builder/i18n/en.ts +35 -0
  26. package/src/builder/i18n/es.ts +35 -0
  27. package/src/builder/i18n/fr.ts +35 -0
  28. package/src/builder/i18n/it.ts +35 -0
  29. package/src/builder/i18n/ja.ts +35 -0
  30. package/src/builder/i18n/ko.ts +35 -0
  31. package/src/builder/i18n/nl.ts +35 -0
  32. package/src/builder/i18n/pt.ts +35 -0
  33. package/src/builder/i18n/zh-cn.ts +35 -0
  34. package/src/builder/i18n/zh-tw.ts +35 -0
  35. package/src/builder/region-help.ts +3 -1
  36. package/src/builder/region-settings.ts +30 -5
  37. package/src/i18n/ar.ts +5 -0
  38. package/src/i18n/cs.ts +5 -0
  39. package/src/i18n/da.ts +5 -0
  40. package/src/i18n/de.ts +5 -0
  41. package/src/i18n/el.ts +5 -0
  42. package/src/i18n/es.ts +5 -0
  43. package/src/i18n/fi.ts +5 -0
  44. package/src/i18n/fr.ts +5 -0
  45. package/src/i18n/he.ts +5 -0
  46. package/src/i18n/it.ts +5 -0
  47. package/src/i18n/ja.ts +5 -0
  48. package/src/i18n/ko.ts +5 -0
  49. package/src/i18n/nb.ts +5 -0
  50. package/src/i18n/pl.ts +5 -0
  51. package/src/i18n/pt.ts +5 -0
  52. package/src/i18n/ru.ts +5 -0
  53. package/src/i18n/sv.ts +5 -0
  54. package/src/i18n/tr.ts +5 -0
  55. package/src/i18n/uk.ts +5 -0
  56. package/src/i18n/zh.ts +5 -0
  57. package/src/i18n.ts +10 -0
  58. package/src/metadata.ts +72 -9
  59. package/src/runtime/authz.ts +9 -5
  60. package/src/runtime/context.ts +4 -0
  61. package/src/runtime/da-styles.ts +37 -0
  62. package/src/runtime/documents.ts +3 -2
  63. package/src/runtime/engine.ts +95 -13
  64. package/src/runtime/items.ts +33 -0
  65. package/src/runtime/logic.ts +79 -8
  66. package/src/runtime/maps.ts +50 -4
  67. package/src/runtime/process-types.ts +139 -0
  68. package/src/runtime/processes.ts +2 -0
  69. package/src/runtime/regions.ts +4 -0
  70. package/src/runtime/render.ts +13 -2
  71. package/src/runtime/routes.ts +2 -2
  72. package/src/runtime/workflow-diagram.ts +45 -0
  73. package/src/workflow.ts +10 -2
package/src/i18n/pl.ts CHANGED
@@ -676,6 +676,11 @@ export const pl: Record<MessageKey, string> = {
676
676
  'process.queued': "Uruchomiono w tle (zadanie {id}).",
677
677
  'process.background_type': "Proces „{name}” ({type}) nie może działać w tle.",
678
678
  'workflow.started': "Przepływ pracy {id} uruchomiony.",
679
+ 'process.no_geocoder': 'Ta instalacja nie ma skonfigurowanego geokodera.',
680
+ 'process.address_not_found': 'Nie znaleziono miejsca dla „{address}”.',
681
+ 'process.task_created': 'Utworzono zadanie {id}.',
682
+ 'item.map_of': 'Mapa: {position}',
683
+ 'process.no_task': 'Brak zadania do obsłużenia.',
679
684
  'workflow.terminated': "Przepływ pracy {id} przerwany.",
680
685
  'workflow.retried': "Przepływ pracy {id} działa ponownie.",
681
686
  'workflow.no_instance': "Nie wybrano przepływu pracy.",
package/src/i18n/pt.ts CHANGED
@@ -676,6 +676,11 @@ export const pt: Record<MessageKey, string> = {
676
676
  'process.queued': "Iniciado em segundo plano (tarefa {id}).",
677
677
  'process.background_type': "O processo “{name}” ({type}) não pode ser executado em segundo plano.",
678
678
  'workflow.started': "Fluxo de trabalho {id} iniciado.",
679
+ 'process.no_geocoder': 'Esta instalação não tem um geocodificador configurado.',
680
+ 'process.address_not_found': 'Nenhum lugar encontrado para "{address}".',
681
+ 'process.task_created': 'Tarefa {id} criada.',
682
+ 'item.map_of': 'Mapa de {position}',
683
+ 'process.no_task': 'Nenhuma tarefa para tratar.',
679
684
  'workflow.terminated': "Fluxo de trabalho {id} terminado.",
680
685
  'workflow.retried': "O fluxo de trabalho {id} está novamente em execução.",
681
686
  'workflow.no_instance': "Nenhum fluxo de trabalho selecionado.",
package/src/i18n/ru.ts CHANGED
@@ -676,6 +676,11 @@ export const ru: Record<MessageKey, string> = {
676
676
  'process.queued': "Запущено в фоне (задание {id}).",
677
677
  'process.background_type': "Процесс «{name}» ({type}) нельзя выполнять в фоне.",
678
678
  'workflow.started': "Рабочий процесс {id} запущен.",
679
+ 'process.no_geocoder': 'Для этой установки не настроен геокодер.',
680
+ 'process.address_not_found': 'Место для «{address}» не найдено.',
681
+ 'process.task_created': 'Задача {id} создана.',
682
+ 'item.map_of': 'Карта: {position}',
683
+ 'process.no_task': 'Нет задачи для обработки.',
679
684
  'workflow.terminated': "Рабочий процесс {id} прерван.",
680
685
  'workflow.retried': "Рабочий процесс {id} снова выполняется.",
681
686
  'workflow.no_instance': "Рабочий процесс не выбран.",
package/src/i18n/sv.ts CHANGED
@@ -676,6 +676,11 @@ export const sv: Record<MessageKey, string> = {
676
676
  'process.queued': "Startad i bakgrunden (jobb {id}).",
677
677
  'process.background_type': "Processen ”{name}” ({type}) kan inte köras i bakgrunden.",
678
678
  'workflow.started': "Arbetsflöde {id} har startats.",
679
+ 'process.no_geocoder': 'Den här installationen har ingen geokodare.',
680
+ 'process.address_not_found': 'Ingen plats hittades för "{address}".',
681
+ 'process.task_created': 'Uppgift {id} har skapats.',
682
+ 'item.map_of': 'Karta över {position}',
683
+ 'process.no_task': 'Ingen uppgift att hantera.',
679
684
  'workflow.terminated': "Arbetsflöde {id} har avslutats.",
680
685
  'workflow.retried': "Arbetsflöde {id} körs igen.",
681
686
  'workflow.no_instance': "Inget arbetsflöde är valt.",
package/src/i18n/tr.ts CHANGED
@@ -676,6 +676,11 @@ export const tr: Record<MessageKey, string> = {
676
676
  'process.queued': "Arka planda başlatıldı (iş {id}).",
677
677
  'process.background_type': "“{name}” işlemi ({type}) arka planda çalıştırılamaz.",
678
678
  'workflow.started': "{id} iş akışı başlatıldı.",
679
+ 'process.no_geocoder': 'Bu kurulum için bir coğrafi kodlayıcı ayarlanmamış.',
680
+ 'process.address_not_found': '"{address}" için yer bulunamadı.',
681
+ 'process.task_created': '{id} görevi oluşturuldu.',
682
+ 'item.map_of': '{position} haritası',
683
+ 'process.no_task': 'İşlenecek görev yok.',
679
684
  'workflow.terminated': "{id} iş akışı sonlandırıldı.",
680
685
  'workflow.retried': "{id} iş akışı yeniden çalışıyor.",
681
686
  'workflow.no_instance': "İş akışı seçilmedi.",
package/src/i18n/uk.ts CHANGED
@@ -676,6 +676,11 @@ export const uk: Record<MessageKey, string> = {
676
676
  'process.queued': "Запущено у фоні (завдання {id}).",
677
677
  'process.background_type': "Процес «{name}» ({type}) не можна виконувати у фоні.",
678
678
  'workflow.started': "Робочий процес {id} запущено.",
679
+ 'process.no_geocoder': 'Для цього встановлення не налаштовано геокодер.',
680
+ 'process.address_not_found': 'Місце для «{address}» не знайдено.',
681
+ 'process.task_created': 'Завдання {id} створено.',
682
+ 'item.map_of': 'Карта: {position}',
683
+ 'process.no_task': 'Немає завдання для обробки.',
679
684
  'workflow.terminated': "Робочий процес {id} перервано.",
680
685
  'workflow.retried': "Робочий процес {id} знову виконується.",
681
686
  'workflow.no_instance': "Робочий процес не вибрано.",
package/src/i18n/zh.ts CHANGED
@@ -676,6 +676,11 @@ export const zh: Record<MessageKey, string> = {
676
676
  'process.queued': "已在后台启动(作业 {id})。",
677
677
  'process.background_type': "流程“{name}”({type}) 不能在后台运行。",
678
678
  'workflow.started': "工作流 {id} 已启动。",
679
+ 'process.no_geocoder': '此安装未设置地理编码器。',
680
+ 'process.address_not_found': '找不到"{address}"的位置。',
681
+ 'process.task_created': '已创建任务 {id}。',
682
+ 'item.map_of': '{position} 的地图',
683
+ 'process.no_task': '没有可处理的任务。',
679
684
  'workflow.terminated': "工作流 {id} 已终止。",
680
685
  'workflow.retried': "工作流 {id} 正在重新运行。",
681
686
  'workflow.no_instance': "未选择工作流。",
package/src/i18n.ts CHANGED
@@ -703,6 +703,11 @@ const en = {
703
703
  'process.queued': 'Started in the background (job {id}).',
704
704
  'process.background_type': 'Process "{name}" ({type}) cannot run in the background.',
705
705
  'workflow.started': 'Workflow {id} started.',
706
+ 'process.no_geocoder': 'No geocoder is set up for this installation.',
707
+ 'process.address_not_found': 'No place found for "{address}".',
708
+ 'process.task_created': 'Task {id} created.',
709
+ 'item.map_of': 'Map of {position}',
710
+ 'process.no_task': 'No task to act on.',
706
711
  'workflow.terminated': 'Workflow {id} terminated.',
707
712
  'workflow.retried': 'Workflow {id} is running again.',
708
713
  'workflow.no_instance': 'No workflow is selected.',
@@ -1607,6 +1612,11 @@ const nl: Record<MessageKey, string> = {
1607
1612
  'process.queued': 'Op de achtergrond gestart (taak {id}).',
1608
1613
  'process.background_type': 'Proces "{name}" ({type}) kan niet op de achtergrond draaien.',
1609
1614
  'workflow.started': 'Workflow {id} is gestart.',
1615
+ 'process.no_geocoder': 'Deze installatie heeft geen geocoder.',
1616
+ 'process.address_not_found': 'Geen plaats gevonden voor "{address}".',
1617
+ 'process.task_created': 'Taak {id} is aangemaakt.',
1618
+ 'item.map_of': 'Kaart van {position}',
1619
+ 'process.no_task': 'Geen taak om op te handelen.',
1610
1620
  'workflow.terminated': 'Workflow {id} is beëindigd.',
1611
1621
  'workflow.retried': 'Workflow {id} loopt weer.',
1612
1622
  'workflow.no_instance': 'Er is geen workflow gekozen.',
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>`;