pgkiln 0.82.0 → 0.84.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 (99) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/db/migrations/154_notifications.sql +186 -0
  3. package/docs/apex-feature-parity.md +4 -3
  4. package/docs/guide/03-builder.md +10 -1
  5. package/docs/guide/04-pages-and-regions.md +44 -1
  6. package/docs/guide/06-processing.md +22 -1
  7. package/docs/guide/07-dynamic-actions.md +28 -0
  8. package/docs/guide/09-reference.md +15 -0
  9. package/docs/guide/11-from-apex.md +3 -1
  10. package/docs/guide/12-development.md +16 -1
  11. package/package.json +1 -1
  12. package/public/app.css +31 -0
  13. package/public/app.js +163 -4
  14. package/public/builder.css +25 -2
  15. package/public/data-modeler.js +8 -5
  16. package/public/sql-monitor.js +5 -2
  17. package/public/sw.js +5 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/ai-builder.ts +50 -46
  20. package/src/builder/api.ts +49 -49
  21. package/src/builder/autorest.ts +92 -87
  22. package/src/builder/changelog.ts +39 -37
  23. package/src/builder/code-editor.ts +1 -1
  24. package/src/builder/components.ts +13 -8
  25. package/src/builder/data-modeler.ts +89 -84
  26. package/src/builder/data-security.ts +39 -37
  27. package/src/builder/dataload.ts +69 -65
  28. package/src/builder/datapump.ts +108 -97
  29. package/src/builder/ddl-tools.ts +38 -34
  30. package/src/builder/document-view.ts +11 -11
  31. package/src/builder/i18n/de.ts +1635 -0
  32. package/src/builder/i18n/en.ts +1635 -0
  33. package/src/builder/i18n/es.ts +1635 -0
  34. package/src/builder/i18n/fr.ts +1635 -0
  35. package/src/builder/i18n/it.ts +1635 -0
  36. package/src/builder/i18n/ja.ts +1635 -0
  37. package/src/builder/i18n/ko.ts +1635 -0
  38. package/src/builder/i18n/nl.ts +1635 -0
  39. package/src/builder/i18n/pt.ts +1635 -0
  40. package/src/builder/i18n/zh-cn.ts +1635 -0
  41. package/src/builder/i18n/zh-tw.ts +1635 -0
  42. package/src/builder/i18n.ts +18 -0
  43. package/src/builder/notification-settings.ts +27 -0
  44. package/src/builder/object-actions.ts +18 -16
  45. package/src/builder/object-browser.ts +2 -2
  46. package/src/builder/querybuilder.ts +42 -36
  47. package/src/builder/quicksql.ts +16 -18
  48. package/src/builder/region-settings.ts +201 -199
  49. package/src/builder/report-settings.ts +64 -60
  50. package/src/builder/rest-modules.ts +145 -143
  51. package/src/builder/routes.ts +3 -0
  52. package/src/builder/sampledata.ts +59 -52
  53. package/src/builder/scheduling.ts +180 -159
  54. package/src/builder/scripts.ts +71 -65
  55. package/src/builder/sql-charts.ts +36 -30
  56. package/src/builder/sql-monitor.ts +95 -89
  57. package/src/builder/sql-utilities.ts +47 -40
  58. package/src/builder/top-sql.ts +21 -17
  59. package/src/builder/unload.ts +48 -44
  60. package/src/builder/worksheet.ts +3 -3
  61. package/src/cli/replace.ts +2 -0
  62. package/src/cron-schedule.ts +47 -17
  63. package/src/i18n/ar.ts +9 -0
  64. package/src/i18n/cs.ts +9 -0
  65. package/src/i18n/da.ts +9 -0
  66. package/src/i18n/de.ts +9 -0
  67. package/src/i18n/el.ts +9 -0
  68. package/src/i18n/es.ts +9 -0
  69. package/src/i18n/fi.ts +9 -0
  70. package/src/i18n/fr.ts +9 -0
  71. package/src/i18n/he.ts +9 -0
  72. package/src/i18n/it.ts +9 -0
  73. package/src/i18n/ja.ts +9 -0
  74. package/src/i18n/ko.ts +9 -0
  75. package/src/i18n/nb.ts +9 -0
  76. package/src/i18n/pl.ts +9 -0
  77. package/src/i18n/pt.ts +9 -0
  78. package/src/i18n/ru.ts +9 -0
  79. package/src/i18n/sv.ts +9 -0
  80. package/src/i18n/tr.ts +9 -0
  81. package/src/i18n/uk.ts +9 -0
  82. package/src/i18n/zh.ts +9 -0
  83. package/src/i18n.ts +18 -0
  84. package/src/metadata.ts +11 -3
  85. package/src/runtime/calendar.ts +3 -2
  86. package/src/runtime/context.ts +2 -0
  87. package/src/runtime/engine.ts +2 -1
  88. package/src/runtime/feedback.ts +1 -1
  89. package/src/runtime/grid.ts +1 -0
  90. package/src/runtime/lists.ts +43 -11
  91. package/src/runtime/maps.ts +7 -4
  92. package/src/runtime/notifications.ts +237 -0
  93. package/src/runtime/processes.ts +45 -1
  94. package/src/runtime/regions.ts +11 -15
  95. package/src/runtime/render.ts +18 -4
  96. package/src/runtime/routes.ts +16 -4
  97. package/src/runtime/signin.ts +2 -1
  98. package/src/runtime/tree.ts +4 -3
  99. package/src/session.ts +13 -4
