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
@@ -38,6 +38,10 @@ page, optionally inside a region. Name them `P<page>_<NAME>` (uppercase letters,
38
38
  | `qrcode` | display only: the value as a QR code (SVG drawn on the server) | whatever was set |
39
39
  | `image` | display only: a picture from a URL in the value, or a BLOB from a query ([Display image](#display-image-and-geocoded-address)) | a URL, or nothing |
40
40
  | `address` | a text field with *Check address*: the installation's geocoder offers matching addresses ([Geocoded address](#display-image-and-geocoded-address)) | the address as text |
41
+ | `image_upload` | (158; APEX: Image Upload) a [file item](16-files.md) for pictures: `image/*` only, a preview, cropping before upload (`{"crop": "free"}` by default; `"1:1"`, `"4:3"`, `"3:4"`, `"16:9"`, `"3:2"`, `"2:3"`) and a maximum size applied in the browser (`{"max_px": 1600}`); column settings as a file item's | as a `file` item |
42
+ | `autocomplete` | (158; APEX: Text Field with autocomplete) a text field with suggestions from the list of values; any text may be typed (a one-value `combobox`) | text |
43
+ | `percent_graph` | (158; APEX: Percent Graph) display only: a 0–100 value as a bar (a `<progress>` element, read out as the number); values above 100 show a full bar, other text nothing | whatever was set |
44
+ | `display_map` | (158; APEX: Display Map) display only: a small map of `lat,lng` with a pin, from the [map regions' tile server](04-pages-and-regions.md) (`MAP_TILE_URL`), and a *Show map* link. The position is the value, or another item's: `{"coordinates_item": "P3_LOCATION", "zoom": 15}` | `lat,lng`, or nothing |
41
45
  | `plugin` | an [item plug-in](04-pages-and-regions.md#plug-ins-with-their-own-code): a text field its JavaScript enhances; `config`: `{"plugin": "char_counter", "attributes": {"MAX": "140"}}` | text |
42
46
 
43
47
  Use multi-value items in SQL with `string_to_array`:
@@ -79,6 +83,7 @@ browser's date and time field.
79
83
  | `help` | Help text under the field |
80
84
  | `lov` | List of values for select/radio/checkbox group/multi-select/popup/display items |
81
85
  | `readonly_condition` | SQL expression; when true the item is shown read-only and ignored on submit |
86
+ | `condition_type`, `condition_expr`, `condition_value` | A [server-side condition](06-processing.md#server-side-conditions) (sprint 154): when it doesn't hold, the item isn't rendered and takes no submitted value |
82
87
  | `authz` | Authorization scheme; unauthorized users don't see the item and can't set it |
83
88
  | `config` | Attributes, see below |
84
89
 
@@ -79,11 +79,32 @@ process fails, earlier inserts and updates are rolled back too.
79
79
 
80
80
  ## Validations
81
81
 
82
- | `type` | Passes when |
83
- |---|---|
84
- | `not_null` | the associated item has a value |
85
- | `sql` | `expression` (a SQL boolean expression, binds allowed) is true |
86
- | `regex` | the item's value matches `expression` (a POSIX regular expression, checked in Postgres) |
82
+ APEX's validation types (sprint 154–155; APEX: *Validation → Type*):
83
+
84
+ | `type` | APEX | Passes when |
85
+ |---|---|---|
86
+ | `sql` | Expression | `expression` (a SQL boolean expression, binds allowed) is true |
87
+ | `exists` / `not_exists` | Rows returned / No rows returned | the query in `expression` returns a row / no row |
88
+ | `function_body` | Function Body (returning Boolean) | the PL/pgSQL body in `expression` returns true |
89
+ | `function_error` | Function Body (returning Error Text) | the body returns null or empty; otherwise its text is the error message |
90
+ | `not_null` | Item is NOT NULL | the associated item has a value |
91
+ | `not_null_or_zero` | Item is NOT NULL or zero | the item has a value that is not zero |
92
+ | `not_zero` | Item is NOT zero | the item is empty or not zero |
93
+ | `no_spaces` | Item contains no spaces | the item has no white space |
94
+ | `alphanumeric` | Item is alphanumeric | the item has only letters and digits (any script) |
95
+ | `numeric` | Item is numeric | the item is a number (a number item's format mask is read back first) |
96
+ | `valid_date` | Item is a valid date | the item is a date in the application's date format, or ISO |
97
+ | `valid_timestamp` | Item is a valid timestamp | the item is `YYYY-MM-DD HH:MI[:SS]` (or with a `T`) |
98
+ | `equals` / `not_equals` | Item = Value / Item != Value | the item's value is / isn't `expression` |
99
+ | `in_list` / `not_in_list` | Item is (NOT) contained in Value | the item's value is / isn't one of `expression`'s values, colon separated (`CLERK:ANALYST`) |
100
+ | `chars_only` / `chars_any` / `chars_none` | Item contains only / at least one / none of the characters in Value | checked against the characters of `expression` |
101
+ | `regex` | Item matches Regular Expression | the item's value matches `expression` (a POSIX regular expression, checked in Postgres) |
102
+
103
+ The format checks (`no_spaces` … `valid_timestamp`, `chars_*`, `regex`, `not_zero`, `not_in_list`,
104
+ `not_equals`) pass an empty item, as APEX's do: add `not_null` (or *Value required*) when it must
105
+ have one. The database refuses a type without the associated item or expression it needs. The SQL
106
+ types run as the application's role; the [Advisor](03-builder.md#advisor) names SQL validations a type
107
+ says exactly (`:P3_SAL is not null` → `not_null`).
87
108
 
88
109
  Plus: every **required** editable item must have a value.
89
110
 
@@ -92,6 +113,10 @@ Plus: every **required** editable item must have a value.
92
113
  | `item_name` | The message is shown next to this item (otherwise at the top of the page) |
93
114
  | `message` | The error message |
94
115
  | `when_button` | Only validate for this request. A `DELETE` request runs only the validations with `when_button` `DELETE` (e.g. "approved requests can't be deleted"), not the item checks |
116
+ | `condition_type`, `condition_expr`, `condition_value` | Only validate when the [server-side condition](#server-side-conditions) holds, e.g. `request_in` `SAVE,CREATE` (sprint 154) |
117
+ | `authz` | Only validate for users the authorization scheme lets through (sprint 155) |
118
+ | `always_execute` | Also validate for a `DELETE` request (APEX: *Always Execute*) |
119
+ | `display_location` | Where the error shows: `inline_and_notification` (next to the item and at the top), `inline` (empty: next to the item, with "correct the errors below" at the top) or `notification` (at the top only). Without an associated item it is at the top |
95
120
 
96
121
  Example: commission only for salesmen.
97
122
 
@@ -122,7 +147,7 @@ two combine well; see error handling below.
122
147
  |---|---|
123
148
  | `point` | `submit` (after validations) or `load` (when the page is shown, after form fetch) |
124
149
  | `when_button` | Only for this request (empty = every submit with a button) |
125
- | `condition_type`, `condition_expr`, `condition_value` | Only when the [condition](#conditions-of-computations-processes-and-branches) holds (server-side condition, as for computations and branches) |
150
+ | `condition_type`, `condition_expr`, `condition_value` | Only when the [condition](#server-side-conditions) holds (server-side condition, as for computations and branches) |
126
151
  | `parent_process` | The name of a `chain` process on the page: this process then runs only inside that chain |
127
152
  | `region_id` | The form or grid region, for `form_dml` / `grid_dml` |
128
153
  | `config` | Settings of a `data_load`, `invoke_api`, `download`, `chain`, `workflow`, `send_push` or `plugin` process (JSON) |
@@ -393,6 +418,23 @@ queued in the page's transaction and sent after the submit commits; a user witho
393
418
  nothing (no error). The application needs push notifications on (Settings → Progressive Web App).
394
419
  From SQL the same is `meta.send_push(...)` ([chapter 9](09-reference.md#push-notifications-from-sql)).
395
420
 
421
+ ### Declarative process types
422
+
423
+ Sprint 157 (APEX: page process types). Each type calls what pgkiln already has; its settings go in
424
+ the process's attributes (`config`), checked when it is saved and again when it runs.
425
+
426
+ | `type` | APEX | Settings | What it does |
427
+ |---|---|---|---|
428
+ | `close_dialog` | Close Dialog | `{"items": ["P5_ID"]}` | Closes the modal page after the submit (a modal page closes after a submit anyway; this one also hands the items' values to the page below, where items of the same name take them before its [Dialog Closed](07-dynamic-actions.md#dialog-closed) actions run) |
429
+ | `clear_session_state` | Clear Session State | `{"items": ["P3_NAME"], "pages": [3, 4], "all": false}` | Empties the items, every `P<n>_` item of the pages, or all page and application items |
430
+ | `reset_pagination` | Reset Pagination | – | Reports and grids start on their first page after the submit |
431
+ | `user_preferences` | User Preferences | `{"action": "set", "preference": "DEFAULT_DEPT", "item": "P1_DEPT"}`, or `"reset"` | Stores (or removes) a [user preference](#user-preferences); nothing for the public user |
432
+ | `server_geocoding` | Server Side Geocoding | `{"address_item": "P3_ADDRESS", "coordinates_item": "P3_LOCATION", "country": "nl"}` | The installation's geocoder (`GEOCODER_URL`); the first place's `lat,lng` goes into the coordinates item |
433
+ | `print_report` | Print Report | `{"document": "EMPLOYEE_SHEET"}` | A [document template](16-files.md#document-templates) or report query, sent as the answer to the submit |
434
+ | `human_task_create` | Human Task – Create | `{"definition": "LEAVE_APPROVAL", "detail_pk": "&P7_ID.", "parameters": {"DAYS": "&P7_DAYS."}, "id_item": "P7_TASK_ID"}` | Creates a task ([approvals](#approvals-and-the-task-list)) and puts its id in the item |
435
+ | `human_task_manage` | Human Task – Manage | `{"action": "approve", "task": "&P8_TASK_ID.", "comment": "&P8_COMMENT."}` | `approve`, `reject`, `complete` (with `"outcome"`), `claim`, `release`, `delegate` (with `"to"`) or `cancel`; the task functions decide who may |
436
+ | `form_init` | Form – Initialization | – | Nothing: a form region fetches its row by itself. Kept so imported pages show it |
437
+
396
438
  ### Generate text with AI
397
439
 
398
440
  An `ai_generate` process (APEX: *Generate Text with AI*) sends a prompt to a large language model
@@ -489,7 +531,7 @@ looked up by SQL, a derived value.
489
531
  | `point` | `before_header`: when the page is shown (after the form fetch, before the `load` processes). `after_submit`: after the posted values are stored, before the validations |
490
532
  | `type` | How `expression` is read, see below |
491
533
  | `expression` | The value, item, query, expression or function body |
492
- | `condition_type`, `condition_expr`, `condition_value` | Only when the [condition](#conditions-of-computations-processes-and-branches) holds |
534
+ | `condition_type`, `condition_expr`, `condition_value` | Only when the [condition](#server-side-conditions) holds |
493
535
  | `authz` | Only for users who pass this authorization scheme |
494
536
  | `seq` | Order; later computations see the values of earlier ones |
495
537
 
@@ -545,7 +587,7 @@ dialog closes instead, and the calling page reloads.
545
587
  | `target_url` | A path inside the application (after `/a/<alias>/`), e.g. `12?view=month` or `account`; `&ITEM.` values are URL-encoded. Other sites are refused (no scheme, `//`, `\` or `..`) |
546
588
  | `target_function` | `function`: a PL/pgSQL function body that returns such a path (APEX: *Function returning a URL*), run as the application's role with binds; checked like `target_url` (`meta.branch_path_ok`). An empty result: the branch doesn't apply; a refused one shows a message and is logged |
547
589
  | `target_app` | `app`: the alias of another application of this installation (APEX: *Branch to page in another application*). It must exist with that page (else the branch doesn't apply and a message is shown); the items are signed for that application, page and user. That application's own sign-in and authorization apply when the browser gets there |
548
- | `condition_type`, `condition_expr`, `condition_value` | Only when the [condition](#conditions-of-computations-processes-and-branches) holds |
590
+ | `condition_type`, `condition_expr`, `condition_value` | Only when the [condition](#server-side-conditions) holds |
549
591
  | `authz`, `seq`, `build_option` | As for other components |
550
592
 
551
593
  A `before_header` branch to the page itself is skipped (it would loop).
@@ -560,18 +602,47 @@ begin
560
602
  end
561
603
  ```
562
604
 
563
- ### Conditions of computations, processes and branches
605
+ ### Server-side conditions
606
+
607
+ Every component has the same server-side condition (sprint 154; APEX: *Server-side Condition*):
608
+ regions, items, buttons, dynamic actions, validations, processes, computations, branches and
609
+ application computations. In the Page Designer it is the **Server-side condition** group:
610
+ *Condition type*, *Expression / item* and *Value*.
564
611
 
565
612
  | `condition_type` | Holds when |
566
613
  |---|---|
567
- | (empty) | always |
568
- | `sql` | `condition_expr`, a boolean SQL expression, is true |
569
- | `exists` / `not_exists` | the query in `condition_expr` returns a row / no row |
570
- | `item_null` / `item_not_null` | the item named in `condition_expr` is empty / has a value |
571
- | `item_equals` / `item_not_equals` | that item's value is / is not `condition_value` |
572
- | `request_in` | the request (the button pressed) is one of `condition_value`, comma separated |
573
-
574
- A condition whose SQL fails counts as false and shows the error.
614
+ | (empty) | always (a region's or button's `condition` without a type: the SQL below) |
615
+ | `sql` | the expression, a boolean SQL expression, is true |
616
+ | `exists` / `not_exists` | the query in the expression returns a row / no row |
617
+ | `function_body` | the PL/pgSQL body in the expression returns true (`return :P3_QTY::int > 0;`) |
618
+ | `request_in` / `request_not_in` | the request (the button pressed) is / is not one of the value's names, comma separated (`SAVE,CREATE`) |
619
+ | `item_null` / `item_not_null` | the item named in the expression is empty / has a value |
620
+ | `item_zero` / `item_not_zero` | that item is / is not zero (an empty item is not zero) |
621
+ | `item_null_or_zero` / `item_not_null_or_zero` | that item is empty or zero / neither |
622
+ | `item_equals` / `item_not_equals` | that item's value is / is not the value |
623
+ | `item_equals_item` / `item_not_equals_item` | that item's value is / is not the value of the item named in the value |
624
+ | `item_in_list` / `item_not_in_list` | that item's value is / is not one of the value's entries, separated by colons (`CLERK:ANALYST`) |
625
+ | `page_in` / `page_not_in` | the current page is / is not one of the value's page numbers (`1,3`) |
626
+ | `page_is_dialog` / `page_is_not_dialog` | the page is / is not a modal dialog |
627
+ | `user_signed_in` / `user_public` | the user is signed in / is the public user |
628
+ | `language_in` / `language_not_in` | the application's language for this request is / is not one of the value's codes (`de,pt-br`; `pt` also matches `pt-br`) |
629
+ | `always` / `never` | always / never (for a component you want to keep but switch off: a build option does it for a release) |
630
+
631
+ Only `sql`, `exists`, `not_exists` and `function_body` run SQL, as the application's database
632
+ role, in the request's transaction; the others are decided by the server without a query. The
633
+ database refuses a condition whose expression or value doesn't fit its type (`meta.condition_ok`).
634
+
635
+ A condition whose SQL fails counts as false and shows the error. Regions and buttons keep their
636
+ `condition` column as the expression: without a type it is SQL, as before, so older exports keep
637
+ working. Dynamic actions name theirs `server_condition_type`, `server_condition_expr` and
638
+ `server_condition_value` (their `condition_type` is the client-side condition): when it doesn't
639
+ hold, the dynamic action isn't on the page.
640
+
641
+ The Page Designer's tree shows the condition in words on the component's filter badge (*When
642
+ P3_JOB is one of CLERK, ANALYST*), in the builder's language. The
643
+ [Advisor](03-builder.md#advisor) names SQL conditions a type can say exactly (`:P3_ID is not null`,
644
+ `:REQUEST in ('SAVE', 'CREATE')`, `:P3_JOB = 'CLERK'`) as information; `<>` and `not in` stay SQL,
645
+ because in SQL an empty item makes them false.
575
646
 
576
647
  **HR example, page 22 (Leave planner).** Computations fill the employee (yours, when none is
577
648
  chosen: `sql_query` with condition `item_null`), the name (`function_body`) and the pending
@@ -1099,6 +1170,22 @@ background runs with the tenant of the session that queued it. Filter your own t
1099
1170
  (`where tenant_id = meta.tenant_id()`, or a row level security policy). Use row level security in
1100
1171
  the tenant's tables as well: the tenant is a value the application sets, not a database boundary.
1101
1172
 
1173
+ ### Workflow diagram region
1174
+
1175
+ Sprint 160 (APEX 24.2+: Workflow Diagram region). A region of type `workflow_diagram` draws one
1176
+ workflow instance in a running application: its version's steps, the ones it went through (green),
1177
+ where it is now (blue, several with parallel branches) and a failed step (red, with the error above
1178
+ the diagram). Each reached step's tooltip lists when it was reached and what happened. The instance
1179
+ id comes from an item:
1180
+
1181
+ ```json
1182
+ {"instance_item": "P9_WORKFLOW_ID"}
1183
+ ```
1184
+
1185
+ It is read through `meta.workflows` as the application's role, so a user sees only workflows they
1186
+ started or administer (the Workflow Console's rule); others see the empty text. The diagram is SVG
1187
+ drawn on the server: it needs no JavaScript.
1188
+
1102
1189
  ### Send e-mail steps
1103
1190
 
1104
1191
  A `send_mail` step (APEX 26.1: the *Send E-Mail* activity) queues an e-mail through the application's mail service,
@@ -12,7 +12,8 @@ your own from a static application file.
12
12
  |---|---|
13
13
  | `event` | When it runs, see [Events](#events) |
14
14
  | `trigger_element` | What it listens to: 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) or event names (`custom`), comma separated. Empty for `load` and `before_submit` |
15
- | `condition_type`, `condition_value` | Optional client-side condition on the (first) trigger item's value (for `dialog_closed`: on the dialog's page number): `equals`, `not_equals`, `in_list` (comma-separated values), `is_null`, `is_not_null` |
15
+ | `condition_type`, `condition_value` | Optional client-side condition on the (first) trigger item's value (for `dialog_closed`: on the dialog's page number): `equals`, `not_equals`, `greater`, `greater_equal`, `less`, `less_equal` (numbers as numbers, other values such as ISO dates as text; an empty value fails them), `in_list`, `not_in_list` (comma-separated values), `is_null`, `is_not_null`. APEX's *JavaScript expression* isn't offered: the Content-Security-Policy runs no code from strings; use `execute_javascript` with a registered function |
16
+ | `server_condition_type`, `server_condition_expr`, `server_condition_value` | A [server-side condition](06-processing.md#server-side-conditions) (sprint 154): when it doesn't hold for this request, the dynamic action isn't on the page at all, e.g. `user_signed_in` or `page_is_dialog` |
16
17
  | `action` | What to do, see below |
17
18
  | `affected_items`, `affected_region_id` | What it acts on |
18
19
  | `code` | SQL for server-side actions; the function's name for `execute_javascript` |
@@ -38,6 +39,14 @@ your own from a static application file.
38
39
  | `before_submit` | The page is about to be submitted; a declined `confirm` keeps the page | – |
39
40
  | `custom` | JavaScript raised the event: `pgkiln.trigger('ORDER_SAVED', value)` (APEX: custom events, `apex.event.trigger`) | Event names (case-insensitive) |
40
41
  | `menu_entry` | An entry `{"label": …, "action": "NAME"}` of a [menu button](06-processing.md#menu-buttons-and-badges) was chosen (APEX: Menu entry chosen); the condition sees the entry's name ([below](#menu-entry-chosen)) | Entry names, or menu button names (any of their entries) |
42
+ | `double_click` | An item, button or region was double-clicked (sprint 156) | Item names, button names, region ids or static ids |
43
+ | `key_down` / `key_press` | A key goes down in an item / a key that types a character (APEX: Key Down, Key Press); a `cancel_event` keeps the key out of the field | Item names |
44
+ | `mouse_enter` / `mouse_leave` | The pointer enters / leaves an item, button or region (once per element, not while moving inside it) | Item names, button names, region ids or static ids |
45
+ | `select` | Text is selected in an item | Item names |
46
+ | `resize` / `scroll` | The window is resized / scrolled (at most once per frame) | – |
47
+ | `page_unload` | The page is left (APEX: Page Unload); only quick actions in the browser finish | – |
48
+ | `before_refresh` | A region is about to be refreshed by a `refresh_region` action (APEX: Before Refresh); a `cancel_event` or declined `confirm` keeps it as it is | Region ids or static ids |
49
+ | `facets_change` | A faceted search or smart filters region's facets were applied (APEX: Facets Change); runs on the page that comes back with the new filters | The facets region's id or static id |
41
50
 
42
51
  **The actions of one event and trigger run one after the other, in sequence order** (APEX: the true
43
52
  actions of a dynamic action), each waiting for the one before (a server action for its answer). A
@@ -82,6 +91,12 @@ actions of a dynamic action), each waiting for the one before (a server action f
82
91
  | `share` | browser | The device's share sheet with the page's title, `message` and address; where there is none, the address is copied |
83
92
  | `wait_popup` | browser | Shows a modal "please wait" popup with `message` (default *Please wait…*) until `remove_wait_popup` or the next page (APEX: Show Wait Popup); [below](#wait-popup) |
84
93
  | `remove_wait_popup` | browser | Removes the wait popup (APEX: Remove Wait Popup) |
94
+ | `cancel_event` | browser | Stops the actions after it, and the event's own effect: a button doesn't submit or follow its link, a typed key (`key_down`, `key_press`) stays out of the field, a refresh doesn't happen (APEX: Cancel Event). Sprint 156 |
95
+ | `set_style` | browser | Sets CSS properties on the affected items or region: `{"styles": {"color": "#b42318", "font-weight": "bold"}}` in the attributes (APEX: Set Style). Properties from a fixed list (colours, font, text, border, opacity, visibility, padding, margin, width); values without `url()` or `var()`, checked when saved and again before they reach the page |
96
+ | `collapse_tree` / `expand_tree` | browser | Closes / opens every node of the affected tree region (APEX: Collapse Tree, Expand Tree) |
97
+ | `show_ai_assistant` | browser | Opens the affected AI assistant region (or the inline dialog it sits in) and puts the cursor in its question (APEX: Show AI Assistant) |
98
+ | `trigger_geocoding` | browser | The affected geocoded address item looks its text up, as its *Check address* button does (APEX: Trigger Geocoding) |
99
+ | `open_report_dialog` | browser | Opens a dialog of the affected interactive report: `code` is `filter`, `columns`, `sort`, `highlight`, `compute`, `aggregate`, `chart`, `group`, `pivot`, `break`, `rows` or `saved` (APEX: Invoke Interactive Report Dialog) |
85
100
 
86
101
  Server-side actions first store `items_to_submit` in session state, run as the application's
87
102
  database role like everything else, and check the page's and the dynamic action's authorization.
@@ -38,6 +38,13 @@ What's missing is listed in the [feature parity matrix](../apex-feature-parity.m
38
38
  | Before-header processes | Process `point = 'load'` |
39
39
  | Page computations (static, item, SQL query, SQL expression, PL/SQL function body) | Computations `before_header` / `after_submit`; function bodies are PL/pgSQL ([chapter 6](06-processing.md#computations)) |
40
40
  | Branches (page or URL, *When button pressed*, server-side condition), before header and after processing | Branches with `when_button` and a condition; URLs stay inside the application ([chapter 6](06-processing.md#branches)) |
41
+ | Validation types (Rows returned, Expression, Function Body returning Boolean / Error Text, Item is NOT NULL / numeric / a valid date …, Item = Value, Item in Value, character checks, Regular Expression), Always Execute, Display Location | The same types as `type` (`exists`, `sql`, `function_body`, `function_error`, `not_null`, `numeric`, `valid_date`, `equals`, `in_list`, `chars_only`, `regex` …), `always_execute`, `display_location` ([chapter 6](06-processing.md#validations)). Interactive grid column validations aren't there yet: use a validation on the grid's process or a check constraint |
42
+ | Dynamic action events (Double Click, Key Down / Press, Mouse Enter / Leave, Select, Resize, Scroll, Page Unload, Before Refresh, Facets Change), conditions (greater / less than, not in list) and actions (Cancel Event, Set Style, Collapse / Expand Tree, Show AI Assistant, Trigger Geocoding, Invoke Interactive Report Dialog) | The same events, conditions and actions ([chapter 7](07-dynamic-actions.md)). Not yet: grid, calendar, map and tree component events; JavaScript expression conditions (use a registered function) |
43
+ | Workflow Diagram region | `workflow_diagram` with `{"instance_item": …}` ([chapter 6](06-processing.md#workflow-diagram-region)) |
44
+ | Map layers Points, Lines, Polygons, Heat Map, Extruded Polygons | `"layer"`: `markers`, `lines`, `polygons`, `heat`, `extruded` (drawn flat, shaded by height), with fill and line colours, width and opacity per layer or per row ([chapter 4](04-pages-and-regions.md#map)) |
45
+ | Items Image Upload, Text Field with autocomplete, Percent Graph, Display Map | `image_upload`, `autocomplete`, `percent_graph`, `display_map` ([chapter 5](05-items.md#item-types)) |
46
+ | Process types Close Dialog, Clear Session State, Reset Pagination, User Preferences, Server Side Geocoding, Print Report, Human Task – Create / – Manage, Form – Initialization | The same as `type` ([chapter 6](06-processing.md#declarative-process-types)), settings in the attributes |
47
+ | Server-side Condition (Rows returned, Expression, Function Body, Request = / != / is contained in, Item is NULL / NOT NULL / zero / = Value / in colon list, Current page in, Current page is a modal dialog, User is authenticated / public user, Current language in, Always / Never …) | The same list as `condition_type`: `exists`, `sql`, `function_body`, `request_in` / `request_not_in`, `item_null` / `item_not_null` / `item_zero` / `item_equals` / `item_in_list`, `page_in`, `page_is_dialog`, `user_signed_in` / `user_public`, `language_in`, `always` / `never` ([chapter 6](06-processing.md#server-side-conditions)). APEX's colon lists stay colon lists; *Request is contained in* takes commas |
41
48
  | Build options (include / exclude) | Build options; `!NAME` for APEX's "exclude when the option is included" ([chapter 6](06-processing.md#build-options)) |
42
49
  | Menu button, button badge | Button action `menu`; `badge` / `badge_query` |
43
50
  | Dynamic actions *Set Focus*, *Add / Remove Class*, *Show Success / Error Message*, *Clear Errors* | `set_focus`, `add_class` / `remove_class`, `show_success` / `show_error`, `clear_errors` |
@@ -0,0 +1,17 @@
1
+ -- =====================================================================
2
+ -- HR example, part 79: declarative server-side conditions (sprint 154, CD-2)
3
+ --
4
+ -- The Employee page's buttons showed with SQL conditions such as
5
+ -- ":P3_EMPNO is not null". The same tests as condition types: no query,
6
+ -- and the Page Designer's tree says "When P3_EMPNO is not empty".
7
+ -- Department (page 5) gets the same; the leave request's Approve and
8
+ -- Reject (page 7) stay SQL: they call hr.current_empno().
9
+ -- =====================================================================
10
+
11
+ update meta.button b
12
+ set condition_type = case when b.condition ~* ' is not null$' then 'item_not_null' else 'item_null' end,
13
+ condition = substring(b.condition from '^:([A-Z][A-Z0-9_]*) ')
14
+ from meta.page p join meta.app a on a.id = p.app_id
15
+ where b.page_id = p.id and a.alias = 'hr' and p.page_no in (3, 5)
16
+ and b.condition_type is null
17
+ and b.condition ~ '^:P[35]_(EMPNO|DEPTNO) is (not )?null$';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.80.0",
3
+ "version": "0.82.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.css CHANGED
@@ -1497,6 +1497,8 @@ path.map-near { stroke: #1f2733; stroke-dasharray: 6 4; fill: #1f2733; fill-opac
1497
1497
  .wf-invoke_api rect { stroke: #1f7a8c; stroke-width: 2; stroke-dasharray: 2 2; }
1498
1498
  .wf-active rect { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 3; }
1499
1499
  .wf-active .wf-name { fill: var(--text); }
1500
+ .wf-done rect { fill: var(--success-soft, #e7f5ed); stroke: var(--success, #17794a); }
1501
+ .wf-failed rect { fill: var(--danger-soft, #fde7e4); stroke: var(--danger, #b42318); stroke-width: 3; }
1500
1502
  .wf-warnings { background: var(--warning-soft); color: var(--text); border-color: var(--border); }
1501
1503
  .wf-version-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0; }
1502
1504
  .wf-version-diagram summary { cursor: pointer; }
@@ -2251,3 +2253,13 @@ body.nav-pillar-dark .t-nav details:has(> summary.in-trail) > ul { border-inline
2251
2253
  .mfa-factors { list-style: none; padding: 0; margin: 0 0 .75rem; }
2252
2254
  .mfa-factors li { padding: .4rem 0; border-bottom: 1px solid var(--border); }
2253
2255
  @media (max-width: 480px) { .mfa-codes { columns: 1; } }
2256
+
2257
+ /* (sprint 158) Percent Graph and Display Map items */
2258
+ .percent-graph { display: flex; align-items: center; gap: .5rem; }
2259
+ .percent-graph progress { flex: 1; max-width: 16rem; height: .75rem; accent-color: var(--t-accent, #2563eb); }
2260
+ .percent-graph-value { font-variant-numeric: tabular-nums; min-width: 3.5ch; text-align: right; }
2261
+ .display-map { margin: 0; display: inline-block; max-width: 100%; }
2262
+ .display-map-tile { position: relative; width: 256px; max-width: 100%; aspect-ratio: 1; overflow: hidden; border-radius: 6px; border: 1px solid var(--t-border, #d0d5dd); }
2263
+ .display-map-tile img { display: block; width: 100%; height: 100%; }
2264
+ .display-map-pin { position: absolute; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #d92d20; border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.35); }
2265
+ .display-map figcaption { font-size: .8rem; margin-top: .25rem; }
package/public/app.js CHANGED
@@ -341,9 +341,17 @@ const pgkilnUnsaved = (() => {
341
341
  case 'in_list': return da.cond.value.split(',').map((s) => s.trim()).includes(v);
342
342
  case 'is_null': return v === '';
343
343
  case 'is_not_null': return v !== '';
344
+ // (156) numbers compared as numbers, other values (ISO dates) as text
345
+ case 'greater': return v !== '' && compare(v, da.cond.value) > 0;
346
+ case 'greater_equal': return v !== '' && compare(v, da.cond.value) >= 0;
347
+ case 'less': return v !== '' && compare(v, da.cond.value) < 0;
348
+ case 'less_equal': return v !== '' && compare(v, da.cond.value) <= 0;
349
+ case 'not_in_list': return !da.cond.value.split(',').map((s) => s.trim()).includes(v);
344
350
  default: return true;
345
351
  }
346
352
  }
353
+ const NUM = /^\s*-?\d+(\.\d+)?\s*$/;
354
+ const compare = (a, b) => (NUM.test(a) && NUM.test(b) ? Number(a) - Number(b) : a < b ? -1 : a > b ? 1 : 0);
347
355
 
348
356
  // ------------------------------------------------------------ messages and errors (dynamic actions)
349
357
  // class names a dynamic action may add or remove (the database checks the same pattern)
@@ -499,6 +507,8 @@ const pgkilnUnsaved = (() => {
499
507
 
500
508
  /** (085) what runDa returns to stop the actions after it (a declined confirm) */
501
509
  const STOP = Symbol('stop');
510
+ /** (156) does a Cancel Event in these actions hold its condition (decided at once, so the event's effect can be stopped)? */
511
+ const cancels = (list) => list.some((da) => da.action === 'cancel_event' && conditionHolds(da));
502
512
  const text = (k) => (meta.texts && meta.texts[k]) || k;
503
513
 
504
514
  /** The actions of one event and trigger, one after the other in sequence order; false when one stopped them. */
@@ -580,6 +590,37 @@ const pgkilnUnsaved = (() => {
580
590
  }
581
591
  if (initial || !holds) return;
582
592
  switch (da.action) {
593
+ // (156) stops the actions after it; the triggering event's own effect is stopped by its handler (cancels())
594
+ case 'cancel_event':
595
+ return STOP;
596
+ case 'set_style':
597
+ for (const el of targets(da)) for (const [k, v] of Object.entries(da.styles || {})) el.style.setProperty(k, v);
598
+ return;
599
+ case 'collapse_tree':
600
+ case 'expand_tree':
601
+ for (const el of targets(da)) el.querySelectorAll('.tree-view details').forEach((d) => (d.open = da.action === 'expand_tree'));
602
+ return;
603
+ case 'show_ai_assistant':
604
+ for (const el of targets(da)) {
605
+ const dlg = el.closest('dialog[data-inline-region]') || (el.matches('dialog[data-inline-region]') ? el : null);
606
+ if (dlg && !dlg.open) dlg.showModal();
607
+ el.hidden = false;
608
+ const details = el.querySelector(':scope > details');
609
+ if (details) details.open = true;
610
+ const input = el.querySelector('textarea, input[type=text]') || (el.id && document.querySelector(`form[id^="${el.id}"] textarea`));
611
+ if (input) input.focus();
612
+ else el.scrollIntoView({ block: 'nearest' });
613
+ }
614
+ return;
615
+ case 'trigger_geocoding':
616
+ for (const n of da.items) document.querySelector(`[data-geocode="${CSS.escape(n)}"]`)?.click();
617
+ return;
618
+ case 'open_report_dialog':
619
+ for (const el of targets(da)) {
620
+ const item = da.dialog && el.querySelector(`.ir-item[data-ir-dialog="${CSS.escape(da.dialog)}"]`);
621
+ if (item) item.open = true;
622
+ }
623
+ return;
583
624
  case 'alert':
584
625
  window.alert(da.message || '');
585
626
  return;
@@ -698,6 +739,12 @@ const pgkilnUnsaved = (() => {
698
739
  return;
699
740
  }
700
741
  }
742
+ // (156) before_refresh: the region's actions run before its refresh is asked for
743
+ if (da.action === 'refresh_region' && da.region) {
744
+ const r = document.getElementById(`R${da.region}`);
745
+ const before = regionRun('before_refresh', r);
746
+ if (before.length && !(await runChain(before))) return;
747
+ }
701
748
  const data = { ...(extra || {}) };
702
749
  for (const n of da.submit) data[n] = itemValue(n);
703
750
  const busy = targets(da);
@@ -727,6 +774,12 @@ const pgkilnUnsaved = (() => {
727
774
  }
728
775
 
729
776
  const das = meta.das || [];
777
+ // region events (trigger: region ids or static ids, case-insensitive)
778
+ const regionRun = (event, region) => {
779
+ if (!region || !/^R\d+$/.test(region.id || '')) return [];
780
+ const keys = [region.id.slice(1), (region.dataset.staticId || '').toLowerCase()].filter(Boolean);
781
+ return das.filter((da) => da.event === event && da.trigger.some((t) => keys.includes(String(t).toLowerCase())));
782
+ };
730
783
  // (121) an AI agent's javascript tools: after the answer, the registered function of the action
731
784
  // (pgkiln.actions.register) and the dynamic actions of the custom event of that name, with the arguments
732
785
  scriptsLoaded.then(async () => {
@@ -760,6 +813,8 @@ const pgkilnUnsaved = (() => {
760
813
  const btn = e.target.closest('[data-button]');
761
814
  if (!btn || e.target.closest('[data-menu-entry]')) return;
762
815
  const run = das.filter((da) => da.event === 'click' && da.trigger.includes(btn.dataset.button));
816
+ // (156) a Cancel Event whose condition holds: the button doesn't submit or follow its link
817
+ if (cancels(run)) e.preventDefault();
763
818
  // "Generate text with AI" on a submit button: the AJAX call replaces the submit
764
819
  // (without JavaScript the button submits and the page process runs instead)
765
820
  const ai = run.some((da) => da.action === 'ai_generate');
@@ -803,12 +858,68 @@ const pgkilnUnsaved = (() => {
803
858
  document.addEventListener('focusin', itemEvent('focus'));
804
859
  document.addEventListener('focusout', itemEvent('blur'));
805
860
  document.addEventListener('keyup', itemEvent('key_release'));
806
- // region events (trigger: region ids or static ids, case-insensitive)
807
- const regionRun = (event, region) => {
808
- if (!region || !/^R\d+$/.test(region.id || '')) return [];
809
- const keys = [region.id.slice(1), (region.dataset.staticId || '').toLowerCase()].filter(Boolean);
810
- return das.filter((da) => da.event === event && da.trigger.some((t) => keys.includes(String(t).toLowerCase())));
861
+ // (156) key_down, key_press (a key that types a character), select (text selected in an item)
862
+ document.addEventListener('keydown', (e) => {
863
+ const name = e.target && e.target.name;
864
+ if (!name) return;
865
+ const down = das.filter((da) => da.event === 'key_down' && da.trigger.includes(name));
866
+ const press = e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey ? das.filter((da) => da.event === 'key_press' && da.trigger.includes(name)) : [];
867
+ // a Cancel Event on key_down / key_press keeps the key out of the field
868
+ if (cancels([...down, ...press])) e.preventDefault();
869
+ if (down.length) runChain(down);
870
+ if (press.length) runChain(press);
871
+ });
872
+ document.addEventListener('select', itemEvent('select'), true);
873
+ // double_click, mouse_enter, mouse_leave: an item (its name), a button (its name) or a region (id or static id)
874
+ const elementRun = (event, target) => {
875
+ if (!target || !target.closest) return [];
876
+ const item = target.closest('[name]');
877
+ const btn = target.closest('[data-button]');
878
+ const region = target.closest('section[id^="R"], div[id^="R"], dialog[id^="R"]');
879
+ const names = [item && item.name, btn && btn.dataset.button].filter(Boolean);
880
+ return [...new Set([...das.filter((da) => da.event === event && da.trigger.some((t) => names.includes(t))), ...regionRun(event, region)])];
811
881
  };
882
+ document.addEventListener('dblclick', (e) => {
883
+ const run = elementRun('double_click', e.target);
884
+ if (run.length) runChain(run);
885
+ });
886
+ // enter / leave: once per element, not again while moving inside it
887
+ const hovered = new Set();
888
+ document.addEventListener('mouseover', (e) => {
889
+ for (const da of elementRun('mouse_enter', e.target)) if (!hovered.has(da)) { hovered.add(da); runChain([da]); }
890
+ });
891
+ document.addEventListener('mouseout', (e) => {
892
+ const leaving = elementRun('mouse_enter', e.target).concat(elementRun('mouse_leave', e.target));
893
+ const still = new Set([...elementRun('mouse_enter', e.relatedTarget), ...elementRun('mouse_leave', e.relatedTarget)]);
894
+ for (const da of leaving) {
895
+ if (still.has(da)) continue;
896
+ hovered.delete(da);
897
+ if (da.event === 'mouse_leave') runChain([da]);
898
+ }
899
+ });
900
+ // resize, scroll: the window (at most one run per animation frame)
901
+ for (const [event, dom] of [['resize', 'resize'], ['scroll', 'scroll']]) {
902
+ const run = das.filter((da) => da.event === event);
903
+ if (!run.length) continue;
904
+ let queued = false;
905
+ window.addEventListener(dom, () => {
906
+ if (queued) return;
907
+ queued = true;
908
+ requestAnimationFrame(() => { queued = false; runChain(run); });
909
+ }, { passive: true });
910
+ }
911
+ // page_unload: the page is left (only quick, local actions finish)
912
+ window.addEventListener('pagehide', () => runChain(das.filter((da) => da.event === 'page_unload')));
913
+ // facets_change: a faceted search region's facets were applied (the page came back from its form)
914
+ {
915
+ const params = new URLSearchParams(location.search);
916
+ const changed = params.get('facets_changed');
917
+ if (changed) {
918
+ params.delete('facets_changed');
919
+ history.replaceState(history.state, '', `${location.pathname}${params.size ? `?${params}` : ''}${location.hash}`);
920
+ runChain(regionRun('facets_change', document.getElementById(`R${changed.replace(/\D/g, '')}`)));
921
+ }
922
+ }
812
923
  // after_refresh: a region replaced by a refresh, a lazy load or a master row
813
924
  document.addEventListener('pgkiln:replaced', (e) => {
814
925
  const run = regionRun('after_refresh', e.detail);
@@ -1011,6 +1122,10 @@ const pgkilnUnsaved = (() => {
1011
1122
  const dlg = document.getElementById('t-dialog');
1012
1123
  if (dlg) dlg.close();
1013
1124
  if (!e.data.reload) return;
1125
+ // (157) items a close_dialog process returned: items of the same name on this page take their values
1126
+ if (e.data.items && typeof e.data.items === 'object')
1127
+ for (const [name, value] of Object.entries(e.data.items))
1128
+ if (/^[A-Z][A-Z0-9_]{0,99}$/.test(name) && typeof value === 'string' && wrapperOf(name)) setItemValue(name, value);
1014
1129
  const page = /^\d{1,9}$/.test(String(e.data.page || '')) ? String(e.data.page) : '';
1015
1130
  const handlers = das.filter((da) => da.event === 'dialog_closed' && (!da.trigger.length || da.trigger.includes(page)));
1016
1131
  if (!handlers.length) return location.reload();
@@ -1248,7 +1363,10 @@ const pgkilnUnsaved = (() => {
1248
1363
 
1249
1364
  // Inside a dialog: close on success or cancel.
1250
1365
  if (window.parent !== window) {
1251
- const tell = (reload) => window.parent.postMessage({ type: 'pgkiln:close', reload, page: body.dataset.dialogPage || '' }, location.origin);
1366
+ // (157) a close_dialog process's items go along to the page below
1367
+ let items = null;
1368
+ try { items = body.dataset.dialogItems ? JSON.parse(body.dataset.dialogItems) : null; } catch { items = null; }
1369
+ const tell = (reload) => window.parent.postMessage({ type: 'pgkiln:close', reload, page: body.dataset.dialogPage || '', items }, location.origin);
1252
1370
  if (body.dataset.dialogClose) tell(true);
1253
1371
  document.addEventListener('click', (e) => {
1254
1372
  if (!e.target.closest('[data-dialog-cancel]')) return;
@@ -1841,7 +1959,20 @@ document.addEventListener('change', (e) => {
1841
1959
  const custom = e.target.closest('fieldset')?.querySelector('[data-facet-range]');
1842
1960
  custom?.querySelectorAll('input').forEach((i) => (i.value = ''));
1843
1961
  }
1844
- e.target.form?.requestSubmit();
1962
+ // (156) the page that comes back runs the region's facets_change dynamic actions
1963
+ const f = e.target.form;
1964
+ const region = e.target.closest('[id^="R"]');
1965
+ if (f && region && /^R\d+$/.test(region.id)) {
1966
+ let mark = f.querySelector('input[name="facets_changed"]');
1967
+ if (!mark) {
1968
+ mark = document.createElement('input');
1969
+ mark.type = 'hidden';
1970
+ mark.name = 'facets_changed';
1971
+ f.appendChild(mark);
1972
+ }
1973
+ mark.value = region.id.slice(1);
1974
+ }
1975
+ f?.requestSubmit();
1845
1976
  });
1846
1977
  document.addEventListener('input', (e) => {
1847
1978
  const box = e.target.closest?.('[data-facet-range]');
@@ -3162,6 +3293,17 @@ document.addEventListener('DOMContentLoaded', () => {
3162
3293
  }
3163
3294
  });
3164
3295
 
3296
+ // (159) Leaflet path options from a shape layer's checked style
3297
+ function shapeStyle(st, kind) {
3298
+ if (!st) return {};
3299
+ const o = {};
3300
+ if (st.stroke_color) o.color = st.stroke_color;
3301
+ if (typeof st.stroke_width === 'number') o.weight = st.stroke_width;
3302
+ if (st.fill_color) { o.fillColor = st.fill_color; o.fill = kind !== 'lines'; }
3303
+ if (typeof st.opacity === 'number') { if (kind === 'lines') o.opacity = st.opacity; else o.fillOpacity = st.opacity; }
3304
+ return o;
3305
+ }
3306
+
3165
3307
  // One layer's Leaflet parts: markers (clustered or not) or a heat map, with its lines and areas.
3166
3308
  function layerParts(l, popup, data, several) {
3167
3309
  const parts = [];
@@ -3170,8 +3312,14 @@ function layerParts(l, popup, data, several) {
3170
3312
  else for (const p of l.points) parts.push(placeMarker(p, l, popup, several));
3171
3313
  if (l.shapes.length)
3172
3314
  parts.push(window.L.geoJSON({ type: 'FeatureCollection', features: l.shapes }, {
3173
- style: () => (several ? { className: `map-c${l.color}` } : {}),
3174
- onEachFeature: (f, layer) => layer.bindPopup(() => popup(f.properties)),
3315
+ // (159) a lines / polygons / extruded layer's colours, width and opacity (checked on the server: #rrggbb, numbers)
3316
+ style: (f) => ({ ...(several ? { className: `map-c${l.color}` } : {}), ...shapeStyle(f.properties && f.properties.style, l.kind) }),
3317
+ onEachFeature: (f, layer) => {
3318
+ layer.bindPopup(() => popup(f.properties));
3319
+ // a shape's title as its tooltip (and its height on an extruded layer)
3320
+ const tip = [f.properties && f.properties.title, f.properties && typeof f.properties.height === 'number' ? String(f.properties.height) : ''].filter(Boolean).join(': ');
3321
+ if (tip && layer.bindTooltip) layer.bindTooltip(tip, { sticky: true });
3322
+ },
3175
3323
  }));
3176
3324
  return parts;
3177
3325
  }