pgkiln 0.38.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 +3 -0
- package/db/migrations/093_workspace_preferences.sql +34 -0
- package/db/migrations/094_task_vacation_expiry.sql +182 -0
- package/docs/apex-feature-parity.md +8 -8
- package/docs/guide/03-builder.md +62 -2
- package/docs/guide/04-pages-and-regions.md +27 -5
- package/docs/guide/06-processing.md +27 -1
- package/docs/guide/12-development.md +15 -6
- package/docs/guide/13-rest-api.md +3 -1
- package/examples/hr/hr_56_more_charts.sql +57 -0
- package/examples/hr/hr_57_task_policies.sql +18 -0
- package/examples/hr/hr_58_facet_charts.sql +17 -0
- package/package.json +1 -1
- package/public/app.css +30 -0
- package/public/sw.js +2 -1
- package/src/app.ts +2 -0
- package/src/automations.ts +6 -0
- package/src/blueprint-from-app.ts +240 -0
- package/src/builder/blueprints.ts +31 -1
- package/src/builder/components.ts +7 -0
- package/src/builder/home.ts +3 -2
- package/src/builder/region-settings.ts +13 -5
- package/src/builder/sql-utilities.ts +278 -0
- package/src/builder/sql.ts +3 -1
- package/src/builder/ui.ts +16 -3
- package/src/builder/workspace-backups.ts +78 -0
- package/src/builder/workspaces.ts +83 -1
- package/src/cli/replace.ts +2 -0
- package/src/i18n/ar.ts +25 -0
- package/src/i18n/cs.ts +25 -0
- package/src/i18n/da.ts +25 -0
- package/src/i18n/de.ts +25 -0
- package/src/i18n/el.ts +25 -0
- package/src/i18n/es.ts +25 -0
- package/src/i18n/fi.ts +25 -0
- package/src/i18n/fr.ts +25 -0
- package/src/i18n/he.ts +25 -0
- package/src/i18n/it.ts +25 -0
- package/src/i18n/ja.ts +25 -0
- package/src/i18n/ko.ts +25 -0
- package/src/i18n/nb.ts +25 -0
- package/src/i18n/pl.ts +25 -0
- package/src/i18n/pt.ts +25 -0
- package/src/i18n/ru.ts +25 -0
- package/src/i18n/sv.ts +25 -0
- package/src/i18n/tr.ts +25 -0
- package/src/i18n/uk.ts +25 -0
- package/src/i18n/zh.ts +25 -0
- package/src/i18n.ts +50 -0
- package/src/runtime/charts.ts +154 -5
- package/src/runtime/facet-state.ts +3 -0
- package/src/runtime/facets.ts +25 -0
- package/src/runtime/rest.ts +8 -2
- package/src/runtime/tasks.ts +62 -4
- package/src/session.ts +5 -0
- package/src/workflow.ts +6 -4
|
@@ -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
|
|
@@ -64,7 +65,7 @@ src/
|
|
|
64
65
|
sqlscript.ts SQL scripts: splitScript() (statements, line numbers, psql commands), runScript() (stop/continue, transaction, savepoints)
|
|
65
66
|
quicksql.ts Quick SQL: shorthand parser and PostgreSQL DDL generator (unit tested)
|
|
66
67
|
xlsx.ts Excel writer for report downloads and Unload Data (typed cells, streamed through fflate's Zip)
|
|
67
|
-
automations.ts cron parser, next run in a time zone, scheduler, running automations (the actions run
|
|
68
|
+
automations.ts cron parser, next run in a time zone, scheduler (also meta.task_expire_tick, 094), running automations (the actions run
|
|
68
69
|
in PL/pgSQL: meta.automation_execute, shared with meta.run_automation; migration 044)
|
|
69
70
|
html.ts auto-escaping html`` templates
|
|
70
71
|
richtext.ts rich text and Markdown items: allow-list HTML sanitiser, Markdown renderer
|
|
@@ -115,11 +116,11 @@ 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)
|
|
122
|
-
charts.ts server-rendered charts (SVG and CSS classes): bar … radar, gauges, Gantt (time axis, dependencies), pyramid, polar, drill-down marks, data table
|
|
123
|
+
charts.ts server-rendered charts (SVG and CSS classes): bar … radar, gauges, Gantt (time axis, dependencies), pyramid, polar, range, box plot (quantile, boxes), stock, drill-down marks, data table
|
|
123
124
|
calendar.ts calendar region: month/week/day/list views, create links, drag and drop (moveEvent, moveCalendarEvent;
|
|
124
125
|
the route POST …/calendar/:id/move is in routes.ts)
|
|
125
126
|
links.ts page links with checksums; fillItems() fills #column# in link items
|
|
@@ -143,7 +144,7 @@ src/
|
|
|
143
144
|
template-components.ts template components: template language (allow-list, directives, escaping), plug-in files, report column templates
|
|
144
145
|
builtin-components.ts built-in template components (ut_avatar, ut_badge, ut_comments, ut_media_list, ut_metric_card, ut_timeline)
|
|
145
146
|
template-region.ts template_component region
|
|
146
|
-
tasks.ts task list region and task actions (approvals)
|
|
147
|
+
tasks.ts task list region and task actions (approvals); (094) the vacation rule panel and route
|
|
147
148
|
data-reporter.ts Data Reporter region (migration 057): sources from the region's config, checkDef (offered columns, whitelists),
|
|
148
149
|
reportQuery/chartQuery, the list and editor (GET form), save/delete routes (meta.save_data_report)
|
|
149
150
|
workflows.ts workflow console region and its actions
|
|
@@ -187,6 +188,8 @@ src/
|
|
|
187
188
|
forms.ts generic component property form (lookups, render, save)
|
|
188
189
|
backups.ts Application → Backups (migration 082): list, back up now, download, restore (replaceApp), keep N;
|
|
189
190
|
backupTick() for the scheduler's daily backups of changed applications
|
|
191
|
+
workspace-backups.ts Workspace utilities → Manage backups (sprint 62): the backups of every application of the current
|
|
192
|
+
workspace, back them all up (only the changed ones by default)
|
|
190
193
|
availability.ts Application → Availability (migration 083): status, message, users, build status (Run only is
|
|
191
194
|
enforced by locks.ts blockingLock); availabilityBadge() in the application header
|
|
192
195
|
feedback.ts Application → Feedback (migration 084): the setting, the list with filters, status and answers
|
|
@@ -213,6 +216,8 @@ src/
|
|
|
213
216
|
search.ts app search, "where used" (appEntries, search, whereUsed, usedInPanel)
|
|
214
217
|
advisor.ts Advisor: EXPLAIN every SQL fragment, reference checks, plpgsql_check
|
|
215
218
|
ddl-tools.ts SQL Workshop → DDL and Compare (src/ddl.ts)
|
|
219
|
+
sql-utilities.ts SQL Workshop → Utilities (sprint 62): the tools' tiles, OBJECT_REPORTS (fixed catalogue SQL over the
|
|
220
|
+
connection's usable schemas), About Database, Database Monitor (administrators); read only, with a timeout
|
|
216
221
|
login-page.ts Application settings → Login page and Forgot password; Create a login page (page 9999)
|
|
217
222
|
top-sql.ts Top SQL per app role from pg_stat_statements
|
|
218
223
|
diagnostics.ts Activity → Debug messages (level, list, one request's entries, purge); Workspace utilities →
|
|
@@ -240,8 +245,11 @@ src/
|
|
|
240
245
|
workspaces.ts workspaces (064): loadWorkspaces/appAllowed (checked in ui.ts developer() for every /apps/:id and /pages/:pid
|
|
241
246
|
request), the current workspace (session state __WS), placeApp, the switcher, Workspace utilities → Workspaces;
|
|
242
247
|
(090) isolation and the workspace's database roles, workshopDb (the SQL Workshop's connection)
|
|
248
|
+
(093) workspace preferences (sql_workshop: checked in ui.ts developer() for /builder/sql…;
|
|
249
|
+
rest_services: checked in runtime/rest.ts) and extension links (the rail's Extensions menu in shell())
|
|
243
250
|
locks.ts page and application locks (blockingLock, checked in ui.ts developer() for every builder POST; appOfPath), developer comments, administrators
|
|
244
|
-
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
|
|
245
253
|
ai-builder.ts App Builder AI (migration 062): the builder's AI service (meta.builder_ai), SQL Workshop → AI (SQL from a
|
|
246
254
|
question, shown not run; explain), describe tables (meta.ai_table_note, COMMENT ON, AI drafts),
|
|
247
255
|
create pages with AI (proposals checked by checkProposals, created with meta.generate_page)
|
|
@@ -281,7 +289,7 @@ test/
|
|
|
281
289
|
page-logic.test.ts download, chain (background jobs) and workflow processes, function/app branches, dialog_closed (HR page 28)
|
|
282
290
|
code-editor.test.ts code editor: completions scoped to the app's role, the check, marked fields
|
|
283
291
|
builder-home.test.ts App Builder home: search, sort, views, Recent, Create/Import pages, dashboard, utilities
|
|
284
|
-
charts.test.ts chart markup per kind (geometry as classes), gauges, Gantt time axis and dependencies, pyramid, polar, drill-down links
|
|
292
|
+
charts.test.ts chart markup per kind (geometry as classes), gauges, Gantt time axis and dependencies, pyramid, polar, range, box plot, stock, drill-down links
|
|
285
293
|
calendar.test.ts calendar views, create links, moving events (pure and over HTTP)
|
|
286
294
|
rest-sources.test.ts REST data sources, web credentials, SSRF checks, invoke_api (mock service + HR page 23)
|
|
287
295
|
workflow-invoke.test.ts workflow invoke_api steps: the call between transactions, faults, retry, lease, Advisor, export (mock service)
|
|
@@ -305,6 +313,7 @@ test/
|
|
|
305
313
|
ai-assistant.test.ts AI assistant region (context, tools as the app role, writes, REST, histories, limits, sessions), OpenAI,
|
|
306
314
|
natural-language report filters, builder settings; against ai-script-mock.ts
|
|
307
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
|
|
308
317
|
ai-builder.test.ts App Builder AI: the builder's service, SQL from a question, explain, describe tables, pages with AI
|
|
309
318
|
ai-script-mock.ts a scripted mock of Claude (SSE, tool_use with a thinking block) and OpenAI (tool calls), reply by reply
|
|
310
319
|
ai-mock.ts a local mock of the Claude Messages API (SSE stream) and OpenAI Chat Completions
|
|
@@ -16,7 +16,9 @@ same row level security, and both publish an OpenAPI description.
|
|
|
16
16
|
## REST modules in the builder
|
|
17
17
|
|
|
18
18
|
**Shared Components → REST modules** (APEX: RESTful Services). A module is a set of handlers under
|
|
19
|
-
`/a/<alias>/rest/<name
|
|
19
|
+
`/a/<alias>/rest/<name>/`. A workspace's administrators can turn the REST modules of all its applications off with
|
|
20
|
+
the workspace preference *Enable RESTful services* ([chapter 3](03-builder.md#workspace-preferences)): they then
|
|
21
|
+
answer 404.
|
|
20
22
|
|
|
21
23
|
```json
|
|
22
24
|
[{"method": "GET", "path": "orders", "type": "collection", "source": "select id, customer, total from sales.orders order by id"},
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- HR example, part 56: range, box plot and stock charts
|
|
3
|
+
-- (docs/guide/04-pages-and-regions.md, chart regions)
|
|
4
|
+
--
|
|
5
|
+
-- On page 32 "Project plan":
|
|
6
|
+
-- - a range chart of the salary range per department (lowest and
|
|
7
|
+
-- highest salary), drilling down to the department's employees;
|
|
8
|
+
-- - a box plot of the salaries per job, computed from one row per
|
|
9
|
+
-- employee;
|
|
10
|
+
-- - a stock chart of a made-up share price over the last 30 working
|
|
11
|
+
-- days (open, high, low and close), generated in the query.
|
|
12
|
+
-- =====================================================================
|
|
13
|
+
|
|
14
|
+
insert into meta.region (page_id, seq, title, type, columns, template, source, config)
|
|
15
|
+
select p.id, r.seq, r.title, r.type, r.cols, r.tpl, r.source, r.config::jsonb
|
|
16
|
+
from meta.page p join meta.app a on a.id = p.app_id, (values
|
|
17
|
+
(60, 'Salary range per department', 'chart', 4, 'standard',
|
|
18
|
+
$q$select d.dname as "Department", min(e.sal) as "Lowest", max(e.sal) as "Highest", d.deptno
|
|
19
|
+
from hr.dept d join hr.emp e on e.deptno = d.deptno and e.active
|
|
20
|
+
group by d.deptno, d.dname
|
|
21
|
+
order by d.dname$q$,
|
|
22
|
+
'{"kind": "range", "link": {"page": 2, "items": {"P2_DEPTNO": "#deptno#"}}}'),
|
|
23
|
+
(70, 'Salaries per job', 'chart', 4, 'standard',
|
|
24
|
+
$q$select e.job as "Job", e.sal as "Salary"
|
|
25
|
+
from hr.emp e
|
|
26
|
+
where e.active
|
|
27
|
+
order by e.job$q$,
|
|
28
|
+
'{"kind": "boxplot"}'),
|
|
29
|
+
(80, 'Share price (demo data)', 'chart', 4, 'standard',
|
|
30
|
+
$q$with days as (
|
|
31
|
+
select d::date as day, row_number() over (order by d) as n
|
|
32
|
+
from generate_series(current_date - 41, current_date - 1, interval '1 day') d
|
|
33
|
+
where extract(isodow from d) < 6
|
|
34
|
+
order by d desc limit 30
|
|
35
|
+
), price as (
|
|
36
|
+
select day, n, 100 + 8 * sin(n / 4.0) + n / 3.0 as mid from days
|
|
37
|
+
)
|
|
38
|
+
select to_char(day, 'DD Mon') as "Day",
|
|
39
|
+
round((mid - 1.5 * cos(n))::numeric, 2) as "Open",
|
|
40
|
+
round((mid + 2 + abs(sin(n * 1.7)))::numeric, 2) as "High",
|
|
41
|
+
round((mid - 2 - abs(cos(n * 1.3)))::numeric, 2) as "Low",
|
|
42
|
+
round((mid + 1.5 * cos(n))::numeric, 2) as "Close"
|
|
43
|
+
from price
|
|
44
|
+
order by day$q$,
|
|
45
|
+
'{"kind": "stock"}')
|
|
46
|
+
) r(seq, title, type, cols, tpl, source, config)
|
|
47
|
+
where a.alias = 'hr' and p.page_no = 32;
|
|
48
|
+
|
|
49
|
+
insert into meta.translation (app_id, language, source, target)
|
|
50
|
+
select a.id, 'nl', t.source, t.target
|
|
51
|
+
from meta.app a, (values
|
|
52
|
+
('Salary range per department', 'Salarisbereik per afdeling'),
|
|
53
|
+
('Salaries per job', 'Salarissen per functie'),
|
|
54
|
+
('Share price (demo data)', 'Aandelenkoers (voorbeeldgegevens)')
|
|
55
|
+
) t(source, target)
|
|
56
|
+
where a.alias = 'hr'
|
|
57
|
+
on conflict do nothing;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- HR example, part 57: what happens to overdue tasks, and vacation rules
|
|
3
|
+
-- (docs/guide/06-processing.md, approvals)
|
|
4
|
+
--
|
|
5
|
+
-- - a leave request nobody decided on within its 2 days escalates once to
|
|
6
|
+
-- the administrators (role admin), with priority 1; overdue again, it
|
|
7
|
+
-- expires;
|
|
8
|
+
-- - access requests of the onboarding workflow are renewed twice, then
|
|
9
|
+
-- expire (the workflow then ends: its step has no "expired" branch).
|
|
10
|
+
-- Managers set a vacation rule on the task list (page 14): while they are
|
|
11
|
+
-- away, the user they name decides on their leave requests.
|
|
12
|
+
-- =====================================================================
|
|
13
|
+
|
|
14
|
+
update meta.task_definition d
|
|
15
|
+
set expiration = x.expiration, max_renewals = x.max_renewals, escalate_role = x.escalate_role
|
|
16
|
+
from meta.app a, (values ('LEAVE_APPROVAL', 'escalate', null::int, 'admin'),
|
|
17
|
+
('ONBOARD_ACCESS', 'renew', 2, null)) x(name, expiration, max_renewals, escalate_role)
|
|
18
|
+
where a.alias = 'hr' and d.app_id = a.id and d.name = x.name;
|
|
@@ -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
package/public/app.css
CHANGED
|
@@ -465,6 +465,10 @@ table.report { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
|
|
465
465
|
.chart-x { display: flex; margin-left: 2.6rem; font-size: 0.75rem; color: var(--muted); }
|
|
466
466
|
.chart-x span { flex: 1 1 0; min-width: 0; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0.3rem 1px 0; }
|
|
467
467
|
.chart-x span.skip { visibility: hidden; }
|
|
468
|
+
.chart-x.sparse span:not(.skip) { overflow: visible; display: flex; justify-content: center; }
|
|
469
|
+
/* a narrow chart (a region of a few columns, a phone): every other label of a sparse axis */
|
|
470
|
+
.chart:has(> .chart-plot) { container-type: inline-size; }
|
|
471
|
+
@container (max-width: 480px) { .chart-x.sparse span.odd { visibility: hidden; } }
|
|
468
472
|
.chart-line .chart-x, .chart-area .chart-x { margin-right: 0; }
|
|
469
473
|
.chart-cols { position: absolute; inset: 0; display: flex; }
|
|
470
474
|
.col-group { position: relative; flex: 1 1 0; display: flex; justify-content: center; gap: 2px; outline-offset: -2px; }
|
|
@@ -473,6 +477,22 @@ table.report { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
|
|
473
477
|
.col { position: absolute; left: 0; right: 0; background: var(--c); border-radius: 4px 4px 0 0; }
|
|
474
478
|
.col-value { position: absolute; left: 0; right: 0; text-align: center; font-size: 0.72rem; color: var(--muted); transform: translateY(-1.3em); font-variant-numeric: tabular-nums; pointer-events: none; }
|
|
475
479
|
.col.neg { border-radius: 0 0 4px 4px; }
|
|
480
|
+
/* range: a floating column from low to high */
|
|
481
|
+
.col.range-col { border-radius: 4px; }
|
|
482
|
+
/* box plot: whisker from minimum to maximum with caps, the box between the quartiles, the median across it */
|
|
483
|
+
.box-slot { flex-basis: 32px; }
|
|
484
|
+
.box-whisker { position: absolute; left: 50%; width: 0; border-left: 1.5px solid var(--muted); }
|
|
485
|
+
.box-cap { position: absolute; left: 25%; right: 25%; border-top: 1.5px solid var(--muted); }
|
|
486
|
+
.box-iqr { position: absolute; left: 0; right: 0; box-sizing: border-box; background: color-mix(in srgb, var(--c) 35%, var(--surface)); border: 1.5px solid var(--c); border-radius: 3px; }
|
|
487
|
+
.box-median { position: absolute; left: 0; right: 0; border-top: 2.5px solid var(--text); }
|
|
488
|
+
/* stock: a candle per row, the wick from low to high, the body from open to close */
|
|
489
|
+
.stock-slot { flex-basis: 14px; }
|
|
490
|
+
.stock-wick { position: absolute; left: 50%; width: 0; border-left: 1.5px solid var(--muted); }
|
|
491
|
+
.stock-body { position: absolute; left: 0; right: 0; border-radius: 1px; }
|
|
492
|
+
.stock-body.up { background: var(--success); }
|
|
493
|
+
.stock-body.down { background: var(--danger); }
|
|
494
|
+
.stock-thin .col-group { gap: 0; }
|
|
495
|
+
.stock-thin .stock-slot { flex-basis: 6px; }
|
|
476
496
|
.line-plot { overflow: visible; }
|
|
477
497
|
.chart-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
|
478
498
|
.chart-lines .line { fill: none; stroke: var(--c); stroke-width: 2px; stroke-linejoin: round; stroke-linecap: round; }
|
|
@@ -972,6 +992,11 @@ td[data-add]:hover > .cal-add, .cal-add:focus-visible { opacity: 1; }
|
|
|
972
992
|
.facet legend { font-weight: 650; font-size: 0.85rem; margin-bottom: 0.3rem; padding: 0; display: flex; gap: 0.5rem; }
|
|
973
993
|
.facet-clear { font-weight: 400; font-size: 0.8rem; }
|
|
974
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; }
|
|
975
1000
|
.facet-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
976
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; }
|
|
977
1002
|
.js .facet-actions .btn-hot { display: none; }
|
|
@@ -1190,6 +1215,11 @@ dl.details dd { margin: 0; overflow-wrap: anywhere; }
|
|
|
1190
1215
|
.task-subject { overflow-wrap: anywhere; }
|
|
1191
1216
|
.task-meta { font-size: 0.85rem; margin-top: 0.15rem; }
|
|
1192
1217
|
.task-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: 0.6rem; }
|
|
1218
|
+
/* (094) the vacation rule: label above each field, in a row that wraps */
|
|
1219
|
+
.task-vacation { margin-bottom: 1rem; }
|
|
1220
|
+
.task-vacation > summary { cursor: pointer; }
|
|
1221
|
+
.vacation-fields { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: flex-end; }
|
|
1222
|
+
.vacation-fields label { display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 10rem; max-width: 16rem; }
|
|
1193
1223
|
.task-actions input[name="comment"] { flex: 1 1 14rem; min-width: 0; }
|
|
1194
1224
|
.task-delegate { display: inline-flex; gap: 0.3rem; }
|
|
1195
1225
|
.task-delegate input { width: 9rem; }
|
package/public/sw.js
CHANGED
|
@@ -52,7 +52,8 @@ self.addEventListener('fetch', (event) => {
|
|
|
52
52
|
// keep successful pages of this app (not the sign-in page, not downloads)
|
|
53
53
|
if (PGKILN.offlinePages && res.ok && !res.redirected && (res.headers.get('content-type') || '').startsWith('text/html') && !signInOrOut(url)) {
|
|
54
54
|
const copy = res.clone();
|
|
55
|
-
|
|
55
|
+
// waitUntil: the worker isn't stopped before the copy is kept (a page used offline right after)
|
|
56
|
+
event.waitUntil(caches.open(PAGES).then((c) => c.put(req, copy)).catch(() => {}));
|
|
56
57
|
}
|
|
57
58
|
return res;
|
|
58
59
|
})
|
package/src/app.ts
CHANGED
|
@@ -69,6 +69,7 @@ import { runtimeForAlias } from './workspace-db.ts';
|
|
|
69
69
|
import { galleryRoutes } from './builder/gallery.ts';
|
|
70
70
|
import { appGroupRoutes } from './builder/app-groups.ts';
|
|
71
71
|
import { crossAppRoutes } from './builder/cross-app.ts';
|
|
72
|
+
import { workspaceBackupRoutes } from './builder/workspace-backups.ts';
|
|
72
73
|
import { runtimeScope } from './db.ts';
|
|
73
74
|
|
|
74
75
|
export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
@@ -177,6 +178,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
177
178
|
await app.register(galleryRoutes);
|
|
178
179
|
await app.register(appGroupRoutes);
|
|
179
180
|
await app.register(crossAppRoutes);
|
|
181
|
+
await app.register(workspaceBackupRoutes);
|
|
180
182
|
await app.register(sharedRoutes);
|
|
181
183
|
await app.register(designerRoutes);
|
|
182
184
|
await app.register(lockRoutes);
|
package/src/automations.ts
CHANGED
|
@@ -306,6 +306,12 @@ export function startScheduler() {
|
|
|
306
306
|
} catch (e) {
|
|
307
307
|
console.error('push notifications:', (e as Error).message);
|
|
308
308
|
}
|
|
309
|
+
try {
|
|
310
|
+
// (094) overdue tasks: expire, renew or escalate them by their definition's expiration policy
|
|
311
|
+
await owner.query('select meta.task_expire_tick()');
|
|
312
|
+
} catch (e) {
|
|
313
|
+
console.error('task expiration:', (e as Error).message);
|
|
314
|
+
}
|
|
309
315
|
try {
|
|
310
316
|
// (082) a daily backup of every application that changed (src/builder/backups.ts)
|
|
311
317
|
await (await import('./builder/backups.ts')).backupTick();
|
|
@@ -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) => {
|
|
@@ -544,6 +544,9 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
544
544
|
validate: (v) => {
|
|
545
545
|
if (v.due_in && !/^\s*\d+\s*(minute|hour|day|week|month)s?\s*$/i.test(String(v.due_in))) return 'Due in: a number and a unit, e.g. 2 days or 4 hours.';
|
|
546
546
|
if (v.priority !== null && v.priority !== undefined && (Number(v.priority) < 1 || Number(v.priority) > 5)) return 'Priority: 1 (urgent) to 5 (low).';
|
|
547
|
+
if (v.expiration && !v.due_in) return 'When overdue: set "Due in" first (a task without a due date is never overdue).';
|
|
548
|
+
if (v.expiration === 'renew' && v.max_renewals !== null && v.max_renewals !== undefined && v.max_renewals !== '' && (Number(v.max_renewals) < 1 || Number(v.max_renewals) > 100)) return 'Maximum renewals: 1 to 100.';
|
|
549
|
+
if (v.expiration === 'escalate' && !String(v.escalate_role ?? '').trim()) return 'Escalation role: the role that takes the task over.';
|
|
547
550
|
return null;
|
|
548
551
|
},
|
|
549
552
|
fields: [
|
|
@@ -555,6 +558,10 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
555
558
|
{ name: 'initiator_can_complete', label: 'The person who requested it may complete it', kind: 'bool', group: 'Participants' },
|
|
556
559
|
{ name: 'priority', label: 'Priority (1 urgent – 5 low)', kind: 'int', group: 'Deadline' },
|
|
557
560
|
{ name: 'due_in', label: 'Due in', kind: 'text', group: 'Deadline', help: 'e.g. 2 days; overdue tasks are marked in the task list.' },
|
|
561
|
+
{ name: 'expiration', label: 'When overdue', kind: 'select', options: ['', 'expire', 'renew', 'escalate'], group: 'Deadline',
|
|
562
|
+
help: 'Empty: the task stays open. expire: it ends as expired. renew: the due date moves on by "Due in" (up to the maximum renewals), then it expires. escalate: once, the escalation role becomes an owner, the priority 1 and the task unassigned with a new due date; overdue again, it expires. A workflow waiting for the task goes on with the outcome "expired".' },
|
|
563
|
+
{ name: 'max_renewals', label: 'Maximum renewals', kind: 'int', group: 'Deadline', help: 'For renew: 1 to 100 (default 3).' },
|
|
564
|
+
{ name: 'escalate_role', label: 'Escalation role', kind: 'text', group: 'Deadline', help: 'For escalate: the role (e.g. a manager role) whose members may then act on the task.' },
|
|
558
565
|
{ name: 'details_page', label: 'Details page', kind: 'page', group: 'Details', help: 'The subject links here, setting the item below to the task\'s record key.' },
|
|
559
566
|
{ name: 'details_item', label: 'Details item', kind: 'upper', group: 'Details', help: 'e.g. P7_ID' },
|
|
560
567
|
{ name: 'action_code', label: 'On completion (SQL)', kind: 'code', wide: true, group: 'Action',
|