pgkiln 0.43.0 → 0.45.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 (69) hide show
  1. package/db/migrations/100_generated_labels.sql +381 -0
  2. package/db/migrations/101_theme_choice.sql +15 -0
  3. package/db/migrations/102_app_computations.sql +345 -0
  4. package/db/migrations/103_shortcuts.sql +341 -0
  5. package/db/migrations/104_report_queries.sql +413 -0
  6. package/db/migrations/105_component_settings_groups.sql +367 -0
  7. package/docs/apex-feature-parity.md +12 -12
  8. package/docs/guide/02-concepts.md +24 -0
  9. package/docs/guide/03-builder.md +48 -9
  10. package/docs/guide/04-pages-and-regions.md +20 -4
  11. package/docs/guide/06-processing.md +22 -0
  12. package/docs/guide/09-reference.md +14 -0
  13. package/docs/guide/12-development.md +2 -1
  14. package/docs/guide/14-globalization.md +5 -1
  15. package/docs/guide/16-files.md +23 -0
  16. package/docs/guide/18-cli.md +4 -0
  17. package/package.json +1 -1
  18. package/public/app.css +40 -1
  19. package/public/app.js +69 -1
  20. package/src/appfiles.ts +18 -2
  21. package/src/builder/advisor.ts +7 -2
  22. package/src/builder/appsheets.ts +53 -13
  23. package/src/builder/appwizard.ts +5 -3
  24. package/src/builder/code-editor.ts +6 -1
  25. package/src/builder/components.ts +85 -3
  26. package/src/builder/forms.ts +1 -1
  27. package/src/builder/shared.ts +39 -6
  28. package/src/builder/subscriptions.ts +30 -4
  29. package/src/cli/replace.ts +6 -2
  30. package/src/i18n/ar.ts +2 -0
  31. package/src/i18n/cs.ts +2 -0
  32. package/src/i18n/da.ts +2 -0
  33. package/src/i18n/de.ts +2 -0
  34. package/src/i18n/el.ts +2 -0
  35. package/src/i18n/es.ts +2 -0
  36. package/src/i18n/fi.ts +2 -0
  37. package/src/i18n/fr.ts +2 -0
  38. package/src/i18n/he.ts +2 -0
  39. package/src/i18n/it.ts +2 -0
  40. package/src/i18n/ja.ts +2 -0
  41. package/src/i18n/ko.ts +2 -0
  42. package/src/i18n/nb.ts +2 -0
  43. package/src/i18n/pl.ts +2 -0
  44. package/src/i18n/pt.ts +2 -0
  45. package/src/i18n/ru.ts +2 -0
  46. package/src/i18n/sv.ts +2 -0
  47. package/src/i18n/tr.ts +2 -0
  48. package/src/i18n/uk.ts +2 -0
  49. package/src/i18n/zh.ts +2 -0
  50. package/src/i18n.ts +4 -0
  51. package/src/metadata.ts +14 -1
  52. package/src/runtime/account.ts +5 -5
  53. package/src/runtime/component-settings.ts +42 -0
  54. package/src/runtime/context.ts +4 -0
  55. package/src/runtime/devbar.ts +16 -13
  56. package/src/runtime/documents.ts +29 -3
  57. package/src/runtime/engine.ts +2 -2
  58. package/src/runtime/facets.ts +34 -3
  59. package/src/runtime/grid.ts +22 -4
  60. package/src/runtime/items.ts +14 -11
  61. package/src/runtime/locale.ts +4 -1
  62. package/src/runtime/logic.ts +12 -7
  63. package/src/runtime/regions.ts +4 -3
  64. package/src/runtime/render.ts +8 -2
  65. package/src/runtime/report.ts +16 -10
  66. package/src/runtime/routes.ts +9 -6
  67. package/src/runtime/shortcuts.ts +43 -0
  68. package/src/security.ts +2 -1
  69. package/src/subscriptions.ts +67 -0
@@ -371,15 +371,20 @@ The wizards (every *Create application* flow and the page wizards) make pages th
371
371
  forms and dialog titles the singular (*Employee*).
372
372
  - **Reports** show a foreign key's parent by its display column (a name, title or label, else its first
373
373
  text column): *Manager* shows KING, *Department* shows SALES. The key itself stays in the query,
374
- hidden, so filters can use it. A row starts with a pencil that opens the form.
374
+ hidden, so filters can use it; a report never has two columns with the same heading (also a faceted
375
+ search report, whose facets filter on the display column). A row starts with a pencil that opens the form.
376
+ - **Interactive grids** (the grid page and both grids of a master-detail page) use the same labels as
377
+ headings (*Department Number*, *Hire Date*); a foreign key is a select list of the parent's names.
375
378
  - **Files**: a `bytea` column becomes one file upload item that also fills the columns that go with it
376
379
  (`<col>_name`/`_filename`, `<col>_mime`/`_mime_type`, `<col>_size`, `<col>_updated`; or `filename`
377
380
  and `mime_type` next to the only `bytea` column). Reports leave these columns out. Columns named like
378
381
  *photo*, *image* or *logo* accept pictures only.
