pgkiln 0.31.0 → 0.35.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 (107) hide show
  1. package/README.md +2 -2
  2. package/SECURITY.md +10 -0
  3. package/db/migrations/076_form_lost_update.sql +50 -0
  4. package/db/migrations/077_warn_unsaved.sql +41 -0
  5. package/db/migrations/078_url_region.sql +12 -0
  6. package/db/migrations/079_developer_toolbar.sql +53 -0
  7. package/db/migrations/080_search.sql +326 -0
  8. package/db/migrations/081_mail.sql +621 -0
  9. package/db/migrations/082_app_backups.sql +73 -0
  10. package/db/migrations/083_availability.sql +156 -0
  11. package/db/migrations/084_feedback.sql +46 -0
  12. package/db/migrations/085_page_behaviour.sql +65 -0
  13. package/db/migrations/086_data_settings.sql +599 -0
  14. package/docs/apex-feature-parity.md +55 -35
  15. package/docs/guide/01-installation.md +1 -0
  16. package/docs/guide/03-builder.md +118 -2
  17. package/docs/guide/04-pages-and-regions.md +115 -0
  18. package/docs/guide/05-items.md +47 -0
  19. package/docs/guide/06-processing.md +210 -2
  20. package/docs/guide/07-dynamic-actions.md +60 -2
  21. package/docs/guide/08-security.md +13 -2
  22. package/docs/guide/09-reference.md +21 -0
  23. package/docs/guide/11-from-apex.md +5 -1
  24. package/docs/guide/12-development.md +33 -1
  25. package/docs/guide/14-globalization.md +15 -0
  26. package/docs/guide/15-extensions.md +1 -1
  27. package/docs/guide/18-cli.md +30 -1
  28. package/docs/guide/20-ai-agents.md +3 -2
  29. package/examples/hr/hr_50_search.sql +47 -0
  30. package/examples/hr/hr_51_mail.sql +70 -0
  31. package/examples/hr/hr_52_feedback.sql +15 -0
  32. package/examples/hr/hr_53_data_settings.sql +108 -0
  33. package/package.json +3 -1
  34. package/public/app.css +62 -0
  35. package/public/app.js +408 -11
  36. package/public/builder.css +11 -0
  37. package/public/builder.js +27 -0
  38. package/src/app.ts +8 -0
  39. package/src/appfiles.ts +12 -0
  40. package/src/automations.ts +12 -0
  41. package/src/builder/advisor.ts +54 -14
  42. package/src/builder/arrange.ts +2 -2
  43. package/src/builder/availability.ts +74 -0
  44. package/src/builder/backups.ts +134 -0
  45. package/src/builder/components.ts +109 -15
  46. package/src/builder/designer.ts +8 -3
  47. package/src/builder/feedback.ts +82 -0
  48. package/src/builder/forms.ts +4 -1
  49. package/src/builder/home.ts +3 -0
  50. package/src/builder/locks.ts +15 -5
  51. package/src/builder/mail.ts +425 -0
  52. package/src/builder/newapp.ts +1 -1
  53. package/src/builder/region-help.ts +71 -0
  54. package/src/builder/routes.ts +44 -1
  55. package/src/builder/search.ts +4 -4
  56. package/src/builder/shared.ts +3 -2
  57. package/src/builder/ui.ts +7 -1
  58. package/src/cli/apps.ts +46 -5
  59. package/src/cli/main.ts +21 -5
  60. package/src/cli/mcp.ts +16 -4
  61. package/src/cli/replace.ts +36 -4
  62. package/src/geocode.ts +44 -0
  63. package/src/i18n/ar.ts +39 -0
  64. package/src/i18n/cs.ts +39 -0
  65. package/src/i18n/da.ts +39 -0
  66. package/src/i18n/de.ts +39 -0
  67. package/src/i18n/el.ts +39 -0
  68. package/src/i18n/es.ts +39 -0
  69. package/src/i18n/fi.ts +39 -0
  70. package/src/i18n/fr.ts +39 -0
  71. package/src/i18n/he.ts +39 -0
  72. package/src/i18n/it.ts +39 -0
  73. package/src/i18n/ja.ts +39 -0
  74. package/src/i18n/ko.ts +39 -0
  75. package/src/i18n/nb.ts +39 -0
  76. package/src/i18n/pl.ts +39 -0
  77. package/src/i18n/pt.ts +39 -0
  78. package/src/i18n/ru.ts +39 -0
  79. package/src/i18n/sv.ts +39 -0
  80. package/src/i18n/tr.ts +39 -0
  81. package/src/i18n/uk.ts +39 -0
  82. package/src/i18n/zh.ts +39 -0
  83. package/src/i18n.ts +78 -0
  84. package/src/mail/mail.ts +148 -0
  85. package/src/mail/providers.ts +444 -0
  86. package/src/metadata.ts +26 -8
  87. package/src/runtime/ajax.ts +97 -0
  88. package/src/runtime/authz.ts +1 -1
  89. package/src/runtime/availability.ts +37 -0
  90. package/src/runtime/context.ts +46 -6
  91. package/src/runtime/data-export.ts +94 -0
  92. package/src/runtime/data-items.ts +194 -0
  93. package/src/runtime/devbar.ts +182 -0
  94. package/src/runtime/engine.ts +26 -1
  95. package/src/runtime/feedback.ts +128 -0
  96. package/src/runtime/items.ts +8 -0
  97. package/src/runtime/processes.ts +83 -2
  98. package/src/runtime/regions.ts +8 -0
  99. package/src/runtime/render.ts +33 -7
  100. package/src/runtime/rest.ts +4 -0
  101. package/src/runtime/routes.ts +59 -5
  102. package/src/runtime/search.ts +252 -0
  103. package/src/runtime/url-region.ts +93 -0
  104. package/src/security.ts +11 -1
  105. package/src/server.ts +2 -0
  106. package/src/session.ts +8 -2
  107. package/src/workingcopy.ts +2 -2
@@ -3,6 +3,8 @@
3
3
  // from this spec, and SQL column names only ever come from here (never from
4
4
  // the request).
5
5
 
6
+ import { ALL_REGION_CONFIG_HELP, regionConfigHelp } from './region-help.ts';
7
+ import { searchConfigProblems } from '../runtime/search.ts';
6
8
  import { objectStoreProblem } from '../objectstore.ts';
7
9
  import { formatSettingsProblem } from '../runtime/format.ts';
8
10
  import { scheduleProblem } from '../automations.ts';
