pgkiln 0.70.0 → 0.71.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/db/migrations/137_create_page_types.sql +904 -0
- package/db/migrations/138_app_utilities.sql +153 -0
- package/docs/apex-feature-parity.md +6 -5
- package/docs/guide/03-builder.md +107 -4
- package/docs/guide/04-pages-and-regions.md +3 -1
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/06-processing.md +18 -0
- package/docs/guide/09-reference.md +15 -1
- package/docs/guide/12-development.md +22 -1
- package/docs/guide/16-files.md +1 -1
- package/examples/hr/hr_73_configuration_options.sql +17 -0
- package/examples/hr/hr_74_app_utilities.sql +22 -0
- package/package.json +1 -1
- package/public/app.css +9 -0
- package/public/builder.css +45 -0
- package/src/app.ts +10 -0
- package/src/builder/advisor.ts +2 -2
- package/src/builder/app-updates.ts +3 -0
- package/src/builder/components.ts +3 -1
- package/src/builder/create-page.ts +259 -0
- package/src/builder/dependencies.ts +311 -0
- package/src/builder/features.ts +313 -0
- package/src/builder/home.ts +2 -0
- package/src/builder/i18n/de.ts +4 -0
- package/src/builder/i18n/en.ts +4 -0
- package/src/builder/i18n/es.ts +4 -0
- package/src/builder/i18n/fr.ts +4 -0
- package/src/builder/i18n/it.ts +4 -0
- package/src/builder/i18n/ja.ts +4 -0
- package/src/builder/i18n/ko.ts +4 -0
- package/src/builder/i18n/nl.ts +4 -0
- package/src/builder/i18n/pt.ts +4 -0
- package/src/builder/i18n/zh-cn.ts +4 -0
- package/src/builder/i18n/zh-tw.ts +4 -0
- package/src/builder/login-page.ts +17 -14
- package/src/builder/newapp.ts +41 -23
- package/src/builder/routes.ts +13 -2
- package/src/builder/ui-defaults.ts +161 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +154 -0
- package/src/builder/utilities.ts +181 -0
- package/src/builder/wizards.ts +211 -26
- package/src/cli/replace.ts +9 -1
- package/src/history.ts +155 -0
- package/src/runtime/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
package/docs/guide/16-files.md
CHANGED
|
@@ -406,7 +406,7 @@ The HR sample has `EMP_XML`, for XML files like `/static/samples/employees.xml`.
|
|
|
406
406
|
### Data loading in an application
|
|
407
407
|
|
|
408
408
|
For end users, add a page with a `file` item and a button, and a **process of type
|
|
409
|
-
`data_load
|
|
409
|
+
`data_load`** ((sprint 137) **Create page → Data loading** makes the definition and such a page for a table). The process runs **as the application's database role**, so grants, row level
|
|
410
410
|
security, triggers and the audit trail apply, just as they do for the form.
|
|
411
411
|
|
|
412
412
|
| `config` key | Meaning |
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- HR example, part 73: a configurable build option (sprint 137, AP-3;
|
|
3
|
+
-- migration 137; docs/guide/06-processing.md "Build options",
|
|
4
|
+
-- 03-builder.md "Add feature")
|
|
5
|
+
--
|
|
6
|
+
-- LEAVE_FORECAST (part 22: the Forecast region of page 22, excluded) is
|
|
7
|
+
-- marked configurable. In the builder, Create page → Feature →
|
|
8
|
+
-- Configuration options then adds page 9020 "Configuration" where HR's
|
|
9
|
+
-- administrators (king, demo) switch the forecast on and off while the
|
|
10
|
+
-- application runs, without a developer. Nothing else changes until that
|
|
11
|
+
-- feature is added.
|
|
12
|
+
-- =====================================================================
|
|
13
|
+
|
|
14
|
+
update meta.build_option o set configurable = true,
|
|
15
|
+
description = coalesce(o.description, 'The leave forecast on the leave planner (page 22)')
|
|
16
|
+
from meta.app a
|
|
17
|
+
where a.id = o.app_id and a.alias = 'hr' and o.name = 'LEAVE_FORECAST';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- HR example, part 74: an application's Utilities (sprint 138, AP-5;
|
|
3
|
+
-- migration 138; docs/guide/03-builder.md "Utilities")
|
|
4
|
+
--
|
|
5
|
+
-- UI defaults (App Builder → Utilities → UI defaults, or Workspace
|
|
6
|
+
-- utilities → UI defaults) for hr.leave_request: the page wizards and the
|
|
7
|
+
-- Create Application wizard label its "days" column "Working days" and
|
|
8
|
+
-- show it with a number format mask, and a generated form item for
|
|
9
|
+
-- "reason" gets a help text. Pages that exist already don't change: create
|
|
10
|
+
-- a form on hr.leave_request (Create Page → Form) to see them.
|
|
11
|
+
--
|
|
12
|
+
-- The rest of the Utilities page needs no example data: Database object
|
|
13
|
+
-- dependencies, Change history (recorded from the next change on),
|
|
14
|
+
-- Upgrade application and the application checksum work on the HR
|
|
15
|
+
-- application as it is.
|
|
16
|
+
-- =====================================================================
|
|
17
|
+
|
|
18
|
+
insert into meta.ui_default (schema_name, table_name, column_name, label, format_mask, help, alignment, updated_by)
|
|
19
|
+
values
|
|
20
|
+
('hr', 'leave_request', 'days', 'Working days', '990', 'Working days the leave takes, without weekends and public holidays.', 'end', 'admin'),
|
|
21
|
+
('hr', 'leave_request', 'reason', null, null, 'Optional: what the leave is for. Your manager sees it when deciding.', null, 'admin')
|
|
22
|
+
on conflict (schema_name, table_name, column_name) do nothing;
|
package/package.json
CHANGED
package/public/app.css
CHANGED
|
@@ -1602,6 +1602,15 @@ img.tc-avatar { object-fit: cover; }
|
|
|
1602
1602
|
.tc-media { display: flex; gap: 0.8rem; align-items: center; padding: 0.6rem 0; min-width: 0; overflow-wrap: anywhere; }
|
|
1603
1603
|
.tc-media > .tc-stack { flex: 1; }
|
|
1604
1604
|
.tc-media-badge { flex: none; }
|
|
1605
|
+
/* (137) ut_content_row: avatar, title and description, secondary text and badge on the right */
|
|
1606
|
+
.tc-content-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
|
1607
|
+
.tc-content-row { display: flex; gap: 0.8rem; align-items: center; padding: 0.6rem 0; min-width: 0; overflow-wrap: anywhere; }
|
|
1608
|
+
.tc-content-row + .tc-content-row { border-top: 1px solid var(--border); }
|
|
1609
|
+
.tc-content-row > .tc-stack { flex: 1; }
|
|
1610
|
+
.tc-content-row-end { display: flex; flex-direction: column; align-items: flex-end; gap: 0.25rem; flex: none; max-width: 40%; text-align: end; }
|
|
1611
|
+
@media (max-width: 480px) { .tc-content-row { flex-wrap: wrap; } .tc-content-row-end { flex-direction: row; align-items: center; max-width: none; margin-inline-start: auto; } }
|
|
1612
|
+
/* (137) a checkbox group shown as switches ({"switches": true}): one per line */
|
|
1613
|
+
.radio-group.switch-group { flex-direction: column; align-items: flex-start; }
|
|
1605
1614
|
.tc-metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr)); gap: 0.75rem; }
|
|
1606
1615
|
.tc-metric { gap: 0.25rem; }
|
|
1607
1616
|
.tc-metric-label { margin: 0; color: var(--muted); font-size: 0.85rem; font-weight: 600; }
|
package/public/builder.css
CHANGED
|
@@ -1600,3 +1600,48 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
1600
1600
|
|
|
1601
1601
|
/* (sprint 136) a button inside an alert (e.g. Add vector column without pgvector) wraps on a phone instead of overflowing */
|
|
1602
1602
|
.alert .btn { white-space: normal; overflow-wrap: anywhere; max-width: 100%; }
|
|
1603
|
+
|
|
1604
|
+
/* (sprint 137, AP-3) Create page: the grid of page types with small previews, the features, the type's step */
|
|
1605
|
+
.cp-tabs { margin-bottom: 0.9rem; flex-wrap: wrap; }
|
|
1606
|
+
.cp-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); gap: 0.75rem; }
|
|
1607
|
+
.cp-grid > li { min-width: 0; }
|
|
1608
|
+
.cp-type { display: flex; flex-direction: column; gap: 0.35rem; height: 100%; padding: 0.6rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
|
|
1609
|
+
a.cp-type:hover, a.cp-type:focus-visible { border-color: var(--accent); }
|
|
1610
|
+
.cp-type b { font-size: 0.95rem; }
|
|
1611
|
+
.cp-type small { color: var(--muted); line-height: 1.35; }
|
|
1612
|
+
.cp-type .tag { align-self: flex-start; }
|
|
1613
|
+
.cp-done { opacity: 0.6; }
|
|
1614
|
+
.cp-preview { display: block; border-radius: var(--radius-sm); background: var(--surface-2); padding: 4px; }
|
|
1615
|
+
.cp-preview-lg { max-width: 16rem; }
|
|
1616
|
+
.cp-svg { display: block; width: 100%; height: auto; }
|
|
1617
|
+
.cp-svg .pv-line { fill: var(--border-strong); }
|
|
1618
|
+
.cp-svg .pv-soft { fill: color-mix(in srgb, var(--accent) 28%, var(--surface)); }
|
|
1619
|
+
.cp-svg .pv-acc { fill: var(--accent); }
|
|
1620
|
+
.cp-svg .pv-bg { fill: var(--surface); }
|
|
1621
|
+
.cp-svg .pv-frame { fill: var(--surface); stroke: var(--border-strong); stroke-width: 1; }
|
|
1622
|
+
.cp-svg .pv-stroke { fill: none; stroke: var(--border-strong); stroke-width: 1.5; }
|
|
1623
|
+
.cp-feature-icon { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 10px; background: var(--accent-soft, var(--surface-2)); color: var(--accent); }
|
|
1624
|
+
.cp-step { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; margin-bottom: 0.75rem; }
|
|
1625
|
+
.cp-step > .cp-preview { flex: 0 1 12rem; }
|
|
1626
|
+
.cp-step > p { flex: 1 1 14rem; margin: 0; }
|
|
1627
|
+
.cp-adds { padding-inline-start: 1.2rem; overflow-wrap: anywhere; }
|
|
1628
|
+
|
|
1629
|
+
/* (sprint 138, AP-5) an application's Utilities: filters, the dashboard figures, object dependencies, icons, UI defaults */
|
|
1630
|
+
.util-filters { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem 0.75rem; margin-bottom: 0.75rem; }
|
|
1631
|
+
.util-filters .field { margin: 0; min-width: 0; flex: 1 1 9rem; }
|
|
1632
|
+
.util-filters .buttons { margin: 0; }
|
|
1633
|
+
.util-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: 0.75rem; margin: 0; }
|
|
1634
|
+
.util-stats div { border: 1px solid var(--border); border-radius: var(--radius-sm, 6px); padding: 0.5rem 0.75rem; }
|
|
1635
|
+
.util-stats dt { color: var(--muted); font-size: 0.8rem; }
|
|
1636
|
+
.util-stats dd { margin: 0; font-size: 1.4rem; font-weight: 650; }
|
|
1637
|
+
.util-sum code, .util-history td, .dep-table td { overflow-wrap: anywhere; }
|
|
1638
|
+
.util-sum code { word-break: break-all; }
|
|
1639
|
+
.tag-success { background: var(--success-soft); color: var(--success); }
|
|
1640
|
+
.dep-problem td:first-child, .dep-missing { color: var(--danger); }
|
|
1641
|
+
.dep-list { margin: 0.25rem 0 0; padding-left: 1rem; }
|
|
1642
|
+
.dep-list a { display: inline-flex; gap: 0.3rem; align-items: center; }
|
|
1643
|
+
.util-icons { display: grid; grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); gap: 0.5rem; margin: 0 0 1rem; padding: 0; list-style: none; }
|
|
1644
|
+
.util-icons .icon-tile { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; padding: 0.6rem 0.25rem; border: 1px solid var(--border); border-radius: var(--radius-sm, 6px); text-align: center; }
|
|
1645
|
+
.util-icons code { font-size: 0.75rem; overflow-wrap: anywhere; }
|
|
1646
|
+
.ui-defaults :is(input, select, textarea) { width: 100%; min-width: 7rem; }
|
|
1647
|
+
.util-upgrade + .util-upgrade { margin-top: 1rem; }
|
package/src/app.ts
CHANGED
|
@@ -41,6 +41,10 @@ import { layoutRoutes } from './builder/layouts.ts';
|
|
|
41
41
|
import { dataLoadRoutes } from './builder/dataload.ts';
|
|
42
42
|
import { builderRoutes } from './builder/routes.ts';
|
|
43
43
|
import { appUpdateHooks } from './builder/app-updates.ts';
|
|
44
|
+
import { historyHooks, utilityRoutes } from './builder/utilities.ts';
|
|
45
|
+
import { dependencyRoutes } from './builder/dependencies.ts';
|
|
46
|
+
import { upgradeRoutes } from './builder/upgrade.ts';
|
|
47
|
+
import { uiDefaultRoutes } from './builder/ui-defaults.ts';
|
|
44
48
|
import { dbUserRoutes } from './builder/db-users.ts';
|
|
45
49
|
import { dataPumpRoutes } from './builder/datapump.ts';
|
|
46
50
|
import { schedulingRoutes } from './builder/scheduling.ts';
|
|
@@ -165,6 +169,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
165
169
|
});
|
|
166
170
|
builderErrorPages(app);
|
|
167
171
|
appUpdateHooks(app); // (116) who changed an application last
|
|
172
|
+
historyHooks(app); // (138) a snapshot before an application's first recorded change
|
|
168
173
|
await app.register(cookie);
|
|
169
174
|
await app.register(formbody, { bodyLimit: 5 * 1024 * 1024 });
|
|
170
175
|
// file upload items; oversized files are cut off and reported, not thrown
|
|
@@ -269,6 +274,11 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
269
274
|
await app.register(mailQueueRoutes);
|
|
270
275
|
await app.register(networkAccessRoutes);
|
|
271
276
|
await app.register(workspaceMailRoutes);
|
|
277
|
+
// (sprint 138, AP-5) an application's Utilities
|
|
278
|
+
await app.register(utilityRoutes);
|
|
279
|
+
await app.register(dependencyRoutes);
|
|
280
|
+
await app.register(upgradeRoutes);
|
|
281
|
+
await app.register(uiDefaultRoutes);
|
|
272
282
|
app.get('/', async (_req, reply) => reply.redirect('/builder/home'));
|
|
273
283
|
return app;
|
|
274
284
|
}
|
package/src/builder/advisor.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface Finding {
|
|
|
44
44
|
export type SqlShape = 'select' | 'boolean' | 'statements' | 'regex';
|
|
45
45
|
|
|
46
46
|
/** Which fields hold SQL, and of what shape, per component kind (null: decided by the row). */
|
|
47
|
-
function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[] {
|
|
47
|
+
export function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[] {
|
|
48
48
|
switch (kind) {
|
|
49
49
|
case 'region':
|
|
50
50
|
return [
|
|
@@ -105,7 +105,7 @@ function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[]
|
|
|
105
105
|
const conditionSql = (row: any): { name: string; shape: SqlShape }[] =>
|
|
106
106
|
row.condition_type === 'sql' ? [{ name: 'condition_expr', shape: 'boolean' }] : row.condition_type === 'exists' || row.condition_type === 'not_exists' ? [{ name: 'condition_expr', shape: 'select' }] : [];
|
|
107
107
|
|
|
108
|
-
const EXPLAINABLE = /^\s*(\(|select|with|values|table|insert|update|delete|merge)\b/i;
|
|
108
|
+
export const EXPLAINABLE = /^\s*(\(|select|with|values|table|insert|update|delete|merge)\b/i;
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
111
|
* The problem with one piece of SQL, or null. Runs inside the caller's
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
2
2
|
import { owner } from '../db.ts';
|
|
3
|
+
import { recordHistory } from '../history.ts';
|
|
3
4
|
import type { Session } from '../session.ts';
|
|
4
5
|
import { BASE } from './ui.ts';
|
|
5
6
|
|
|
@@ -50,6 +51,8 @@ export function appUpdateHooks(app: FastifyInstance) {
|
|
|
50
51
|
appId = Number(m[1]);
|
|
51
52
|
}
|
|
52
53
|
await recordChange(appId, n.s.username).catch((err) => req.log.warn({ err }, 'app_change not recorded'));
|
|
54
|
+
// (sprint 138) what changed, per component (src/history.ts)
|
|
55
|
+
await recordHistory(appId, n.s.username).catch((err) => req.log.warn({ err }, 'change history not recorded'));
|
|
53
56
|
return payload;
|
|
54
57
|
});
|
|
55
58
|
}
|
|
@@ -545,11 +545,13 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
545
545
|
plural: 'Build options',
|
|
546
546
|
icon: 'settings',
|
|
547
547
|
summary: (o) => `${o.name} (${o.status})`,
|
|
548
|
-
defaults: { status: 'include' },
|
|
548
|
+
defaults: { status: 'include', configurable: false },
|
|
549
549
|
fields: [
|
|
550
550
|
{ name: 'name', label: 'Name', kind: 'upper', help: 'e.g. FEATURE_EXPORT; components name it in their Build option property.' },
|
|
551
551
|
{ name: 'status', label: 'Status', kind: 'select', options: ['include', 'exclude'], help: 'exclude: every component with this option is left out of the running application (and those with "Not" are included).' },
|
|
552
552
|
{ name: 'description', label: 'Description', kind: 'text', wide: true },
|
|
553
|
+
// (137) the Configuration Options feature
|
|
554
|
+
{ name: 'configurable', label: 'Configurable', kind: 'bool', help: 'The application\'s administrators may switch it on the Configuration page (Create page → Feature → Configuration options).' },
|
|
553
555
|
],
|
|
554
556
|
},
|
|
555
557
|
rest_module: {
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { featureStates } from './features.ts';
|
|
6
|
+
import { appOr404 } from './forms.ts';
|
|
7
|
+
import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
8
|
+
import { NO_TABLE, PAGE_KINDS, wizardTables } from './wizards.ts';
|
|
9
|
+
|
|
10
|
+
// (sprint 137, AP-3) Create Page like APEX's: a grid of page types with small previews (tab Component), the
|
|
11
|
+
// features (tab Feature, features.ts) and Create page as copy (tab Copy), at /builder/apps/:id/create-page.
|
|
12
|
+
// A type that needs a table asks for one first (?type=…), then goes to the options step (wizards.ts); the
|
|
13
|
+
// others go to the options step at once. Blank page and Create pages with AI stay as they were. Plain links and
|
|
14
|
+
// forms: no JavaScript needed. The previews are inline SVG drawn with classes (public/builder.css), no styles.
|
|
15
|
+
|
|
16
|
+
/** The grid's order (APEX's names), each with the step it starts with. */
|
|
17
|
+
const GRID: { key: string; label: string; help: string; table: boolean }[] = [
|
|
18
|
+
{ key: 'blank', label: 'Blank page', help: 'An empty page to build in the Page Designer.', table: false },
|
|
19
|
+
...['calendar', 'cards', 'chart', 'comments', 'content_row', 'dashboard', 'data_load', 'facets', 'form', 'grid', 'report', 'map',
|
|
20
|
+
'master_detail', 'media_list', 'report_form', 'search', 'smart_filters', 'timeline', 'tree', 'tasks', 'wizard', 'workflows'].map((k) => {
|
|
21
|
+
const d = PAGE_KINDS.find((x) => x[0] === k)!;
|
|
22
|
+
return { key: k, label: d[1], help: d[2], table: !NO_TABLE.has(k) };
|
|
23
|
+
}),
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- previews (80 × 50, inline SVG, classes only)
|
|
27
|
+
const rect = (x: number, y: number, w: number, h: number, cls = 'pv-line', rx = 1.5) => `<rect class="${cls}" x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}"/>`;
|
|
28
|
+
const circle = (cx: number, cy: number, r: number, cls = 'pv-soft') => `<circle class="${cls}" cx="${cx}" cy="${cy}" r="${r}"/>`;
|
|
29
|
+
const rows = (y0: number, n: number, x = 6, w = 68, gap = 7) => Array.from({ length: n }, (_, i) => rect(x, y0 + i * gap, w, 3)).join('');
|
|
30
|
+
const table = `${rect(6, 6, 68, 6, 'pv-soft')}${rows(16, 5)}`;
|
|
31
|
+
const listRows = (n: number, avatar: boolean, right = false) => Array.from({ length: n }, (_, i) => {
|
|
32
|
+
const y = 7 + i * 13;
|
|
33
|
+
return `${avatar ? circle(11, y + 4, 4.5) : ''}${rect(avatar ? 20 : 6, y, 30, 3.5, 'pv-acc')}${rect(avatar ? 20 : 6, y + 5.5, 40, 2.5)}${right ? rect(62, y + 1, 12, 5, 'pv-soft', 2.5) : ''}`;
|
|
34
|
+
}).join('');
|
|
35
|
+
const PREVIEWS: Record<string, string> = {
|
|
36
|
+
blank: rect(6, 6, 68, 38, 'pv-frame', 3),
|
|
37
|
+
calendar: `${rect(6, 6, 68, 6, 'pv-soft')}${Array.from({ length: 15 }, (_, i) => rect(6 + (i % 5) * 14, 15 + Math.floor(i / 5) * 10, 12, 8, i === 7 ? 'pv-acc' : 'pv-line')).join('')}`,
|
|
38
|
+
cards: Array.from({ length: 4 }, (_, i) => `${rect(6 + (i % 2) * 35, 6 + Math.floor(i / 2) * 20, 33, 18, 'pv-frame', 2)}${rect(9 + (i % 2) * 35, 9 + Math.floor(i / 2) * 20, 18, 3, 'pv-acc')}${rect(9 + (i % 2) * 35, 15 + Math.floor(i / 2) * 20, 24, 2.5)}`).join(''),
|
|
39
|
+
chart: [14, 26, 20, 32, 10].map((h, i) => rect(10 + i * 13, 44 - h, 9, h, i === 3 ? 'pv-acc' : 'pv-soft', 1)).join('') + rect(6, 44, 68, 1.5),
|
|
40
|
+
comments: listRows(3, true),
|
|
41
|
+
content_row: listRows(3, true, true),
|
|
42
|
+
dashboard: Array.from({ length: 4 }, (_, i) => {
|
|
43
|
+
const x = 6 + (i % 2) * 35;
|
|
44
|
+
const y = 6 + Math.floor(i / 2) * 20;
|
|
45
|
+
return `${rect(x, y, 33, 18, 'pv-frame', 2)}${[6, 10, 7, 12].map((h, j) => rect(x + 4 + j * 7, y + 15 - h, 5, h, j === i ? 'pv-acc' : 'pv-soft', 0.8)).join('')}`;
|
|
46
|
+
}).join(''),
|
|
47
|
+
data_load: `${rect(18, 26, 44, 18, 'pv-frame', 2)}${rect(37, 10, 6, 18, 'pv-acc', 1)}<path class="pv-acc" d="M30 14 L40 4 L50 14 Z"/>${rect(24, 32, 32, 2.5)}${rect(24, 37, 24, 2.5)}`,
|
|
48
|
+
facets: `${rect(6, 6, 18, 38, 'pv-soft', 2)}${[10, 16, 22, 28].map((y) => rect(9, y, 12, 2.5)).join('')}${rect(28, 6, 46, 6, 'pv-soft')}${rows(16, 5, 28, 46)}`,
|
|
49
|
+
form: [8, 20, 32].map((y) => `${rect(8, y, 16, 3)}${rect(28, y - 2, 44, 7, 'pv-frame', 1.5)}`).join('') + rect(56, 42, 16, 5, 'pv-acc', 2),
|
|
50
|
+
grid: `${table}${[25, 45].map((x) => rect(x, 13, 0.8, 34, 'pv-line', 0)).join('')}${rect(25.8, 22, 19.2, 6, 'pv-frame', 0)}`,
|
|
51
|
+
report: `${rect(6, 3, 30, 5, 'pv-frame', 1.5)}${rect(6, 11, 68, 6, 'pv-soft')}${rows(21, 4)}`,
|
|
52
|
+
map: `${rect(6, 6, 68, 38, 'pv-soft', 3)}<path class="pv-stroke" d="M6 30 C 24 20, 40 40, 74 18"/>${circle(48, 20, 5, 'pv-acc')}${circle(48, 20, 2, 'pv-bg')}`,
|
|
53
|
+
master_detail: `${rect(6, 4, 68, 5, 'pv-soft')}${rows(12, 2)}${rect(6, 12, 68, 4, 'pv-acc')}${rect(6, 28, 68, 5, 'pv-soft')}${rows(36, 2)}`,
|
|
54
|
+
media_list: Array.from({ length: 3 }, (_, i) => `${rect(6, 6 + i * 13, 9, 9, 'pv-soft', 2)}${rect(19, 7 + i * 13, 30, 3.5, 'pv-acc')}${rect(19, 12.5 + i * 13, 40, 2.5)}${rect(62, 8 + i * 13, 12, 5, 'pv-soft', 2.5)}`).join(''),
|
|
55
|
+
report_form: `${rect(6, 6, 42, 6, 'pv-soft')}${rows(16, 4, 6, 42)}${rect(52, 10, 22, 32, 'pv-frame', 2)}${[15, 22, 29].map((y) => rect(55, y, 16, 3)).join('')}${rect(59, 35, 12, 4, 'pv-acc', 1.5)}`,
|
|
56
|
+
search: `${rect(6, 6, 68, 8, 'pv-frame', 4)}${circle(12, 10, 2.2, 'pv-acc')}${[18, 30].map((y) => `${rect(6, y, 26, 3.5, 'pv-acc')}${rect(6, y + 5, 60, 2.5)}`).join('')}`,
|
|
57
|
+
smart_filters: `${rect(6, 3, 68, 7, 'pv-frame', 3.5)}${rect(8, 12, 16, 4, 'pv-soft', 2)}${rect(26, 12, 14, 4, 'pv-soft', 2)}${rect(6, 19, 68, 5, 'pv-soft')}${rows(27, 3)}`,
|
|
58
|
+
timeline: `${rect(13, 4, 1.5, 42, 'pv-line', 0)}${[8, 22, 36].map((y, i) => `${circle(13.75, y, 3, i === 0 ? 'pv-acc' : 'pv-soft')}${rect(21, y - 3, 28, 3, 'pv-acc')}${rect(21, y + 2, 46, 2.5)}`).join('')}`,
|
|
59
|
+
tree: `${rect(6, 5, 24, 4, 'pv-acc')}<path class="pv-stroke" d="M10 9 V38 M10 18 H18 M10 28 H18 M10 38 H18 M22 32 V38 M22 38 H28"/>${rect(20, 16, 22, 3.5)}${rect(20, 26, 26, 3.5)}${rect(30, 36, 20, 3.5)}`,
|
|
60
|
+
tasks: [8, 20, 32].map((y, i) => `${rect(8, y, 7, 7, i === 0 ? 'pv-acc' : 'pv-frame', 1.5)}${rect(19, y, 34, 3)}${rect(19, y + 4.5, 22, 2.5)}${rect(60, y + 1, 14, 5, 'pv-soft', 2.5)}`).join(''),
|
|
61
|
+
wizard: `<path class="pv-stroke" d="M16 9 H64"/>${[16, 40, 64].map((x, i) => circle(x, 9, 4, i === 0 ? 'pv-acc' : 'pv-soft')).join('')}${[20, 30].map((y) => `${rect(10, y, 14, 3)}${rect(28, y - 2, 42, 7, 'pv-frame', 1.5)}`).join('')}${rect(54, 41, 16, 5, 'pv-acc', 2)}`,
|
|
62
|
+
workflows: `${rect(4, 19, 18, 12, 'pv-acc', 2)}${rect(31, 8, 18, 12, 'pv-soft', 2)}${rect(31, 30, 18, 12, 'pv-soft', 2)}${rect(58, 19, 18, 12, 'pv-frame', 2)}<path class="pv-stroke" d="M22 25 L31 14 M22 25 L31 36 M49 14 L58 25 M49 36 L58 25"/>`,
|
|
63
|
+
ai: `<path class="pv-acc" d="M40 6 L44 20 L58 25 L44 30 L40 44 L36 30 L22 25 L36 20 Z"/>${circle(62, 12, 3, 'pv-soft')}${circle(18, 38, 2.5, 'pv-soft')}`,
|
|
64
|
+
};
|
|
65
|
+
/** A page type's small picture, decorative (the label says what it is). */
|
|
66
|
+
export const preview = (key: string): Raw =>
|
|
67
|
+
raw(`<svg class="cp-svg" viewBox="0 0 80 50" aria-hidden="true" focusable="false">${PREVIEWS[key] ?? PREVIEWS.blank}</svg>`);
|
|
68
|
+
|
|
69
|
+
const TABS: [string, string][] = [['component', 'Component'], ['feature', 'Feature'], ['copy', 'Copy']];
|
|
70
|
+
|
|
71
|
+
export async function createPageRoutes(app: FastifyInstance) {
|
|
72
|
+
app.get(`${BASE}/apps/:id/create-page`, async (req: Req, reply) => {
|
|
73
|
+
const s = await developer(req, reply);
|
|
74
|
+
if (!s) return;
|
|
75
|
+
const a = await appOr404(req.params.id);
|
|
76
|
+
if (!a) return reply.code(404).send('Not found');
|
|
77
|
+
const q = req.query as Record<string, string | undefined>;
|
|
78
|
+
const tab = TABS.some(([t]) => t === q.tab) ? q.tab! : 'component';
|
|
79
|
+
const type = GRID.find((g) => g.key === q.type);
|
|
80
|
+
const crumbs: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', type ? `${BASE}/apps/${a.id}/create-page` : undefined]];
|
|
81
|
+
const nextPage = async () => {
|
|
82
|
+
const used = new Set((await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((r) => r.page_no as number));
|
|
83
|
+
let n = Math.max(0, ...[...used].filter((x) => x < 9000)) + 1;
|
|
84
|
+
while (used.has(n)) n++;
|
|
85
|
+
return n;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// a page type that needs a table: choose it first (APEX: the data source step)
|
|
89
|
+
if (type) {
|
|
90
|
+
let body: Raw;
|
|
91
|
+
if (type.key === 'blank') {
|
|
92
|
+
body = html`<form method="post" action="${BASE}/apps/${a.id}/pages">${csrf(s)}
|
|
93
|
+
<div class="form-grid">
|
|
94
|
+
${input('page_no', 'Page number', await nextPage(), { type: 'number', required: true })}
|
|
95
|
+
${input('name', 'Name', '', { required: true })}
|
|
96
|
+
${select('mode', 'Page mode', 'normal', [['normal', 'Normal page'], ['modal', 'Modal dialog']])}
|
|
97
|
+
${input('parent_page', 'Breadcrumb parent', a.home_page ?? '', { type: 'number' })}
|
|
98
|
+
</div>
|
|
99
|
+
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">Back</a><button class="btn btn-hot">Create page</button></div>
|
|
100
|
+
</form>`;
|
|
101
|
+
} else {
|
|
102
|
+
const tables = await wizardTables(a.db_role);
|
|
103
|
+
body = html`<form method="get" action="${BASE}/apps/${a.id}/wizard">
|
|
104
|
+
<input type="hidden" name="kind" value="${type.key}">
|
|
105
|
+
<div class="form-grid">
|
|
106
|
+
${select('table', 'Table or view', '', tables.map((t): [string, string] => [t.t, t.access ? t.t : `${t.t} (no access for ${a.db_role})`]),
|
|
107
|
+
'The next step proposes the columns, keys and foreign keys from the database catalog.')}
|
|
108
|
+
</div>
|
|
109
|
+
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">Back</a><button class="btn btn-hot">Next</button></div>
|
|
110
|
+
</form>`;
|
|
111
|
+
}
|
|
112
|
+
const main = html`${appHeader(a, 'pages', s)}
|
|
113
|
+
${region(type.label, html`<div class="cp-step"><span class="cp-preview cp-preview-lg">${preview(type.key)}</span><p class="muted">${type.help}</p></div>${body}`)}`;
|
|
114
|
+
return send(reply, s, shell(s, `Create page · ${a.name}`, [...crumbs, [type.label]], main));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const tabs = html`<nav class="gal-tabs segmented cp-tabs" aria-label="Create page">${TABS.map(([t, l]) =>
|
|
118
|
+
html`<a href="${BASE}/apps/${a.id}/create-page?tab=${t}"${t === tab ? raw(' aria-current="page"') : ''}>${l}</a>`)}</nav>`;
|
|
119
|
+
let body: Raw;
|
|
120
|
+
if (tab === 'component') {
|
|
121
|
+
const href = (g: (typeof GRID)[number]) =>
|
|
122
|
+
g.key !== 'blank' && !g.table ? `${BASE}/apps/${a.id}/wizard?kind=${g.key}` : `${BASE}/apps/${a.id}/create-page?type=${g.key}`;
|
|
123
|
+
body = html`<p class="muted u-mt0">Choose a page type. Types on a table ask for it next; every type proposes its settings and opens the new page in the Page Designer.</p>
|
|
124
|
+
<ul class="cp-grid">${GRID.map((g) => html`<li><a class="cp-type" href="${href(g)}"><span class="cp-preview">${preview(g.key)}</span><b>${g.label}</b><small>${g.help}</small></a></li>`)}
|
|
125
|
+
<li><a class="cp-type" href="${BASE}/apps/${a.id}/ai-pages"><span class="cp-preview">${preview('ai')}</span><b>Create pages with AI</b><small>Describe the pages in your own words; check the proposals before they are made.</small></a></li></ul>`;
|
|
126
|
+
} else if (tab === 'feature') {
|
|
127
|
+
const states = await featureStates(a.id);
|
|
128
|
+
body = html`<p class="muted u-mt0">Features add pages and settings to the application. Each lists what it adds before it creates anything; an installed feature is greyed out.</p>
|
|
129
|
+
<ul class="cp-grid">${states.map(({ f, installed, blocked }) => html`<li><a class="cp-type${installed || blocked ? ' cp-done' : ''}" href="${BASE}/apps/${a.id}/features/${f.key}">
|
|
130
|
+
<span class="cp-feature-icon">${icon(f.icon)}</span><b>${f.label}</b><small>${f.help}</small>
|
|
131
|
+
${installed ? html`<span class="tag">Installed</span>` : blocked ? html`<span class="tag">Not available</span>` : ''}</a></li>`)}</ul>`;
|
|
132
|
+
} else {
|
|
133
|
+
body = await copyStep1(a, s.username!);
|
|
134
|
+
}
|
|
135
|
+
const main = html`${appHeader(a, 'pages', s)}${region('Create page', html`${tabs}${body}`)}`;
|
|
136
|
+
return send(reply, s, shell(s, `Create page · ${a.name}`, crumbs, main));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------- Create page as copy
|
|
140
|
+
// step 2 (?from=): the page to copy; step 3 (?from=&page=): number, name and the shared components; POST: copy
|
|
141
|
+
app.get(`${BASE}/apps/:id/copy-page`, async (req: Req, reply) => {
|
|
142
|
+
const s = await developer(req, reply);
|
|
143
|
+
if (!s) return;
|
|
144
|
+
const a = await appOr404(req.params.id);
|
|
145
|
+
if (!a) return reply.code(404).send('Not found');
|
|
146
|
+
const q = req.query as Record<string, string | undefined>;
|
|
147
|
+
const from = await sourceApp(a.id, q.from);
|
|
148
|
+
if (!from) {
|
|
149
|
+
flash(s, 'Choose an application of this workspace.', 'error');
|
|
150
|
+
return back(reply, s, `${BASE}/apps/${a.id}/create-page?tab=copy`);
|
|
151
|
+
}
|
|
152
|
+
const pages = (await owner.query<{ id: number; page_no: number; name: string }>('select id, page_no, name from meta.page where app_id = $1 order by page_no', [from.id])).rows;
|
|
153
|
+
const page = pages.find((p) => String(p.page_no) === q.page);
|
|
154
|
+
const crumbs: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page?tab=copy`], ['Copy']];
|
|
155
|
+
let body: Raw;
|
|
156
|
+
if (!page) {
|
|
157
|
+
body = html`<form method="get" action="${BASE}/apps/${a.id}/copy-page"><input type="hidden" name="from" value="${from.id}">
|
|
158
|
+
<div class="form-grid">${select('page', `Page of ${from.name}`, '', pages.map((p): [string, string] => [String(p.page_no), `${p.page_no} ${p.name}`]))}</div>
|
|
159
|
+
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=copy">Back</a><button class="btn btn-hot">Next</button></div></form>`;
|
|
160
|
+
} else {
|
|
161
|
+
const refs = (await owner.query<{ kind: string; name: string; in_target: boolean }>('select * from meta.page_shared_refs($1, $2)', [page.id, a.id])).rows;
|
|
162
|
+
const used = new Set((await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((r) => r.page_no as number));
|
|
163
|
+
let no = used.has(page.page_no) ? Math.max(0, ...[...used].filter((x) => x < 9000)) + 1 : page.page_no;
|
|
164
|
+
while (used.has(no)) no++;
|
|
165
|
+
const same = from.id === a.id;
|
|
166
|
+
const options = async (kind: string) => (await owner.query<{ n: string }>(SHARED_NAMES[kind], [a.id])).rows.map((r) => r.n);
|
|
167
|
+
const mapRows = await Promise.all(refs.map(async (r) => {
|
|
168
|
+
const names = await options(r.kind);
|
|
169
|
+
const opts: [string, string][] = [...(same || r.in_target ? [] : [['', 'Copy it into this application'] as [string, string]]), ...names.map((n): [string, string] => [n, `Use ${n}`])];
|
|
170
|
+
return html`<tr><td>${KIND_LABELS[r.kind] ?? r.kind}</td><td><code>${r.name}</code></td><td>
|
|
171
|
+
<select name="map:${r.kind}:${r.name}" aria-label="${KIND_LABELS[r.kind] ?? r.kind} ${r.name}">${opts.map(([v, l]) => html`<option value="${v}"${v === (r.in_target || same ? r.name : '') ? raw(' selected') : ''}>${l}</option>`)}</select></td></tr>`;
|
|
172
|
+
}));
|
|
173
|
+
body = html`<p class="muted u-mt0">Page ${page.page_no} <b>${page.name}</b> of ${from.name}: its regions, items, buttons, dynamic actions, validations, processes, computations and branches. Items named P${page.page_no}_… are renamed to the new number, also where the page's code refers to them.</p>
|
|
174
|
+
<form method="post" action="${BASE}/apps/${a.id}/copy-page">${csrf(s)}
|
|
175
|
+
<input type="hidden" name="from" value="${from.id}"><input type="hidden" name="page" value="${page.page_no}">
|
|
176
|
+
<div class="form-grid">
|
|
177
|
+
${input('new_page', 'New page number', no, { type: 'number', required: true })}
|
|
178
|
+
${input('name', 'Name', page.name, { required: true })}
|
|
179
|
+
</div>
|
|
180
|
+
${refs.length
|
|
181
|
+
? html`<h3>Shared components</h3><p class="muted small">The page refers to these by name. ${same ? 'In the same application they stay as they are, or point to another of the same kind.' : 'Use one of this application\'s, or copy it from the other application.'}</p>
|
|
182
|
+
<div class="table-wrap"><table class="report"><thead><tr><th>Kind</th><th>Name</th><th>In this application</th></tr></thead><tbody>${mapRows}</tbody></table></div>`
|
|
183
|
+
: html`<p class="muted">The page uses no shared components.</p>`}
|
|
184
|
+
${same ? '' : html`<p class="muted small">Its SQL runs as this application's database role <code>${a.db_role ?? '(none)'}</code>: grant it what ${from.name}'s role may read and change.</p>`}
|
|
185
|
+
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/copy-page?from=${from.id}">Back</a><button class="btn btn-hot">Copy page</button></div>
|
|
186
|
+
</form>`;
|
|
187
|
+
}
|
|
188
|
+
const main = html`${appHeader(a, 'pages', s)}${region('Create page as copy', body)}`;
|
|
189
|
+
return send(reply, s, shell(s, `Copy page · ${a.name}`, crumbs, main));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
app.post(`${BASE}/apps/:id/copy-page`, async (req: Req, reply) => {
|
|
193
|
+
const s = await developer(req, reply);
|
|
194
|
+
if (!s) return;
|
|
195
|
+
const a = await appOr404(req.params.id);
|
|
196
|
+
if (!a) return reply.code(404).send('Not found');
|
|
197
|
+
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
198
|
+
const from = await sourceApp(a.id, String(b.from ?? ''));
|
|
199
|
+
const retry = `${BASE}/apps/${a.id}/copy-page?from=${encodeURIComponent(String(b.from ?? ''))}&page=${encodeURIComponent(String(b.page ?? ''))}`;
|
|
200
|
+
if (!from) {
|
|
201
|
+
flash(s, 'Choose an application of this workspace.', 'error');
|
|
202
|
+
return back(reply, s, `${BASE}/apps/${a.id}/create-page?tab=copy`);
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const page = /^\d{1,6}$/.test(String(b.page ?? '')) ? Number(b.page) : NaN;
|
|
206
|
+
const no = /^\d{1,6}$/.test(String(b.new_page ?? '').trim()) ? Number(String(b.new_page).trim()) : NaN;
|
|
207
|
+
if (!(no >= 1)) throw new Error('Enter a page number.');
|
|
208
|
+
const name = String(b.name ?? '').trim().slice(0, 200);
|
|
209
|
+
if (!name) throw new Error('Enter a name.');
|
|
210
|
+
// map:<kind>:<NAME> → a name of the target's (empty: copy); only known kinds pass
|
|
211
|
+
const map: Record<string, string> = {};
|
|
212
|
+
for (const [k, v] of Object.entries(b)) {
|
|
213
|
+
const m = /^map:([a-z_]+):(.{1,100})$/.exec(k);
|
|
214
|
+
if (m && Object.hasOwn(SHARED_NAMES, m[1]) && typeof v === 'string') map[`${m[1]}:${m[2]}`] = v.slice(0, 100);
|
|
215
|
+
}
|
|
216
|
+
const id = await owner.tx(async (c) => (await c.query('select meta.copy_page($1, $2, $3, $4, $5, $6::jsonb) as id', [from.id, page, a.id, no, name, JSON.stringify(map)])).rows[0].id as number);
|
|
217
|
+
flash(s, `Page ${no} "${name}" created as a copy of page ${page}${from.id === a.id ? '' : ` of ${from.name}`}.`);
|
|
218
|
+
return back(reply, s, `${BASE}/pages/${id}`);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
flash(s, (e as Error).message, 'error');
|
|
221
|
+
return back(reply, s, retry);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const KIND_LABELS: Record<string, string> = {
|
|
227
|
+
authz_scheme: 'Authorization scheme', build_option: 'Build option', lov: 'List of values', list: 'List',
|
|
228
|
+
template_component: 'Template component', search_config: 'Search configuration', data_load_def: 'Data load definition',
|
|
229
|
+
};
|
|
230
|
+
/** The target application's names of each kind (the choices of the mapping). */
|
|
231
|
+
const SHARED_NAMES: Record<string, string> = {
|
|
232
|
+
authz_scheme: 'select name as n from meta.authz_scheme where app_id = $1 order by 1',
|
|
233
|
+
build_option: 'select name as n from meta.build_option where app_id = $1 order by 1',
|
|
234
|
+
lov: 'select name as n from meta.lov where app_id = $1 order by 1',
|
|
235
|
+
list: 'select name as n from meta.list where app_id = $1 order by 1',
|
|
236
|
+
template_component: 'select static_id as n from meta.template_component where app_id = $1 order by 1',
|
|
237
|
+
search_config: 'select name as n from meta.search_config where app_id = $1 order by 1',
|
|
238
|
+
data_load_def: 'select name as n from meta.data_load_def where app_id = $1 order by 1',
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** An application of the same workspace as the target (the target itself too), or null. */
|
|
242
|
+
async function sourceApp(targetId: number, from: string | undefined) {
|
|
243
|
+
if (!/^\d{1,9}$/.test(String(from ?? ''))) return null;
|
|
244
|
+
return (await owner.one<{ id: number; name: string; alias: string }>(
|
|
245
|
+
'select id, name, alias from meta.app where id = $1 and meta.app_workspace(id) = meta.app_workspace($2)', [from, targetId],
|
|
246
|
+
)) ?? null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Copy, step 1: the application to copy from (this one first). */
|
|
250
|
+
async function copyStep1(a: { id: number; name: string }, _user: string): Promise<Raw> {
|
|
251
|
+
const apps = (await owner.query<{ id: number; name: string; alias: string }>(
|
|
252
|
+
'select id, name, alias from meta.app where meta.app_workspace(id) = meta.app_workspace($1) order by id <> $1, lower(name)', [a.id],
|
|
253
|
+
)).rows;
|
|
254
|
+
return html`<p class="muted u-mt0">Copy a page of this application or another application of the workspace, under a new number and name. The shared components it uses (lists of values, authorization schemes, lists…) are mapped to this application's or copied.</p>
|
|
255
|
+
<form method="get" action="${BASE}/apps/${a.id}/copy-page">
|
|
256
|
+
<div class="form-grid">${select('from', 'Copy from application', String(a.id), apps.map((x): [string, string] => [String(x.id), x.id === a.id ? `${x.name} (this application)` : `${x.name} (${x.alias})`]))}</div>
|
|
257
|
+
<div class="buttons"><button class="btn btn-hot">Next</button></div>
|
|
258
|
+
</form>`;
|
|
259
|
+
}
|