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.
- package/db/migrations/147_condition_types.sql +80 -0
- package/db/migrations/148_validation_types.sql +44 -0
- package/db/migrations/149_dynamic_action_events.sql +45 -0
- package/db/migrations/150_process_types.sql +23 -0
- package/db/migrations/151_item_types.sql +16 -0
- package/db/migrations/152_workflow_diagram_region.sql +10 -0
- package/docs/apex-feature-parity.md +10 -8
- package/docs/guide/02-concepts.md +3 -3
- package/docs/guide/04-pages-and-regions.md +17 -3
- package/docs/guide/05-items.md +5 -0
- package/docs/guide/06-processing.md +104 -17
- package/docs/guide/07-dynamic-actions.md +16 -1
- package/docs/guide/11-from-apex.md +7 -0
- package/examples/hr/hr_79_condition_types.sql +17 -0
- package/package.json +1 -1
- package/public/app.css +12 -0
- package/public/app.js +157 -9
- package/src/appfiles.ts +4 -4
- package/src/builder/advisor.ts +43 -7
- package/src/builder/code-editor.ts +14 -10
- package/src/builder/components.ts +51 -21
- package/src/builder/conditions.ts +64 -0
- package/src/builder/designer.ts +9 -1
- package/src/builder/i18n/de.ts +35 -0
- package/src/builder/i18n/en.ts +35 -0
- package/src/builder/i18n/es.ts +35 -0
- package/src/builder/i18n/fr.ts +35 -0
- package/src/builder/i18n/it.ts +35 -0
- package/src/builder/i18n/ja.ts +35 -0
- package/src/builder/i18n/ko.ts +35 -0
- package/src/builder/i18n/nl.ts +35 -0
- package/src/builder/i18n/pt.ts +35 -0
- package/src/builder/i18n/zh-cn.ts +35 -0
- package/src/builder/i18n/zh-tw.ts +35 -0
- package/src/builder/region-help.ts +3 -1
- package/src/builder/region-settings.ts +30 -5
- package/src/i18n/ar.ts +5 -0
- package/src/i18n/cs.ts +5 -0
- package/src/i18n/da.ts +5 -0
- package/src/i18n/de.ts +5 -0
- package/src/i18n/el.ts +5 -0
- package/src/i18n/es.ts +5 -0
- package/src/i18n/fi.ts +5 -0
- package/src/i18n/fr.ts +5 -0
- package/src/i18n/he.ts +5 -0
- package/src/i18n/it.ts +5 -0
- package/src/i18n/ja.ts +5 -0
- package/src/i18n/ko.ts +5 -0
- package/src/i18n/nb.ts +5 -0
- package/src/i18n/pl.ts +5 -0
- package/src/i18n/pt.ts +5 -0
- package/src/i18n/ru.ts +5 -0
- package/src/i18n/sv.ts +5 -0
- package/src/i18n/tr.ts +5 -0
- package/src/i18n/uk.ts +5 -0
- package/src/i18n/zh.ts +5 -0
- package/src/i18n.ts +10 -0
- package/src/metadata.ts +72 -9
- package/src/runtime/authz.ts +9 -5
- package/src/runtime/context.ts +4 -0
- package/src/runtime/da-styles.ts +37 -0
- package/src/runtime/documents.ts +3 -2
- package/src/runtime/engine.ts +95 -13
- package/src/runtime/items.ts +33 -0
- package/src/runtime/logic.ts +79 -8
- package/src/runtime/maps.ts +50 -4
- package/src/runtime/process-types.ts +139 -0
- package/src/runtime/processes.ts +2 -0
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +13 -2
- package/src/runtime/routes.ts +2 -2
- package/src/runtime/workflow-diagram.ts +45 -0
- package/src/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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
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,
|
package/src/runtime/authz.ts
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
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
|
}
|
package/src/runtime/context.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/runtime/documents.ts
CHANGED
|
@@ -23,7 +23,8 @@ interface ReportQuery {
|
|
|
23
23
|
authz: string | null;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
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
|
}
|
package/src/runtime/engine.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/runtime/items.ts
CHANGED
|
@@ -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)}&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>`;
|