pgkiln 0.70.0 → 0.72.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/db/migrations/139_opentelemetry.sql +36 -0
- package/docs/apex-feature-parity.md +9 -7
- package/docs/guide/01-installation.md +108 -0
- package/docs/guide/03-builder.md +116 -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 +18 -1
- package/docs/guide/12-development.md +35 -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/app.js +50 -0
- package/public/builder.css +48 -0
- package/src/ai/service.ts +14 -0
- package/src/app.ts +20 -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 +3 -0
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/it.ts +6 -0
- package/src/builder/i18n/ja.ts +6 -0
- package/src/builder/i18n/ko.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/i18n/zh-cn.ts +6 -0
- package/src/builder/i18n/zh-tw.ts +6 -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/telemetry.ts +206 -0
- 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/builder/workspaces.ts +2 -1
- package/src/cli/replace.ts +9 -1
- package/src/history.ts +155 -0
- package/src/instance-checks.ts +11 -4
- package/src/layout.ts +2 -1
- package/src/mail/mail.ts +14 -0
- package/src/runtime/ai-agents.ts +9 -0
- package/src/runtime/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
- package/src/runtime/routes.ts +3 -0
- package/src/session.ts +26 -0
- package/src/telemetry/browser.ts +91 -0
- package/src/telemetry/config.ts +342 -0
- package/src/telemetry/export.ts +484 -0
- package/src/telemetry/http.ts +126 -0
- package/src/telemetry/trace.ts +456 -0
- package/src/webclient.ts +25 -0
- package/src/workflow.ts +14 -7
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { unifiedDiff } from '../cli/diff.ts';
|
|
3
|
+
import { owner } from '../db.ts';
|
|
4
|
+
import { appChecksum, ensureSnapshot, pageChecksums, shortSum } from '../history.ts';
|
|
5
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
6
|
+
import { icon, ICONS, LUCIDE_VERSION, lucideNames, searchIcons } from '../icons.ts';
|
|
7
|
+
import { appOfPath } from './locks.ts';
|
|
8
|
+
import { appHeader, BASE, designerUrl, developer, region, select, send, shell, type Req } from './ui.ts';
|
|
9
|
+
|
|
10
|
+
// (sprint 138, AP-5) App Builder → an application's Utilities (APEX: Application
|
|
11
|
+
// Utilities): one page for what an application's maintenance needs, the tools
|
|
12
|
+
// that existed elsewhere (Advisor, Search, Debug messages, Backups, Activity,
|
|
13
|
+
// Working copies, Export) and the new ones: Database object dependencies
|
|
14
|
+
// (dependencies.ts), Change history (below; recorded by src/history.ts), Upgrade
|
|
15
|
+
// application (upgrade.ts), UI defaults (ui-defaults.ts), the application and
|
|
16
|
+
// page checksums (history.ts) and the icon browser (/builder/icons).
|
|
17
|
+
|
|
18
|
+
const appOf = async (id: string) => (/^\d{1,9}$/.test(id) ? owner.one('select * from meta.app where id = $1', [id]) : undefined);
|
|
19
|
+
const when = (t: string | null | undefined) => (t ? String(t).slice(0, 16).replace('T', ' ') : '');
|
|
20
|
+
|
|
21
|
+
/** A unified diff with its added and removed lines marked (escaped). */
|
|
22
|
+
export function diffHtml(path: string, a: string | null | undefined, b: string | null | undefined, names: [string, string]): Raw {
|
|
23
|
+
const text = unifiedDiff(path, a === null || a === undefined ? undefined : Buffer.from(a), b === null || b === undefined ? undefined : Buffer.from(b), names);
|
|
24
|
+
return html`<pre class="code-block diff">${text.replace(/\n$/, '').split('\n').map((l) =>
|
|
25
|
+
l.startsWith('+') && !l.startsWith('+++') ? html`<span class="diff-add">${l}</span>\n` : l.startsWith('-') && !l.startsWith('---') ? html`<span class="diff-del">${l}</span>\n` : html`${l}\n`)}</pre>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The files of one change as diffs (before → after). */
|
|
29
|
+
function changeDiff(before: Record<string, string> | null, after: Record<string, string> | null): Raw {
|
|
30
|
+
const paths = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort();
|
|
31
|
+
return html`${paths.map((p) => (before?.[p] === after?.[p] ? '' : diffHtml(p, before?.[p], after?.[p], ['before', 'after'])))}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The component of a history row in the builder (a page component opens the page designer). */
|
|
35
|
+
const componentUrl = (appId: number, page: number | null) => (page !== null && page !== undefined && page >= 0 ? designerUrl(appId, page) : `${BASE}/apps/${appId}/shared`);
|
|
36
|
+
|
|
37
|
+
/** (registered on the root instance, like app-updates.ts) a first snapshot before an application's first recorded change */
|
|
38
|
+
export function historyHooks(app: FastifyInstance) {
|
|
39
|
+
const snapshotted = new Set<number>();
|
|
40
|
+
app.addHook('preHandler', async (req) => {
|
|
41
|
+
if (req.method !== 'POST' || !req.url.startsWith(`${BASE}/`)) return;
|
|
42
|
+
const target = await appOfPath(req.url).catch(() => null);
|
|
43
|
+
if (!target || snapshotted.has(target.appId)) return;
|
|
44
|
+
await ensureSnapshot(target.appId).catch((err) => req.log.warn({ err }, 'app snapshot not taken'));
|
|
45
|
+
snapshotted.add(target.appId);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function utilityRoutes(app: FastifyInstance) {
|
|
50
|
+
// ---------------------------------------------------------------- the Utilities page
|
|
51
|
+
app.get(`${BASE}/apps/:id/utilities`, async (req: Req, reply) => {
|
|
52
|
+
const s = await developer(req, reply);
|
|
53
|
+
if (!s) return;
|
|
54
|
+
const a = await appOf(req.params.id);
|
|
55
|
+
if (!a) return reply.code(404).send('Not found');
|
|
56
|
+
const doc = (await owner.one('select meta.export_app($1) as d', [a.alias])).d;
|
|
57
|
+
const sum = appChecksum(doc);
|
|
58
|
+
const pageSums = pageChecksums(doc);
|
|
59
|
+
const counts = await owner.one(
|
|
60
|
+
`select (select count(*) from meta.page where app_id = $1)::int as pages,
|
|
61
|
+
(select count(*) from meta.region r join meta.page p on p.id = r.page_id where p.app_id = $1)::int as regions,
|
|
62
|
+
(select count(*) from meta.item i join meta.page p on p.id = i.page_id where p.app_id = $1)::int as items,
|
|
63
|
+
(select count(*) from meta.process x join meta.page p on p.id = x.page_id where p.app_id = $1)::int as processes,
|
|
64
|
+
(select count(*) from meta.lov where app_id = $1)::int as lovs,
|
|
65
|
+
(select count(*) from meta.component_change where app_id = $1 and changed_at > now() - interval '7 days')::int as week`,
|
|
66
|
+
[a.id],
|
|
67
|
+
);
|
|
68
|
+
const pages = (await owner.query<{ page_no: number; name: string }>('select page_no, name from meta.page where app_id = $1 order by page_no', [a.id])).rows;
|
|
69
|
+
const recent = (await owner.query<{ page_no: number; changed_at: string; changed_by: string | null; n: number }>(
|
|
70
|
+
`select distinct on (page_no) page_no, changed_at::text, changed_by, count(*) over (partition by page_no)::int as n
|
|
71
|
+
from meta.component_change where app_id = $1 and page_no is not null order by page_no, changed_at desc`, [a.id])).rows
|
|
72
|
+
.sort((x, y) => (x.changed_at < y.changed_at ? 1 : -1)).slice(0, 10);
|
|
73
|
+
const pageName = new Map(pages.map((p) => [p.page_no, p.name]));
|
|
74
|
+
const card = (href: string, ic: string, title: string, text: string) =>
|
|
75
|
+
html`<a class="ab-tile" href="${href}"><span class="ab-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`;
|
|
76
|
+
const u = `${BASE}/apps/${a.id}`;
|
|
77
|
+
const main = html`${appHeader(a, 'utilities', s)}
|
|
78
|
+
<div class="ab-tiles ab-tiles-wrap util-tiles">
|
|
79
|
+
${card(`${u}/advisor`, 'check', 'Advisor', 'Plans every SQL statement as the application\'s role and checks every reference.')}
|
|
80
|
+
${card(`${u}/utilities/dependencies`, 'database', 'Database object dependencies', 'The tables, views, sequences and functions the application uses, missing ones and missing grants.')}
|
|
81
|
+
${card(`${u}/utilities/history`, 'history', 'Change history', 'Who changed which component and when, with the differences.')}
|
|
82
|
+
${card(`${u}/utilities/upgrade`, 'refresh', 'Upgrade application', 'Components that can use a newer default of this pgkiln version.')}
|
|
83
|
+
${card(`${BASE}/ui-defaults`, 'sliders-horizontal', 'UI defaults', 'Labels, format masks, help and lists of values per table column, used by the page wizards.')}
|
|
84
|
+
${card(`${u}/search`, 'search', 'Search', 'Names, SQL, conditions, settings and help texts of every component.')}
|
|
85
|
+
${card(`${u}/debug`, 'list', 'Debug messages', 'Timed steps of each request and messages from meta.debug().')}
|
|
86
|
+
${card(`${u}/activity`, 'activity', 'Activity', 'Page views, errors, sign-ins and the application\'s logs.')}
|
|
87
|
+
${card(`${u}/backups`, 'archive', 'Backups', 'Back up now, download and restore earlier versions.')}
|
|
88
|
+
${card(`${u}/working-copies`, 'layers', 'Working copies', 'Change a copy, compare it and merge it back.')}
|
|
89
|
+
${card(`${u}/export`, 'download', 'Export', `The application as a file (checksum ${shortSum(sum)}).`)}
|
|
90
|
+
${card(`${BASE}/icons`, 'image', 'Icons', 'Browse the icons the icon picker offers.')}
|
|
91
|
+
</div>
|
|
92
|
+
${region('Application dashboard', html`<dl class="util-stats">
|
|
93
|
+
<div><dt>Pages</dt><dd>${counts.pages}</dd></div><div><dt>Regions</dt><dd>${counts.regions}</dd></div>
|
|
94
|
+
<div><dt>Items</dt><dd>${counts.items}</dd></div><div><dt>Processes</dt><dd>${counts.processes}</dd></div>
|
|
95
|
+
<div><dt>Lists of values</dt><dd>${counts.lovs}</dd></div><div><dt>Changes in 7 days</dt><dd>${counts.week}</dd></div>
|
|
96
|
+
</dl>`)}
|
|
97
|
+
${region('Recently updated pages', recent.length
|
|
98
|
+
? html`<div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">Page</th><th scope="col">Last change</th><th scope="col">By</th><th scope="col">Changes</th></tr></thead><tbody>
|
|
99
|
+
${recent.map((r) => html`<tr><td data-label="Page"><a href="${designerUrl(a.id, r.page_no)}">${r.page_no}${pageName.has(r.page_no) ? `: ${pageName.get(r.page_no)}` : ''}</a></td><td data-label="Last change">${when(r.changed_at)}</td>
|
|
100
|
+
<td data-label="By">${r.changed_by ?? 'command line'}</td><td data-label="Changes"><a href="${u}/utilities/history?page=${r.page_no}">${r.n}</a></td></tr>`)}</tbody></table></div>`
|
|
101
|
+
: html`<p class="muted u-mt0">No recorded changes yet: the change history starts with the first change after the upgrade to this version.</p>`)}
|
|
102
|
+
${region('Application checksum', html`<p class="u-mt0">The checksum of the application's definition (its export without the alias): the same definition gives the same checksum on any installation, so two installations, or an application and its working copy, compare at a glance.</p>
|
|
103
|
+
<p class="util-sum"><code>${sum}</code></p>
|
|
104
|
+
<details><summary>Page checksums (${pageSums.size})</summary><div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">Page</th><th scope="col">Checksum</th></tr></thead><tbody>
|
|
105
|
+
${[...pageSums].map(([p, h]) => html`<tr><td data-label="Page">${p}${pageName.has(p) ? `: ${pageName.get(p)}` : ''}</td><td data-label="Checksum"><code>${shortSum(h)}</code></td></tr>`)}</tbody></table></div></details>`, '', 'checksum')}`;
|
|
106
|
+
return send(reply, s, shell(s, `Utilities · ${a.name}`, [['App Builder', BASE], [a.name, u], ['Utilities']], main));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------- change history
|
|
110
|
+
app.get(`${BASE}/apps/:id/utilities/history`, async (req: Req, reply) => {
|
|
111
|
+
const s = await developer(req, reply);
|
|
112
|
+
if (!s) return;
|
|
113
|
+
const a = await appOf(req.params.id);
|
|
114
|
+
if (!a) return reply.code(404).send('Not found');
|
|
115
|
+
const q = req.query ?? {};
|
|
116
|
+
const dev = typeof q.dev === 'string' ? q.dev.slice(0, 200) : '';
|
|
117
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
118
|
+
const from = typeof q.from === 'string' && DATE.test(q.from) ? q.from : '';
|
|
119
|
+
const to = typeof q.to === 'string' && DATE.test(q.to) ? q.to : '';
|
|
120
|
+
const page = typeof q.page === 'string' && /^-?\d{1,9}$/.test(q.page) ? Number(q.page) : null;
|
|
121
|
+
const text = typeof q.q === 'string' ? q.q.trim().slice(0, 100) : '';
|
|
122
|
+
const shown = typeof q.c === 'string' && /^\d{1,18}$/.test(q.c) ? q.c : '';
|
|
123
|
+
const devs = (await owner.query<{ d: string | null }>('select distinct changed_by as d from meta.component_change where app_id = $1 order by 1 nulls last', [a.id])).rows.map((r) => r.d);
|
|
124
|
+
const params: unknown[] = [a.id];
|
|
125
|
+
const where = ['app_id = $1'];
|
|
126
|
+
const add = (sql: string, v: unknown) => {
|
|
127
|
+
params.push(v);
|
|
128
|
+
where.push(sql.replace('?', `$${params.length}`));
|
|
129
|
+
};
|
|
130
|
+
if (dev === '(command line)') where.push('changed_by is null');
|
|
131
|
+
else if (dev) add('changed_by = ?', dev);
|
|
132
|
+
if (from) add(`changed_at >= ?::date`, from);
|
|
133
|
+
if (to) add(`changed_at < ?::date + 1`, to);
|
|
134
|
+
if (page !== null) add('page_no = ?', page);
|
|
135
|
+
if (text) add(`(label ilike '%' || ? || '%' escape '\\')`, text.replace(/[\\%_]/g, (m) => `\\${m}`));
|
|
136
|
+
const rows = (await owner.query(
|
|
137
|
+
`select id::text, component, label, page_no, op, changed_by, source, changed_at::text from meta.component_change
|
|
138
|
+
where ${where.join(' and ')} order by changed_at desc, id desc limit 500`, params)).rows;
|
|
139
|
+
const detail = shown ? await owner.one('select id::text, label, page_no, op, before, after, changed_by, source, changed_at::text from meta.component_change where app_id = $1 and id = $2', [a.id, shown]) : null;
|
|
140
|
+
const keep = new URLSearchParams(Object.entries({ dev, from, to, page: page === null ? '' : String(page), q: text }).filter(([, v]) => v));
|
|
141
|
+
const link = (id: string) => `?${new URLSearchParams([...keep, ['c', id]])}#change`;
|
|
142
|
+
const OPS: Record<string, string> = { added: 'tag-info', changed: 'tag-warning', deleted: 'tag-error' };
|
|
143
|
+
const main = html`${appHeader(a, 'utilities', s)}
|
|
144
|
+
${region('Change history', html`<p class="muted u-mt0">Every change made in the builder (and every import, restore or merge) is compared with the application before it: each component that was added, changed or deleted is listed here with who and when. Changes from the command line or the MCP server have no developer.</p>
|
|
145
|
+
<form method="get" class="util-filters">
|
|
146
|
+
${select('dev', 'Developer', dev, [['', 'All'], ...devs.map((d): [string, string] => (d === null ? ['(command line)', 'Command line / import'] : [d, d]))])}
|
|
147
|
+
<div class="field"><label class="label" for="f_from">From</label><input id="f_from" name="from" type="date" value="${from}"></div>
|
|
148
|
+
<div class="field"><label class="label" for="f_to">To</label><input id="f_to" name="to" type="date" value="${to}"></div>
|
|
149
|
+
<div class="field"><label class="label" for="f_page">Page</label><input id="f_page" name="page" type="number" value="${page ?? ''}"></div>
|
|
150
|
+
<div class="field"><label class="label" for="f_q">Component</label><input id="f_q" name="q" type="search" value="${text}" placeholder="e.g. P3_ID"></div>
|
|
151
|
+
<div class="buttons"><button class="btn btn-sm">${icon('filter')} Show</button></div></form>
|
|
152
|
+
${rows.length
|
|
153
|
+
? html`<div class="table-wrap"><table class="table util-history report-reflow"><thead><tr><th scope="col">When</th><th scope="col">Developer</th><th scope="col">Component</th><th scope="col">Change</th><th scope="col">Differences</th></tr></thead><tbody>
|
|
154
|
+
${rows.map((r) => html`<tr${r.id === shown ? raw(' aria-current="true"') : ''}><td data-label="When">${when(r.changed_at)}</td><td data-label="Developer">${r.changed_by ?? html`<span class="muted">${r.source === 'import' ? 'import' : 'command line'}</span>`}</td>
|
|
155
|
+
<td data-label="Component"><a href="${componentUrl(a.id, r.page_no)}">${r.label}</a></td>
|
|
156
|
+
<td data-label="Change"><span class="tag ${OPS[r.op] ?? ''}">${r.op}</span>${r.source !== 'builder' ? html` <span class="tag">${r.source}</span>` : ''}</td>
|
|
157
|
+
<td data-label="Differences"><a class="btn btn-sm" href="${link(r.id)}">Show</a></td></tr>`)}</tbody></table></div>`
|
|
158
|
+
: html`<p class="muted">No changes${where.length > 1 ? ' with these filters' : ' recorded yet'}.</p>`}`)}
|
|
159
|
+
${detail ? region(`Differences: ${detail.label}`, html`<p class="muted u-mt0">${detail.op} by ${detail.changed_by ?? 'the command line'}, ${when(detail.changed_at)}. Lines with <code>-</code> are before the change, lines with <code>+</code> after it.</p>${changeDiff(detail.before, detail.after)}`, '', 'change') : ''}`;
|
|
160
|
+
return send(reply, s, shell(s, `Change history · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Utilities', `${BASE}/apps/${a.id}/utilities`], ['Change history']], main));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------- icons
|
|
164
|
+
app.get(`${BASE}/icons`, async (req: Req, reply) => {
|
|
165
|
+
const s = await developer(req, reply);
|
|
166
|
+
if (!s) return;
|
|
167
|
+
const q = typeof req.query?.q === 'string' ? req.query.q.trim().slice(0, 60) : '';
|
|
168
|
+
const needle = q.toLowerCase().replace(/^fa-/, '');
|
|
169
|
+
const own = (ICONS as readonly string[]).filter((n) => !needle || n.includes(needle));
|
|
170
|
+
const lucide = needle ? searchIcons(q, 300) : [];
|
|
171
|
+
const tile = (n: string) => html`<li><span class="icon-tile">${icon(n, 'icon icon-2x')}<code>${n}</code></span></li>`;
|
|
172
|
+
const main = html`<h1 class="u-mb1">Icons</h1>
|
|
173
|
+
${region('Icon browser', html`<p class="muted u-mt0">The icons of the icon picker: pgkiln's own set and ${String(lucideNames().size)} Lucide icons (${LUCIDE_VERSION}, ISC licence). Use the name in an icon field; modifiers such as <code>lg</code>, <code>spin</code> or <code>success</code> may follow it (e.g. <code>truck lg success</code>), and Font APEX names work where Lucide has the same icon (<code>fa-users</code>).</p>
|
|
174
|
+
<form method="get" role="search" class="util-filters"><div class="field"><label class="label" for="f_icon_q">Search</label><input id="f_icon_q" name="q" type="search" value="${q}" placeholder="e.g. truck, money, arrow"></div>
|
|
175
|
+
<div class="buttons"><button class="btn btn-sm">${icon('search')} Search</button></div></form>
|
|
176
|
+
<h3>pgkiln (${own.length})</h3><ul class="util-icons">${own.map(tile)}</ul>
|
|
177
|
+
${needle ? html`<h3>Lucide (${lucide.length}${lucide.length === 300 ? '+' : ''})</h3>${lucide.length ? html`<ul class="util-icons">${lucide.map(tile)}</ul>` : html`<p class="muted">No Lucide icon matches.</p>`}`
|
|
178
|
+
: html`<p class="muted">Search to see the matching Lucide icons.</p>`}`)}`;
|
|
179
|
+
return send(reply, s, shell(s, 'Icons', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Icons']], main));
|
|
180
|
+
});
|
|
181
|
+
}
|
package/src/builder/wizards.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
|
|
|
3
3
|
import { html, raw, type Raw } from '../html.ts';
|
|
4
4
|
import { ICONS } from '../icons.ts';
|
|
5
5
|
import { appOr404 } from './forms.ts';
|
|
6
|
+
import { addDashboard } from './appsheets.ts';
|
|
6
7
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
7
8
|
|
|
8
9
|
// Create page wizards (APEX: Create Page). Step 1, on the app's Pages tab,
|
|
@@ -23,7 +24,32 @@ export const WIZARD_KINDS: [string, string, string][] = [
|
|
|
23
24
|
['facets', 'Faceted search', 'A report with a filter panel: values with counts, ranges and a search field.'],
|
|
24
25
|
['master_detail', 'Master detail', 'A grid of the table and, below it, an editable grid of the selected row\'s details.'],
|
|
25
26
|
];
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* (sprint 137, AP-3) The page types that reuse other regions (APEX: Create a Page → Component), made by the same
|
|
29
|
+
* meta.generate_page (migration 137), and the dashboard (addDashboard). WIZARD_KINDS above stay the types the
|
|
30
|
+
* AI path and blueprints propose.
|
|
31
|
+
*/
|
|
32
|
+
export const MORE_KINDS: [string, string, string][] = [
|
|
33
|
+
['report', 'Interactive report', 'An interactive report of the table on its own page, optionally with a modal form page.'],
|
|
34
|
+
['smart_filters', 'Smart filters', 'An interactive report with one search field above it that suggests filters (chips) from the columns.'],
|
|
35
|
+
['search', 'Search page', 'A search region over the application\'s search configurations, or a new one on a table.'],
|
|
36
|
+
['data_load', 'Data loading', 'A data load definition for the table and a page where users upload a CSV, Excel, JSON or XML file into it.'],
|
|
37
|
+
['tree', 'Tree', 'The rows as a tree: an id, a parent (from a foreign key to the table itself) and a label.'],
|
|
38
|
+
['timeline', 'Timeline', 'Rows as events on a vertical line: when, who, a title and a text (template component Timeline).'],
|
|
39
|
+
['media_list', 'Media list', 'Rows with initials, a title, a description and a badge (template component Media list).'],
|
|
40
|
+
['content_row', 'Content row', 'Rows with initials, a title and a description, and a secondary text and a badge on the right (template component Content row).'],
|
|
41
|
+
['comments', 'Comments', 'Rows as a conversation: who wrote what and when (template component Comments).'],
|
|
42
|
+
['dashboard', 'Dashboard', 'Charts of the application\'s tables, chosen from the data like a new application\'s dashboard.'],
|
|
43
|
+
['wizard', 'Wizard', 'A form over several steps, one page per step with a progress list, Previous and Next; Finish adds the row.'],
|
|
44
|
+
['tasks', 'Unified task list', 'The approval and action tasks of the signed-in user (a task list region).'],
|
|
45
|
+
['workflows', 'Workflow console', 'The workflows the user started or administers, with their state and history.'],
|
|
46
|
+
];
|
|
47
|
+
/** All the page types step 2 knows: [key, label, description]. */
|
|
48
|
+
export const PAGE_KINDS: [string, string, string][] = [...WIZARD_KINDS, ...MORE_KINDS];
|
|
49
|
+
const KINDS = new Set(PAGE_KINDS.map((k) => k[0]));
|
|
50
|
+
/** Page types that need no table (search takes one optionally, for a new search configuration). */
|
|
51
|
+
export const NO_TABLE = new Set(['search', 'tasks', 'workflows', 'dashboard']);
|
|
52
|
+
const TASK_CONTEXTS: [string, string][] = [['my', 'My tasks (to act on or claim)'], ['initiated', 'Tasks I requested'], ['admin', 'Tasks I administer']];
|
|
27
53
|
const CHART_KINDS = ['bar', 'column', 'line', 'area', 'donut', 'pie', 'funnel'];
|
|
28
54
|
const FUNCTIONS: [string, string][] = [['count', 'Count'], ['sum', 'Sum'], ['avg', 'Average'], ['min', 'Minimum'], ['max', 'Maximum']];
|
|
29
55
|
|
|
@@ -110,6 +136,55 @@ export function wizardOptions(kind: string, b: Record<string, unknown>) {
|
|
|
110
136
|
o.search = b.search === 'true';
|
|
111
137
|
o.form_page = formPage();
|
|
112
138
|
break;
|
|
139
|
+
// (137) the page types of migration 137
|
|
140
|
+
case 'report':
|
|
141
|
+
o.form_page = formPage();
|
|
142
|
+
break;
|
|
143
|
+
case 'smart_filters':
|
|
144
|
+
o.facets = list(b.facets);
|
|
145
|
+
o.form_page = formPage();
|
|
146
|
+
break;
|
|
147
|
+
case 'tree':
|
|
148
|
+
for (const k of ['id_column', 'parent_column', 'label_column']) o[k] = str(k) || null;
|
|
149
|
+
o.expanded = /^\d{1,2}$/.test(str('expanded')) ? Number(str('expanded')) : 1;
|
|
150
|
+
o.form_page = formPage();
|
|
151
|
+
break;
|
|
152
|
+
case 'timeline':
|
|
153
|
+
for (const k of ['title', 'when', 'who', 'body']) o[k] = str(k) || null;
|
|
154
|
+
o.form_page = formPage();
|
|
155
|
+
break;
|
|
156
|
+
case 'media_list':
|
|
157
|
+
case 'content_row':
|
|
158
|
+
for (const k of ['title', 'description', 'badge', ...(kind === 'content_row' ? ['misc'] : [])]) o[k] = str(k) || null;
|
|
159
|
+
o.form_page = formPage();
|
|
160
|
+
break;
|
|
161
|
+
case 'comments':
|
|
162
|
+
for (const k of ['user', 'date', 'comment']) o[k] = str(k) || null;
|
|
163
|
+
break;
|
|
164
|
+
case 'data_load':
|
|
165
|
+
o.definition = str('definition').toUpperCase() || null;
|
|
166
|
+
o.mode = ['append', 'merge', 'replace'].includes(str('mode')) ? str('mode') : 'append';
|
|
167
|
+
o.skip_errors = b.skip_errors === 'true';
|
|
168
|
+
break;
|
|
169
|
+
case 'wizard':
|
|
170
|
+
o.columns = list(b.columns);
|
|
171
|
+
o.steps = /^\d{1,2}$/.test(str('steps')) ? Number(str('steps')) : 2;
|
|
172
|
+
o.return_page = pageNo(b.return_page);
|
|
173
|
+
break;
|
|
174
|
+
case 'search':
|
|
175
|
+
o.configs = list(b.configs).map((c) => c.toUpperCase());
|
|
176
|
+
o.form_page = formPage();
|
|
177
|
+
break;
|
|
178
|
+
case 'tasks':
|
|
179
|
+
o.context = TASK_CONTEXTS.some(([c]) => c === str('context')) ? str('context') : 'my';
|
|
180
|
+
o.completed = b.completed === 'true';
|
|
181
|
+
break;
|
|
182
|
+
case 'workflows':
|
|
183
|
+
o.context = str('context') === 'admin' ? 'admin' : 'initiator';
|
|
184
|
+
break;
|
|
185
|
+
case 'dashboard':
|
|
186
|
+
o.tables = list(b.tables);
|
|
187
|
+
break;
|
|
113
188
|
case 'master_detail': {
|
|
114
189
|
const d = str('detail');
|
|
115
190
|
const at = d.lastIndexOf('|');
|
|
@@ -130,30 +205,31 @@ export async function wizardRoutes(app: FastifyInstance) {
|
|
|
130
205
|
if (!a) return reply.code(404).send('Not found');
|
|
131
206
|
const kind = String(req.query.kind ?? '');
|
|
132
207
|
const table = String(req.query.table ?? '');
|
|
133
|
-
const rel = KINDS.has(kind) && table
|
|
208
|
+
const rel = KINDS.has(kind) && table && kind !== 'dashboard'
|
|
134
209
|
? await owner.one<{ t: string; relkind: string }>(
|
|
135
210
|
`select meta.wizard_qname(c.oid) as t, c.relkind::text from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
136
211
|
where c.oid = to_regclass($1) and c.relkind in ('r', 'p', 'v', 'm') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')`,
|
|
137
212
|
[table],
|
|
138
213
|
)
|
|
139
214
|
: null;
|
|
140
|
-
if (!rel) {
|
|
215
|
+
if (!rel && !(NO_TABLE.has(kind) && !table)) {
|
|
141
216
|
flash(s, KINDS.has(kind) ? 'Choose a table or view.' : 'Choose a page type.', 'error');
|
|
142
|
-
|
|
217
|
+
// (137) back to the type's table step on the Create page grid
|
|
218
|
+
return back(reply, s, KINDS.has(kind) ? `${BASE}/apps/${a.id}/create-page?type=${encodeURIComponent(kind)}` : `${BASE}/apps/${a.id}/create-page`);
|
|
143
219
|
}
|
|
144
220
|
const [cols, defaults] = await Promise.all([
|
|
145
|
-
owner.query<Col>('select * from meta.wizard_catalog($1::regclass)', [rel.t]).then((r) => r.rows),
|
|
146
|
-
owner.one<{ d: Record<string, any> }>('select meta.wizard_defaults($1, $2::regclass) as d', [kind, rel
|
|
221
|
+
rel ? owner.query<Col>('select * from meta.wizard_catalog($1::regclass)', [rel.t]).then((r) => r.rows) : Promise.resolve([] as Col[]),
|
|
222
|
+
owner.one<{ d: Record<string, any> }>('select meta.wizard_defaults($1, $2::regclass) as d', [kind, rel?.t ?? null]).then((r) => r?.d ?? {}),
|
|
147
223
|
]);
|
|
148
224
|
const nextPage = await freePage(a.id, Math.max(0, ...(await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((p) => p.page_no)) + 1);
|
|
149
225
|
const pk = cols.find((c) => c.is_pk)?.column_name ?? null;
|
|
150
226
|
const role = a.db_role as string | null;
|
|
151
|
-
const access = role
|
|
227
|
+
const access = role && rel
|
|
152
228
|
? await owner.one<{ s: boolean; i: boolean; u: boolean; d: boolean }>(
|
|
153
229
|
`select has_table_privilege($1, $2::regclass, 'select') as s, has_table_privilege($1, $2::regclass, 'insert') as i,
|
|
154
230
|
has_table_privilege($1, $2::regclass, 'update') as u, has_table_privilege($1, $2::regclass, 'delete') as d
|
|
155
231
|
where exists (select 1 from pg_roles where rolname = $1)`,
|
|
156
|
-
[role, rel
|
|
232
|
+
[role, rel!.t],
|
|
157
233
|
)
|
|
158
234
|
: null;
|
|
159
235
|
|
|
@@ -171,6 +247,8 @@ export async function wizardRoutes(app: FastifyInstance) {
|
|
|
171
247
|
)}</div></fieldset>`;
|
|
172
248
|
const formPage = (help: string) => input('form_page', 'Form page', '', { type: 'number', placeholder: `e.g. ${nextPage + 1} (empty: no form)`, help });
|
|
173
249
|
const needsPk = ['report_form', 'grid', 'form', 'master_detail'].includes(kind);
|
|
250
|
+
const allCols = (c: Col) => c.kind !== 'binary';
|
|
251
|
+
const dates = (c: Col) => c.kind === 'date' || c.kind === 'timestamp';
|
|
174
252
|
|
|
175
253
|
let fields: Raw | string = '';
|
|
176
254
|
switch (kind) {
|
|
@@ -228,43 +306,134 @@ export async function wizardRoutes(app: FastifyInstance) {
|
|
|
228
306
|
${check('search', 'A search field above the facets', defaults.search !== false)}
|
|
229
307
|
${pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : ''}`;
|
|
230
308
|
break;
|
|
309
|
+
// (137) the page types of migration 137
|
|
310
|
+
case 'report':
|
|
311
|
+
fields = pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : '';
|
|
312
|
+
break;
|
|
313
|
+
case 'smart_filters':
|
|
314
|
+
fields = html`
|
|
315
|
+
${colChecks('facets', 'Filters (suggested in the search field)', defaults.facets ?? [], (c) => notBinary(c) && c.kind !== 'point' && c.kind !== 'other', (c) => (c.fk_table ? `: values of ${c.fk_table}.${c.fk_display ?? c.column_name}` : ['number', 'date', 'timestamp'].includes(c.kind) ? ': ranges' : ': values'))}
|
|
316
|
+
${pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : ''}`;
|
|
317
|
+
break;
|
|
318
|
+
case 'tree':
|
|
319
|
+
fields = html`<div class="form-grid">
|
|
320
|
+
${select('id_column', 'Node id', defaults.id_column ?? '', colOpts(allCols, false), 'What identifies a node: usually the primary key.')}
|
|
321
|
+
${select('parent_column', 'Parent', defaults.parent_column ?? '', colOpts(allCols), 'The column with the parent node\'s id; proposed from a foreign key to the table itself. Rows whose parent is not in the table are the top level.')}
|
|
322
|
+
${select('label_column', 'Label', defaults.label_column ?? '', colOpts(notBinary, false))}
|
|
323
|
+
${input('expanded', 'Levels open at first', defaults.expanded ?? 1, { type: 'number' })}
|
|
324
|
+
${pk ? formPage('A modal form page, opened by clicking a node.') : ''}
|
|
325
|
+
</div>`;
|
|
326
|
+
break;
|
|
327
|
+
case 'timeline':
|
|
328
|
+
fields = html`<div class="form-grid">
|
|
329
|
+
${select('title', 'Title', defaults.title ?? '', colOpts(notBinary, false))}
|
|
330
|
+
${select('when', 'When', defaults.when ?? '', colOpts(dates), 'The events come newest first.')}
|
|
331
|
+
${select('who', 'Who', defaults.who ?? '', colOpts(notBinary))}
|
|
332
|
+
${select('body', 'Text', defaults.body ?? '', colOpts(notBinary))}
|
|
333
|
+
${pk ? formPage('A modal form page, linked from each title.') : ''}
|
|
334
|
+
</div>`;
|
|
335
|
+
break;
|
|
336
|
+
case 'media_list':
|
|
337
|
+
case 'content_row':
|
|
338
|
+
fields = html`<div class="form-grid">
|
|
339
|
+
${select('title', 'Title', defaults.title ?? '', colOpts(notBinary, false), 'Its first two letters are the initials.')}
|
|
340
|
+
${select('description', 'Description', defaults.description ?? '', colOpts(notBinary))}
|
|
341
|
+
${kind === 'content_row' ? select('misc', 'Secondary text (on the right)', defaults.misc ?? '', colOpts(notBinary)) : ''}
|
|
342
|
+
${select('badge', 'Badge', defaults.badge ?? '', colOpts(notBinary))}
|
|
343
|
+
${pk ? formPage('A modal form page, linked from each title.') : ''}
|
|
344
|
+
</div>`;
|
|
345
|
+
break;
|
|
346
|
+
case 'comments':
|
|
347
|
+
fields = html`<div class="form-grid">
|
|
348
|
+
${select('user', 'Who wrote it', defaults.user ?? '', colOpts(notBinary))}
|
|
349
|
+
${select('date', 'When', defaults.date ?? '', colOpts(dates), 'The newest first.')}
|
|
350
|
+
${select('comment', 'Comment', defaults.comment ?? '', colOpts(notBinary, false))}
|
|
351
|
+
</div>`;
|
|
352
|
+
break;
|
|
353
|
+
case 'data_load': {
|
|
354
|
+
const defs = (await owner.query<{ name: string; table_name: string }>('select name, table_name from meta.data_load_def where app_id = $1 order by name', [a.id])).rows;
|
|
355
|
+
fields = html`<div class="form-grid">
|
|
356
|
+
${input('definition', 'Data load definition', defaults.definition ?? '', { required: true, help: `Upper case, e.g. ORDERS_LOAD. A new definition for ${rel?.t ?? 'the table'}; an existing one of that name is used as it is${defs.length ? ` (this application has ${defs.map((d) => d.name).join(', ')})` : ''}.` })}
|
|
357
|
+
${select('mode', 'Mode', defaults.mode ?? 'append', [['append', 'Append: add the rows'], ['merge', 'Merge: update rows with the same key, add the others'], ['replace', 'Replace: delete all rows first']])}
|
|
358
|
+
</div>
|
|
359
|
+
${check('skip_errors', 'Skip rows with errors', !!defaults.skip_errors, 'Load the good rows and list the others; otherwise nothing is loaded when a row fails.')}`;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case 'wizard':
|
|
363
|
+
fields = html`<div class="form-grid">
|
|
364
|
+
${input('steps', 'Steps', defaults.steps ?? 2, { type: 'number', help: 'One page per step, numbered from the page number up; the columns are shared out in order.' })}
|
|
365
|
+
${input('return_page', 'After Finish or Cancel, go to page', defaults.return_page ?? '', { type: 'number', placeholder: a.home_page ? `empty: the home page (${a.home_page})` : 'empty: this page' })}
|
|
366
|
+
</div>
|
|
367
|
+
${colChecks('columns', 'Columns', defaults.columns ?? [], (c) => !c.is_pk && !c.generated && c.kind !== 'binary' && c.kind !== 'geometry', (c) => (c.not_null && !c.has_default ? ', required: always included' : c.fk_table ? `, a select list of ${c.fk_table}` : ''))}`;
|
|
368
|
+
break;
|
|
369
|
+
case 'search': {
|
|
370
|
+
const configs = (await owner.query<{ name: string; label: string; source: string }>('select name, label, source from meta.search_config where app_id = $1 order by seq, name', [a.id])).rows;
|
|
371
|
+
const tables = await wizardTables(role);
|
|
372
|
+
fields = html`
|
|
373
|
+
${configs.length
|
|
374
|
+
? html`<fieldset class="prop-group"><legend>Search configurations</legend><div class="qb-pick">${configs.map((c) => html`<label class="check"><input type="checkbox" name="configs" value="${c.name}" checked> ${c.label} <span class="muted small">${c.name}, ${c.source.slice(0, 60)}</span></label>`)}</div></fieldset>`
|
|
375
|
+
: html`<p class="muted">This application has no search configurations yet (<a href="${BASE}/apps/${a.id}/shared?type=search_config">Shared Components → Search configurations</a>): choose a table to search below.</p>`}
|
|
376
|
+
<div class="form-grid">
|
|
377
|
+
${select('table', 'And a new configuration on', rel?.t ?? '', [['', '- none -'], ...tables.filter((t) => t.access).map((t): [string, string] => [t.t, t.t])], 'Searches the table\'s text columns and shows its name (or first text) column as the result\'s title.')}
|
|
378
|
+
${formPage('With a table: a modal form page that each result links to.')}
|
|
379
|
+
</div>`;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
case 'tasks':
|
|
383
|
+
fields = html`<div class="form-grid">${select('context', 'Show', defaults.context ?? 'my', TASK_CONTEXTS)}</div>
|
|
384
|
+
${check('completed', 'Also completed tasks', !!defaults.completed)}
|
|
385
|
+
<p class="muted small">Tasks come from task definitions (Shared Components → Task definitions) and approvals started in processes or workflows.</p>`;
|
|
386
|
+
break;
|
|
387
|
+
case 'workflows':
|
|
388
|
+
fields = html`<div class="form-grid">${select('context', 'Show', defaults.context ?? 'initiator', [['initiator', 'The workflows the user started'], ['admin', 'The workflows the user administers']])}</div>`;
|
|
389
|
+
break;
|
|
390
|
+
case 'dashboard': {
|
|
391
|
+
const tables = (await wizardTables(role)).filter((t) => t.access);
|
|
392
|
+
fields = tables.length
|
|
393
|
+
? html`<fieldset class="prop-group"><legend>Tables</legend><div class="qb-pick">${tables.map((t, n) => html`<label class="check"><input type="checkbox" name="tables" value="${t.t}"${n < 12 ? raw(' checked') : ''}> ${t.t}</label>`)}</div></fieldset>
|
|
394
|
+
<p class="muted small">A chart per table (at most six) of the rows per parent row or per repeating value, read from the data; tables with row level security are left out.</p>`
|
|
395
|
+
: html`<p class="alert alert-error" role="status">The application's database role can't read any table.</p>`;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
231
398
|
case 'master_detail': {
|
|
399
|
+
const t = rel!.t;
|
|
232
400
|
const details = (
|
|
233
401
|
await owner.query<{ t: string; col: string }>(
|
|
234
402
|
`select meta.wizard_qname(con.conrelid) as t, a.attname::text as col
|
|
235
403
|
from pg_constraint con join pg_attribute a on a.attrelid = con.conrelid and a.attnum = con.conkey[1]
|
|
236
404
|
where con.confrelid = $1::regclass and con.contype = 'f' and con.conrelid <> con.confrelid and array_length(con.conkey, 1) = 1
|
|
237
405
|
order by 1, 2`,
|
|
238
|
-
[
|
|
406
|
+
[t],
|
|
239
407
|
)
|
|
240
408
|
).rows;
|
|
241
409
|
fields = details.length
|
|
242
|
-
? select('detail', 'Detail table', defaults.detail ? `${defaults.detail}|${defaults.detail_column}` : '', details.map((d): [string, string] => [`${d.t}|${d.col}`, `${d.t} (${d.col} → ${
|
|
243
|
-
: html`<p class="alert alert-error" role="status">No table has a foreign key to ${
|
|
410
|
+
? select('detail', 'Detail table', defaults.detail ? `${defaults.detail}|${defaults.detail_column}` : '', details.map((d): [string, string] => [`${d.t}|${d.col}`, `${d.t} (${d.col} → ${t})`]), 'Tables with a foreign key to this table. Selecting a master row shows its details; new detail rows get the master\'s key.')
|
|
411
|
+
: html`<p class="alert alert-error" role="status">No table has a foreign key to ${t}: a master-detail page needs one.</p>`;
|
|
244
412
|
break;
|
|
245
413
|
}
|
|
246
414
|
}
|
|
247
415
|
|
|
248
|
-
const
|
|
416
|
+
const kindDef = PAGE_KINDS.find((k) => k[0] === kind)!;
|
|
417
|
+
const kindName = kindDef[1];
|
|
249
418
|
const main = html`${appHeader(a, 'pages', s)}
|
|
250
|
-
${region(`${kindName} on ${rel.t}
|
|
251
|
-
<p class="muted u-mt0">${
|
|
252
|
-
${needsPk && !pk ? html`<p class="alert alert-error" role="status">${rel.t} has no single-column primary key, which this page type needs.</p>` : ''}
|
|
253
|
-
${role && access && !(access.s && (!needsPk || (access.i && access.u && access.d)))
|
|
254
|
-
? html`<p class="alert alert-error" role="status">The application's database role <code>${role}</code> ${access.s ? 'can read but not change' : 'has no access to'} ${rel.t}: grant it the privileges the page needs (${needsPk ? 'select, insert, update, delete' : 'select'}).</p>`
|
|
419
|
+
${region(rel && kind !== 'search' ? `${kindName} on ${rel.t}` : kindName, html`
|
|
420
|
+
<p class="muted u-mt0">${kindDef[2]} ${rel ? 'The proposals come from the table\'s columns, key and foreign keys; change them below.' : ''} <a href="${BASE}/apps/${a.id}/create-page">Back to choose another type${rel ? ' or table' : ''}</a>.</p>
|
|
421
|
+
${rel && needsPk && !pk ? html`<p class="alert alert-error" role="status">${rel.t} has no single-column primary key, which this page type needs.</p>` : ''}
|
|
422
|
+
${rel && role && access && !(access.s && (!(needsPk || kind === 'wizard' || kind === 'data_load') || (access.i && (kind === 'wizard' || (access.u && access.d)))))
|
|
423
|
+
? html`<p class="alert alert-error" role="status">The application's database role <code>${role}</code> ${access.s ? 'can read but not change' : 'has no access to'} ${rel.t}: grant it the privileges the page needs (${needsPk ? 'select, insert, update, delete' : kind === 'wizard' ? 'select, insert' : kind === 'data_load' ? 'select, insert, update, delete for merge or replace' : 'select'}).</p>`
|
|
255
424
|
: ''}
|
|
256
425
|
<form method="post" action="${BASE}/apps/${a.id}/wizard">${csrf(s)}
|
|
257
|
-
<input type="hidden" name="kind" value="${kind}"
|
|
426
|
+
<input type="hidden" name="kind" value="${kind}">${rel && kind !== 'search' ? html`<input type="hidden" name="table" value="${rel.t}">` : ''}
|
|
258
427
|
<div class="form-grid">
|
|
259
428
|
${input('report_page', 'Page number', nextPage, { type: 'number', required: true })}
|
|
260
429
|
${input('label', 'Name', defaults.label ?? '', { help: 'The page title and the navigation entry.' })}
|
|
261
430
|
${select('icon', 'Menu icon', defaults.icon ?? 'table', [...ICONS])}
|
|
262
431
|
</div>
|
|
263
|
-
${['report_form', 'grid'].includes(kind) ? '' : check('nav', 'Add a navigation entry', !!defaults.nav)}
|
|
432
|
+
${['report_form', 'grid', 'dashboard'].includes(kind) ? '' : check('nav', 'Add a navigation entry', !!defaults.nav)}
|
|
264
433
|
${fields}
|
|
265
434
|
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}">Cancel</a><button class="btn btn-hot">Create page</button></div>
|
|
266
435
|
</form>`)}`;
|
|
267
|
-
return send(reply, s, shell(s, `Create page · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], [
|
|
436
|
+
return send(reply, s, shell(s, `Create page · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page`], [kindName]], main));
|
|
268
437
|
});
|
|
269
438
|
|
|
270
439
|
app.post(`${BASE}/apps/:id/wizard`, async (req: Req, reply) => {
|
|
@@ -272,26 +441,42 @@ export async function wizardRoutes(app: FastifyInstance) {
|
|
|
272
441
|
if (!s) return;
|
|
273
442
|
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
274
443
|
const kind = KINDS.has(String(b.kind)) ? String(b.kind) : 'report_form';
|
|
275
|
-
const table = String(b.table ?? '');
|
|
276
|
-
const retry = `${BASE}/apps/${req.params.id}/wizard?kind=${encodeURIComponent(kind)}
|
|
444
|
+
const table = kind === 'dashboard' || kind === 'tasks' || kind === 'workflows' ? '' : String(b.table ?? '');
|
|
445
|
+
const retry = `${BASE}/apps/${req.params.id}/wizard?kind=${encodeURIComponent(kind)}${table && kind !== 'search' ? `&table=${encodeURIComponent(table)}` : ''}`;
|
|
277
446
|
try {
|
|
278
447
|
const page = pageNo(b.report_page);
|
|
279
448
|
if (page === null) throw new Error('Enter a page number.');
|
|
280
449
|
const options = wizardOptions(kind, b);
|
|
450
|
+
if (!table && !NO_TABLE.has(kind)) throw new Error('Choose a table or view.');
|
|
281
451
|
const id = await owner.tx(async (c) => {
|
|
282
|
-
const a = await c.query('select alias from meta.app where id = $1', [req.params.id]);
|
|
452
|
+
const a = await c.query('select alias, db_role from meta.app where id = $1', [req.params.id]);
|
|
283
453
|
if (!a.rowCount) throw new Error('This application does not exist.');
|
|
284
454
|
// report and form and grid keep their own icons and need no step 2 (older forms post here directly)
|
|
285
455
|
if (kind === 'report_form' && options.form_page === null) throw new Error('Enter the form page number.');
|
|
286
456
|
if (['report_form', 'grid'].includes(kind) && !options.icon) options.icon = kind === 'grid' ? 'grid' : 'table';
|
|
287
|
-
|
|
457
|
+
if (kind === 'dashboard') {
|
|
458
|
+
// (137) only tables the application's role can read
|
|
459
|
+
const readable = new Set((await wizardTables(a.rows[0].db_role)).filter((t) => t.access).map((t) => t.t));
|
|
460
|
+
const tables = (options.tables as string[]).filter((t) => readable.has(t));
|
|
461
|
+
if (!tables.length) throw new Error('Choose at least one table the application can read.');
|
|
462
|
+
const made = await addDashboard(c, a.rows[0].alias, tables, page);
|
|
463
|
+
if (!made) throw new Error('None of these tables has a column worth a chart (a foreign key, or values that repeat).');
|
|
464
|
+
const pid = (await c.query('select id from meta.page where app_id = $1 and page_no = $2', [req.params.id, page])).rows[0].id as number;
|
|
465
|
+
if (options.label) {
|
|
466
|
+
await c.query('update meta.page set name = $2, title = $2 where id = $1', [pid, options.label]);
|
|
467
|
+
await c.query('update meta.nav_entry set label = $3 where app_id = $1 and target_page = $2', [req.params.id, page, options.label]);
|
|
468
|
+
}
|
|
469
|
+
if (options.icon) await c.query('update meta.nav_entry set icon = $3 where app_id = $1 and target_page = $2', [req.params.id, page, options.icon]);
|
|
470
|
+
return pid;
|
|
471
|
+
}
|
|
472
|
+
return (await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb) as id', [a.rows[0].alias, kind, table || null, page, JSON.stringify(options)])).rows[0].id as number;
|
|
288
473
|
});
|
|
289
474
|
const pages = (await owner.query('select page_no from meta.page where app_id = $1 and id >= $2 order by page_no', [req.params.id, id])).rows.map((r) => r.page_no);
|
|
290
|
-
flash(s, `${pages.length > 1 ? `Pages ${pages.join('
|
|
475
|
+
flash(s, `${pages.length > 1 ? `Pages ${pages.join(', ')}` : `Page ${page}`} created${table ? ` for ${table}. Make sure the app's database role has privileges on it` : ''}.`);
|
|
291
476
|
return back(reply, s, `${BASE}/pages/${id}`);
|
|
292
477
|
} catch (e) {
|
|
293
478
|
flash(s, (e as Error).message, 'error');
|
|
294
|
-
return back(reply, s, KINDS.has(String(b.kind)) && table ? retry : `${BASE}/apps/${req.params.id}`);
|
|
479
|
+
return back(reply, s, KINDS.has(String(b.kind)) && (table || NO_TABLE.has(kind)) ? retry : `${BASE}/apps/${req.params.id}`);
|
|
295
480
|
}
|
|
296
481
|
});
|
|
297
482
|
}
|
|
@@ -385,7 +385,8 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
|
|
385
385
|
<div class="buttons"><button class="btn">${tx('wsp.save_devs')}</button></div></form>`)}
|
|
386
386
|
</div>
|
|
387
387
|
${serviceRegion(s, w)}
|
|
388
|
-
${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p
|
|
388
|
+
${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>
|
|
389
|
+
<p>${tx.rich('wsp.telemetry_text', { link: html`<a href="${BASE}/workspaces/${w.id}/telemetry">${tx('wsp.telemetry')}</a>` })}</p>`, '', 'mail-network')}
|
|
389
390
|
<div class="columns">${preferencesRegion(s, w, without, unlinked)}${linksRegion(s, w, links)}</div>
|
|
390
391
|
${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
|
|
391
392
|
${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
|
package/src/cli/replace.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { randomBytes } from 'node:crypto';
|
|
13
13
|
import type pg from 'pg';
|
|
14
14
|
import { regionKeys } from '../appfiles.ts';
|
|
15
|
+
import { ensureSnapshot, recordHistory } from '../history.ts';
|
|
15
16
|
|
|
16
17
|
/** Tables of an application that are its definition: replaced. */
|
|
17
18
|
export const REPLACED = [
|
|
@@ -86,7 +87,10 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
|
|
|
86
87
|
// (126) outgoing calls the network access lists refused (Administration → Network access; a log, 7 days)
|
|
87
88
|
'network_refusal',
|
|
88
89
|
// (136) rows queued for their embeddings (the vector providers travel in meta.app)
|
|
89
|
-
'embedding_queue'
|
|
90
|
+
'embedding_queue',
|
|
91
|
+
// (138) the change history and the snapshot it compares with (this installation's record)
|
|
92
|
+
'app_snapshot', 'component_change',
|
|
93
|
+
];
|
|
90
94
|
/** Children of pages (replaced with their page). */
|
|
91
95
|
const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
|
|
92
96
|
/** References into replaced tables from kept data, repointed below: "table.column". */
|
|
@@ -140,6 +144,9 @@ export async function replaceApp(db: Db, doc: unknown, alias: string, opts: Repl
|
|
|
140
144
|
const target = (await db.query<{ id: number }>('select id from meta.app where alias = $1 for update', [alias])).rows[0];
|
|
141
145
|
if (!target) throw new Error(`application ${alias} not found`);
|
|
142
146
|
const old = target.id;
|
|
147
|
+
// (sprint 138) the change history compares the application before and after (src/history.ts); not on a bare pool
|
|
148
|
+
const inTx = !('totalCount' in db);
|
|
149
|
+
if (inTx) await ensureSnapshot(old, db as pg.ClientBase);
|
|
143
150
|
const backup = opts.backup === undefined ? { reason: 'import' as const } : opts.backup;
|
|
144
151
|
if (backup) await db.query('select meta.backup_app($1, $2, $3, $4)', [old, backup.reason, backup.user ?? null, backup.note ?? null]);
|
|
145
152
|
const tmpAlias = `pgkiln-replace-${randomBytes(6).toString('hex')}`;
|
|
@@ -251,5 +258,6 @@ export async function replaceApp(db: Db, doc: unknown, alias: string, opts: Repl
|
|
|
251
258
|
[old, neu],
|
|
252
259
|
);
|
|
253
260
|
await db.query('delete from meta.app where id = $1', [neu]);
|
|
261
|
+
if (inTx) await recordHistory(old, opts.backup ? (opts.backup.user ?? null) : null, 'import', db as pg.ClientBase).catch(() => {});
|
|
254
262
|
return old;
|
|
255
263
|
}
|