pgkiln 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SECURITY.md CHANGED
@@ -72,6 +72,7 @@ first version (MVP). Every finding below has a regression test in
72
72
  | Builder screens like APEX's (sprint 61) | The sign-in page's workspace is checked after the password (only the developer's workspaces; a wrong one answers like a wrong password, failures are throttled and logged as before); *Remember workspace and username* is an HttpOnly cookie for `/builder` holding no secret, read defensively and escaped; the redirect after sign-in is a `/builder` path only. Workspace message and banner: administrators and the workspace's administrators, CSRF, plain text escaped, the banner colour from a fixed list. Utilization: the same. SQL Workshop home: your own recent commands only (by id and username), the schema checked against the schemas the session's connection may use (and set as a parameter of `set_config`). Application groups: every query bound to the current workspace; groups and applications of other workspaces are ignored. App Gallery: keys are names from `examples/gallery/gallery.json`, never paths; sample apps (shipped SQL as the owner, demo accounts with known passwords) are for administrators only; starter apps go through the blueprint review and its signed create step. Cross application reports: fixed SQL per topic, the workspace id as a parameter. |
73
73
  | Workspace preferences, extension links, Manage backups (sprint 62) | Preferences and links: administrators and the workspace's administrators, CSRF, logged. The SQL Workshop preference is checked in `developer()` for every `/builder/sql` GET and POST before a route runs, so no SQL Workshop route can skip it; it is not a sandbox (a developer of several workspaces uses it in another; isolation is the boundary). RESTful services off: the module lookup answers 404 before authentication or any handler SQL. Extension links: only `http://`/`https://` URLs without spaces, quotes or angle brackets (checked in Node and by a CHECK constraint), escaped, opened with `rel="noopener noreferrer"`. Manage backups: only the current workspace's applications, through the existing `meta.backup_app`. SQL Workshop utilities: fixed catalogue SQL chosen by a key from a fixed list (own-property lookup), run read only with a statement timeout on the SQL Workshop's connection (an isolated workspace's developer login sees only its roles' schemas); the Database Monitor shows other sessions' query texts, so it is for administrators only and cancels nothing. |
74
74
  | Approval vacation rules and expiration (sprint 64) | A vacation rule is always the signed-in user's own (`meta.app_user()` in a security-definer function; no user field is read from the form); its table is closed to the runtime roles; the delegate's rights are computed in `meta.task_rights` on every call and only for tasks the user owns by name or is assigned (never role ownership), so they end with the rule. Dates are checked as dates; CSRF and an in-app `next`. The expiry tick runs as the owner from the scheduler only (no grant). |
75
+ | Blueprint from an application (sprint 66) | Builder only, CSRF; the application must be in the developer's current workspace (administrators: any). The catalog is read as the owner but only for tables the application's database role may SELECT, never `meta`. Sample rows are opt-in (the page warns about personal data), read in a read-only transaction with a statement timeout through `devDbForApp` (an isolated workspace's developer login), at most 20 per table. The result only fills the editor: creating still goes through the signed review. |
75
76
  | Drawers, built-in template components, Theme Roller additions (0.29.0) | Dialog positions and sizes, base styles, dark-mode colours, item and report-column template options only reach the page from fixed lists (checked when saved and again when rendered: a hand-edited value is ignored); the browser gets dialog shapes as JSON values from those lists. Built-in template components pass the same template checks as plug-ins; *Copy into this application* takes a static id from the fixed list, needs CSRF and the application in the developer's workspace, and never overwrites the application's own component. |
76
77
  | Report row selection across pages (0.29.0) | `POST …/report/<id>/select` needs the CSRF token, page access and a visible report region whose `selection` names the item (and the item visible); it only changes that item's session state; values are bounded (5000, 400 characters, no `:`) and treated as user input, like a submit; output is escaped. |
77
78
  | Instance settings (0.29.0) | Administrators only, with CSRF; only whole numbers within each setting's range are stored (a bad stored value falls back to the environment variable or default); the configuration overview never shows secrets (connection strings, keys: only whether they are set); changes are logged.
@@ -7,7 +7,7 @@ open item and open an issue or pull request; see [CONTRIBUTING.md](../CONTRIBUTI
7
7
  Legend: ✅ available · 🟡 partial (see notes) · ❌ not yet · ➖ not planned (a deliberate choice,
8
8
  or better served by the PostgreSQL ecosystem; see the notes and [extensions](guide/15-extensions.md))
9
9
 
10
- Last reviewed: 2026-10-13 (sprint 64: approval vacation rules and expiration policies; sprint 63: range, box plot and stock charts; sprint 62: workspace preferences (SQL Workshop and RESTful services per workspace), extension links, Manage backups across applications, SQL Workshop utilities with object reports, About Database and a database monitor; sprint 61: the builder's screens compared with APEX 26.1: the workspace sign-in page, the Administration menu with workspace message, environment banner and utilization, the SQL Workshop home, the App Gallery, application groups and cross application reports (four rows added); sprint 60: isolated workspaces with their own database logins, row-level security on pgkiln's tables and workspace database roles, workspace administrators; sprint 59: the Page Designer's help pane, Quick edit from the developer toolbar; sprint 58: page groups; sprint 57: region templates hero, title bar, buttons container, inline dialog and drawer, wizard with wizard progress, the carousel; sprint 56: Generate DDL and Schema Comparison in the SQL Workshop, `pgkiln ddl` and `pgkiln compare`; sprint 55: an editable login page with a Login region and the Minimal page template, Forgot password through the application's mail service; sprint 54: interactive grid views, the single row view, group by and chart; sprint 53: application settings, user preferences and items kept per user, dynamic translations, `meta.data_export` and data export downloads, display image and geocoded address items; sprint 52: every dynamic action event and action, Ajax callbacks, an error handling function, the session timeout warning; sprint 51: checked imports, application backups, availability and Run only, feedback, type-specific attribute help; sprint 50: lost update detection, warn on unsaved changes, the URL and search regions with search configurations, the developer toolbar with a session state viewer, and e-mail through SMTP with OAuth2, Microsoft Graph, Amazon SES and HTTP APIs; import checks, application availability, property help and e-mail compared again; 2026-10-06: pgkiln 0.31.0, in progress: static application files and the Execute JavaScript dynamic action; region, item, dynamic action and process plug-ins; conditional and dynamic theme styles; XML and Excel in `meta.parse_data`, zips in SQL, `meta.v_boolean`; file items in S3-compatible object storage; map layers by the visible area and as vector tiles; a graphical query builder; tenants for workflows and tasks; application files as YAML and region static IDs; Lucide icons and icon modifiers; PWA push notifications; 0.30.0: 136 icons and an icon picker; an axe-core accessibility audit; cropping pictures; session sharing between applications; twenty-two built-in languages; 0.29.0: workspaces; the Iris base style; drawers; built-in template components including the metric card; Theme Roller dark-mode colours, live preview and template options on items and report columns; report selection across pages; instance settings; fourteen built-in languages; 0.28.0: AI with Claude and OpenAI; 0.27.0: Data Reporter; sample data generator; create application from several sheets, pasted data or existing tables; 0.26.0: working copies with a three-way merge; theme, library and boilerplate applications with subscriptions; 0.25.0: Gantt, pyramid and polar charts; map layers, clustering and server-side spatial filters; REST data sources that write back from forms and grids and synchronise into tables, OAuth2 password and refresh-token grants; debug messages per request and an install/upgrade log; `meta.web_request()` and `meta.parse_data()`; Theme Roller style variants and template options; 0.24.0: automations with several actions, error handling per row and runs from SQL; invoke-API workflow steps; Unload Data in the Data Workshop; create page wizards for cards, calendar, chart, map, faceted search, form and master-detail; create an application from a file; 0.23.0: interactive grid aggregates, frozen/moved/resized columns, layouts and saved grid reports, master-detail, row actions and copy/paste; download processes, execution chains in the background, workflow processes, branches to a function's URL or another app, the Dialog Closed event; number format masks, automatic time zone, runtime messages in German, French and Spanish; SQL scripts, Quick SQL, a query builder, XML loading and data load definitions; custom authentication, lists, page and application locks with developer comments, supporting objects; 0.22.0: keyset paging, report PDFs and REST collections streamed from a cursor, database-account authentication; 0.21.0: HTTP-header authentication behind a reverse proxy; 0.20.0: a popup list of values with server-side search; 0.19.0: row-range pagination, maximum row counts and row limits, lazy loading and caching of regions, streamed CSV/Excel downloads; 0.18.0: calendar week/day/list views with drag and drop, bubble/gauge/funnel/radar charts and drill-down, smart filters, a region display selector and more facet types, computations, conditional branches, build options, menu buttons, REST data sources and web credentials, rich text/Markdown, rating, combobox, date range and QR code items; 0.17.0: an App Builder home, workspace dashboard and utilities like APEX's; 0.16.0: a Page Designer with drag-and-drop layout, a code editor with autocomplete, template components and plug-ins, parallel branches and versions of workflows, the `pgkiln` command line with one file per component; 0.15.0: drop and paste files, heat maps, filtering a report by the map area; 0.14.0: stacked, combo, scatter and pie charts, several files per upload item; 0.13.0: map and tree regions; 0.12.0: REST modules in the builder; 0.11.0: workflows and Progressive Web Apps; 0.10.0: LDAP, SAML, "Keep me signed in", document templates, JSON loading, approvals and the task list; pgkiln installs no application, HR is an example).
10
+ Last reviewed: 2026-10-14 (sprint 66: a blueprint from an existing application; sprint 65: facet charts; sprint 64: approval vacation rules and expiration policies; sprint 63: range, box plot and stock charts; sprint 62: workspace preferences (SQL Workshop and RESTful services per workspace), extension links, Manage backups across applications, SQL Workshop utilities with object reports, About Database and a database monitor; sprint 61: the builder's screens compared with APEX 26.1: the workspace sign-in page, the Administration menu with workspace message, environment banner and utilization, the SQL Workshop home, the App Gallery, application groups and cross application reports (four rows added); sprint 60: isolated workspaces with their own database logins, row-level security on pgkiln's tables and workspace database roles, workspace administrators; sprint 59: the Page Designer's help pane, Quick edit from the developer toolbar; sprint 58: page groups; sprint 57: region templates hero, title bar, buttons container, inline dialog and drawer, wizard with wizard progress, the carousel; sprint 56: Generate DDL and Schema Comparison in the SQL Workshop, `pgkiln ddl` and `pgkiln compare`; sprint 55: an editable login page with a Login region and the Minimal page template, Forgot password through the application's mail service; sprint 54: interactive grid views, the single row view, group by and chart; sprint 53: application settings, user preferences and items kept per user, dynamic translations, `meta.data_export` and data export downloads, display image and geocoded address items; sprint 52: every dynamic action event and action, Ajax callbacks, an error handling function, the session timeout warning; sprint 51: checked imports, application backups, availability and Run only, feedback, type-specific attribute help; sprint 50: lost update detection, warn on unsaved changes, the URL and search regions with search configurations, the developer toolbar with a session state viewer, and e-mail through SMTP with OAuth2, Microsoft Graph, Amazon SES and HTTP APIs; import checks, application availability, property help and e-mail compared again; 2026-10-06: pgkiln 0.31.0, in progress: static application files and the Execute JavaScript dynamic action; region, item, dynamic action and process plug-ins; conditional and dynamic theme styles; XML and Excel in `meta.parse_data`, zips in SQL, `meta.v_boolean`; file items in S3-compatible object storage; map layers by the visible area and as vector tiles; a graphical query builder; tenants for workflows and tasks; application files as YAML and region static IDs; Lucide icons and icon modifiers; PWA push notifications; 0.30.0: 136 icons and an icon picker; an axe-core accessibility audit; cropping pictures; session sharing between applications; twenty-two built-in languages; 0.29.0: workspaces; the Iris base style; drawers; built-in template components including the metric card; Theme Roller dark-mode colours, live preview and template options on items and report columns; report selection across pages; instance settings; fourteen built-in languages; 0.28.0: AI with Claude and OpenAI; 0.27.0: Data Reporter; sample data generator; create application from several sheets, pasted data or existing tables; 0.26.0: working copies with a three-way merge; theme, library and boilerplate applications with subscriptions; 0.25.0: Gantt, pyramid and polar charts; map layers, clustering and server-side spatial filters; REST data sources that write back from forms and grids and synchronise into tables, OAuth2 password and refresh-token grants; debug messages per request and an install/upgrade log; `meta.web_request()` and `meta.parse_data()`; Theme Roller style variants and template options; 0.24.0: automations with several actions, error handling per row and runs from SQL; invoke-API workflow steps; Unload Data in the Data Workshop; create page wizards for cards, calendar, chart, map, faceted search, form and master-detail; create an application from a file; 0.23.0: interactive grid aggregates, frozen/moved/resized columns, layouts and saved grid reports, master-detail, row actions and copy/paste; download processes, execution chains in the background, workflow processes, branches to a function's URL or another app, the Dialog Closed event; number format masks, automatic time zone, runtime messages in German, French and Spanish; SQL scripts, Quick SQL, a query builder, XML loading and data load definitions; custom authentication, lists, page and application locks with developer comments, supporting objects; 0.22.0: keyset paging, report PDFs and REST collections streamed from a cursor, database-account authentication; 0.21.0: HTTP-header authentication behind a reverse proxy; 0.20.0: a popup list of values with server-side search; 0.19.0: row-range pagination, maximum row counts and row limits, lazy loading and caching of regions, streamed CSV/Excel downloads; 0.18.0: calendar week/day/list views with drag and drop, bubble/gauge/funnel/radar charts and drill-down, smart filters, a region display selector and more facet types, computations, conditional branches, build options, menu buttons, REST data sources and web credentials, rich text/Markdown, rating, combobox, date range and QR code items; 0.17.0: an App Builder home, workspace dashboard and utilities like APEX's; 0.16.0: a Page Designer with drag-and-drop layout, a code editor with autocomplete, template components and plug-ins, parallel branches and versions of workflows, the `pgkiln` command line with one file per component; 0.15.0: drop and paste files, heat maps, filtering a report by the map area; 0.14.0: stacked, combo, scatter and pie charts, several files per upload item; 0.13.0: map and tree regions; 0.12.0: REST modules in the builder; 0.11.0: workflows and Progressive Web Apps; 0.10.0: LDAP, SAML, "Keep me signed in", document templates, JSON loading, approvals and the task list; pgkiln installs no application, HR is an example).
11
11
 
12
12
  ## At a glance
13
13
 
@@ -67,7 +67,7 @@ application reports, Manage Service), so the share available dropped without any
67
67
  | Charts | ✅ | Bar, column, stacked, line, area, combination (columns + lines), scatter, bubble, donut, pie, gauge, funnel, radar, **Gantt** (start/end, progress, milestones, dependencies, a today line, a time axis from hours to years), **pyramid** (area-proportional segments, or two series back to back) and **polar** (polar area); (sprint 63) **range** (floating columns from low to high), **box plot** (from one value per row or five statistics) and **stock** (candlesticks from open, high, low and close); multi-series, tooltips, data table, palette checked for colour-vision deficiency; drill-down links on chart marks (checksummed, only to pages the user may open). Drawn on the server as SVG without inline styles. **Missing:** other Oracle JET types (e.g. sunburst, treemap, timeline, picto), editing a Gantt by drag and drop |
68
68
  | Cards and metric cards (26.1 metric card template) | ✅ | Cards from SQL, with a KPI "metric" style |
69
69
  | Calendar | ✅ | Month, week, day and list views (agenda list on phones); create on click (a checksummed link with the slot); drag and drop to move events through the developer's move SQL, run as the app's role (CSRF, a *who may drag* authorization, only events the user sees). Works without JavaScript; dragging adds on top ([chapter 4](guide/04-pages-and-regions.md#calendar)) |
70
- | Faceted search | ✅ | Checkbox, range (predefined, or a custom from/to on numbers and dates), star rating and search facets with live counts that honour the other facets; exclude on checkbox facets (26.1). Values travel as query parameters, never as SQL text. **Missing:** facet charts |
70
+ | Faceted search | ✅ | Checkbox, range (predefined, or a custom from/to on numbers and dates), star rating and search facets with live counts that honour the other facets; exclude on checkbox facets (26.1). Values travel as query parameters, never as SQL text. (sprint 65) **Facet charts**: a bar or pie chart of a facet's counts, each mark filtering on its value |
71
71
  | Smart filters | ✅ | One search field with filter chips and suggestions over a report, the same facet types; URL-based, works without JavaScript ([chapter 4](guide/04-pages-and-regions.md#smart_filters)) |
72
72
  | Static content, dynamic content | ✅ | `static` (HTML with substitutions) and `dynamic` (a SELECT returning HTML) |
73
73
  | Breadcrumb | ✅ | Automatic, from the page's breadcrumb parent |
@@ -207,7 +207,7 @@ application reports, Manage Service), so the share available dropped without any
207
207
  | Automations (scheduled) | ✅ | Shared Components → Automations: cron schedules with time zones, once or per row of a query, **several ordered actions** with their own conditions, **error handling** (stop, skip the row and continue, or disable; row errors in the run history), roles, run history and Run now, **on-demand runs from SQL** with `meta.run_automation()` (like `APEX_AUTOMATION.EXECUTE`, synchronous in the caller's transaction), safe with several servers ([chapter 6](guide/06-processing.md#automations)) |
208
208
  | AI assistant, natural-language reports (NL2IR), AI agents and tools (26.1) | ✅ | Region type **AI assistant**: a conversation per session, context queries (RAG over developer queries, run as the app's role, read-only), and **tools** the model may call: SQL with bound arguments checked against declared parameters (read-only unless marked as writing, row limit, timeout) and REST data sources, each optionally behind an authorization scheme. **Natural-language report filters**: an "Ask in your own words" box turns a question into the report's normal filters, search and sort, limited to its visible columns ([chapter 4](guide/04-pages-and-regions.md)). Agents are defined per region (not as shared components); answers are not streamed. `pgvector` covers semantic search on the data side |
209
209
  | *Generate Text with AI* process, structured outputs (26.1) | ✅ | **AI services** (Workspace utilities, administrators): Claude through `@anthropic-ai/sdk` or OpenAI through `openai`, encrypted write-only keys (or `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`), per-app access with daily request and token limits, a usage log. Page process `ai_generate` (text into an item, or structured output into several items with a schema built from the items or your own) and a dynamic action that runs it without a page submit; `meta.ai_generate` / `ai_result` / `ai_available` from SQL ([chapter 6](guide/06-processing.md)). Prompts, item values included, go to the chosen provider |
210
- | Blueprints, spec-driven development (26.1) | ✅ | Create → **From a blueprint**: a JSON spec of tables (types, required, unique, allowed values, references), pages, navigation and sample data; optionally drafted by AI from a description; always reviewed (problems, SQL, pages, menu) before the application is created in one transaction; saved blueprints. **Missing:** a blueprint from an existing application |
210
+ | Blueprints, spec-driven development (26.1) | ✅ | Create → **From a blueprint**: a JSON spec of tables (types, required, unique, allowed values, references), pages, navigation and sample data; optionally drafted by AI from a description; always reviewed (problems, SQL, pages, menu) before the application is created in one transaction; saved blueprints; (sprint 66) **a blueprint from an existing application** (its tables, pages, menu and optionally sample rows, with notes on what didn't carry over) |
211
211
 
212
212
  ## Administration
213
213
 
@@ -353,6 +353,24 @@ in **one transaction**: if anything fails, nothing is created. Creation only acc
353
353
  text the review showed you (it is signed with your session); change it and review again. **Save**
354
354
  keeps a blueprint in the list (`meta.blueprint`, with the application last created from it).
355
355
 
356
+ **From an application** (on the Blueprints page; APEX 26.1: a blueprint from an application) opens a blueprint of an
357
+ application of the current workspace in the editor, for a copy, a new version or a test system:
358
+
359
+ - **tables**: the tables its regions read (a form's table, the first `schema.table` after `FROM` in a query) that the
360
+ application's database role may read, with their columns: types (integers, numbers, dates, timestamps, booleans,
361
+ text; other types become text, binary columns are left out), *required* (NOT NULL without a default), *unique*
362
+ (a one-column unique constraint), allowed values (an enum, or a `CHECK (col IN (…))`) and references to another of
363
+ these tables' `id`. Every blueprint table gets an `id`: an old key with another name stays a required, unique column,
364
+ and a reference to it a plain column;
365
+ - **pages**: a report with its form page becomes `report_form`, and grids, cards, calendars, charts, maps, faceted
366
+ searches and forms keep their type and page number; other pages become `blank` pages with their name;
367
+ - **navigation**: the menu entries of those pages;
368
+ - **sample rows** only when ticked: up to 20 per table, read read-only through the application's developer
369
+ connection. They are copied into the blueprint as they are, so leave this off for personal or confidential data.
370
+
371
+ The new name, alias and schema end in *copy*; a list of **notes** says what didn't carry over. Nothing is created
372
+ until you review and confirm it, as for any blueprint.
373
+
356
374
  ### Importing
357
375
 
358
376
  **Import** (a tile, or `/builder/import`): paste the JSON of an export and optionally give a new
@@ -555,7 +573,7 @@ out, other keys are kept, columns the query no longer returns stay listed so you
555
573
  | `chart` | Chart type (also Gantt, pyramid, polar, range, box plot and stock, with the columns each expects), the text when there are no rows, the gauge's range and thresholds, and the drill-down link (page and items, `#column#` and `#series#`); lists the query's columns |
556
574
  | `cards` | Cards or KPI tiles, the text when there are no rows, and the link (page and items) |
557
575
  | `calendar` | The views and the one shown first, the hours of the week and day views, the edit link of each event, the create-on-click link (`#start#`, `#end#`, `#date#`), and drag and drop (the SQL with `:EVENT_ID`, `:NEW_START`, `:NEW_END`, the key column, who may drag); warns when the query lacks `start_date` or `title` |
558
- | `facets` | The report region it filters and a search field on/off; per column of that report: facet on/off, label, type (checkboxes, ranges, star rating), values shown, exclude, ranges (`..1000; 1000..3000 = Middle; 3000..`), from/to and order |
576
+ | `facets` | The report region it filters and a search field on/off; per column of that report: facet on/off, label, type (checkboxes, ranges, star rating), values shown, exclude, ranges (`..1000; 1000..3000 = Middle; 3000..`), from/to, a chart of the counts (bar or pie) and order |
559
577
  | `smart_filters` | The same per-column facets, the suggestions per facet and the search field's placeholder |
560
578
  | `display_selector` | Tabs or a select list, "Show all", remember the choice; per other region of the page: in a tab and the tab name (saved in that region's settings) |
561
579
  | `ai_assistant` | The AI service (those the application may use), system prompt, welcome text, placeholder, error message, tool rounds per question, questions per conversation, public or not, and the context queries and tools as JSON (checked when saving: query shapes, parameter types, REST data sources and authorization schemes that exist) ([AI assistant](04-pages-and-regions.md#ai_assistant-ai-assistant)) |
@@ -748,6 +748,11 @@ Counts take the search and all *other* facets into account. Three kinds of facet
748
748
  With `"search": true` the panel starts with a search field: the same search as the report's own
749
749
  (`r<id>_q`, the row as text contains the term).
750
750
 
751
+ **Facet charts** (APEX: facet charts): with `"chart": "bar"` or `"chart": "pie"` a facet shows its counts as a chart,
752
+ collapsed under its heading (*Chart*). Each bar or slice, and each row of the chart's data table, links to the page
753
+ filtered on that value (a checkbox facet's value, or a range), the other filters kept. Values with no rows are left out
754
+ of the chart. In the HR sample, page 21 has a bar chart of jobs and a pie chart of salary ranges.
755
+
751
756
  Attributes:
752
757
 
753
758
  ```json
@@ -774,6 +779,7 @@ Attributes:
774
779
  | `facets[].ranges` | range: `[{"from": …, "to": …, "label": …}]`, numbers or `YYYY-MM-DD` dates; either bound may be left out (at most 20) |
775
780
  | `facets[].custom` | range: `true` adds *from*/*to* fields (default: only when there are no `ranges`) |
776
781
  | `facets[].max` | star: the highest rating, 2–10 (default 5) |
782
+ | `facets[].chart` | `bar` or `pie`: a chart of the facet's counts under it (faceted search only) |
777
783
 
778
784
  Only filters that a facet on the page allows are read from the URL: a value for a column without a
779
785
  facet, a range that isn't one of the facet's own, or a *from*/*to* on a facet without `custom`
@@ -25,6 +25,7 @@ src/
25
25
  appfiles.ts application export as one file per component (dir layout, static ids) and back
26
26
  blueprint.ts blueprints (migration 063): checkBlueprint (names, types, references, pages, sample rows), tableOrder,
27
27
  blueprintSql, buildBlueprint (tables, grants, rows, pages via meta.generate_page, menu), the AI draft schema
28
+ blueprint-from-app.ts (sprint 66) blueprintFromApp: an application's tables (catalog), pages and menu as a blueprint, notes; sample rows
28
29
  subscriptions.ts application types and subscriptions (migration 056): offers, subscribe, refresh, publish, in sync
29
30
  workingcopy.ts working copies (migration 055): create, three-way compare per component (base, main, copy),
30
31
  merge into the main application or refresh the copy, both through cli/replace.ts
@@ -115,7 +116,7 @@ src/
115
116
  style (user choice, default), themeCss() (only hex values and constants reach the CSS)
116
117
  template-options.ts template options: the fixed CSS class list per region and button, templateClasses() (unknown values ignored)
117
118
  regions.ts region shell + chart (drill-down links, gauge settings)/cards/dynamic dispatch with row limits, lazy placeholder and cache, buttons (menu buttons, badges)
118
- report.ts, report-views.ts (group by, pivot, chart), compute.ts (computed column expressions), grid.ts (aggregates, row actions, Actions menu, single row view, layoutFromForm), grid-layout.ts (column layouts: clean, arrange, per user), master-detail.ts (signed master row selection, details), facets.ts, items.ts
119
+ report.ts, report-views.ts (group by, pivot, chart), compute.ts (computed column expressions), grid.ts (aggregates, row actions, Actions menu, single row view, layoutFromForm), grid-layout.ts (column layouts: clean, arrange, per user), master-detail.ts (signed master row selection, details), facets.ts (facetChart: a facet's counts as a chart, sprint 65), items.ts
119
120
  (report.ts: paging with row ranges and max_rows, keyset paging (keysetPlan, seekCondition, signed r<id>_k), pagerNav, streamed CSV/Excel downloads with a cursor;
120
121
  items.ts: lovOptions, searchLov/lovLookup for popup LOVs, served by POST /a/:alias/:page/lov/:item/search in routes.ts)
121
122
  region-cache.ts region caching (keys per scope, CSRF placeholder, invalidation on submit) and lazy regions (GET …/region/:id is in routes.ts)
@@ -247,7 +248,8 @@ src/
247
248
  (093) workspace preferences (sql_workshop: checked in ui.ts developer() for /builder/sql…;
248
249
  rest_services: checked in runtime/rest.ts) and extension links (the rail's Extensions menu in shell())
249
250
  locks.ts page and application locks (blockingLock, checked in ui.ts developer() for every builder POST; appOfPath), developer comments, administrators
250
- blueprints.ts Create → From a blueprint: list, JSON editor, AI draft, review (signed with the session), create in one transaction
251
+ blueprints.ts Create → From a blueprint: list, JSON editor, AI draft, review (signed with the session), create in one transaction;
252
+ (sprint 66) From an application
251
253
  ai-builder.ts App Builder AI (migration 062): the builder's AI service (meta.builder_ai), SQL Workshop → AI (SQL from a
252
254
  question, shown not run; explain), describe tables (meta.ai_table_note, COMMENT ON, AI drafts),
253
255
  create pages with AI (proposals checked by checkProposals, created with meta.generate_page)
@@ -311,6 +313,7 @@ test/
311
313
  ai-assistant.test.ts AI assistant region (context, tools as the app role, writes, REST, histories, limits, sessions), OpenAI,
312
314
  natural-language report filters, builder settings; against ai-script-mock.ts
313
315
  blueprints.test.ts blueprints: checks and SQL, review then create (rows, pages, grants), one transaction, AI draft, save
316
+ blueprint-from-app.test.ts a blueprint of HR: checks clean, pages and menu, notes, a working copy with sample rows, workspaces
314
317
  ai-builder.test.ts App Builder AI: the builder's service, SQL from a question, explain, describe tables, pages with AI
315
318
  ai-script-mock.ts a scripted mock of Claude (SSE, tool_use with a thinking block) and OpenAI (tool calls), reply by reply
316
319
  ai-mock.ts a local mock of the Claude Messages API (SSE stream) and OpenAI Chat Completions
@@ -0,0 +1,17 @@
1
+ -- =====================================================================
2
+ -- HR example, part 58: facet charts
3
+ -- (docs/guide/04-pages-and-regions.md, faceted search)
4
+ --
5
+ -- The faceted search on page 21 shows a bar chart of the jobs and a pie
6
+ -- chart of the salary ranges under their facets; a bar or a slice filters
7
+ -- the report on its value.
8
+ -- =====================================================================
9
+
10
+ update meta.region r
11
+ set config = jsonb_set(r.config, '{facets}', (
12
+ select jsonb_agg(case f->>'column' when 'job' then f || '{"chart": "bar"}'
13
+ when 'salary' then f || '{"chart": "pie"}'
14
+ else f end order by n)
15
+ from jsonb_array_elements(r.config->'facets') with ordinality x(f, n)))
16
+ from meta.page p join meta.app a on a.id = p.app_id
17
+ where p.id = r.page_id and a.alias = 'hr' and p.page_no = 21 and r.type = 'facets';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.39.0",
3
+ "version": "0.40.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
@@ -992,6 +992,11 @@ td[data-add]:hover > .cal-add, .cal-add:focus-visible { opacity: 1; }
992
992
  .facet legend { font-weight: 650; font-size: 0.85rem; margin-bottom: 0.3rem; padding: 0; display: flex; gap: 0.5rem; }
993
993
  .facet-clear { font-weight: 400; font-size: 0.8rem; }
994
994
  .facet-value { font-weight: 400; font-size: 0.9rem; }
995
+ /* (sprint 65) a facet's chart of its counts, collapsed under the legend */
996
+ .facet-chart { margin: 0.1rem 0 0.4rem; }
997
+ .facet-chart > summary { cursor: pointer; font-size: 0.8rem; color: var(--muted); }
998
+ .facet-chart .chart { margin-top: 0.4rem; font-size: 0.85rem; }
999
+ .facet-chart .donut-figure { max-width: 9rem; margin: 0 auto; }
995
1000
  .facet-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
996
1001
  .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; }
997
1002
  .js .facet-actions .btn-hot { display: none; }
@@ -0,0 +1,240 @@
1
+ import { owner, type Db } from './db.ts';
2
+ import { LIMITS } from './blueprint.ts';
3
+
4
+ // A blueprint from an existing application (sprint 66; APEX 26.1: create a
5
+ // blueprint from an application). The application's tables (those its
6
+ // regions read: a form's table, the first schema.table after FROM in a
7
+ // query) become the blueprint's tables, its pages the blueprint's pages and
8
+ // its menu the navigation. The result is a starting point that the developer
9
+ // reviews in the blueprint editor: checkBlueprint() checks it like any other,
10
+ // and `notes` say what did not carry over.
11
+ //
12
+ // Only tables the application's database role may read are used (the owner's
13
+ // catalog is not a way to see other schemas). Sample rows are read only when
14
+ // asked for, at most 20 per table, read only, on the connection the caller
15
+ // gives (the builder: devDbForApp, an isolated workspace's developer login).
16
+
17
+ const NAME = /^[a-z][a-z0-9_]{0,62}$/;
18
+ const SAMPLE_ROWS = 20;
19
+
20
+ interface Col {
21
+ name: string;
22
+ type: string;
23
+ required: boolean;
24
+ unique: boolean;
25
+ values: string[];
26
+ references: string | null;
27
+ }
28
+ interface Table {
29
+ key: string; // schema.table
30
+ name: string;
31
+ label: string | null;
32
+ hasId: boolean;
33
+ columns: Col[];
34
+ }
35
+
36
+ /** The first schema.table after FROM in a query (lower case, quotes removed), or null. */
37
+ export function firstTable(source: string | null | undefined): string | null {
38
+ const m = /\bfrom\s+("?[a-z_][\w$]*"?)\s*\.\s*("?[a-z_][\w$]*"?)/i.exec(source ?? '');
39
+ return m ? `${m[1].replace(/"/g, '').toLowerCase()}.${m[2].replace(/"/g, '').toLowerCase()}` : null;
40
+ }
41
+
42
+ /** The literals of a CHECK (col = ANY (ARRAY['a'::text, …])) or (col IN ('a', …)), or null. */
43
+ export function checkValues(def: string): string[] | null {
44
+ const m = /^CHECK \(\(?\(?"?[a-z_][\w$]*"?\)?(?:::text)? = ANY \(\(?ARRAY\[(.*)\]\)?(?:::text\[\])?\)\)\)?$/i.exec(def.trim());
45
+ if (!m) return null;
46
+ const values = [...m[1].matchAll(/'((?:[^']|'')*)'(?:::[a-z ]+)?/gi)].map((x) => x[1].replace(/''/g, "'"));
47
+ return values.length ? values : null;
48
+ }
49
+
50
+ const TYPES: Record<string, string> = {
51
+ int2: 'integer', int4: 'integer', int8: 'integer', numeric: 'number', float4: 'number', float8: 'number', money: 'number',
52
+ date: 'date', timestamp: 'timestamp', timestamptz: 'timestamp', bool: 'boolean', text: 'text', varchar: 'text', bpchar: 'text', citext: 'text',
53
+ };
54
+
55
+ export async function blueprintFromApp(appId: number, opts: { sampleRows?: Db | null } = {}) {
56
+ const db = owner;
57
+ const notes: string[] = [];
58
+ const app = await db.one<{ name: string; alias: string; authentication: string; db_role: string | null }>(
59
+ 'select name, alias, authentication, db_role from meta.app where id = $1', [appId]);
60
+ if (!app) throw new Error('No such application.');
61
+ const regions = (await db.query<{ page_no: number; page_name: string; type: string; table_name: string | null; source: string | null; config: any }>(
62
+ `select p.page_no, p.name as page_name, r.type, r.table_name, r.source, r.config
63
+ from meta.region r join meta.page p on p.id = r.page_id where p.app_id = $1 order by p.page_no, r.seq, r.id`, [appId])).rows;
64
+ const tableOf = (r: (typeof regions)[number]) => (r.table_name ? r.table_name.toLowerCase().replace(/"/g, '') : firstTable(r.source));
65
+
66
+ // ---------------------------------------------------------------- tables
67
+ const wanted = [...new Set(regions.map(tableOf).filter((t): t is string => !!t))];
68
+ const readable = (await db.query<{ key: string; oid: number; label: string | null }>(
69
+ `select format('%s.%s', n.nspname, c.relname) as key, c.oid::int, obj_description(c.oid, 'pg_class') as label
70
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
71
+ where c.relkind in ('r', 'p') and format('%s.%s', n.nspname, c.relname) = any($1::text[])
72
+ and ($2::text is null or has_table_privilege($2::text, c.oid, 'SELECT'))
73
+ order by array_position($1::text[], format('%s.%s', n.nspname, c.relname))`, [wanted, app.db_role])).rows;
74
+ const tables: Table[] = [];
75
+ for (const t of readable.slice(0, LIMITS.tables)) {
76
+ const name = t.key.split('.')[1];
77
+ if (!NAME.test(name) || tables.some((x) => x.name === name)) {
78
+ notes.push(`Table ${t.key} is left out: ${NAME.test(name) ? 'another schema has a table with that name' : 'its name is not a-z, 0-9 and _'}.`);
79
+ continue;
80
+ }
81
+ const cols = (await db.query<{ name: string; typname: string; typtype: string; notnull: boolean; has_default: boolean; generated: boolean; pk: boolean; uniq: boolean; enum_values: string[] | null; checks: string[]; fk_table: string | null; fk_col: string | null }>(
82
+ `select a.attname as name, t.typname, t.typtype, a.attnotnull as notnull, a.atthasdef as has_default, a.attgenerated <> '' as generated,
83
+ exists (select 1 from pg_constraint k where k.conrelid = a.attrelid and k.contype = 'p' and k.conkey = array[a.attnum]) as pk,
84
+ exists (select 1 from pg_constraint k where k.conrelid = a.attrelid and k.contype = 'u' and k.conkey = array[a.attnum]) as uniq,
85
+ (select array_agg(e.enumlabel order by e.enumsortorder) from pg_enum e where e.enumtypid = t.oid) as enum_values,
86
+ coalesce((select array_agg(pg_get_constraintdef(k.oid)) from pg_constraint k where k.conrelid = a.attrelid and k.contype = 'c' and k.conkey = array[a.attnum]), '{}') as checks,
87
+ (select format('%s.%s', fn.nspname, fc.relname) from pg_constraint k join pg_class fc on fc.oid = k.confrelid join pg_namespace fn on fn.oid = fc.relnamespace
88
+ where k.conrelid = a.attrelid and k.contype = 'f' and k.conkey = array[a.attnum] limit 1) as fk_table,
89
+ (select fa.attname from pg_constraint k join pg_attribute fa on fa.attrelid = k.confrelid and fa.attnum = k.confkey[1]
90
+ where k.conrelid = a.attrelid and k.contype = 'f' and k.conkey = array[a.attnum] limit 1) as fk_col
91
+ from pg_attribute a join pg_type t on t.oid = a.atttypid
92
+ where a.attrelid = $1 and a.attnum > 0 and not a.attisdropped order by a.attnum`, [t.oid])).rows;
93
+ const table: Table = { key: t.key, name, label: t.label?.split('\n')[0].slice(0, 80) || null, hasId: false, columns: [] };
94
+ for (const c of cols) {
95
+ if (c.name === 'id' && c.pk) {
96
+ table.hasId = true;
97
+ continue;
98
+ }
99
+ if (c.generated) continue;
100
+ if (!NAME.test(c.name) || c.name === 'id') {
101
+ notes.push(`${t.key}.${c.name} is left out: the blueprint gives every table an id, and names are a-z, 0-9 and _.`);
102
+ continue;
103
+ }
104
+ if (table.columns.length >= LIMITS.columns) {
105
+ notes.push(`${t.key}: only the first ${LIMITS.columns} columns are kept.`);
106
+ break;
107
+ }
108
+ if (c.typname === 'bytea') {
109
+ notes.push(`${t.key}.${c.name} is left out: blueprints have no column type for files or binary data.`);
110
+ continue;
111
+ }
112
+ const type = c.typtype === 'e' ? 'text' : TYPES[c.typname];
113
+ if (!type) notes.push(`${t.key}.${c.name} (${c.typname}) becomes text.`);
114
+ if (c.pk) notes.push(`${t.key}'s key is ${c.name}: the blueprint's tables get an id key; ${c.name} stays a required, unique column.`);
115
+ const values = c.enum_values ?? c.checks.map(checkValues).find((v) => v) ?? [];
116
+ table.columns.push({
117
+ name: c.name, type: type ?? 'text', required: c.pk || (c.notnull && !c.has_default), unique: c.pk || c.uniq,
118
+ values: (type ?? 'text') === 'text' ? values.slice(0, LIMITS.values).filter((v) => v && v.length <= 100) : [],
119
+ references: c.fk_table && c.fk_col === 'id' ? c.fk_table : null,
120
+ });
121
+ if (c.fk_table && c.fk_col !== 'id') notes.push(`${t.key}.${c.name} refers to ${c.fk_table}.${c.fk_col}: blueprint references point at an id, so it stays a plain column.`);
122
+ }
123
+ if (!table.columns.length) {
124
+ notes.push(`Table ${t.key} is left out: it has no columns besides its id.`);
125
+ continue;
126
+ }
127
+ tables.push(table);
128
+ }
129
+ // references only to tables of the blueprint (by their name there)
130
+ const byKey = new Map(tables.map((t) => [t.key, t.name]));
131
+ for (const t of tables)
132
+ for (const c of t.columns)
133
+ if (c.references) {
134
+ const target = byKey.get(c.references);
135
+ if (!target) notes.push(`${t.key}.${c.name} refers to ${c.references}, which the application doesn't show: it stays a plain column.`);
136
+ c.references = target ?? null;
137
+ if (!target) c.type = 'integer';
138
+ }
139
+ for (const w of wanted) if (!readable.some((r) => r.key === w)) notes.push(`${w} is left out: not a table the application's role may read.`);
140
+
141
+ // ---------------------------------------------------------------- pages
142
+ const PRIORITY = ['grid', 'facets', 'calendar', 'cards', 'map', 'chart', 'report', 'form'];
143
+ const byPage = new Map<number, (typeof regions)>();
144
+ for (const r of regions) byPage.set(r.page_no, [...(byPage.get(r.page_no) ?? []), r]);
145
+ const known = new Set(tables.map((t) => t.key));
146
+ const nameOf = (key: string | null) => (key && known.has(key) ? byKey.get(key)! : null);
147
+ const pages: Record<string, unknown>[] = [];
148
+ const formPages = new Map<string, number>(); // table → the first page with just a form on it
149
+ for (const [no, rs] of byPage) {
150
+ const data = rs.filter((r) => PRIORITY.includes(r.type));
151
+ if (no > 1 && data.length === 1 && data[0].type === 'form' && nameOf(tableOf(data[0])) && !formPages.has(nameOf(tableOf(data[0]))!))
152
+ formPages.set(nameOf(tableOf(data[0]))!, no);
153
+ }
154
+ const consumed = new Set<number>();
155
+ const pageNos = [...byPage.keys()].filter((n) => n >= 2 && n <= 99999).sort((a, b) => a - b);
156
+ const used = new Set<number>([1, ...pageNos]);
157
+ const freeNo = () => {
158
+ let n = Math.max(...used) + 1;
159
+ while (used.has(n)) n++;
160
+ used.add(n);
161
+ return n;
162
+ };
163
+ for (const no of pageNos) {
164
+ if (consumed.has(no) || pages.length >= LIMITS.pages) continue;
165
+ const rs = byPage.get(no)!;
166
+ const label = rs[0].page_name;
167
+ const main = [...rs].filter((r) => PRIORITY.includes(r.type)).sort((a, b) => PRIORITY.indexOf(a.type) - PRIORITY.indexOf(b.type))[0];
168
+ const table = main
169
+ ? nameOf(main.type === 'facets' ? firstTable(rs.find((r) => r.type === 'report')?.source) : tableOf(main))
170
+ : null;
171
+ if (!main || !table) {
172
+ pages.push({ type: 'blank', page: no, label });
173
+ continue;
174
+ }
175
+ if (main.type === 'report') {
176
+ const form = formPages.get(table);
177
+ const formPage = form && form !== no && !consumed.has(form) ? form : freeNo();
178
+ consumed.add(formPage);
179
+ pages.push({ type: 'report_form', table, page: no, form_page: formPage, label });
180
+ } else if (main.type === 'form') {
181
+ if (formPages.get(table) === no && pages.some((p) => p.table === table && p.type === 'report_form')) continue;
182
+ pages.push({ type: 'form', table, page: no, label });
183
+ } else pages.push({ type: main.type, table, page: no, label });
184
+ }
185
+ if (pageNos.length > LIMITS.pages) notes.push(`Only the first ${LIMITS.pages} pages are kept.`);
186
+
187
+ // ---------------------------------------------------------------- navigation
188
+ const pageSet = new Set<number>([1, ...pages.flatMap((p) => [p.page as number, ...(p.form_page ? [p.form_page as number] : [])])]);
189
+ const navigation = (await db.query<{ label: string; page: number; icon: string | null }>(
190
+ `select label, target_page as page, icon from meta.nav_entry where app_id = $1 and target_page is not null order by seq, id`, [appId])).rows
191
+ .filter((n) => pageSet.has(n.page) && n.label)
192
+ .slice(0, LIMITS.pages)
193
+ .map((n) => ({ label: n.label.slice(0, 80), page: n.page, ...(n.icon && /^[a-z-]{1,30}$/.test(n.icon) ? { icon: n.icon } : {}) }));
194
+
195
+ // ---------------------------------------------------------------- sample rows (asked for only)
196
+ const sample_data: { table: string; columns: string[]; rows: unknown[][] }[] = [];
197
+ if (opts.sampleRows) {
198
+ for (const t of tables) {
199
+ const columns = [...(t.hasId ? ['id'] : []), ...t.columns.map((c) => c.name)];
200
+ const [schema, rel] = t.key.split('.');
201
+ const res = await opts.sampleRows.tx(async (c) => {
202
+ await c.query('set transaction read only');
203
+ await c.query(`set local statement_timeout = '10s'`);
204
+ return c.query({ text: `select ${columns.map((x) => `"${x}"`).join(', ')} from "${schema}"."${rel}" ${t.hasId ? 'order by id' : ''} limit ${SAMPLE_ROWS}`, rowMode: 'array' });
205
+ }).catch((e: Error) => {
206
+ notes.push(`No sample rows from ${t.key}: ${e.message}`);
207
+ return null;
208
+ });
209
+ if (!res?.rows.length) continue;
210
+ sample_data.push({
211
+ table: t.name, columns,
212
+ rows: res.rows.map((row: unknown[]) => row.map((v) =>
213
+ v === null || typeof v === 'boolean' || typeof v === 'number' ? v
214
+ : v instanceof Date ? v.toISOString()
215
+ : typeof v === 'object' ? JSON.stringify(v).slice(0, 2000)
216
+ : String(v).slice(0, 2000))),
217
+ });
218
+ }
219
+ }
220
+
221
+ const alias = `${app.alias}-copy`.slice(0, 50);
222
+ const spec = {
223
+ blueprint: 1,
224
+ name: `${app.name} (copy)`.slice(0, 100),
225
+ alias,
226
+ schema: alias.replace(/-/g, '_'),
227
+ authentication: app.authentication === 'none' ? 'none' : 'app_users',
228
+ tables: tables.map((t) => ({
229
+ name: t.name, ...(t.label ? { label: t.label } : {}),
230
+ columns: t.columns.map((c) => ({
231
+ name: c.name, ...(c.references ? { references: c.references } : { type: c.type }),
232
+ ...(c.required ? { required: true } : {}), ...(c.unique ? { unique: true } : {}), ...(c.values.length ? { values: c.values } : {}),
233
+ })),
234
+ })),
235
+ pages,
236
+ ...(navigation.length ? { navigation } : {}),
237
+ ...(sample_data.length ? { sample_data } : {}),
238
+ };
239
+ return { spec, notes };
240
+ }
@@ -11,6 +11,9 @@ import { builderService } from './ai-builder.ts';
11
11
  import { addDashboard } from './appsheets.ts';
12
12
  import { checkNewApp, createApp, createAppError } from './newapp.ts';
13
13
  import { currentWorkspace } from './workspaces.ts';
14
+ import { blueprintFromApp } from '../blueprint-from-app.ts';
15
+ import { devDbForApp } from '../workspace-db.ts';
16
+ import { isAdmin } from './locks.ts';
14
17
  import { back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
15
18
  import { WIZARD_KINDS } from './wizards.ts';
16
19
 
@@ -148,6 +151,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
148
151
  const s = await developer(req, reply);
149
152
  if (!s) return;
150
153
  const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id where b.workspace_id = $1 order by lower(b.name), b.id`, [currentWorkspace(s)])).rows;
154
+ const apps = (await owner.query(`select id, name, alias from meta.app where meta.app_workspace(id) = $1 order by lower(name), id`, [currentWorkspace(s)])).rows;
151
155
  return page(s, reply, '', html`<h1>Blueprints</h1>
152
156
  <p class="muted">Describe an application as a blueprint (tables, pages, menu, sample rows), review it and create it. <a class="btn btn-hot" href="${BASE}/blueprints/new">${icon('plus')} New blueprint</a></p>
153
157
  ${list.length
@@ -156,7 +160,33 @@ export async function blueprintRoutes(app: FastifyInstance) {
156
160
  <td data-label="Application created">${x.app_id ? html`<a href="${BASE}/apps/${x.app_id}">${x.alias}</a>` : '—'}</td><td data-label="Changed">${String(x.updated).slice(0, 16)}</td>
157
161
  <td><form method="post" action="${BASE}/blueprints/${x.id}/delete">${csrf(s)}<button class="btn btn-small">Delete</button></form></td></tr>`)}
158
162
  </tbody></table></div>`
159
- : html`<p class="muted">No saved blueprints yet.</p>`}`);
163
+ : html`<p class="muted">No saved blueprints yet.</p>`}
164
+ ${apps.length ? region('From an application', html`<form method="post" action="${BASE}/blueprints/from-app">${csrf(s)}
165
+ <p class="muted u-mt0">A blueprint of an application of this workspace: the tables its pages use, its pages and its menu, to review and change in the editor (for a copy, a new version or a test system). Nothing is created until you confirm the review.</p>
166
+ <div class="form-grid">
167
+ <div class="field"><label class="label" for="f_bp_app">Application</label><select id="f_bp_app" name="app">${apps.map((a) => html`<option value="${a.id}">${a.name} (${a.alias})</option>`)}</select></div>
168
+ <div class="field"><label class="check"><input type="checkbox" name="rows" value="true"> Sample rows: up to 20 per table</label>
169
+ <small class="help">The rows are copied into the blueprint as they are: leave this off for personal or confidential data.</small></div>
170
+ </div>
171
+ <div class="buttons"><button class="btn">${icon('layers')} Create a blueprint</button></div></form>`) : ''}`);
172
+ });
173
+
174
+ // (sprint 66) a blueprint from an application of the current workspace, opened in the editor
175
+ app.post(`${BASE}/blueprints/from-app`, async (req: Req, reply) => {
176
+ const s = await developer(req, reply);
177
+ if (!s) return;
178
+ const id = /^\d{1,9}$/.test(String(req.body?.app ?? '')) ? Number(req.body!.app) : null;
179
+ const a = id === null ? null : await owner.one<{ id: number; name: string }>(
180
+ `select id, name from meta.app where id = $1 and (meta.app_workspace(id) = $2 or $3)`, [id, currentWorkspace(s), await isAdmin(s.username)]);
181
+ if (!a) return reply.code(404).send('Not found');
182
+ const rows = req.body?.rows === 'true';
183
+ const { spec, notes } = await blueprintFromApp(a.id, { sampleRows: rows ? await devDbForApp(a.id) : null });
184
+ await logActivity({ appId: a.id, username: s.username, event: 'blueprint', ip: clientIp(req), detail: `blueprint from the application${rows ? ', with sample rows' : ''}` });
185
+ return editor(s, reply, {
186
+ name: `${a.name} blueprint`.slice(0, 100), spec: JSON.stringify(spec, null, 2),
187
+ message: html`<div class="alert alert-info" role="status"><p>A blueprint of ${a.name}: review it, change the name, alias and schema, then <b>Review</b>.</p>
188
+ ${notes.length ? html`<details><summary>${notes.length} note${notes.length === 1 ? '' : 's'} on what didn't carry over</summary><ul>${notes.map((n) => html`<li>${n}</li>`)}</ul></details>` : ''}</div>`,
189
+ });
160
190
  });
161
191
 
162
192
  app.get(`${BASE}/blueprints/new`, async (req: Req, reply) => {
@@ -286,10 +286,10 @@ export const rangesText = (ranges: unknown) =>
286
286
  .map((r: any) => `${r?.from ?? ''}..${r?.to ?? ''}${r?.label ? ` = ${r.label}` : ''}`)
287
287
  .join('; ');
288
288
 
289
- const FACET_FORM_KEYS = ['label', 'limit', 'type', 'ranges', 'custom', 'exclude'];
289
+ const FACET_FORM_KEYS = ['label', 'limit', 'type', 'ranges', 'custom', 'exclude', 'chart'];
290
290
 
291
291
  /** The facets table of a faceted search or smart filters form (and the report it filters). */
292
- function mergeFacetList(out: Config, config: Config, b: Body, a: Allowed) {
292
+ function mergeFacetList(out: Config, config: Config, b: Body, a: Allowed, charts = true) {
293
293
  const set = setter(out);
294
294
  const report = Number(b.report);
295
295
  const columns = a.reports.get(report);
@@ -306,6 +306,7 @@ function mergeFacetList(out: Config, config: Config, b: Body, a: Allowed) {
306
306
  ranges: parseRanges(b[`ranges_${i}`]),
307
307
  custom: b[`custom_${i}`] === 'true',
308
308
  exclude: b[`exclude_${i}`] === 'true',
309
+ chart: charts && (b[`chart_${i}`] === 'bar' || b[`chart_${i}`] === 'pie') ? b[`chart_${i}`] : null,
309
310
  seq: Number(b[`seq_${i}`]) || (i + 1) * 10,
310
311
  }))
311
312
  .filter((f) => f.on && columns.includes(f.column))
@@ -323,6 +324,8 @@ function mergeFacetList(out: Config, config: Config, b: Body, a: Allowed) {
323
324
  ...(f.type === 'range' && f.ranges.length ? { ranges: f.ranges } : {}),
324
325
  // a range facet without ranges has from/to fields unless switched off; with ranges, only when switched on
325
326
  ...(f.type === 'range' && f.custom !== !f.ranges.length ? { custom: f.custom } : {}),
327
+ // (sprint 65) a chart of the counts (faceted search only)
328
+ ...(f.chart ? { chart: f.chart } : {}),
326
329
  };
327
330
  });
328
331
  set('facets', facets.length ? facets : undefined);
@@ -338,7 +341,8 @@ export function mergeFacetsSettings(config: Config, b: Body, a: Allowed): Config
338
341
 
339
342
  export function mergeSmartFiltersSettings(config: Config, b: Body, a: Allowed): Config {
340
343
  const out = { ...config };
341
- if (!mergeFacetList(out, config, b, a)) return out;
344
+ // (sprint 65) facet charts belong to faceted search
345
+ if (!mergeFacetList(out, config, b, a, false)) return out;
342
346
  const set = setter(out);
343
347
  const n = Number(b.suggestions);
344
348
  set('suggestions', b.suggestions !== undefined && b.suggestions !== '' && Number.isInteger(n) && n >= 0 && n <= 10 && n !== 3 ? n : undefined);
@@ -621,6 +625,7 @@ async function facetsFields(r: RegionRow, pageId: number, appId: number, id: (n:
621
625
  <td data-label="Exclude"><input type="checkbox" name="exclude_${i}" value="true"${f?.exclude === true ? raw(' checked') : ''} aria-label="Users may exclude values of ${n}"></td>
622
626
  <td data-label="Ranges"><input name="ranges_${i}" value="${rangesText(f?.ranges)}" placeholder="..1000; 1000..3000 = Middle; 3000.." aria-label="Ranges of ${n}"></td>
623
627
  <td data-label="From/to"><input type="checkbox" name="custom_${i}" value="true"${custom ? raw(' checked') : ''} aria-label="Users may type a range for ${n}"></td>
628
+ ${smart ? '' : html`<td data-label="Chart"><select name="chart_${i}" aria-label="Chart of the facet on ${n}">${opt('', 'None', f?.chart)}${opt('bar', 'Bar', f?.chart)}${opt('pie', 'Pie', f?.chart)}</select></td>`}
624
629
  <td data-label="Order"><input name="seq_${i}" type="number" value="${(i + 1) * 10}" aria-label="Order of ${n}" class="u-mw6"></td>
625
630
  </tr>`;
626
631
  });
@@ -640,8 +645,8 @@ async function facetsFields(r: RegionRow, pageId: number, appId: number, id: (n:
640
645
  ${target
641
646
  ? html`<fieldset class="prop-group"><legend>Facets</legend>
642
647
  ${all.length
643
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Column</th><th>Facet</th><th>Label</th><th>Type</th><th>Values shown</th><th>Exclude</th><th>Ranges</th><th>From/to</th><th>Order</th></tr></thead><tbody>${rows}</tbody></table></div>
644
- <small class="help">Checkboxes list the most frequent values (Exclude lets users filter them out instead). Ranges and From/to need a number or date column: ranges are <code>from..to = label</code> separated by <code>;</code>, an open end left empty (<code>..1000</code>, <code>2020-01-01..</code>); a range includes its start, not its end. A star rating offers "4 stars and up" and so on (up to 5; <code>"max"</code> in the JSON changes it).</small>`
648
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Column</th><th>Facet</th><th>Label</th><th>Type</th><th>Values shown</th><th>Exclude</th><th>Ranges</th><th>From/to</th>${smart ? '' : html`<th>Chart</th>`}<th>Order</th></tr></thead><tbody>${rows}</tbody></table></div>
649
+ <small class="help">Checkboxes list the most frequent values (Exclude lets users filter them out instead). Ranges and From/to need a number or date column: ranges are <code>from..to = label</code> separated by <code>;</code>, an open end left empty (<code>..1000</code>, <code>2020-01-01..</code>); a range includes its start, not its end. A star rating offers "4 stars and up" and so on (up to 5; <code>"max"</code> in the JSON changes it).${smart ? '' : ' Chart: a bar or pie chart of the counts under the facet (collapsed); a bar or slice filters on its value.'}</small>`
645
650
  : html`<p class="muted">The report has no columns.</p>`}
646
651
  </fieldset>`
647
652
  : ''}`;
@@ -103,6 +103,8 @@ export interface FacetDef {
103
103
  exclude: boolean;
104
104
  /** star: the highest rating */
105
105
  max: number;
106
+ /** (sprint 65) a chart of the values' counts under the facet (faceted search regions) */
107
+ chart?: 'bar' | 'pie';
106
108
  }
107
109
 
108
110
  /** A region's config.facets as definitions (unknown types and malformed entries are left out). */
@@ -126,6 +128,7 @@ export function facetDefs(config: Record<string, any> | null | undefined): Facet
126
128
  custom: type === 'range' && (f.custom === true || (f.custom !== false && !ranges.length)),
127
129
  exclude: type === 'checkbox' && f.exclude === true,
128
130
  max,
131
+ ...(f.chart === 'bar' || f.chart === 'pie' ? { chart: f.chart } : {}),
129
132
  });
130
133
  }
131
134
  return out;
@@ -6,6 +6,7 @@ import type { Region } from '../metadata.ts';
6
6
  import { bindValues, publicError, stripSemicolon, toState, type PageContext } from './context.ts';
7
7
  import { heading } from './items.ts';
8
8
  import { fieldsOf, regionUrl } from './report.ts';
9
+ import { renderChartBody } from './charts.ts';
9
10
  import {
10
11
  CUSTOM_RANGE, facetDefs, facetFilters, facetKeys, facetParamNames, facetWhere, rangeKind, rangeLabel, rangeSql, rangeValue,
11
12
  reportFacetDefs, searchSql, hidesColumns, userColumnNames, type FacetDef,
@@ -105,6 +106,19 @@ export function starLabel(ctx: PageContext, f: FacetDef, n: number) {
105
106
  return html`<span class="facet-stars" aria-hidden="true">${'★'.repeat(n)}<span class="facet-stars-off">${'★'.repeat(Math.max(0, f.max - n))}</span></span><span class="sr-only">${ctx.locale.t(n === 1 ? 'facets.star_up' : 'facets.stars_up', { n: String(n) })}</span>`;
106
107
  }
107
108
 
109
+ /**
110
+ * (sprint 65; APEX: facet charts) A facet's counts as a bar or pie chart, collapsed under the facet; each
111
+ * mark (and the data table) links to the page filtered on that value, the other filters kept.
112
+ */
113
+ function facetChart(ctx: PageContext, report: Region, f: FacetDef, label: string, entries: { label: string; n: number; apply: (p: URLSearchParams) => void }[]) {
114
+ const shown = entries.filter((e) => e.n > 0);
115
+ if (!f.chart || !shown.length) return '';
116
+ const t = ctx.locale.t;
117
+ const link = (i: number) => html`href="${regionUrl(ctx, report, (p) => { shown[i].apply(p); p.delete(`r${report.id}_p`); })}"`;
118
+ return html`<details class="facet-chart"><summary>${t('report.chart')}</summary>
119
+ ${renderChartBody(f.chart, label, shown.map((e) => [e.label, e.n]), [{ name: label }, { name: t('report.rows') }], ctx.css, ctx.locale.lang, t, { link })}</details>`;
120
+ }
121
+
108
122
  export async function renderFacets(ctx: PageContext, r: Region): Promise<Raw> {
109
123
  const fs = await facetSource(ctx, r);
110
124
  if (!isFacetSource(fs)) return fs ?? html``;
@@ -142,8 +156,13 @@ export async function renderFacets(ctx: PageContext, r: Region): Promise<Raw> {
142
156
  // keep selected values visible even when their count dropped to 0
143
157
  for (const s of selected) if (s && !values.some((x) => x.v === s)) values.push({ v: s, n: 0 });
144
158
  const excluded = f.exclude && ctx.params.get(k.exclude + f.column) === '1';
159
+ const chart = facetChart(ctx, report, f, label, values.map((x) => ({
160
+ label: toState(x.v) ?? '', n: x.n,
161
+ apply: (p) => { p.delete(k.values + f.column); p.delete(k.exclude + f.column); p.append(k.values + f.column, x.v); },
162
+ })));
145
163
  groups.push(html`<fieldset class="facet${excluded ? ' facet-excluded' : ''}">
146
164
  <legend>${label}${clearLink(f)}</legend>
165
+ ${chart}
147
166
  ${values.map((x) => html`<label class="check facet-value"><input type="checkbox" form="${formId}" name="${k.values}${f.column}" value="${x.v}"${selected.has(x.v) ? raw(' checked') : ''} data-facet>
148
167
  <span class="facet-label">${toState(x.v)}</span><span class="facet-count">${x.n}</span></label>`)}
149
168
  ${f.exclude
@@ -178,8 +197,14 @@ export async function renderFacets(ctx: PageContext, r: Region): Promise<Raw> {
178
197
  const input = (which: 'from' | 'to', value: string) =>
179
198
  html`<label class="sr-only" for="${fid}_${which}">${label}: ${t(`facets.${which}`)}</label>
180
199
  <input id="${fid}_${which}" type="${kind === 'date' ? 'date' : 'number'}"${kind === 'number' ? raw(' step="any"') : ''} form="${formId}" name="${which === 'from' ? k.from : k.to}${f.column}" value="${isCustom ? value : ''}" placeholder="${t(`facets.${which}`)}">`;
200
+ const rangeChart = facetChart(ctx, report, f, label, f.ranges.map((x, i) => ({
201
+ label: f.type === 'star' ? ctx.locale.t(Number(x.from) === 1 ? 'facets.star_up' : 'facets.stars_up', { n: String(x.from) }) : rangeLabel(x, t),
202
+ n: counts![i],
203
+ apply: (p) => { for (const n of [k.from, k.to]) p.delete(n + f.column); p.set(name, rangeValue(x)); },
204
+ })));
181
205
  groups.push(html`<fieldset class="facet facet-range">
182
206
  <legend>${label}${clearLink(f)}</legend>
207
+ ${rangeChart}
183
208
  ${f.ranges.length ? radio('', !chosen && !isCustom, t('facets.any')) : ''}
184
209
  ${f.ranges.map((x, i) => radio(rangeValue(x), chosen === rangeValue(x), f.type === 'star' ? starLabel(ctx, f, Number(x.from)) : rangeLabel(x, t), counts![i]))}
185
210
  ${f.custom