@@ -49,6 +51,8 @@ export interface Field {
49
51
  group?: string;
50
52
  /** shown, not editable (the form still posts it) */
51
53
  readonly?: boolean;
54
+ /** (sprint 51) help that depends on the component being edited (wins over `help` when it gives some) */
55
+ helpBy?: (row: any) => { text: string; url: string | null } | null;
52
56
  }
53
57
 
54
58
  export interface ComponentSpec {
@@ -100,9 +104,9 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
100
104
  { name: 'title', label: 'Title', kind: 'text', group: 'Identification' },
101
105
  { name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
102
106
  help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
103
- { name: 'type', label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin'], group: 'Identification' },
107
+ { name: 'type', label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
104
108
  { name: 'source', label: 'Source', kind: 'code', wide: true, group: 'Source',
105
- help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions.' },
109
+ help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
106
110
  { name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
107
111
  help: 'Read the rows of a REST data source (Shared Components) instead of a table: the source above is then optional SQL over them, e.g. select * from rest where price > 10. Parameters: {"rest_params": {"city": "&P1_CITY."}} in the attributes.' },
108
112
  { name: 'table_name', label: 'Table (form, grid)', kind: 'text', help: 'e.g. sales.orders', group: 'Source' },
@@ -117,7 +121,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
117
121
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
118
122
  buildOption('Security'),
119
123
  { name: 'config', label: 'Attributes (JSON)', kind: 'json', wide: true, group: 'Attributes',
120
- help: 'report: {"page_size":15,"pagination":"range" (rows X–Y without a total),"keyset":["id"] (with range: Next/Previous seek on these unique columns),"max_rows":10000,"searchable":true,"sortable":true,"interactive":true,"mobile":"reflow"|"scroll","hidden":["col"],"headings":{"col":"Label"},"link":{"column":"id","page":3,"items":{"P3_ID":"#id#"}},"empty":"No rows","pdf":{"layout":"NAME","columns":["col"],"widths":{"col":40},"align":{"col":"right"}}} (pdf widths in mm; layouts under Shared Components → Report layouts) · report, grid, cards: {"formats":{"sal":"FML999G990D00","hiredate":"DD-MON-YYYY"}} (number or date format masks per column) · chart: {"kind":"bar"|"column"|"line"|"area"|"donut","max_rows":1000,"format_mask":"FML999G990"} · cards: {"style":"metric","link":{...},"max_rows":500} · dynamic: {"max_rows":1000} · grid: {"page_size":25,"allow":{"insert":true,"update":true,"delete":true},"readonly":["col"],"columns":{"deptno":{"lov":"LOV:DEPARTMENTS","required":true}},"aggregates":{"sal":["sum","avg"]},"layout":{"order":["col"],"hidden":["col"],"widths":{"col":160},"frozen":1},"row_actions":{"edit":{"page":3,"items":{"P3_ID":"#id#"}},"duplicate":true,"delete":true,"links":[{"label":"…","page":4,"items":{}}]},"select_row":{"column":"id","item":"P1_ID"} (a master),"master":{"item":"P1_ID","column":"parent_id"} (a detail; any region type),"actions":true,"saved_reports":true,"public_reports":"SCHEME"} · calendar: {"link":{...}} · facets: {"report":<region id>,"search":true,"facets":[{"column":"job","label":"Job","exclude":true},{"column":"sal","type":"range","ranges":[{"to":1000},{"from":1000}],"custom":true},{"column":"rating","type":"star","max":5}]} · smart_filters: {"report":<region id>,"suggestions":3,"placeholder":"…","facets":[…as facets]} · display_selector: {"style":"tabs"|"select","show_all":true,"remember":true} · list: {"list":"NAME","template":"links"|"badges"|"cards"|"tabs"} (lists under Shared Components → Lists); any region: {"display_selector":true} puts it in the page\'s display selector; report, chart, cards, dynamic, tree and template component regions: {"lazy":true} loads it after the page shows, {"cache":{"scope":"user"|"session"|"all","seconds":300}} keeps its HTML (a submit of the page empties it) · data_reporter: {"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"order_v","columns":[{"name":"total","label":"Total","format":"FML999G990D00"}]}],"sharing":true,"share_authz":"SCHEME","page_size":25} (the Data Reporter settings below edit it) · ai_assistant: {"service":"NAME","system":"…","welcome":"…","context":[{"name":"policy","sql":"select … :AI_PROMPT …"}],"tools":[{"name":"my_leave","description":"…","sql":"select … where status = :STATUS","parameters":{"STATUS":{"type":"string","enum":["PENDING"]}}},{"name":"dept","type":"rest","source":"DEPARTMENT","description":"…"}],"max_rounds":5,"max_turns":20} (the AI assistant settings below edit it) · report: {"ai_filter":{"service":"NAME"}} adds an "Ask in your own words" box (natural-language filters) · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (a region plug-in; a Source SELECT feeds its template)' },
124
+ help: ALL_REGION_CONFIG_HELP, helpBy: (row: any) => regionConfigHelp(row?.type) },
121
125
  ],
122
126
  },
123
127
  item: {
@@ -131,7 +135,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
131
135
  validate: (v) => formatSettingsProblem(v.config),
132
136
  fields: [
133
137
  { name: 'name', label: 'Name', kind: 'upper', help: 'Referenced in SQL as :NAME, e.g. P3_NAME', group: 'Identification' },
134
- { name: 'type', label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'hidden', 'display', 'password', 'plugin'], group: 'Identification' },
138
+ { name: 'type', label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin'], group: 'Identification' },
135
139
  { name: 'label', label: 'Label', kind: 'text', group: 'Identification' },
136
140
  { name: 'region_id', label: 'Region', kind: 'region', group: 'Layout' },
137
141
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
@@ -145,7 +149,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
145
149
  { name: 'readonly_condition', label: 'Read-only condition (SQL)', kind: 'code', group: 'Security', help: 'When true the item is shown read-only and ignored on submit.' },
146
150
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
147
151
  buildOption('Security'),
148
- { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
152
+ { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
149
153
  ],
150
154
  },
151
155
  button: {
@@ -195,13 +199,13 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
195
199
  : null,
196
200
  fields: [
197
201
  { name: 'name', label: 'Name', kind: 'text', group: 'When' },
198
- { name: 'event', label: 'Event', kind: 'select', options: ['change', 'click', 'load', 'dialog_closed'], group: 'When',
199
- help: 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region.' },
200
- { name: 'trigger_element', label: 'Item(s) / button / dialog page', kind: 'upper', group: 'When', help: 'Comma separated item names, or a button name for click, or for dialog_closed the dialog page numbers (empty: any dialog).' },
202
+ { name: 'event', label: 'Event', kind: 'select', options: ['change', 'click', 'load', 'dialog_closed', 'focus', 'blur', 'key_release', 'after_refresh', 'selection_change', 'before_submit', 'custom'], group: 'When',
203
+ help: 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region. focus / blur / key_release: an item gets or loses the focus, a key is released in it. after_refresh: a region was refreshed or loaded. selection_change: rows were selected in a report (row selection) or a grid\'s master row. before_submit: the page is about to be submitted (a declined confirm keeps it). custom: JavaScript raised it with pgkiln.trigger(\'NAME\', value). The actions of one event and trigger run in sequence order; a declined confirm stops the ones after it.' },
204
+ { name: 'trigger_element', label: 'Item(s) / button / region / page / event', kind: 'upper', group: 'When', help: 'Comma separated: 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), event names (custom). Empty for load and before_submit.' },
201
205
  { name: 'condition_type', label: 'Client-side condition', kind: 'select', options: ['', 'equals', 'not_equals', 'in_list', 'is_null', 'is_not_null'], group: 'When' },
202
206
  { name: 'condition_value', label: 'Condition value', kind: 'text', group: 'When' },
203
- { name: 'action', label: 'Action', kind: 'select', options: ['show', 'hide', 'enable', 'disable', 'set_value', 'execute_sql', 'refresh_region', 'refresh_item', 'alert', 'submit', 'set_focus', 'add_class', 'remove_class', 'show_success', 'show_error', 'clear_errors', 'ai_generate', 'execute_javascript', 'plugin', 'push_subscribe'], group: 'Action',
204
- help: 'show/hide/enable/disable reverse automatically when the condition is false. set_focus: the first affected item (or the region). show_error: on the affected items, or at the top. clear_errors: of the affected items, or all. ai_generate: runs the "Generate text with AI" process named in Code through AJAX, without submitting the page (on a submit button, the button submits instead when JavaScript is off). execute_javascript: calls the function named in Code, registered by a static application file (Shared Components → Static Application Files). push_subscribe: turns on push notifications on the user\'s device (use it on click: browsers ask only after one; the app needs push notifications on in Settings → Progressive Web App); Message is shown when it worked.' },
207
+ { name: 'action', label: 'Action', kind: 'select', options: ['show', 'hide', 'enable', 'disable', 'set_value', 'execute_sql', 'refresh_region', 'refresh_item', 'alert', 'submit', 'set_focus', 'add_class', 'remove_class', 'show_success', 'show_error', 'clear_errors', 'ai_generate', 'execute_javascript', 'plugin', 'push_subscribe', 'confirm', 'close_dialog', 'cancel_dialog', 'clear', 'open_region', 'close_region', 'download', 'print', 'current_position', 'share'], group: 'Action',
208
+ help: 'show/hide/enable/disable reverse automatically when the condition is false. set_focus: the first affected item (or the region). show_error: on the affected items, or at the top. clear_errors: of the affected items, or all. ai_generate: runs the "Generate text with AI" process named in Code through AJAX, without submitting the page (on a submit button, the button submits instead when JavaScript is off). execute_javascript: calls the function named in Code, registered by a static application file (Shared Components → Static Application Files). push_subscribe: turns on push notifications on the user\'s device (use it on click: browsers ask only after one; the app needs push notifications on in Settings → Progressive Web App); Message is shown when it worked. confirm: asks Message; No stops the actions after it. close_dialog / cancel_dialog: close the modal page this runs on (close_dialog refreshes the page below and runs its dialog_closed actions). clear: empties the affected items. open_region / close_region: a collapsible region opens or closes (another region shows or hides). download: the file of the download process named in Code (Items to submit are sent first). print: the browser\'s print dialog. current_position: the device\'s position as latitude,longitude into the first affected item. share: the device\'s share sheet for this page (Message as the text), or the link copied.' },
205
209
  { name: 'affected_items', label: 'Affected items', kind: 'upper', group: 'Action' },
206
210
  { name: 'affected_region_id', label: 'Affected region', kind: 'region', group: 'Action' },
207
211
  { name: 'code', label: 'SQL', kind: 'code', wide: true, group: 'Action', help: 'set_value: a SELECT whose columns set the affected items · execute_sql: any SQL; returned columns named like items set them · ai_generate: the name of an ai_generate process on this page (its output items are updated; items to submit default to the items its prompts use) · execute_javascript: the name of a function registered with pgkiln.actions.register(name, fn) in a static file the page loads.' },
@@ -249,15 +253,15 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
249
253
  },
250
254
  fields: [
251
255
  { name: 'name', label: 'Name', kind: 'text' },
252
- { name: 'type', label: 'Type', kind: 'select', options: ['sql', 'form_dml', 'grid_dml', 'data_load', 'invoke_api', 'download', 'chain', 'workflow', 'ai_generate', 'plugin', 'send_push'],
256
+ { name: 'type', label: 'Type', kind: 'select', options: ['sql', 'form_dml', 'grid_dml', 'data_load', 'invoke_api', 'download', 'chain', 'workflow', 'ai_generate', 'plugin', 'send_push', 'send_email'],
253
257
  help: 'download: a file from the query in Code · chain: runs the processes that name it as their chain, in sequence (optionally in the background) · workflow: start, terminate or retry a workflow · ai_generate: Generate text with AI (an AI service the application may use) · send_push: a push notification to a user\'s devices (Settings → Progressive Web App → push notifications)' },
254
- { name: 'point', label: 'Point', kind: 'select', options: ['submit', 'load'], help: 'A chain\'s processes run when the chain does (their own point is not used).' },
258
+ { name: 'point', label: 'Point', kind: 'select', options: ['submit', 'load', 'ajax'], help: 'A chain\'s processes run when the chain does (their own point is not used). ajax: an Ajax callback (APEX: On Demand) that JavaScript calls by name with pgkiln.server.process(\'NAME\', {items: [\'P1_X\'], x01: …}); SQL processes only; the last statement\'s rows come back as JSON, and the items it sets are set on the page.' },
255
259
  { name: 'parent_process', label: 'Chain (parent process)', kind: 'text', help: 'The name of a chain process on this page: this process then runs only inside that chain, in its sequence.' },
256
260
  { name: 'code', label: 'Code (SQL / PL/pgSQL call)', kind: 'code', wide: true,
257
261
  help: 'e.g. select sales.ship_order(:P3_ID::int) as p3_status — returned columns named like items set them. RAISE EXCEPTION messages are shown to the user; USING COLUMN = \'sal\' puts it on that field. · download: a query returning the file\'s content (bytea or text), file name and MIME type, e.g. select content, filename, mime_type from docs.file where id = :P5_ID::int — several rows are sent as one zip file.' },
258
262
  { name: 'region_id', label: 'Form / grid region (form_dml, grid_dml)', kind: 'region' },
259
- { name: 'config', label: 'Configuration (data_load, invoke_api, download, chain, workflow, ai_generate, plugin, send_push)', kind: 'json', wide: true,
260
- help: 'data_load: {"file_item":"P5_FILE","definition":"ORDERS_LOAD"} (a data load definition: table, format, mapping, transformations) or {"file_item":"P5_FILE","table":"sales.orders","mode":"append | merge | replace","skip_errors":false,"headers":true,"format":"auto | csv | xlsx | json | xml","row_tag":"order","columns":{"Heading in file":"column"}} — runs as the app\'s database role; columns match by name unless mapped. · invoke_api: {"source":"WEATHER","params":{"city":"&P5_CITY."},"items":{"P5_TEMP":"current.temp"},"status_item":"P5_STATUS"} or {"url":"https://api.example.com/orders/&P5_ID.","method":"POST","credential":"SHOP_API","body":"{\\"note\\": &P5_NOTE.}","items":{…}} — without "items", the first row\'s columns set the items named like them. · download: {"content_column":"content","filename_column":"filename","mime_column":"mime_type","zip_name":"files-&P5_ID..zip","disposition":"attachment | inline"} (all optional; default: the first three columns). · chain: {"background": true, "status_item": "P5_JOB_ID"} runs it after the submit, on the server (status in meta.process_jobs). · workflow: {"action":"start","definition":"APPROVAL","version":"2","detail_pk":"&P5_ID.","variables":{"AMOUNT":"&P5_AMOUNT."},"id_item":"P5_WORKFLOW_ID"} or {"action":"terminate | retry","instance":"&P5_WORKFLOW_ID.","comment":"…"} · send_push: {"to":"&P5_OWNER.","title":"Order &P5_ID. needs approval","body":"…","page":5,"items":{"P5_ID":"&P5_ID."},"tag":"order-&P5_ID.","urgency":"normal | high | low | very-low"} — to the user\'s devices that turned notifications on; the link is signed for that user · ai_generate: {"service":"CLAUDE","system":"You write short, friendly summaries.","prompt":"Summarise: &P5_NOTES.","output_item":"P5_SUMMARY"} (text), {"service":…,"prompt":"Extract the contact from: &P5_TEXT.","output_items":["P5_NAME","P5_EMAIL"]} (structured: a JSON schema built from the items, their labels describe the fields) or {"service":…,"prompt":…,"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"items":{"P5_NAME":"name"}} (your schema; items map to properties or paths); optional "max_tokens", "error_message". &ITEM. values are sent as delimited data, never as instructions. · plugin: {"plugin":"name","attributes":{"NAME":"value or &ITEM."}} (a process plug-in: its function returns the message)' },
263
+ { name: 'config', label: 'Configuration (data_load, invoke_api, download, chain, workflow, ai_generate, plugin, send_push, send_email)', kind: 'json', wide: true,
264
+ help: 'data_load: {"file_item":"P5_FILE","definition":"ORDERS_LOAD"} (a data load definition: table, format, mapping, transformations) or {"file_item":"P5_FILE","table":"sales.orders","mode":"append | merge | replace","skip_errors":false,"headers":true,"format":"auto | csv | xlsx | json | xml","row_tag":"order","columns":{"Heading in file":"column"}} — runs as the app\'s database role; columns match by name unless mapped. · invoke_api: {"source":"WEATHER","params":{"city":"&P5_CITY."},"items":{"P5_TEMP":"current.temp"},"status_item":"P5_STATUS"} or {"url":"https://api.example.com/orders/&P5_ID.","method":"POST","credential":"SHOP_API","body":"{\\"note\\": &P5_NOTE.}","items":{…}} — without "items", the first row\'s columns set the items named like them. · download: {"content_column":"content","filename_column":"filename","mime_column":"mime_type","zip_name":"files-&P5_ID..zip","disposition":"attachment | inline"} (all optional; default: the first three columns). Or {"export":"csv | json | xml | html | xlsx | pdf","file_name":"orders-&P5_ID.","title":"Orders","layout":"A4_LANDSCAPE","separator":";"}: the query\'s rows as one file (APEX_DATA_EXPORT; meta.data_export does csv, json, xml and html from SQL). · chain: {"background": true, "status_item": "P5_JOB_ID"} runs it after the submit, on the server (status in meta.process_jobs). · workflow: {"action":"start","definition":"APPROVAL","version":"2","detail_pk":"&P5_ID.","variables":{"AMOUNT":"&P5_AMOUNT."},"id_item":"P5_WORKFLOW_ID"} or {"action":"terminate | retry","instance":"&P5_WORKFLOW_ID.","comment":"…"} · send_push: {"to":"&P5_OWNER.","title":"Order &P5_ID. needs approval","body":"…","page":5,"items":{"P5_ID":"&P5_ID."},"tag":"order-&P5_ID.","urgency":"normal | high | low | very-low"} — to the user\'s devices that turned notifications on; the link is signed for that user · ai_generate: {"service":"CLAUDE","system":"You write short, friendly summaries.","prompt":"Summarise: &P5_NOTES.","output_item":"P5_SUMMARY"} (text), {"service":…,"prompt":"Extract the contact from: &P5_TEXT.","output_items":["P5_NAME","P5_EMAIL"]} (structured: a JSON schema built from the items, their labels describe the fields) or {"service":…,"prompt":…,"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"items":{"P5_NAME":"name"}} (your schema; items map to properties or paths); optional "max_tokens", "error_message". &ITEM. values are sent as delimited data, never as instructions. · plugin: {"plugin":"name","attributes":{"NAME":"value or &ITEM."}} (a process plug-in: its function returns the message) · send_email: {"to":"&P5_EMAIL.","cc":"…","bcc":"…","from":"…","reply_to":"…","subject":"Order &P5_ID.","body":"plain text","body_html":"<p>HTML (item values escaped)</p>"} or {"to":"&P5_EMAIL.","template":"ORDER_SHIPPED","placeholders":{"NAME":"&P5_NAME."}}; "attachments":"select content, filename, mime from sales.invoice where order_id = :P5_ID" — queued in the page\'s transaction, sent after the commit through the application\'s mail service' },
261
265
  { name: 'when_button', label: 'When button pressed', kind: 'upper' },
262
266
  ...conditionFields('Condition'),
263
267
  { name: 'success_message', label: 'Success message', kind: 'text' },
@@ -437,7 +441,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
437
441
  defaults: { point: 'after_login' },
438
442
  fields: [
439
443
  { name: 'name', label: 'Name', kind: 'text' },
440
- { name: 'point', label: 'Point', kind: 'select', options: ['after_login', 'before_page'] },
444
+ { name: 'point', label: 'Point', kind: 'select', options: ['after_login', 'before_page', 'ajax'], help: 'ajax: an Ajax callback every page can call with pgkiln.server.process(\'NAME\') (a page\'s own ajax process of that name wins).' },
441
445
  { name: 'code', label: 'Code (SQL)', kind: 'code', wide: true, help: 'Returned columns named like application items set them, e.g. select id as ai_customer_id from sales.customer where lower(username) = lower(:APP_USER)' },
442
446
  { name: 'seq', label: 'Sequence', kind: 'int' },
443
447
  { name: 'authz', label: 'Authorization', kind: 'authz', help: AUTHZ_HELP },
@@ -572,6 +576,96 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
572
576
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
573
577
  ],
574
578
  },
579
+ // (080) search configurations: what a search region searches
580
+ search_config: {
581
+ table: 'meta.search_config',
582
+ scope: 'app',
583
+ label: 'Search configuration',
584
+ plural: 'Search configurations',
585
+ icon: 'search',
586
+ summary: (c) => `${c.name} (${c.source_type})`,
587
+ defaults: { source_type: 'table', seq: 10, max_results: 10, searchable_columns: [] },
588
+ validate: (v) => searchConfigProblems(v as never).join(' ') || null,
589
+ fields: [
590
+ { name: 'name', label: 'Name (static ID)', kind: 'upper', group: 'Identification', help: 'e.g. CUSTOMERS; a search region names it in {"configs": ["CUSTOMERS"]}.' },
591
+ { name: 'label', label: 'Label', kind: 'text', group: 'Identification', help: 'The heading of its results, e.g. Customers.' },
592
+ { name: 'seq', label: 'Sequence', kind: 'int', group: 'Identification', help: 'The order of the groups of results.' },
593
+ { name: 'source_type', label: 'Source type', kind: 'select', options: ['table', 'sql', 'list'], group: 'Source',
594
+ help: 'table: a table or view · sql: a SELECT (:ITEM binds allowed) · list: the entries of a list (Shared Components → Lists) the user may see.' },
595
+ { name: 'source', label: 'Source', kind: 'code', wide: true, group: 'Source', help: 'table: sales.customers · sql: select id, name, city from sales.customers where active · list: the list\'s name.' },
596
+ { name: 'searchable_columns', label: 'Searchable columns', kind: 'list', group: 'Source',
597
+ help: 'Comma-separated columns the search text is looked for in (each word must be found in one of them). Empty: the title, subtitle and description columns.' },
598
+ { name: 'title_column', label: 'Title column', kind: 'text', group: 'Result', help: 'Required (table, sql); results are ordered by it.' },
599
+ { name: 'subtitle_column', label: 'Subtitle column', kind: 'text', group: 'Result' },
600
+ { name: 'description_column', label: 'Description column', kind: 'text', group: 'Result' },
601
+ { name: 'badge_column', label: 'Badge column', kind: 'text', group: 'Result' },
602
+ { name: 'icon', label: 'Icon', kind: 'text', group: 'Result', help: 'An icon name (e.g. user), or #COLUMN# for a column that holds icon names.' },
603
+ { name: 'link', label: 'Link', kind: 'json', wide: true, group: 'Result', help: 'Where a result goes: {"page": 3, "items": {"P3_ID": "#ID#"}} (#COLUMN# is the row\'s value). Empty: no link.' },
604
+ { name: 'max_results', label: 'Maximum results', kind: 'int', group: 'Result', help: '1–100 (default 10).' },
605
+ buildOption('Result'),
606
+ ],
607
+ },
608
+ // (081) e-mail templates: meta.send_mail_template and the send_email process fill them
609
+ email_template: {
610
+ table: 'meta.email_template',
611
+ scope: 'app',
612
+ label: 'E-mail template',
613
+ plural: 'E-mail templates',
614
+ icon: 'send',
615
+ summary: (t) => t.name,
616
+ defaults: { subject: 'Order #ORDER_ID# shipped', html_body: '<p>Dear #NAME#,</p>\n<p>Your order #ORDER_ID# is on its way.</p>', text_body: 'Dear #NAME#,\n\nYour order #ORDER_ID# is on its way.' },
617
+ validate: (v) => (typeof v.subject !== 'string' || !v.subject.trim() || /[\r\n]/.test(v.subject) ? 'The subject is required, on one line.' : !v.html_body && !v.text_body ? 'Write an HTML or a plain text body (or both).' : null),
618
+ fields: [
619
+ { name: 'name', label: 'Name (static ID)', kind: 'upper', help: 'e.g. ORDER_SHIPPED: meta.send_mail_template(\'ORDER_SHIPPED\', …) and the Send e-mail process name it.' },
620
+ { name: 'subject', label: 'Subject', kind: 'text', wide: true, help: 'One line; #PLACEHOLDERS# are filled in.' },
621
+ { name: 'html_body', label: 'HTML body', kind: 'code', wide: true,
622
+ help: '#NAME# is the value, HTML-escaped; #NAME!RAW# the value as it is (trusted HTML only). Mail clients ignore most CSS: keep it simple (tables, inline formatting).' },
623
+ { name: 'text_body', label: 'Plain text body', kind: 'textarea', wide: true, help: 'For mail clients that show no HTML. #NAME# is the value as it is.' },
624
+ ],
625
+ },
626
+ // (086) application settings: meta.get_app_setting / meta.set_app_setting
627
+ app_setting: {
628
+ table: 'meta.app_setting',
629
+ scope: 'app',
630
+ label: 'Application setting',
631
+ plural: 'Application settings',
632
+ icon: 'settings',
633
+ summary: (x) => x.name,
634
+ defaults: { keep_on_upgrade: true },
635
+ // no valid values: any value (null, not an empty list)
636
+ beforeSave: async (v) => {
637
+ if (Array.isArray(v.valid_values) && !v.valid_values.length) v.valid_values = null;
638
+ },
639
+ validate: (v) =>
640
+ v.required && !String(v.value ?? '').trim()
641
+ ? 'A required setting needs a value.'
642
+ : v.value && Array.isArray(v.valid_values) && v.valid_values.length && !v.valid_values.includes(v.value)
643
+ ? `The value is one of the valid values: ${v.valid_values.join(', ')}.`
644
+ : null,
645
+ fields: [
646
+ { name: 'name', label: 'Name', kind: 'upper', help: 'e.g. SUPPORT_EMAIL: meta.get_app_setting(\'SUPPORT_EMAIL\') reads it, meta.set_app_setting(\'SUPPORT_EMAIL\', …) changes it.' },
647
+ { name: 'value', label: 'Value', kind: 'text', wide: true },
648
+ { name: 'description', label: 'Description', kind: 'text', wide: true },
649
+ { name: 'valid_values', label: 'Valid values', kind: 'list', help: 'Comma-separated; empty: any value.' },
650
+ { name: 'required', label: 'Value required', kind: 'bool' },
651
+ { name: 'keep_on_upgrade', label: 'Keep the installed value on upgrade', kind: 'bool', help: 'An import over this application keeps the value set here (on), or takes the value from the file (off).' },
652
+ ],
653
+ },
654
+ // (086) dynamic translations: meta.lang(text)
655
+ dynamic_translation: {
656
+ table: 'meta.dynamic_translation',
657
+ scope: 'app',
658
+ label: 'Dynamic translation',
659
+ plural: 'Dynamic translations',
660
+ icon: 'file',
661
+ summary: (x) => `${x.language}: ${x.source}`,
662
+ validate: (v) => (!/^[a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,2}$/.test(String(v.language ?? '')) ? 'Language: a code such as nl, de or pt-BR.' : null),
663
+ fields: [
664
+ { name: 'language', label: 'Language', kind: 'text', help: 'e.g. nl, de, pt-BR' },
665
+ { name: 'source', label: 'Text (as stored)', kind: 'text', wide: true, help: 'The value as the table holds it, e.g. Sales; select meta.lang(dname) from hr.dept shows the translation.' },
666
+ { name: 'target', label: 'Translation', kind: 'text', wide: true },
667
+ ],
668
+ },
575
669
  report_layout: {
576
670
  table: 'meta.report_layout',
577
671
  scope: 'app',
@@ -227,7 +227,10 @@ export async function designerRoutes(app: FastifyInstance) {
227
227
  </form>`;
228
228
  // a lock of another developer (on this page or the whole application) makes the page read-only
229
229
  const locks = (await appLocks(p.app_id)).filter((l) => l.page_no === 0 || l.page_no === p.page_no);
230
- const blocking = locks.find((l) => l.locked_by !== s.username);
230
+ // (083) a Run only application refuses every change, like a lock
231
+ const runOnly = (await owner.one(`select build_status = 'run_only' as ok from meta.app where id = $1`, [p.app_id]))?.ok
232
+ ? { app_id: p.app_id, page_no: 0, locked_by: '', locked_at: '', note: null, run_only: true } : undefined;
233
+ const blocking = runOnly ?? locks.find((l) => l.locked_by !== s.username);
231
234
  const mine = locks.find((l) => l.locked_by === s.username);
232
235
  const commentCount = (await owner.one('select count(*)::int as n from meta.dev_comment where app_id = $1 and page_no = $2', [p.app_id, p.page_no])).n;
233
236
  const center = html`${blocking ? html`<div class="alert alert-error pd-lock" role="status">${icon('key')} ${lockText(blocking)} Your changes will be refused.</div>` : ''}
@@ -301,6 +304,8 @@ export async function designerRoutes(app: FastifyInstance) {
301
304
  ${select('dialog_position', 'Dialog position', p.dialog_position ?? 'center', [['center', 'Centred dialog'], ['right', 'Drawer from the right'], ['left', 'Drawer from the left'], ['top', 'Drawer from the top'], ['bottom', 'Drawer from the bottom']], 'Modal pages only. On phones dialogs and side drawers fill the screen.')}
302
305
  ${select('dialog_size', 'Dialog size', p.dialog_size ?? 'medium', [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large']], 'The width (the height of top and bottom drawers).')}
303
306
  ${input('static_includes', 'JavaScript and CSS files', (p.static_includes ?? []).join(', '), { placeholder: 'e.g. page-3.js, page-3.css', help: 'Static application files (.js, .css) this page loads after the application\'s own, in this order.' })}
307
+ <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="warn_unsaved" value="true"${p.warn_unsaved ? raw(' checked') : ''}> Warn on unsaved changes</label>
308
+ <small class="help">Ask before the user leaves the page (a link, a redirect button, closing the tab) while an item has a changed value. Submitting doesn't ask. Leave an item out with {"warn_unsaved": false} in its attributes.</small></div>
304
309
  ${select('parent_page', 'Breadcrumb parent', p.parent_page ?? '', [['', '- none -'], ...lk.pages.filter((x) => x.page_no !== p.page_no).map((x): [string, string] => [String(x.page_no), `${x.page_no}. ${x.name}`])])}
305
310
  </div></fieldset>
306
311
  <fieldset class="prop-group"><legend>Security</legend><div class="form-grid">
@@ -383,9 +388,9 @@ export async function designerRoutes(app: FastifyInstance) {
383
388
  const before = await owner.one('select app_id, page_no from meta.page where id = $1', [req.params.pid]);
384
389
  await owner.query(
385
390
  `update meta.page set page_no = $2, name = $3, title = $4, requires_auth = $5, mode = $6, parent_page = $7, authz = $8, protection = $9, build_option = $10,
386
- dialog_position = $11, dialog_size = $12, static_includes = $13 where id = $1`,
391
+ dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14 where id = $1`,
387
392
  [req.params.pid, Number(b.page_no), b.name?.trim(), b.title?.trim() || null, b.requires_auth === 'true', b.mode, b.parent_page ? Number(b.parent_page) : null, b.authz || null, b.protection, b.build_option?.trim().toUpperCase() || null,
388
- ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes)],
393
+ ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true'],
389
394
  );
390
395
  // the page's lock and comments follow a new page number
391
396
  if (before && before.page_no !== Number(b.page_no))
@@ -0,0 +1,82 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { appOr404 } from './forms.ts';
6
+ import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
7
+
8
+ // Application → Feedback (APEX: Team Development → Feedback, migration 084):
9
+ // switch the application's Feedback link on or off, read what users sent
10
+ // (newest first, filtered by status and kind), and answer it with a status
11
+ // and a response the user sees on the feedback page. Answers are data, not
12
+ // a change of the application: allowed while it is locked or Run only.
13
+
14
+ const STATUSES: [string, string][] = [['open', 'Open'], ['in_progress', 'In progress'], ['done', 'Done'], ['declined', 'Declined']];
15
+ const KINDS: [string, string][] = [['comment', 'Comment'], ['idea', 'Idea'], ['problem', 'Problem']];
16
+ const when = (d: string | Date) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
17
+
18
+ export async function feedbackRoutes(app: FastifyInstance) {
19
+ app.get(`${BASE}/apps/:id(^\\d+$)/feedback`, async (req: Req, reply) => {
20
+ const s = await developer(req, reply);
21
+ if (!s) return;
22
+ const a = await appOr404(req.params.id);
23
+ if (!a) return reply.code(404).send('Not found');
24
+ const status = STATUSES.some(([v]) => v === req.query.status) ? String(req.query.status) : '';
25
+ const kind = KINDS.some(([v]) => v === req.query.kind) ? String(req.query.kind) : '';
26
+ const rows = (await owner.query(
27
+ `select * from meta.feedback where app_id = $1 and ($2 = '' or status = $2) and ($3 = '' or kind = $3) order by created_at desc limit 300`,
28
+ [a.id, status, kind])).rows;
29
+ const counts = (await owner.query(`select status, count(*)::int as n from meta.feedback where app_id = $1 group by 1`, [a.id])).rows;
30
+ const count = (st: string) => counts.find((c) => c.status === st)?.n ?? 0;
31
+ const body = html`
32
+ ${region('Feedback', html`
33
+ <form method="post" action="${BASE}/apps/${a.id}/feedback/settings" class="u-mb1">${csrf(s)}
34
+ <label class="check"><input type="checkbox" name="feedback" value="true"${a.feedback ? raw(' checked') : ''}> Users can send feedback</label>
35
+ <small class="help">A <b>Feedback</b> link in the application's navigation bar opens a page for a comment, an idea or a problem about the page the user came from, with their earlier feedback and your answers.</small>
36
+ <div class="buttons"><button class="btn btn-sm">Save</button></div></form>
37
+ <p class="muted">${count('open')} open, ${count('in_progress')} in progress, ${count('done')} done, ${count('declined')} declined.</p>
38
+ <form method="get" class="filter-row">
39
+ ${select('status', 'Status', status, [['', 'All'], ...STATUSES])}
40
+ ${select('kind', 'Kind', kind, [['', 'All'], ...KINDS])}
41
+ <button class="btn btn-sm">${icon('filter')} Show</button></form>
42
+ ${rows.length ? html`<ul class="feedback-admin">${rows.map((f) => html`<li class="feedback-item">
43
+ <div class="feedback-meta"><span class="tag">${KINDS.find(([v]) => v === f.kind)?.[1]}</span>
44
+ <b>${f.username}</b> <span class="muted">${when(f.created_at)}${f.page_no ? html` · <a href="/a/${a.alias}/${f.page_no}" target="_blank" rel="noopener">page ${f.page_no}</a>` : ''}${f.rating ? ` · ${'★'.repeat(f.rating)}` : ''}${f.user_agent ? ` · ${f.user_agent}` : ''}</span></div>
45
+ <p class="feedback-body">${f.body}</p>
46
+ <form method="post" action="${BASE}/apps/${a.id}/feedback/${f.id}">${csrf(s)}
47
+ <div class="form-grid">
48
+ ${select('status', 'Status', f.status, STATUSES)}
49
+ <div class="field" data-wide><label class="label" for="f_response_${f.id}">Answer (the user sees it)</label>
50
+ <textarea id="f_response_${f.id}" name="response" rows="2" maxlength="4000">${f.response ?? ''}</textarea>
51
+ ${f.responded_by ? html`<small class="help">By ${f.responded_by}, ${when(f.responded_at)}</small>` : ''}</div>
52
+ </div>
53
+ <div class="buttons"><button class="btn btn-sm btn-hot">Save</button></div></form>
54
+ </li>`)}</ul>` : html`<p class="muted">No feedback${status || kind ? ' with these filters' : ' yet'}.</p>`}`)}`;
55
+ const main = html`${appHeader(a, 'activity')}<div class="ide-body">${body}</div>`;
56
+ return send(reply, s, shell(s, `${a.name} feedback`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['Feedback']], main));
57
+ });
58
+
59
+ app.post(`${BASE}/apps/:id(^\\d+$)/feedback/settings`, async (req: Req, reply) => {
60
+ const s = await developer(req, reply);
61
+ if (!s) return;
62
+ await owner.query('update meta.app set feedback = $2, updated_at = now() where id = $1', [req.params.id, req.body?.feedback === 'true']);
63
+ flash(s, 'Saved.');
64
+ return back(reply, s, `${BASE}/apps/${req.params.id}/feedback`);
65
+ });
66
+
67
+ app.post(`${BASE}/apps/:id(^\\d+$)/feedback/:fid(^\\d+$)`, async (req: Req, reply) => {
68
+ const s = await developer(req, reply);
69
+ if (!s) return;
70
+ const b = req.body ?? {};
71
+ const status = STATUSES.some(([v]) => v === b.status) ? b.status : 'open';
72
+ const response = String(b.response ?? '').replace(/\r\n?/g, '\n').trim().slice(0, 4000) || null;
73
+ const r = await owner.query(
74
+ `update meta.feedback set status = $3, response = $4,
75
+ responded_by = case when $4::text is distinct from response then $5 else responded_by end,
76
+ responded_at = case when $4::text is distinct from response then now() else responded_at end
77
+ where id = $2 and app_id = $1`,
78
+ [req.params.id, req.params.fid, status, response, s.username]);
79
+ flash(s, r.rowCount ? 'Saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
80
+ return back(reply, s, `${BASE}/apps/${req.params.id}/feedback`);
81
+ });
82
+ }
@@ -74,7 +74,10 @@ export function iconPicker(id: string, name: string, label: string, current: str
74
74
  export function componentForm(spec: ComponentSpec, kind: string, row: any, lk: Lookups, action: string, s: Session, submit: string, opts: { id?: string } = {}) {
75
75
  const field = (f: Field) => {
76
76
  const v = row?.[f.name];
77
- const help = f.help ? html`<small class="help">${f.help}</small>` : '';
77
+ const own = row?.id ? f.helpBy?.(row) : null;
78
+ const help = own
79
+ ? html`<small class="help">${own.text}${own.url ? html` <a href="${own.url}" target="_blank" rel="noopener">Guide ▸</a>` : ''}</small>`
80
+ : f.help ? html`<small class="help">${f.help}</small>` : '';
78
81
  const id = `f_${kind}_${f.name}`;
79
82
  let control: Raw;
80
83
  const opts = (list: [string, string][]) =>
@@ -219,6 +219,8 @@ export async function homeRoutes(app: FastifyInstance) {
219
219
  ${region('Export file', html`<form method="post" action="${BASE}/import">${csrf(s)}
220
220
  <div class="field" data-wide><label class="label" for="f_doc">Export JSON</label><textarea id="f_doc" name="doc" class="code" rows="14" required data-code="json"></textarea></div>
221
221
  ${input('alias', 'New alias (optional)', '', { help: 'Leave empty to keep the alias in the export.' })}
222
+ <div class="field"><label class="check"><input type="checkbox" name="check" value="true" checked> Check the application before importing</label>
223
+ <small class="help">The Advisor plans its SQL as the application's database role and checks its references before anything is saved: errors (a missing table, column or function, a wrong type, a missing grant) refuse the import.</small></div>
222
224
  <div class="buttons"><a class="btn" href="${BASE}">Cancel</a><button class="btn btn-hot">Import</button></div>
223
225
  </form>`)}
224
226
  </div>`;
@@ -285,6 +287,7 @@ export async function homeRoutes(app: FastifyInstance) {
285
287
  ${(await isAdmin(s.username)) ? card(`${BASE}/instance`, icon('settings'), 'Instance settings', 'Session length, sign-in throttling and the server\'s configuration (administrators).') : ''}
286
288
  ${(await isAdmin(s.username)) ? card(`${BASE}/workspaces`, icon('layers'), 'Workspaces', 'Groups of applications and their developers (administrators).') : ''}
287
289
  ${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), 'AI services', 'Claude and OpenAI for the applications: models, keys, limits and usage (administrators).') : ''}
290
+ ${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), 'Mail services', 'SMTP, Microsoft 365, Amazon SES, Resend, Postmark, SendGrid or Mailgun for the applications\' e-mail (administrators).') : ''}
288
291
  ${(await isAdmin(s.username)) ? card(`${BASE}/installation`, icon('history'), 'Installation', 'Version, install and upgrade runs, applied migrations (administrators).') : ''}
289
292
  </div>`;
290
293
  return send(reply, s, shell(s, 'Workspace utilities', [['App Builder', BASE], ['Workspace utilities']], main));
@@ -21,6 +21,8 @@ export interface Lock {
21
21
  locked_by: string;
22
22
  locked_at: string;
23
23
  note: string | null;
24
+ /** (083) not a developer's lock: the application's build status is Run only */
25
+ run_only?: boolean;
24
26
  }
25
27
 
26
28
  export const isAdmin = async (username: string | null | undefined) =>
@@ -31,7 +33,10 @@ export const appLocks = async (appId: number) =>
31
33
  (await owner.query<Lock>('select app_id, page_no, locked_by, locked_at::text, note from meta.builder_lock where app_id = $1 order by page_no', [appId])).rows;
32
34
 
33
35
  /** Builder POSTs that are allowed on a locked page or application (locking, unlocking, comments). */
34
- const EXEMPT = /^\/apps\/\d+\/(lock|unlock|comments(\/\d+\/delete)?)$/;
36
+ const EXEMPT = /^\/apps\/\d+\/(lock|unlock|comments(\/\d+\/delete)?|feedback\/\d+)$/;
37
+ /** (083) Builder POSTs allowed on a Run only application: its availability and build status, a backup, locks and comments. */
38
+ // (086) an existing application setting stays editable: settings are what an administrator changes after deployment
39
+ const RUN_ONLY_EXEMPT = /^\/apps\/\d+\/(availability|backups|backups\/settings|lock|unlock|comments(\/\d+\/delete)?|feedback\/\d+|shared\/app_setting\/\d+)$/;
35
40
 
36
41
  /** The application (and page) a builder URL under /apps/:id or /pages/:pid is about, or null. */
37
42
  export async function appOfPath(path: string): Promise<{ appId: number; pageNo: number | null; pageId: number | null } | null> {
@@ -50,10 +55,13 @@ export async function appOfPath(path: string): Promise<{ appId: number; pageNo:
50
55
  */
51
56
  export async function blockingLock(username: string, path: string): Promise<{ lock: Lock; appId: number; pageId: number | null } | null> {
52
57
  const rel = path.split('?')[0].slice(BASE.length);
53
- if (EXEMPT.test(rel)) return null;
54
58
  const target = await appOfPath(path);
55
59
  if (!target) return null;
56
60
  const { appId, pageNo, pageId } = target;
61
+ // (083) a Run only application refuses changes, whoever asks
62
+ if (!RUN_ONLY_EXEMPT.test(rel) && (await owner.one<{ ok: boolean }>(`select build_status = 'run_only' as ok from meta.app where id = $1`, [appId]))?.ok)
63
+ return { lock: { app_id: appId, page_no: 0, locked_by: '', locked_at: '', note: null, run_only: true }, appId, pageId };
64
+ if (EXEMPT.test(rel)) return null;
57
65
  const lock = await owner.one<Lock>(
58
66
  `select app_id, page_no, locked_by, locked_at::text, note from meta.builder_lock
59
67
  where app_id = $1 and (page_no = 0 or page_no = $2) and locked_by <> $3 order by page_no limit 1`,
@@ -62,12 +70,14 @@ export async function blockingLock(username: string, path: string): Promise<{ lo
62
70
  return lock ? { lock, appId, pageId } : null;
63
71
  }
64
72
 
65
- export const lockText = (l: Pick<Lock, 'page_no' | 'locked_by' | 'locked_at' | 'note'>) =>
66
- `${l.page_no ? `Page ${l.page_no}` : 'The application'} is locked by ${l.locked_by} since ${l.locked_at.slice(0, 16)}${l.note ? ` (${l.note})` : ''}.`;
73
+ export const lockText = (l: Pick<Lock, 'page_no' | 'locked_by' | 'locked_at' | 'note' | 'run_only'>) =>
74
+ l.run_only
75
+ ? 'The application is Run only (Availability → Build status).'
76
+ : `${l.page_no ? `Page ${l.page_no}` : 'The application'} is locked by ${l.locked_by} since ${l.locked_at.slice(0, 16)}${l.note ? ` (${l.note})` : ''}.`;
67
77
 
68
78
  /** Refuse a change to a locked page or application: a JSON error or a message on the page it came from. */
69
79
  export async function refuseLocked(req: Req, reply: FastifyReply, s: Session, hit: NonNullable<Awaited<ReturnType<typeof blockingLock>>>) {
70
- const message = `${lockText(hit.lock)} Changes are refused until it is unlocked.`;
80
+ const message = hit.lock.run_only ? `${lockText(hit.lock)} Set it back to Run and build to change it.` : `${lockText(hit.lock)} Changes are refused until it is unlocked.`;
71
81
  if (String(req.headers.accept ?? '').includes('application/json')) return reply.code(423).send({ ok: false, error: message });
72
82
  flash(s, message, 'error');
73
83
  return back(reply, s, hit.pageId ? `${BASE}/pages/${hit.pageId}` : `${BASE}/apps/${hit.appId}`);