package/src/i18n/ko.ts CHANGED
@@ -310,6 +310,15 @@ export const ko: Record<MessageKey, string> = {
310
310
  'account.devices_help': "'로그인 유지'를 선택한 브라우저: {count}개.",
311
311
  'account.forget_devices': "모든 기기에서 로그아웃",
312
312
  'account.devices_forgotten': "모든 기기에서 로그아웃했습니다.",
313
+ 'notify.title': "알림",
314
+ 'notify.none': "알림이 없습니다.",
315
+ 'notify.mark_all_read': "모두 읽음으로 표시",
316
+ 'notify.all': "모든 알림",
317
+ 'notify.marked': "알림을 읽음으로 표시했습니다.",
318
+ 'notify.gone': "이 알림은 더 이상 없습니다.",
319
+ 'notify.bell_one': "알림: 읽지 않음 1개",
320
+ 'notify.bell_n': "알림: 읽지 않음 {n}개",
321
+ 'notify.unread_one': "읽지 않음",
313
322
  'push.title': "알림",
314
323
  'push.help': "앱이 닫혀 있을 때도 이 기기에서 이 앱의 알림을 받습니다.",
315
324
  'push.devices': "알림을 받는 기기: {n}.",
package/src/i18n/nb.ts CHANGED
@@ -310,6 +310,15 @@ export const nb: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Nettlesere der du valgte «Hold meg pålogget»: {count}.",
311
311
  'account.forget_devices': "Logg ut på alle enheter",
312
312
  'account.devices_forgotten': "Logget ut på alle enheter.",
313
+ 'notify.title': "Varsler",
314
+ 'notify.none': "Ingen varsler.",
315
+ 'notify.mark_all_read': "Merk alle som lest",
316
+ 'notify.all': "Alle varsler",
317
+ 'notify.marked': "Varslene er merket som lest.",
318
+ 'notify.gone': "Dette varselet finnes ikke lenger.",
319
+ 'notify.bell_one': "Varsler: 1 ulest",
320
+ 'notify.bell_n': "Varsler: {n} uleste",
321
+ 'notify.unread_one': "Ulest",
313
322
  'push.title': "Varsler",
314
323
  'push.help': "Få varsler fra denne appen på denne enheten, også når appen er lukket.",
315
324
  'push.devices': "Enheter med varsler: {n}.",
package/src/i18n/pl.ts CHANGED
@@ -310,6 +310,15 @@ export const pl: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Przeglądarki, w których wybrano „Nie wylogowuj mnie”: {count}.",
311
311
  'account.forget_devices': "Wyloguj na wszystkich urządzeniach",
312
312
  'account.devices_forgotten': "Wylogowano na wszystkich urządzeniach.",
313
+ 'notify.title': "Powiadomienia",
314
+ 'notify.none': "Brak powiadomień.",
315
+ 'notify.mark_all_read': "Oznacz wszystkie jako przeczytane",
316
+ 'notify.all': "Wszystkie powiadomienia",
317
+ 'notify.marked': "Powiadomienia oznaczono jako przeczytane.",
318
+ 'notify.gone': "To powiadomienie już nie istnieje.",
319
+ 'notify.bell_one': "Powiadomienia: 1 nieprzeczytane",
320
+ 'notify.bell_n': "Powiadomienia: nieprzeczytane: {n}",
321
+ 'notify.unread_one': "Nieprzeczytane",
313
322
  'push.title': "Powiadomienia",
314
323
  'push.help': "Otrzymuj powiadomienia z tej aplikacji na tym urządzeniu, także gdy aplikacja jest zamknięta.",
315
324
  'push.devices': "Urządzenia z powiadomieniami: {n}.",
package/src/i18n/pt.ts CHANGED
@@ -310,6 +310,15 @@ export const pt: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Navegadores onde escolheu “Manter a sessão iniciada”: {count}.",
311
311
  'account.forget_devices': "Terminar sessão em todos os dispositivos",
312
312
  'account.devices_forgotten': "Sessão terminada em todos os dispositivos.",
313
+ 'notify.title': "Notificações",
314
+ 'notify.none': "Sem notificações.",
315
+ 'notify.mark_all_read': "Marcar todas como lidas",
316
+ 'notify.all': "Todas as notificações",
317
+ 'notify.marked': "Notificações marcadas como lidas.",
318
+ 'notify.gone': "Esta notificação já não existe.",
319
+ 'notify.bell_one': "Notificações: 1 por ler",
320
+ 'notify.bell_n': "Notificações: {n} por ler",
321
+ 'notify.unread_one': "Por ler",
313
322
  'push.title': "Notificações",
314
323
  'push.help': "Receba notificações desta aplicação neste dispositivo, mesmo com a aplicação fechada.",
315
324
  'push.devices': "Dispositivos com notificações: {n}.",
package/src/i18n/ru.ts CHANGED
@@ -310,6 +310,15 @@ export const ru: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Браузеров, где выбрано «Не выходить»: {count}.",
311
311
  'account.forget_devices': "Выйти на всех устройствах",
312
312
  'account.devices_forgotten': "Выход выполнен на всех устройствах.",
313
+ 'notify.title': "Уведомления",
314
+ 'notify.none': "Уведомлений нет.",
315
+ 'notify.mark_all_read': "Отметить все как прочитанные",
316
+ 'notify.all': "Все уведомления",
317
+ 'notify.marked': "Уведомления отмечены как прочитанные.",
318
+ 'notify.gone': "Этого уведомления больше нет.",
319
+ 'notify.bell_one': "Уведомления: 1 непрочитанное",
320
+ 'notify.bell_n': "Уведомления: непрочитанных — {n}",
321
+ 'notify.unread_one': "Непрочитанное",
313
322
  'push.title': "Уведомления",
314
323
  'push.help': "Получайте уведомления этого приложения на этом устройстве, даже когда приложение закрыто.",
315
324
  'push.devices': "Устройства с уведомлениями: {n}.",
package/src/i18n/sv.ts CHANGED
@@ -310,6 +310,15 @@ export const sv: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Webbläsare där du valde ”Håll mig inloggad”: {count}.",
311
311
  'account.forget_devices': "Logga ut på alla enheter",
312
312
  'account.devices_forgotten': "Utloggad på alla enheter.",
313
+ 'notify.title': "Aviseringar",
314
+ 'notify.none': "Inga aviseringar.",
315
+ 'notify.mark_all_read': "Markera alla som lästa",
316
+ 'notify.all': "Alla aviseringar",
317
+ 'notify.marked': "Aviseringarna har markerats som lästa.",
318
+ 'notify.gone': "Den här aviseringen finns inte längre.",
319
+ 'notify.bell_one': "Aviseringar: 1 oläst",
320
+ 'notify.bell_n': "Aviseringar: {n} olästa",
321
+ 'notify.unread_one': "Oläst",
313
322
  'push.title': "Aviseringar",
314
323
  'push.help': "Få aviseringar från den här appen på den här enheten, även när appen är stängd.",
315
324
  'push.devices': "Enheter med aviseringar: {n}.",
package/src/i18n/tr.ts CHANGED
@@ -310,6 +310,15 @@ export const tr: Record<MessageKey, string> = {
310
310
  'account.devices_help': "“Oturumum açık kalsın” seçtiğiniz tarayıcılar: {count}.",
311
311
  'account.forget_devices': "Tüm cihazlarda oturumu kapat",
312
312
  'account.devices_forgotten': "Tüm cihazlarda oturum kapatıldı.",
313
+ 'notify.title': "Bildirimler",
314
+ 'notify.none': "Bildirim yok.",
315
+ 'notify.mark_all_read': "Tümünü okundu olarak işaretle",
316
+ 'notify.all': "Tüm bildirimler",
317
+ 'notify.marked': "Bildirimler okundu olarak işaretlendi.",
318
+ 'notify.gone': "Bu bildirim artık yok.",
319
+ 'notify.bell_one': "Bildirimler: 1 okunmamış",
320
+ 'notify.bell_n': "Bildirimler: {n} okunmamış",
321
+ 'notify.unread_one': "Okunmamış",
313
322
  'push.title': "Bildirimler",
314
323
  'push.help': "Uygulama kapalıyken de bu cihazda bu uygulamanın bildirimlerini alın.",
315
324
  'push.devices': "Bildirim alan cihazlar: {n}.",
package/src/i18n/uk.ts CHANGED
@@ -310,6 +310,15 @@ export const uk: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Браузерів, де вибрано «Не виходити»: {count}.",
311
311
  'account.forget_devices': "Вийти на всіх пристроях",
312
312
  'account.devices_forgotten': "Вихід виконано на всіх пристроях.",
313
+ 'notify.title': "Сповіщення",
314
+ 'notify.none': "Сповіщень немає.",
315
+ 'notify.mark_all_read': "Позначити всі як прочитані",
316
+ 'notify.all': "Усі сповіщення",
317
+ 'notify.marked': "Сповіщення позначено як прочитані.",
318
+ 'notify.gone': "Цього сповіщення більше немає.",
319
+ 'notify.bell_one': "Сповіщення: 1 непрочитане",
320
+ 'notify.bell_n': "Сповіщення: непрочитаних — {n}",
321
+ 'notify.unread_one': "Непрочитане",
313
322
  'push.title': "Сповіщення",
314
323
  'push.help': "Отримуйте сповіщення цього застосунку на цьому пристрої, навіть коли застосунок закрито.",
315
324
  'push.devices': "Пристрої зі сповіщеннями: {n}.",
package/src/i18n/zh.ts CHANGED
@@ -310,6 +310,15 @@ export const zh: Record<MessageKey, string> = {
310
310
  'account.devices_help': "您选择了“保持登录”的浏览器:{count}。",
311
311
  'account.forget_devices': "在所有设备上退出登录",
312
312
  'account.devices_forgotten': "已在所有设备上退出登录。",
313
+ 'notify.title': "通知",
314
+ 'notify.none': "没有通知。",
315
+ 'notify.mark_all_read': "全部标为已读",
316
+ 'notify.all': "所有通知",
317
+ 'notify.marked': "通知已标为已读。",
318
+ 'notify.gone': "此通知已不存在。",
319
+ 'notify.bell_one': "通知:1 条未读",
320
+ 'notify.bell_n': "通知:{n} 条未读",
321
+ 'notify.unread_one': "未读",
313
322
  'push.title': "通知",
314
323
  'push.help': "在此设备上接收此应用的通知,即使应用已关闭。",
315
324
  'push.devices': "接收通知的设备:{n}。",
package/src/i18n.ts CHANGED
@@ -337,6 +337,15 @@ const en = {
337
337
  'account.devices_help': 'Browsers where you chose "Keep me signed in": {count}.',
338
338
  'account.forget_devices': 'Sign out on all devices',
339
339
  'account.devices_forgotten': 'Signed out on all devices.',
340
+ 'notify.title': "Notifications",
341
+ 'notify.none': "No notifications.",
342
+ 'notify.mark_all_read': "Mark all read",
343
+ 'notify.all': "All notifications",
344
+ 'notify.marked': "Notifications marked as read.",
345
+ 'notify.gone': "This notification no longer exists.",
346
+ 'notify.bell_one': "Notifications: 1 unread",
347
+ 'notify.bell_n': "Notifications: {n} unread",
348
+ 'notify.unread_one': "Unread",
340
349
  'push.title': "Notifications",
341
350
  'push.help': "Get notifications from this app on this device, also when the app is closed.",
342
351
  'push.devices': "Devices with notifications: {n}.",
@@ -1247,6 +1256,15 @@ const nl: Record<MessageKey, string> = {
1247
1256
  'account.devices_help': 'Browsers waarin je "Ingelogd blijven" koos: {count}.',
1248
1257
  'account.forget_devices': 'Overal afmelden',
1249
1258
  'account.devices_forgotten': 'Op alle apparaten afgemeld.',
1259
+ 'notify.title': "Meldingen",
1260
+ 'notify.none': "Geen meldingen.",
1261
+ 'notify.mark_all_read': "Alles gelezen",
1262
+ 'notify.all': "Alle meldingen",
1263
+ 'notify.marked': "Meldingen als gelezen gemarkeerd.",
1264
+ 'notify.gone': "Deze melding bestaat niet meer.",
1265
+ 'notify.bell_one': "Meldingen: 1 ongelezen",
1266
+ 'notify.bell_n': "Meldingen: {n} ongelezen",
1267
+ 'notify.unread_one': "Ongelezen",
1250
1268
  'push.title': "Meldingen",
1251
1269
  'push.help': "Ontvang meldingen van deze app op dit apparaat, ook als de app gesloten is.",
1252
1270
  'push.devices': "Apparaten met meldingen: {n}.",
package/src/metadata.ts CHANGED
@@ -112,6 +112,10 @@ export interface App {
112
112
  availability_users: string[];
113
113
  /** (084) users can send feedback from the navigation bar */
114
114
  feedback: boolean;
115
+ /** (154) the notification bell in the header */
116
+ notifications: boolean;
117
+ /** (154) refresh the badges every so many seconds (null: never) */
118
+ live_badges_s: number | null;
115
119
  /** (107) the application's version (Release 1.0), shown in its pages' footer */
116
120
  version: string | null;
117
121
  /** (085) schema.function(jsonb) returns jsonb that rewrites error messages; seconds of the session timeout warning */
@@ -320,7 +324,9 @@ export interface DynamicAction {
320
324
  /** (124) a menu button's entry {"action": NAME} was chosen (trigger: entry or menu button names) */
321
325
  | 'menu_entry'
322
326
  /** (149) */
323
- | 'double_click' | 'key_down' | 'key_press' | 'mouse_enter' | 'mouse_leave' | 'select' | 'resize' | 'scroll' | 'page_unload' | 'before_refresh' | 'facets_change';
327
+ | 'double_click' | 'key_down' | 'key_press' | 'mouse_enter' | 'mouse_leave' | 'select' | 'resize' | 'scroll' | 'page_unload' | 'before_refresh' | 'facets_change'
328
+ /** (153) component events: trigger = the grid, calendar, map or tree region */
329
+ | 'grid_row_init' | 'grid_save' | 'calendar_date_select' | 'calendar_view_change' | 'map_object_click' | 'map_change' | 'tree_selection_change' | 'timer';
324
330
  trigger_element: string | null;
325
331
  condition_type: 'equals' | 'not_equals' | 'in_list' | 'is_null' | 'is_not_null' | 'greater' | 'greater_equal' | 'less' | 'less_equal' | 'not_in_list' | null;
326
332
  condition_value: string | null;
@@ -381,7 +387,9 @@ export interface Process {
381
387
  type: 'form_dml' | 'grid_dml' | 'sql' | 'data_load' | 'invoke_api' | 'download' | 'chain' | 'workflow' | 'ai_generate' | 'plugin' | 'send_push' | 'send_email'
382
388
  /** (150) */
383
389
  | 'close_dialog' | 'clear_session_state' | 'reset_pagination' | 'user_preferences' | 'server_geocoding' | 'print_report'
384
- | 'human_task_create' | 'human_task_manage' | 'form_init';
390
+ | 'human_task_create' | 'human_task_manage' | 'form_init'
391
+ /** (154) */
392
+ | 'send_notification';
385
393
  region_id: number | null;
386
394
  code: string | null;
387
395
  config: Record<string, unknown> | null;
@@ -441,7 +449,7 @@ const agg = (table: string, fk: string, parent: string, appId: string) =>
441
449
  export async function loadApp(alias: string) {
442
450
  return runtime.one<App>(
443
451
  `select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.mfa, a.mfa_roles, a.mfa_email, a.api_self_tokens, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
444
- a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds, a.component_settings,
452
+ a.availability, a.availability_message, a.availability_users, a.feedback, a.notifications, a.live_badges_s, a.error_function, a.session_warning_seconds, a.component_settings,
445
453
  a.language, a.languages, a.language_from, a.date_format, a.timestamp_format, a.time_zone, a.time_zone_auto, a.currency,
446
454
  coalesce((select jsonb_agg(jsonb_build_object('name', l.name, 'query', l.query, 'rest_source', l.rest_source)) from meta.lov l where l.app_id = a.id), '[]') as lovs,
447
455
  coalesce((select jsonb_agg(jsonb_build_object('page_no', p.page_no, 'name', p.name, 'title', p.title,
@@ -229,7 +229,8 @@ export async function renderCalendar(ctx: PageContext, r: Region): Promise<Raw>
229
229
  return html`<a class="cal-add" ${linkAttrs(ctx, create.page, items)} aria-label="${label}" title="${label}">+</a>`;
230
230
  };
231
231
  const cellAttrs = (day: Date, hour: number | null) =>
232
- raw(`${movable ? ` data-drop="${iso(day)}${hour === null ? '' : `T${pad(hour)}:00`}"` : ''}${createOk ? ' data-add' : ''}`);
232
+ // (161) data-date: what a calendar_date_select dynamic action's condition sees
233
+ raw(`${movable ? ` data-drop="${iso(day)}${hour === null ? '' : `T${pad(hour)}:00`}"` : ''}${createOk ? ' data-add' : ''} data-date="${iso(day)}${hour === null ? '' : `T${pad(hour)}:00`}"`);
233
234
  const on = (day: string) => events.filter((ev) => ev.start <= day && ev.end >= day);
234
235
  const go = (v: CalendarView, day: Date | null) =>
235
236
  regionUrl(ctx, r, (p) => {
@@ -342,7 +343,7 @@ export async function renderCalendar(ctx: PageContext, r: Region): Promise<Raw>
342
343
  (v) => html`<a class="btn${v === view ? ' active' : ''}" href="${go(v, anchor)}"${v === view ? raw(' aria-current="page"') : ''}>${t(`calendar.view_${v}`)}</a>`,
343
344
  )}</nav>`
344
345
  : '';
345
- return html`<div class="calendar cal-view-${view}"${movable ? raw(` data-calendar="${r.id}"`) : ''}>
346
+ return html`<div class="calendar cal-view-${view}" data-view="${view}"${movable ? raw(` data-calendar="${r.id}"`) : ''}>
346
347
  <div class="cal-toolbar">
347
348
  <div class="buttons">${nav('‹', prev, prevLabel)}${nav(t('calendar.today'), null)}${nav('›', next, nextLabel)}</div>
348
349
  <h3 class="cal-title" aria-live="polite">${title}</h3>
@@ -38,6 +38,8 @@ export interface PageContext {
38
38
  ip: string;
39
39
  errors: Errors;
40
40
  messages: string[];
41
+ /** (161) grid regions whose changes this request saved (POST), or the request before it saved (GET): grid_save dynamic actions */
42
+ savedGrids?: number[];
41
43
  /** rendered inside a modal dialog (iframe) */
42
44
  dialog: boolean;
43
45
  client?: Client;
@@ -18,7 +18,7 @@ import { parseDateMask } from './format.ts';
18
18
  import { conditionHolds, functionBody } from './logic.ts';
19
19
  import { runPluginProcess } from './plugins.ts';
20
20
  import { runDeclarative } from './process-types.ts';
21
- import { BACKGROUND_TYPES, downloadFile, sendEmailProcess, sendPushProcess, workflowProcess, type ChainConfig } from './processes.ts';
21
+ import { BACKGROUND_TYPES, downloadFile, sendEmailProcess, sendNotificationProcess, sendPushProcess, workflowProcess, type ChainConfig } from './processes.ts';
22
22
  import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, toState, type Errors, type PageContext } from './context.ts';
23
23
 
24
24
  const ident = pg.escapeIdentifier;
@@ -621,6 +621,7 @@ async function runOneStep(ctx: PageContext, p: Process, names: Set<string>, dept
621
621
  case 'invoke_api': return await invokeApi(ctx, p, names);
622
622
  case 'workflow': return await workflowProcess(ctx, p, names);
623
623
  case 'send_push': return await sendPushProcess(ctx, p);
624
+ case 'send_notification': return await sendNotificationProcess(ctx, p);
624
625
  case 'send_email': return await sendEmailProcess(ctx, p);
625
626
  case 'ai_generate': return (await runAiProcess(ctx, p, names)).message;
626
627
  case 'plugin': return await runPluginProcess(ctx, p.config, p.name);
@@ -29,7 +29,7 @@ export function feedbackNode(ctx: PageContext): ListNode | null {
29
29
  if (!ctx.app.feedback || ctx.dialog) return null;
30
30
  return {
31
31
  label: ctx.locale.t('feedback.link'), href: `${ctx.base}/${ctx.page.page_no}/feedback`, modal: false, external: false,
32
- icon: 'chat', badge: null, description: null, current: false, inTrail: false, children: [],
32
+ icon: 'chat', badge: null, key: '__feedback', description: null, current: false, inTrail: false, children: [],
33
33
  };
34
34
  }
35
35
 
@@ -831,5 +831,6 @@ export async function gridDml(ctx: PageContext, p: Process): Promise<string | nu
831
831
  if (errors.length) throw new Error(errors.join(' '));
832
832
  const t = ctx.locale.t;
833
833
  const parts = [inserted && t('grid.added', { n: inserted }), updated && t('grid.updated', { n: updated }), deleted && t('grid.deleted', { n: deleted })].filter(Boolean);
834
+ (ctx.savedGrids ??= []).push(r.id);
834
835
  return p.success_message ?? (parts.length ? t('grid.saved_parts', { parts: parts.join(', ') }) : t('grid.no_changes'));
835
836
  }
@@ -28,6 +28,8 @@ export interface ListNode {
28
28
  external: boolean;
29
29
  icon: string | null;
30
30
  badge: string | null;
31
+ /** (154) the entry's id (a query's id column, else its label): finds its badge again for a live refresh */
32
+ key: string;
31
33
  description: string | null;
32
34
  /** the entry's page is the current page */
33
35
  current: boolean;
@@ -50,6 +52,8 @@ interface Entry {
50
52
  url: string | null;
51
53
  icon: string | null;
52
54
  badge: string | null;
55
+ /** (154) a SELECT whose first value is the badge (static entries; wins over badge) */
56
+ badge_query?: string | null;
53
57
  description: string | null;
54
58
  condition?: string | null;
55
59
  authz?: string | null;
@@ -105,7 +109,7 @@ async function entriesOf(ctx: PageContext, name: string): Promise<Entry[] | null
105
109
  }
106
110
  const rows = (
107
111
  await runtime.query(
108
- `select id, parent_id, label, target_page, target_items, target_url, icon, badge, description, condition, authz
112
+ `select id, parent_id, label, target_page, target_items, target_url, icon, badge, badge_query, description, condition, authz
109
113
  from meta.list_entry where app_id = $1 and list_name = $2 and meta.build_option_on(app_id, build_option)
110
114
  order by seq, id`,
111
115
  [ctx.app.id, name.toUpperCase()],
@@ -113,10 +117,28 @@ async function entriesOf(ctx: PageContext, name: string): Promise<Entry[] | null
113
117
  ).rows;
114
118
  return rows.map((r) => ({
115
119
  id: r.id, parent_id: r.parent_id, label: ctx.locale.tr(r.label), page: r.target_page, items: r.target_items ?? {}, url: r.target_url,
116
- icon: r.icon, badge: r.badge, description: r.description ? ctx.locale.tr(r.description) : null, condition: r.condition, authz: r.authz,
120
+ icon: r.icon, badge: r.badge, badge_query: r.badge_query, description: r.description ? ctx.locale.tr(r.description) : null, condition: r.condition, authz: r.authz,
117
121
  }));
118
122
  }
119
123
 
124
+ /**
125
+ * (029, 154) A badge from SQL: the first value of the query's first row, run as the application's
126
+ * role with the page's binds; an error is a page error and no badge. Empty values give no badge.
127
+ */
128
+ export async function queryBadge(ctx: PageContext, query: string, what: string): Promise<string | null> {
129
+ const c = ctx.client;
130
+ if (!c) return null;
131
+ try {
132
+ const sql = stripSemicolon(applyBinds(query, bindValues(ctx)));
133
+ const res = await savepoint(c, () => c.query({ text: sql, rowMode: 'array' }));
134
+ const v = res.rows[0]?.[0];
135
+ return v === null || v === undefined ? null : String(v).trim().slice(0, 100) || null;
136
+ } catch (e) {
137
+ ctx.errors.page.push(await publicError(ctx, e, what));
138
+ return null;
139
+ }
140
+ }
141
+
120
142
  /** The current page and its parent pages (for "in trail"). */
121
143
  function trail(ctx: PageContext) {
122
144
  const pages = new Set<number>();
@@ -156,7 +178,10 @@ export async function listTree(ctx: PageContext, name: string): Promise<ListNode
156
178
  modal: !!e.page && isModal(ctx, e.page),
157
179
  external,
158
180
  icon: e.icon,
159
- badge: e.badge === null ? null : substitute(e.badge, ctx, (x) => x),
181
+ badge: e.badge_query?.trim()
182
+ ? await queryBadge(ctx, e.badge_query, `badge of list entry "${e.label}"`)
183
+ : e.badge === null ? null : substitute(e.badge, ctx, (x) => x),
184
+ key: String(e.id ?? e.label),
160
185
  description: e.description === null ? null : substitute(e.description, ctx, (x) => x),
161
186
  current: isCurrent,
162
187
  inTrail: isCurrent || (e.page !== null && current.has(e.page)) || children.some((c) => c.inTrail),
@@ -179,15 +204,22 @@ export async function listTree(ctx: PageContext, name: string): Promise<ListNode
179
204
 
180
205
  const linkAttrs = (n: ListNode, cls?: string) =>
181
206
  html`href="${n.href}"${cls ? html` class="${cls}"` : ''}${n.current ? raw(' aria-current="page"') : ''}${n.modal ? raw(' data-dialog') : ''}${n.external ? raw(' rel="noopener noreferrer"') : ''}`;
182
- const badge = (n: ListNode) => (n.badge !== null ? html` <span class="badge">${n.badge}</span>` : '');
207
+ /**
208
+ * An entry's badge. live: the prefix of the badge's data-badge key when the application refreshes
209
+ * badges (154); an empty badge is then a hidden placeholder the refresh can fill.
210
+ */
211
+ const badge = (n: ListNode, live?: string) =>
212
+ n.badge !== null
213
+ ? html` <span class="badge"${live ? html` data-badge="${live}${n.key}"` : ''}>${n.badge}</span>`
214
+ : live ? html` <span class="badge" data-badge="${live}${n.key}" hidden></span>` : '';
183
215
 
184
216
  /** Entries as the navigation menu (the same markup as navigation entries). */
185
- export function navMarkup(nodes: ListNode[], topNav: boolean): Raw[] {
217
+ export function navMarkup(nodes: ListNode[], topNav: boolean, live?: string): Raw[] {
186
218
  return nodes.map((n) => {
187
- const label = html`${icon(n.icon ?? 'chevron')}<span>${n.label}</span>${badge(n)}`;
219
+ const label = html`${icon(n.icon ?? 'chevron')}<span>${n.label}</span>${badge(n, live)}`;
188
220
  if (n.children.length)
189
221
  return html`<li><details${n.inTrail && !topNav ? raw(' open') : ''}><summary class="${n.inTrail ? 'in-trail' : null}">${label}</summary><ul>${
190
- n.href ? html`<li><a ${linkAttrs(n)}>${label}</a></li>` : ''}${navMarkup(n.children, topNav)}</ul></details></li>`;
222
+ n.href ? html`<li><a ${linkAttrs(n)}>${label}</a></li>` : ''}${navMarkup(n.children, topNav, live)}</ul></details></li>`;
191
223
  return html`<li><a ${linkAttrs(n, !n.current && n.inTrail ? 'in-trail' : undefined)}>${label}</a></li>`;
192
224
  });
193
225
  }
@@ -195,13 +227,13 @@ export function navMarkup(nodes: ListNode[], topNav: boolean): Raw[] {
195
227
  const hasIcon = (n: ListNode) => isIcon(n.icon);
196
228
 
197
229
  /** Entries as the navigation bar in the header: links, with a menu for entries with children (labels of entries with an icon hide on small screens). */
198
- export function navbarMarkup(nodes: ListNode[], label: string): Raw {
230
+ export function navbarMarkup(nodes: ListNode[], label: string, live?: string): Raw {
199
231
  if (!nodes.length) return raw('');
200
232
  return html`<nav class="t-navbar" aria-label="${label}"><ul>${nodes.map((n) =>
201
233
  n.children.length
202
- ? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon" aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
203
- [...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c)}</a></li>`)}</ul></div></details></li>`
204
- : html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}${hasIcon(n) ? html` aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</a></li>`)}</ul></nav>`;
234
+ ? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon" aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
235
+ [...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c, live)}</a></li>`)}</ul></div></details></li>`
236
+ : html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}${hasIcon(n) ? html` aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</a></li>`)}</ul></nav>`;
205
237
  }
206
238
 
207
239
  function linksMarkup(nodes: ListNode[]): Raw {
@@ -13,7 +13,7 @@ import { encodeTile, tileArea, type TileFeature } from '../mvt.ts';
13
13
 
14
14
  // Map region (APEX: Map region). The SELECT returns, per row, a position
15
15
  // (columns lat and lng, latitude and longitude, or location as "lat,lng")
16
- // and optionally title, body and geojson (a GeoJSON geometry or feature for
16
+ // and optionally id (what a map_object_click dynamic action sees), title, body and geojson (a GeoJSON geometry or feature for
17
17
  // lines and areas; a GeoJSON point is a place), and weight (for a heat map).
18
18
  // With PostGIS installed, a geometry or geography column (WGS 84) is turned
19
19
  // into GeoJSON on the server (ST_AsGeoJSON), so no geojson column is needed.
@@ -59,6 +59,8 @@ interface Point {
59
59
  body: string;
60
60
  href: string | null;
61
61
  weight: number;
62
+ /** (161) the row's id column: what a map_object_click dynamic action's condition sees */
63
+ id?: string;
62
64
  }
63
65
  /** (159, CD-6) a shape layer's look: fixed in the layer's settings, or per row from columns of the same names */
64
66
  export interface ShapeStyle {
@@ -164,6 +166,7 @@ async function loadLayer(ctx: PageContext, def: LayerDef, g: PostGis | null, opt
164
166
  if ((Number.isNaN(lat) || Number.isNaN(lng)) && loc) [lat, lng] = [Number(loc[1]), Number(loc[2])];
165
167
  const title = toState(col(row, 'title')) ?? '';
166
168
  const body = toState(col(row, 'body')) ?? '';
169
+ const id = toState(col(row, 'id')) ?? undefined;
167
170
  let href: string | null = null;
168
171
  if (linkOk && link) {
169
172
  const items: Record<string, string> = {};
@@ -184,14 +187,14 @@ async function loadLayer(ctx: PageContext, def: LayerDef, g: PostGis | null, opt
184
187
  ? { ...def.style, ...shapeStyle({ fill_color: col(row, 'fill_color'), stroke_color: col(row, 'stroke_color'), stroke_width: col(row, 'stroke_width'), opacity: col(row, 'opacity') }) }
185
188
  : undefined;
186
189
  const h = num(col(row, 'height'));
187
- shapes.push({ type: 'Feature', geometry, properties: { title, body, href, ...(style ? { style } : {}), ...(def.kind === 'extruded' && Number.isFinite(h) ? { height: h } : {}) } });
190
+ shapes.push({ type: 'Feature', geometry, properties: { title, body, href, ...(id !== undefined ? { id } : {}), ...(style ? { style } : {}), ...(def.kind === 'extruded' && Number.isFinite(h) ? { height: h } : {}) } });
188
191
  }
189
192
  }
190
193
  // a shape layer draws only shapes
191
194
  if (SHAPE_KINDS.has(def.kind)) continue;
192
195
  const w = num(col(row, 'weight'));
193
196
  const weight = Number.isFinite(w) && w > 0 ? w : Number.isNaN(w) ? 1 : 0;
194
- if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) points.push({ lat, lng, title, body, href, weight });
197
+ if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) points.push({ lat, lng, title, body, href, weight, ...(id !== undefined ? { id } : {}) });
195
198
  }
196
199
  // (159) extruded: each polygon shaded by its height (from light to strong; no 3D view)
197
200
  if (def.kind === 'extruded') {
@@ -296,7 +299,7 @@ export function parseTile(z: string, x: string, y: string) {
296
299
  export async function layerTile(ctx: PageContext, def: LayerDef, t: { z: number; x: number; y: number }) {
297
300
  const { points, shapes, truncated } = await loadLayer(ctx, def, await postgis(), { area: tileArea(t.z, t.x, t.y, 1 / 16), limit: MAX_TILE_ROWS });
298
301
  const features: TileFeature[] = [
299
- ...points.map((p) => ({ geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, properties: { title: p.title, body: p.body, href: p.href, weight: p.weight } })),
302
+ ...points.map((p) => ({ geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, properties: { title: p.title, body: p.body, href: p.href, weight: p.weight, ...(p.id !== undefined ? { id: p.id } : {}) } })),
300
303
  ...(shapes as { geometry: TileFeature['geometry']; properties: TileFeature['properties'] }[]).map((s) => ({ geometry: s.geometry, properties: s.properties })),
301
304
  ];
302
305
  return { tile: encodeTile('places', t.z, t.x, t.y, features), truncated };