379
382
  - **Numbers** get a format mask with group separators (and the scale's decimals for `numeric(p,s)`),
380
- in reports and forms alike, in the application's language (*2,850.00*); keys get none.
381
- - The **Dashboard** comes right after *Home* in the menu; its charts are titled *Employees by Manager*,
382
- rows without a value are labelled *(none)*, and bar charts show the ten largest values (the rest is
383
+ in reports, read-only grid cells and forms alike, in the application's language (*2,850.00*); keys get none.
384
+ - The **Dashboard** comes right after *Home* in the menu; its charts are titled *Employees by Manager*
385
+ (a count) or *Salary by Department* (the sum of an amount column: salary, total, price, cost, value…),
386
+ a donut for six groups or fewer, a line for a date, else a sorted bar; a grouping that says nothing (one
387
+ group, or every group the same size) is left out. Rows without a value are labelled *(none)*, and bar charts show the ten largest values (the rest is
383
388
  one *Other* bar). Tables with row-level security are left out (the application's role may see none of
384
389
  their rows); the result page says which.
385
390
  - The **Home** page has a hero with the application's icon and name, and a card per page of the menu
@@ -493,7 +498,7 @@ step, which keeps the table and type.
493
498
  | **Calendar** | A [calendar](04-pages-and-regions.md#calendar) region (month, week, day and list views) | Start: a date or timestamp column named like *start*, *begin* or *…_date* (not *created*/*updated*); end: one named like *end* or *until*; title: a name column, else the first foreign key's name |
494
499
  | **Chart** | A [chart](04-pages-and-regions.md#chart) region: bar, column, line, area, donut, pie or funnel of the row count, or the sum, average, minimum or maximum of a number column, per label | Label: the first foreign key (its parent's name), else a text column that isn't unique |
495
500
  | **Map** | A [map](04-pages-and-regions.md#map) region with popups and, if ticked, an interactive report of the same rows that the map filters (*Show this area in the list*) | Position: a PostGIS geometry or geography (markers, and lines and areas as shapes), a `point`, `lat`/`lng`-like number columns, or a *location* text column with `latitude,longitude` |
496
- | **Faceted search** | An interactive report (9 columns wide) and a [facets](04-pages-and-regions.md#facets-faceted-search) panel (3 columns, collapsible) with a search field | Up to six facets: foreign keys (values of the parent's name, which the report shows next to the key), booleans, text columns with few values, dates and numbers as ranges with *from*/*to* |
501
+ | **Faceted search** | An interactive report (9 columns wide) and a [facets](04-pages-and-regions.md#facets-faceted-search) panel (3 columns, collapsible) with a search field | Up to six facets: foreign keys (values of the parent's name, which the report shows instead of the key), booleans, text columns with few values, dates and numbers as ranges with *from*/*to* |
497
502
  | **Master detail** | One page with a grid of the table and an editable grid of the selected row's details below it (the grids' [master-detail](04-pages-and-regions.md#grid-interactive-grid)): a hidden item for the selection, both grids with a grid DML process; new detail rows get the master's key | The detail table: a table with a foreign key to this table's primary key |
498
503
 
499
504
  Cards, Calendar, Map and Faceted search take an optional **Form page** number: it adds a modal
@@ -708,11 +713,13 @@ Chapters 4–7 describe every component type and property.
708
713
 
709
714
  While you are signed in to the builder, the pages of your applications show the **developer
710
715
  toolbar** when you run them in the same browser (APEX: the Runtime Developer Toolbar): a compact bar
711
- of icons, centred at the bottom, each with a tooltip. It isn't shown to anyone else, nor inside
716
+ centred at the bottom, each tool an icon with a short text (*Home*, *App 12*, *Page 3*, *Session*,
717
+ *Debug off*, *Quick edit*, *Customize*, *Info*) and a tooltip; on narrow screens, docked at the left or
718
+ right, or with *Icons only* in Settings, just the icons. It isn't shown to anyone else, nor inside
712
719
  dialogs. The page keeps room for it, so it never covers the menu's last entry or a report's pager.
713
720
  After a few seconds without use it shrinks to a slim tab at the edge; point at it or Tab to it to
714
- bring it back. **Settings** (the last icon) docks it at the bottom, top, left or right and turns the
715
- hiding off (kept in your browser). It has:
721
+ bring it back. **Settings** (the last icon) docks it at the bottom, top, left or right, turns the
722
+ hiding off and shows *Icons only* (kept in your browser). It has:
716
723
 
717
724
  | Entry | Does |
718
725
  |---|---|
@@ -860,6 +867,11 @@ is the earlier page: every component in one rail, with **Access control** next t
860
867
  | **Lists of values** | Reusable queries for select lists, referenced as `LOV:NAME` |
861
868
  | **Application items** | Session variables not on any page, set only by server-side code |
862
869
  | **Application processes** | Code that runs *after login* or *before every page* |
870
+ | **Component groups** | Sets of a library application's components that other applications subscribe to at once ([below](#component-groups)) |
871
+ | **Component settings** | Defaults of built-in component types: rows per page, list of values rows, stars ([below](#component-settings)) |
872
+ | **Report queries** | Named queries printed as PDF, Excel, CSV, JSON, XML or HTML with `?doc=NAME` ([chapter 16](16-files.md#report-queries)) |
873
+ | **Shortcuts** | Named texts: `"NAME"` in static content, help texts and confirm messages ([chapter 2](02-concepts.md#shortcuts-name)) |
874
+ | **Application computations** | Computations of (application) items for every page: at sign-in, before every page, after every submit ([chapter 6](06-processing.md#application-computations)) |
863
875
  | **REST modules** | REST endpoints (method, path, SQL) served by pgkiln, with an OpenAPI description ([chapter 13](13-rest-api.md#rest-modules-in-the-builder)) |
864
876
  | **Workflows** | Multi-step processes of tasks, SQL, web service calls (invoke API), decisions, waits and parallel branches, with versions and a diagram ([chapter 6](06-processing.md#workflows)) |
865
877
  | **Task definitions** | Approvals and action tasks: subject, owners, administrators, due date, the SQL that runs on completion ([chapter 6](06-processing.md#approvals-and-the-task-list)) |
@@ -900,6 +912,33 @@ log (`subscription`). A subscribed list of values that uses a REST data source n
900
912
  with the same name in the subscribing application. Subscriptions are links between the
901
913
  applications of this installation and are not exported (the type is).
902
914
 
915
+ #### Component groups
916
+
917
+ **Shared Components → Component Groups** (APEX: Component Groups, migration 105) names a set of a
918
+ theme or library application's components, as `kind:name`: `lov:JOBS, authz_scheme:MANAGER,
919
+ build_option:FEATURE_EXPORT, template_component:status_badge, list:SHORTCUTS`. Saving checks that
920
+ the application has each one (and keeps its name as the application spells it). Another
921
+ application of the same workspace subscribes to the whole group on its Subscriptions page
922
+ (*Subscribe to a component group*): each component is copied and subscribed as above, and a member
923
+ the library no longer has is skipped and named in the message. Groups are exported with the
924
+ application (`shared/component-groups/`).
925
+
926
+ ### Component settings
927
+
928
+ **Shared Components → Component Settings** (APEX: Component Settings, migration 105) are the
929
+ application's defaults for its built-in component types, used where a component sets nothing itself
930
+ (`meta.app.component_settings`, exported with the application):
931
+
932
+ | Setting | pgkiln's default | Bounds |
933
+ |---|---|---|
934
+ | Reports: rows per page (`report_rows`) | 15 | 1–1,000 |
935
+ | Interactive grids: rows per page (`grid_rows`) | 25 | 1–200 |
936
+ | Popup lists of values: rows per page (`popup_lov_rows`) | 25 | 1–100 |
937
+ | Lists of values: maximum rows (`lov_max_rows`) | 5,000 | 1–50,000 |
938
+ | Star ratings: stars (`rating_max`) | 5 | 3–10 |
939
+
940
+ A value out of its bounds (an edited export, say) is ignored: the default applies.
941
+
903
942
  ### Supporting objects
904
943
 
905
944
  APEX's *Supporting Objects*: SQL scripts that create, upgrade or remove what the application needs
@@ -1318,7 +1357,7 @@ imports as empty. Import refuses other formats.
1318
1357
  | Section | Contents |
1319
1358
  |---|---|
1320
1359
  | `app` | the application's settings (name, alias, home page, authentication, theme, languages, …) |
1321
- | `authz_schemes`, `app_items`, `app_processes`, `lovs` | shared components |
1360
+ | `authz_schemes`, `app_items`, `app_processes`, `app_computations`, `shortcuts`, `report_queries`, `component_groups`, `lovs` | shared components |
1322
1361
  | `group_roles` | identity-provider group → role mappings |
1323
1362
  | `text_messages`, `translations` | globalization |
1324
1363
  | `report_layouts` | report layouts; the logo as base64 |
@@ -222,7 +222,8 @@ select e.empno, e.ename, e.job, d.dname as department, e.sal
222
222
 
223
223
  Features for end users:
224
224
 
225
- - **Search** across all columns, or in one column chosen in the list at the start of the search field
225
+ - **Search** across all columns, or in one column chosen under the magnifier at the start of the search field
226
+ (it shows the column's name once one is chosen); Enter searches (without JavaScript, a *Go* button)
226
227
  (Enter searches; *Go* too).
227
228
  - **Column heading menu** (sprint 76; with JavaScript): clicking a heading opens a menu with *Sort ascending*,
228
229
  *Sort descending*, *Hide column*, *Control break* and the column's **values** with their counts (with the current
@@ -455,7 +456,9 @@ page). Regions with `"master": {"item": …}` are its details: they use the item
455
456
  select empno, ename, job, sal from hr.emp where deptno = :P27_DEPTNO::int
456
457
  ```
457
458
 
458
- and show *"Select a row above"* until a row is selected. With JavaScript the details are
459
+ and show *"Select a row above"* until a row is selected. As in APEX, the page opens with the
460
+ master's first row selected, and a search or filter that leaves the selected row out selects the
461
+ first row it shows (a region placed above its master still waits for a selection). With JavaScript the details are
459
462
  refreshed in place (`GET …/region/:id`, no page reload); without, the link reloads the page.
460
463
  The selection is a signed link (bound to the application, page, user, region and value), so a
461
464
  user can only select a row the server showed them; the item is not taken from the URL or a
@@ -479,6 +482,13 @@ on its new rows (and never lets users edit it); saving new rows without a select
479
482
  The developer's `hidden` columns can't be grouped, charted or shown in the single row view.
480
483
  After **Save** the grid comes back as it was: its search, page, sort and view.
481
484
 
485
+ **Navigation and edit mode** (APEX). With JavaScript an editable cell reads as text, with its
486
+ column's format mask (*2,850.00*) or the list's display value, until it is edited: click it, or
487
+ press Enter or F2 on it, or start typing (which replaces the value). On a cell the arrow keys move
488
+ to the next cell; while editing, Escape goes back to the cell and Enter moves to the cell below.
489
+ The control keeps the raw value; Tab moves through the controls as before. On a phone, where the
490
+ rows are cards, the fields show as fields.
491
+
482
492
  **Copy and paste.** Click a cell and Shift+click another to select a range; Ctrl+C copies it
483
493
  as tab-separated text (which spreadsheets paste as cells). Ctrl+V pastes such text (from a
484
494
  spreadsheet or another grid) from the focused cell on, adding rows when the grid allows inserts;
@@ -807,7 +817,9 @@ link is the way to change its dates.
807
817
  A panel of filters, each with a live count, that filters a **report region on the same page**.
808
818
  Counts take the search and all *other* facets into account. Three kinds of facet:
809
819
 
810
- - **checkbox** (the default): the most frequent values of a column, each with a checkbox. With
820
+ - **checkbox** (the default): the most frequent values of a column, each with a checkbox; after the seventh
821
+ the rest sit under *Show all (n)* (open when one of them is chosen), and a boolean's values read *Yes* and
822
+ *No*. With
811
823
  `"exclude": true` the facet gets an *Exclude the selected values* switch: the chosen values are
812
824
  then left out instead (rows where the column is empty stay).
813
825
  - **range**: a number or date column in ranges, as radio buttons ("Any", then each range). A
@@ -817,7 +829,11 @@ Counts take the search and all *other* facets into account. Three kinds of facet
817
829
  - **star**: a rating column as "5 stars and up", "4 stars and up", … down to 1 (`max`, default 5).
818
830
 
819
831
  With `"search": true` the panel starts with a search field: the same search as the report's own
820
- (`r<id>_q`, the row as text contains the term).
832
+ (`r<id>_q`, the row as text contains the term). The report then shows no search field of its own (its
833
+ *Actions* menu stays), so the page has one.
834
+
835
+ The panel starts with the number of rows the search and the facets leave (*Total row count 14*,
836
+ APEX's *Total Row Count*).
821
837
 
822
838
  **Facet charts** (APEX: facet charts): with `"chart": "bar"` or `"chart": "pie"` a facet shows its counts as a chart,
823
839
  collapsed under its heading (*Chart*). Each bar or slice, and each row of the chart's data table, links to the page
@@ -503,6 +503,28 @@ savepoint. A failing computation before the page is shown leaves the item as it
503
503
  error on the page; after a submit it stops the submit (everything is rolled back) and shows the
504
504
  error, like a failing process.
505
505
 
506
+ ### Application computations
507
+
508
+ **Shared Components → Application Computations** (APEX: Application Computations) are
509
+ computations for every page of the application, with the same types, conditions, authorization
510
+ and build option as a page's (migration 102, `meta.app_computation`). Their `point`:
511
+
512
+ | `point` | When |
513
+ |---|---|
514
+ | `after_authentication` | Once, when a user signs in (after the application processes `after_login`) |
515
+ | `before_header` | Every page shown, before the page's own `before_header` computations |
516
+ | `after_submit` | Every page submitted, before the page's own `after_submit` computations (and the validations) |
517
+
518
+ They usually set **application items**, e.g. a user's sales region once at sign-in:
519
+
520
+ ```sql
521
+ select region from sales.rep where lower(username) = lower(:APP_USER) -- AI_REGION, sql_query, after_authentication
522
+ ```
523
+
524
+ An application computation of a page item runs on that item's page only (elsewhere it is skipped,
525
+ without the *unknown item* error a page's computation gives). A failing one behaves like a page
526
+ computation at the same point.
527
+
506
528
  ## Branches
507
529
 
508
530
  After a successful submit pgkiln redirects (POST-redirect-GET). **Branches** decide where to: the
@@ -281,6 +281,7 @@ Run these as the owner (in the SQL Workshop, `psql` or migrations):
281
281
  | `meta.wizard_catalog(table)` | The columns as the wizards see them: kind (`text`, `number`, `date`, `timestamp`, `boolean`, `point`, `geometry`, `binary`, `other`), key, unique, foreign key and the parent's display column |
282
282
  | `meta.wizard_label(name)`, `meta.wizard_table_label(table, plural default true)`, `meta.wizard_column_label(table, column)` | (097) The labels of the wizards: `hiredate` → *Hire Date*, `hr.emp` → *Employees* / *Employee*, `deptno` → *Department*; short comments win ([what the generated pages look like](03-builder.md#what-the-generated-pages-look-like)) |
283
283
  | `meta.wizard_report(table, columns default null)` | (097) A report on a table as the wizards make it: `{"source", "hidden", "headings", "formats"}` (foreign keys joined to their parent's display column) |
284
+ | `meta.wizard_grid_labels(table)` | (100) An interactive grid's `{"headings", "formats"}` as the wizards make them: the labels of `meta.wizard_column_label` and the number format masks (keys get none) |
284
285
  | `meta.app_activity(days default 30)` | (098) The current application's page views, sign-ins and errors of the last days (1 to 366, at most 10,000 rows, no IP addresses), for a signed-in user with the `admin` role of that application; others get an error. The Activity page of the Create Application wizard reads it |
285
286
  | `meta.wizard_files(table)` | (097) The `bytea` columns and the columns holding their file name, mime type, size and change time |
286
287
  | `meta.export_app(alias)` | The application as JSON (`pgkiln/2` format) |
@@ -386,6 +387,19 @@ runtime role, never exported) ([chapter 19](19-rest-data-sources.md#web-credenti
386
387
 
387
388
  **`app_process`**: `app_id`, `seq`, `name`, `point` (`after_login` / `before_page`), `code`, `authz`.
388
389
 
390
+ **`app_computation`** (102): `app_id`, `seq`, `item_name`, `point` (`after_authentication` / `before_header` /
391
+ `after_submit`), `type`, `expression`, `condition_type`, `condition_expr`, `condition_value`, `authz`, `build_option`,
392
+ `comments` ([chapter 6](06-processing.md#application-computations)).
393
+
394
+ **`shortcut`** (103): `app_id`, `name` (uppercase, unique per app), `type` (`text` / `html` / `function_body`), `value`,
395
+ `comments` ([chapter 2](02-concepts.md#shortcuts-name)).
396
+
397
+ **`report_query`** (104): `app_id`, `name` (uppercase, unique per app), `description`, `query`, `format` (`pdf` / `xlsx` /
398
+ `csv` / `json` / `xml` / `html`), `layout`, `file_name`, `title`, `authz`, `comments` ([chapter 16](16-files.md#report-queries)).
399
+
400
+ **`component_group`** (105): `app_id`, `name`, `description`, `members` (`text[]` of `kind:name`) ([chapter 3](03-builder.md#component-groups)).
401
+ `meta.app.component_settings` (105): `report_rows`, `grid_rows`, `popup_lov_rows`, `lov_max_rows`, `rating_max` ([chapter 3](03-builder.md#component-settings)).
402
+
389
403
  **`build_option`**: `app_id`, `name` (uppercase, unique per app), `status` (`include` / `exclude`),
390
404
  `description` ([chapter 6](06-processing.md#build-options)). `meta.build_option_on(app_id, ref)`
391
405
  tells whether a component with that `build_option` value is part of the application.
@@ -114,7 +114,8 @@ src/
114
114
  authz.ts authorization schemes, conditions, visibility (menu requests count as buttons)
115
115
  engine.ts form fetch, validations, processes (conditions, execution chains, queueing background chains; web requests queued by an sql process are made right after it), application processes
116
116
  processes.ts download (file or zip from a query, safe headers), workflow processes, configuration checks of chains
117
- logic.ts computations, branches (page, URL, function returning a URL, another application) and their conditions
117
+ logic.ts computations (the page's and (102) the application's), branches (page, URL, function returning a URL, another application) and their conditions
118
+ shortcuts.ts (103) "NAME" shortcuts in static content, help texts and confirm messages
118
119
  render.ts page chrome (nav, breadcrumb), dynamic action JSON, theme (the page's nonce'd <style>, light/dark and style switches)
119
120
  styles.ts base styles (BASE_STYLES: Iris, Lavender, Standard; baseStyleOf → html data-style), Theme Roller style variants: fixed lists (fonts, sizes, corners), parseStyle/appStyles checks, the request's
120
121
  style (user choice, default), themeCss() (only hex values and constants reach the CSS)
@@ -210,7 +210,11 @@ Under **Settings → Theme**, *Theme style* is **Automatic** (follow the device,
210
210
  **Light** or **Dark**. With **Users may choose light or dark** (on by default, like APEX's
211
211
  *Enable End Users to Choose Theme Style*), the user menu has an *Appearance* switch and My account
212
212
  has the same choice. The choice is saved on the account, so it applies in every application that
213
- allows it. For public applications it's kept in a cookie.
213
+ allows it. Until a user chooses, an application opens in its own *Theme style* (a new Iris application
214
+ light, also when the device is in dark mode); *Automatic* follows the device only when the application
215
+ or the user chose it. A user who isn't signed in has the *Appearance* switch in the user menu (with
216
+ *Sign in*), kept in a cookie of that application only (`pgkiln_mode`, path `/a/<alias>`), so a choice in
217
+ one application doesn't change another's.
214
218
 
215
219
  ## Base styles: Iris, Lavender and Standard
216
220
 
@@ -604,3 +604,26 @@ name.
604
604
  The standard PDF fonts cover Western European text; set `PDF_FONT` (and `PDF_FONT_BOLD`) for other
605
605
  scripts, as for report PDFs. APEX's Word and Excel templates and outputs have no equivalent: the
606
606
  templates here are HTML and the output is PDF.
607
+
608
+ ### Report queries
609
+
610
+ **Shared Components → Report Queries** (APEX: Report Queries, migration 104) are named queries an
611
+ application prints, without a template: the query's rows as a table.
612
+
613
+ | Property | Meaning |
614
+ |---|---|
615
+ | `name` | Pages download it with `?doc=NAME` (a document template of that name wins); a button with the action *document* names it |
616
+ | `query` | A SELECT with `:ITEM` binds (the page's session state), run as the application's role; its columns' names are the headings |
617
+ | `format` | `pdf` (with the report layout), `xlsx`, `csv`, `json`, `xml` or `html` |
618
+ | `layout` | A report layout for the PDF; empty: the default layout |
619
+ | `file_name`, `title` | The file's name without extension and the PDF's or HTML file's title (`&ITEM.` allowed); empty: the name, and the description or the name |
620
+ | `authz` | Who may download it, besides access to the page |
621
+
622
+ ```
623
+ /a/sales/3?doc=ORDERS_BY_REGION the file, with page 3's session state
624
+ ```
625
+
626
+ The files are made like a download process's [data export](06-processing.md#data-export) (Excel and
627
+ PDF hold at most 100,000 rows) and are sent as attachments that a browser never shows as a page
628
+ (`Content-Security-Policy: sandbox`). Signed out, on a public page of an application with sign-in,
629
+ `?doc=` serves only the report queries and document templates of the page's own buttons.
@@ -65,6 +65,10 @@ hr/
65
65
  app-items/ai_ename.json
66
66
  app-processes/0010-link-user-to-employee.json
67
67
  app-processes/0010-link-user-to-employee.code.sql
68
+ app-computations/0010-ai_region-after_authentication.json (by sequence, item and point)
69
+ component-groups/sales_lookups.json
70
+ report-queries/orders_by_region.json (the query in .query.sql)
71
+ shortcuts/delete_confirm_msg.json (a long value in .value.txt, .html or .sql by its type)
68
72
  lovs/jobs.json
69
73
  lovs/jobs.query.sql
70
74
  report-layouts/hr_directory.json (a logo as hr_directory.logo.png)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.css CHANGED
@@ -478,6 +478,8 @@ details.btn-menu[open] > summary .btn-caret { transform: rotate(-90deg); }
478
478
  .report-toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem 1rem; align-items: flex-end; margin-bottom: 0.75rem; }
479
479
  .report-toolbar .field { flex: 0 1 240px; }
480
480
  .search { display: flex; gap: 0.4rem; flex: 1 1 320px; max-width: 560px; margin-left: auto; }
481
+ /* (sprint 78) a report whose search is the facets': the Actions menu at the start of the toolbar */
482
+ .report-toolbar .search:not(:has(input[type=search])) { flex: 0 1 auto; min-width: 0; margin-left: 0; }
481
483
  .chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.75rem; }
482
484
  .chip { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.83rem; padding: 0.15rem 0.3rem 0.15rem 0.7rem; background: var(--accent-soft); border-radius: 99px; }
483
485
  .chip a { text-decoration: none; font-size: 1rem; line-height: 1; padding: 0 0.3rem; border-radius: 99px; }
@@ -1054,6 +1056,20 @@ fieldset.prop-group > legend { font-size: 0.75rem; text-transform: uppercase; le
1054
1056
  .grid-table th.grid-drop-after { box-shadow: inset -3px 0 0 var(--accent); }
1055
1057
  /* copy and paste: a range of selected cells */
1056
1058
  .grid-table td.grid-cell-selected { background: color-mix(in srgb, var(--accent) 16%, var(--surface)); box-shadow: inset 0 0 0 1px var(--accent); }
1059
+ /* cell navigation mode (APEX): with JavaScript an editable cell reads as text (its formatted value, .grid-text)
1060
+ until it is clicked or Enter / F2 edits it; arrow keys move between cells (app.js) */
1061
+ .grid-text { display: none; }
1062
+ .js .grid-table td[data-col]:not(.grid-frozen) { position: relative; }
1063
+ .js .grid-table tbody td[data-col]:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
1064
+ .js .grid-table tbody td[data-col] :is(input:not([type=checkbox]), select) { border-color: transparent; background: transparent; box-shadow: none; }
1065
+ .js .grid-table tbody td[data-col] select { appearance: none; }
1066
+ .js .grid-table tbody td[data-col]:focus-within :is(input:not([type=checkbox]), select) { border-color: var(--border); background: var(--surface); appearance: auto; }
1067
+ .js .grid-table td[data-col] > .grid-text { display: flex; align-items: center; position: absolute; inset: 0; padding: 0 calc(0.85rem + 1px);
1068
+ overflow: hidden; white-space: nowrap; pointer-events: none; }
1069
+ .js .grid-table td[data-col] > .grid-text.num { justify-content: flex-end; font-variant-numeric: tabular-nums; }
1070
+ .js .grid-table td[data-col]:has(> .grid-text) :is(input, select) { color: transparent; }
1071
+ .js .grid-table td[data-col]:focus-within > .grid-text { display: none; }
1072
+ .js .grid-table td[data-col]:focus-within :is(input, select) { color: inherit; }
1057
1073
  .grid-columns-form { border-collapse: collapse; font-size: 0.85rem; }
1058
1074
  .grid-columns-form th, .grid-columns-form td { padding: 0.2rem 0.35rem; text-align: left; white-space: nowrap; }
1059
1075
  .grid-columns-form input[type=number] { width: 5.5rem; }
@@ -1144,6 +1160,10 @@ td[data-add]:hover > .cal-add, .cal-add:focus-visible { opacity: 1; }
1144
1160
  .facet-chart .donut-figure { max-width: 9rem; margin: 0 auto; }
1145
1161
  .facet-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
1146
1162
  .facet-count { font-size: 0.75rem; color: var(--muted); background: var(--surface-3); border-radius: 99px; padding: 0 0.5rem; font-variant-numeric: tabular-nums; }
1163
+ /* (sprint 78) values after the seventh, and the rows the filters leave */
1164
+ .facet-more > summary { cursor: pointer; font-size: 0.85rem; color: var(--accent, var(--link)); padding: 0.15rem 0; }
1165
+ .facet-more[open] > summary { margin-bottom: 0.2rem; }
1166
+ .facet-total { margin: 0 0 0.6rem; font-size: 0.85rem; color: var(--muted); font-variant-numeric: tabular-nums; }
1147
1167
  .js .facet-actions .btn-hot { display: none; }
1148
1168
  .facet-exclude { font-size: 0.85rem; color: var(--muted); margin-top: 0.15rem; }
1149
1169
  .facet-excluded .facet-value .facet-label { text-decoration: line-through; }
@@ -1210,6 +1230,9 @@ input[type=color] { width: 4rem; min-height: 2.4rem; padding: 0.15rem; }
1210
1230
  .report-reflow.grid-table .agg-row td.grid-lead { display: none; }
1211
1231
  .report-reflow.grid-table td.grid-lead:empty { display: none; }
1212
1232
  .grid-resize { display: none !important; }
1233
+ /* a card shows its fields as fields: no navigation mode */
1234
+ .js .report-reflow.grid-table td[data-col] > .grid-text { display: none; }
1235
+ .js .report-reflow.grid-table tbody td[data-col] :is(input, select) { color: inherit; border-color: var(--border); background: var(--surface); appearance: auto; }
1213
1236
  }
1214
1237
 
1215
1238
  /* two-thirds / one-third columns on desktops (builder Users page) */
@@ -1811,6 +1834,14 @@ dialog.crop-dialog::backdrop { background: rgb(15 20 30 / 55%); }
1811
1834
  .devbar-tool::-webkit-details-marker { display: none; }
1812
1835
  .devbar .devbar-tool:hover, .devbar-menu[open] > .devbar-tool, .devbar-quick[aria-pressed="true"] { background: #2f3b4f; color: #fff; }
1813
1836
  .devbar-debug-menu.is-on > .devbar-tool { color: #ffd166; }
1837
+ /* (sprint 79, UI-4) a text next to each icon (App 12, Page 3, Debug off); icons only when chosen or docked at a side */
1838
+ .devbar .devbar-tool:has(.devbar-text) { width: auto; min-width: 2.25rem; padding: 0 0.55rem; grid-auto-flow: column; gap: 0.35rem; white-space: nowrap; }
1839
+ .devbar[data-compact] .devbar-text, .devbar[data-dock="left"] .devbar-text, .devbar[data-dock="right"] .devbar-text { display: none; }
1840
+ .devbar[data-compact] .devbar-tool:has(.devbar-text), .devbar[data-dock="left"] .devbar-tool:has(.devbar-text), .devbar[data-dock="right"] .devbar-tool:has(.devbar-text) { width: 2.25rem; padding: 0; }
1841
+ @media (max-width: 900px) {
1842
+ .devbar .devbar-text { display: none; }
1843
+ .devbar .devbar-tool:has(.devbar-text) { width: 2.25rem; padding: 0; }
1844
+ }
1814
1845
  .devbar a, .devbar-pop a { color: #9cc8ff; }
1815
1846
  .devbar a:focus-visible, .devbar button:focus-visible, .devbar select:focus-visible, .devbar summary:focus-visible, .devbar input:focus-visible { outline: 2px solid #9cc8ff; outline-offset: 2px; }
1816
1847
  .devbar-menu { position: relative; }
@@ -1887,8 +1918,9 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
1887
1918
  .devbar-quick[aria-pressed="true"] { background: var(--accent); color: var(--accent-text); }
1888
1919
 
1889
1920
  /* (sprint 76) the pager: rows per page; a Go button only without JavaScript */
1890
- .pager-size { display: inline-flex; align-items: center; gap: 0.4rem; margin-left: auto; font-size: 0.85rem; }
1921
+ .pager .pager-size { margin-right: 0; display: inline-flex; align-items: center; gap: 0.4rem; margin-left: auto; font-size: 0.85rem; }
1891
1922
  .pager-size select { min-height: 0; padding: 0.15rem 0.4rem; }
1923
+ .pager-size label { white-space: nowrap; }
1892
1924
  .js .no-js-only { display: none; }
1893
1925
 
1894
1926
  /* (sprint 76) the interactive report's Actions menu: one line per entry, submenus, forms in dialogs */
@@ -1914,6 +1946,13 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
1914
1946
  .search-field { display: flex; flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
1915
1947
  .search-field:focus-within { outline: 2px solid var(--accent); outline-offset: 1px; }
1916
1948
  .search-field .search-scope { border: 0; border-right: 1px solid var(--border); border-radius: var(--radius) 0 0 var(--radius); max-width: 9rem; background: var(--surface-2); }
1949
+ /* (sprint 79, IR-3; APEX: the column scope in the magnifier of the search field) all columns: only the magnifier;
1950
+ a column: the magnifier and its name */
1951
+ .search-scope-wrap { position: relative; display: flex; flex: none; }
1952
+ .search-scope-wrap > .icon { position: absolute; left: 0.6rem; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--muted); }
1953
+ .search-field .search-scope-wrap .search-scope { padding-left: 2rem; }
1954
+ .search-field .search-scope-wrap .search-scope:has(option[value=""]:checked) { width: 2.75rem; padding-right: 0.5rem; color: transparent; }
1955
+ .search-field .search-scope option { color: var(--text); }
1917
1956
  .search-field input[type=search] { border: 0; flex: 1; min-width: 0; background: transparent; }
1918
1957
  .search-field input[type=search]:focus { outline: none; }
1919
1958
  /* a column heading's menu */
package/public/app.js CHANGED
@@ -1666,6 +1666,69 @@ const pgkilnUnsaved = (() => {
1666
1666
  data[i].forEach((v, j) => cells[c0 + j] && setCell(cells[c0 + j], v));
1667
1667
  }
1668
1668
  });
1669
+
1670
+ // ------------------------------------------------------------ cell navigation and edit mode (APEX)
1671
+ // An editable cell reads as text (app.css) until it is edited: a click in it, Enter or F2 or typing
1672
+ // edits it. On a cell the arrow keys move to the next one; while editing, Escape goes back to the
1673
+ // cell and Enter to the cell below. Tab moves through the cells' controls as usual.
1674
+ const cellSel = '[data-grid] table.grid-table > tbody:not(.grid-template) > tr > td[data-col]';
1675
+ function focusCell(td, edit) {
1676
+ if (!td) return;
1677
+ const el = control(td);
1678
+ if (edit && el && !el.disabled) {
1679
+ el.focus();
1680
+ if (el.type !== 'checkbox' && el.tagName !== 'SELECT') el.select?.();
1681
+ return;
1682
+ }
1683
+ if (!td.hasAttribute('tabindex')) td.tabIndex = -1;
1684
+ td.focus();
1685
+ }
1686
+ function neighbour(td, dr, dc) {
1687
+ const rows = rowsOf(td.closest('[data-grid]'));
1688
+ const { r, c } = at(td);
1689
+ return rows[r + dr] ? shownCells(rows[r + dr])[c + dc] : undefined;
1690
+ }
1691
+ const MOVES = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
1692
+ document.addEventListener('keydown', (e) => {
1693
+ if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
1694
+ const t = e.target;
1695
+ if (t.matches?.(cellSel)) {
1696
+ const el = control(t);
1697
+ if (MOVES[e.key]) {
1698
+ e.preventDefault();
1699
+ focusCell(neighbour(t, ...MOVES[e.key]), false);
1700
+ } else if (el && (e.key === 'Enter' || e.key === 'F2')) {
1701
+ e.preventDefault();
1702
+ focusCell(t, true);
1703
+ } else if (el?.type === 'checkbox' && e.key === ' ') {
1704
+ e.preventDefault();
1705
+ el.click();
1706
+ } else if (el && e.key.length === 1) focusCell(t, true); // the key types into the control
1707
+ return;
1708
+ }
1709
+ const td = t.closest?.(cellSel);
1710
+ if (!td || !t.matches('input, select')) return;
1711
+ if (e.key === 'Escape') focusCell(td, false);
1712
+ else if (e.key === 'Enter' && !e.shiftKey) {
1713
+ e.preventDefault();
1714
+ const below = neighbour(td, 1, 0);
1715
+ focusCell(below ?? td, !!below);
1716
+ } else if (e.key === 'F2') focusCell(td, false);
1717
+ });
1718
+ // a click in a cell beside its control edits it
1719
+ document.addEventListener('click', (e) => {
1720
+ if (!e.shiftKey && e.target.matches?.(cellSel)) focusCell(e.target, true);
1721
+ });
1722
+ // an edited value shows as itself (a list's display value)
1723
+ const syncText = (e) => {
1724
+ const span = e.target.closest?.('td[data-col]')?.querySelector(':scope > .grid-text');
1725
+ if (!span) return;
1726
+ const el = e.target;
1727
+ if (el.tagName === 'SELECT') span.textContent = el.selectedIndex > 0 ? el.options[el.selectedIndex].text : '';
1728
+ else span.remove();
1729
+ };
1730
+ document.addEventListener('input', syncText);
1731
+ document.addEventListener('change', syncText);
1669
1732
  })();
1670
1733
 
1671
1734
  // Faceted search: apply on change. A range facet's own from/to: typing picks
@@ -3642,7 +3705,7 @@ function areaFilter(map, f) {
3642
3705
  const bar = document.querySelector('.devbar');
3643
3706
  if (!bar) return;
3644
3707
  const KEY = 'pgkiln.devbar';
3645
- let prefs = { dock: 'bottom', autohide: true };
3708
+ let prefs = { dock: 'bottom', autohide: true, compact: false };
3646
3709
  try { prefs = { ...prefs, ...JSON.parse(localStorage.getItem(KEY) || '{}') }; } catch {}
3647
3710
  if (!['bottom', 'top', 'left', 'right'].includes(prefs.dock)) prefs.dock = 'bottom';
3648
3711
  const save = () => { try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch {} };
@@ -3650,6 +3713,10 @@ function areaFilter(map, f) {
3650
3713
  settings?.closest('li')?.removeAttribute('hidden');
3651
3714
  const apply = () => {
3652
3715
  bar.dataset.dock = prefs.dock;
3716
+ // (sprint 79) "Icons only": the tools without their texts
3717
+ bar.toggleAttribute('data-compact', !!prefs.compact);
3718
+ const compact = bar.querySelector('input[name="devbar-compact"]');
3719
+ if (compact) compact.checked = !!prefs.compact;
3653
3720
  bar.querySelectorAll('input[name="devbar-dock"]').forEach((r) => { r.checked = r.value === prefs.dock; });
3654
3721
  const auto = bar.querySelector('input[name="devbar-autohide"]');
3655
3722
  if (auto) auto.checked = !!prefs.autohide;
@@ -3658,6 +3725,7 @@ function areaFilter(map, f) {
3658
3725
  bar.addEventListener('change', (e) => {
3659
3726
  if (e.target.name === 'devbar-dock') prefs.dock = e.target.value;
3660
3727
  else if (e.target.name === 'devbar-autohide') prefs.autohide = e.target.checked;
3728
+ else if (e.target.name === 'devbar-compact') prefs.compact = e.target.checked;
3661
3729
  else return;
3662
3730
  save();
3663
3731
  apply();
package/src/appfiles.ts CHANGED
@@ -116,6 +116,10 @@ const CODE: Record<string, Record<string, string>> = {
116
116
  computation: { expression: 'sql', condition_expr: 'sql' },
117
117
  branch: { condition_expr: 'sql' },
118
118
  app_process: { code: 'sql' },
119
+ app_computation: { expression: 'sql', condition_expr: 'sql' },
120
+ // (103) a shortcut's value: text, HTML or PL/pgSQL (see codeExt)
121
+ shortcut: { value: 'txt' },
122
+ report_query: { query: 'sql' },
119
123
  authz_scheme: { value: 'sql' },
120
124
  lov: { query: 'sql' },
121
125
  automation: { query: 'sql', code: 'sql' },
@@ -134,7 +138,9 @@ const CODE: Record<string, Record<string, string>> = {
134
138
  };
135
139
  const isLong = (v: unknown): v is string => typeof v === 'string' && (v.includes('\n') || v.length > 60);
136
140
  const codeExt = (table: string, column: string, row: any) =>
137
- table === 'region' && column === 'source' && row.type === 'static' ? 'html' : CODE[table][column];
141
+ table === 'region' && column === 'source' && row.type === 'static' ? 'html'
142
+ : table === 'shortcut' ? (row.type === 'html' ? 'html' : row.type === 'function_body' ? 'sql' : 'txt')
143
+ : CODE[table][column];
138
144
 
139
145
  export type Style = 'json' | 'text';
140
146
 
@@ -191,6 +197,11 @@ const NAMED: [section: string, dir: string, table: string][] = [
191
197
  // (086) application settings
192
198
  ['app_settings', 'shared/app-settings', 'app_setting'],
193
199
  ['page_groups', 'shared/page-groups', 'page_group'],
200
+ // (103) shortcuts, (104) report queries
201
+ ['shortcuts', 'shared/shortcuts', 'shortcut'],
202
+ ['report_queries', 'shared/report-queries', 'report_query'],
203
+ // (105) component groups
204
+ ['component_groups', 'shared/component-groups', 'component_group'],
194
205
  ];
195
206
 
196
207
  /** Components of a page: [array in the document, directory, table, key source]. */
@@ -214,10 +225,11 @@ const SINGLE: [section: string, path: string, sort: string[]][] = [
214
225
  ];
215
226
 
216
227
  const ACTIONS_DIR = 'shared/automation-actions';
228
+ const APP_COMPUTATIONS_DIR = 'shared/app-computations';
217
229
  const STATIC_DIR = 'static';
218
230
  const STATIC_INDEX = `${STATIC_DIR}/files.json`;
219
231
 
220
- const KNOWN = new Set(['format', 'app', 'static_files', 'app_processes', 'translations', 'nav', 'list_entries', 'automation_actions', 'pages', ...NAMED.map((n) => n[0]), ...SINGLE.map((s) => s[0])]);
232
+ const KNOWN = new Set(['format', 'app', 'static_files', 'app_processes', 'app_computations', 'translations', 'nav', 'list_entries', 'automation_actions', 'pages', ...NAMED.map((n) => n[0]), ...SINGLE.map((s) => s[0])]);
221
233
 
222
234
  const byColumns = (cols: string[]) => (a: any, b: any) => {
223
235
  for (const c of cols) {
@@ -258,6 +270,9 @@ export function docToFiles(doc: Doc, style: Style = 'json'): FileMap {
258
270
 
259
271
  const procs: any[] = doc.app_processes ?? [];
260
272
  uniqueKeys(procs, (r) => r.name, 'process').forEach((k, i) => w.record('shared/app-processes', `${seqPrefix(procs[i].seq)}-${k}`, 'app_process', procs[i]));
273
+ // (102) application computations, by sequence, item and point
274
+ const comps: any[] = doc.app_computations ?? [];
275
+ uniqueKeys(comps, (r) => `${r.item_name ?? ''} ${r.point ?? ''}`, 'computation').forEach((k, i) => w.record(APP_COMPUTATIONS_DIR, `${seqPrefix(comps[i].seq)}-${k}`, 'app_computation', comps[i]));
261
276
 
262
277
  for (const [section, path, sort] of SINGLE) w.json(path, [...(doc[section] ?? [])].sort(byColumns(sort)));
263
278
 
@@ -453,6 +468,7 @@ export function filesToDoc(files: FileMap): Doc {
453
468
  });
454
469
  }
455
470
  doc.app_processes = readRecords(files, 'shared/app-processes').sort(SEQ_SORT).map(mergeCode);
471
+ doc.app_computations = readRecords(files, APP_COMPUTATIONS_DIR).sort(SEQ_SORT).map(mergeCode);
456
472
  for (const [section, path] of SINGLE) doc[section] = read(path);
457
473
  doc.translations = [...files.keys()]
458
474
  .filter((p) => /^globalization\/translations\/[^/]+\.(json|yaml)$/.test(p))
@@ -57,6 +57,7 @@ function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[]
57
57
  case 'button':
58
58
  return [{ name: 'condition', shape: 'boolean' }, { name: 'badge_query', shape: 'select' }];
59
59
  case 'computation':
60
+ case 'app_computation':
60
61
  return [
61
62
  ...(row.type === 'sql_query' ? [{ name: 'expression', shape: 'select' as const }] : []),
62
63
  ...conditionSql(row),
@@ -83,6 +84,7 @@ function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[]
83
84
  case 'automation_action':
84
85
  return [{ name: 'code', shape: 'statements' }, { name: 'condition', shape: 'boolean' }];
85
86
  case 'document_template':
87
+ case 'report_query':
86
88
  return [{ name: 'query', shape: 'select' }];
87
89
  case 'task_definition':
88
90
  return [{ name: 'action_code', shape: 'statements' }];
@@ -286,7 +288,8 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
286
288
  const buildOptions = new Set(all.filter((x) => x.kind === 'build_option').map((x) => x.row.name));
287
289
  const layouts = new Set(all.filter((x) => x.kind === 'report_layout').map((x) => x.row.name.toUpperCase()));
288
290
  const regionIds = new Set(all.filter((x) => x.kind === 'region').map((x) => x.row.id));
289
- const documents = new Set(all.filter((x) => x.kind === 'document_template').map((x) => x.row.name.toUpperCase()));
291
+ // (104) a document button names a document template or a report query
292
+ const documents = new Set(all.filter((x) => x.kind === 'document_template' || x.kind === 'report_query').map((x) => x.row.name.toUpperCase()));
290
293
  const missing = (entry: Entry | null, field: string, message: string, severity: Severity = 'error') => findings.push({ severity, entry, field, message });
291
294
 
292
295
  for (const e of entries) {
@@ -353,7 +356,7 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
353
356
  && !(await one('select 1 as ok from meta.app a join meta.page p on p.app_id = a.id where a.alias = $1 and p.page_no = $2', [row.target_app, row.target_page])))
354
357
  missing(e, 'Application (app)', `Application ${row.target_app} has no page ${row.target_page}.`);
355
358
  if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
356
- missing(e, 'Document template', row.document ? `Document template ${row.document} doesn't exist.` : 'A document button needs a document template.');
359
+ missing(e, 'Document template', row.document ? `Document template or report query ${row.document} doesn't exist.` : 'A document button needs a document template or a report query.');
357
360
  if (kind === 'rest_module') for (const problem of handlerProblems(row.handlers)) missing(e, 'Handlers (JSON)', problem);
358
361
  if (kind === 'workflow_definition') {
359
362
  const tasks = new Set(all.filter((x) => x.kind === 'task_definition').map((x) => x.row.name.toUpperCase()));
@@ -376,6 +379,8 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
376
379
  for (const problem of [...devRefs.errors, ...devRefs.warnings]) missing(e, field, problem, 'warning');
377
380
  }
378
381
  }
382
+ if (kind === 'report_query' && row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
383
+ if (kind === 'app_computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
379
384
  if (kind === 'document_template') {
380
385
  const problem = templateProblem(row.template ?? '');
381
386
  if (problem) missing(e, 'Template (HTML)', problem);