pgkiln 0.40.0 → 0.41.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/095_rest_catalogs.sql +59 -0
- package/db/migrations/096_developer_email.sql +15 -0
- package/docs/apex-feature-parity.md +12 -9
- package/docs/guide/03-builder.md +37 -1
- package/docs/guide/04-pages-and-regions.md +37 -4
- package/docs/guide/12-development.md +18 -1
- package/docs/guide/19-rest-data-sources.md +39 -1
- package/examples/hr/hr_59_gantt_editing.sql +21 -0
- package/examples/hr/hr_60_tree_charts.sql +46 -0
- package/package.json +1 -1
- package/public/app.css +44 -0
- package/public/app.js +126 -0
- package/public/builder.css +1 -1
- package/public/sw.js +5 -2
- package/src/app.ts +6 -0
- package/src/builder/find-workspace.ts +120 -0
- package/src/builder/home.ts +2 -0
- package/src/builder/mail.ts +39 -0
- package/src/builder/region-settings.ts +19 -1
- package/src/builder/rest-catalogs.ts +424 -0
- package/src/builder/routes.ts +32 -6
- package/src/builder/shared.ts +2 -0
- package/src/builder/workspace-transfer.ts +136 -0
- package/src/i18n/ar.ts +14 -0
- package/src/i18n/cs.ts +14 -0
- package/src/i18n/da.ts +14 -0
- package/src/i18n/de.ts +14 -0
- package/src/i18n/el.ts +14 -0
- package/src/i18n/es.ts +14 -0
- package/src/i18n/fi.ts +14 -0
- package/src/i18n/fr.ts +14 -0
- package/src/i18n/he.ts +14 -0
- package/src/i18n/it.ts +14 -0
- package/src/i18n/ja.ts +14 -0
- package/src/i18n/ko.ts +14 -0
- package/src/i18n/nb.ts +14 -0
- package/src/i18n/pl.ts +14 -0
- package/src/i18n/pt.ts +14 -0
- package/src/i18n/ru.ts +14 -0
- package/src/i18n/sv.ts +14 -0
- package/src/i18n/tr.ts +14 -0
- package/src/i18n/uk.ts +14 -0
- package/src/i18n/zh.ts +14 -0
- package/src/i18n.ts +28 -0
- package/src/restcatalog.ts +605 -0
- package/src/runtime/charts.ts +296 -12
- package/src/runtime/data-reporter.ts +48 -16
- package/src/runtime/gantt-move.ts +63 -0
- package/src/runtime/regions.ts +3 -0
- package/src/runtime/routes.ts +39 -0
- package/src/websources.ts +11 -3
- package/src/workspace-export.ts +240 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } 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 {
|
|
6
|
+
catalogDocument, catalogProblems, insertCatalog, loadCatalog, readCatalogText, replaceCatalog, serviceProblems, serviceUrl, snake,
|
|
7
|
+
sourceFromService, trimBase, wholeCatalogProblems, type Catalog, type CatalogService,
|
|
8
|
+
} from '../restcatalog.ts';
|
|
9
|
+
import { readMultipart } from '../runtime/files.ts';
|
|
10
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
11
|
+
import { webRequest } from '../webclient.ts';
|
|
12
|
+
import { sourceProblems } from '../websources.ts';
|
|
13
|
+
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Body, type Req } from './ui.ts';
|
|
14
|
+
import { currentWorkspace } from './workspaces.ts';
|
|
15
|
+
|
|
16
|
+
// Workspace utilities → REST Source Catalogs (migration 095; APEX: Workspace
|
|
17
|
+
// Utilities → REST Source Catalogs): the current workspace's catalogs, a
|
|
18
|
+
// catalog's services, creating one by hand or from an OpenAPI document (a
|
|
19
|
+
// file, pasted, or read from a URL on the server's allow-list) or a catalog
|
|
20
|
+
// file, and downloading one. Developers of the workspace manage them; an
|
|
21
|
+
// application's Shared Components → REST data sources → New makes a data
|
|
22
|
+
// source from a service (catalogPicker, below).
|
|
23
|
+
|
|
24
|
+
const NOTES = '__CATALOG_NOTES';
|
|
25
|
+
const MAX_DOC_BYTES = 5 * 1024 * 1024;
|
|
26
|
+
const root = `${BASE}/rest-catalogs`;
|
|
27
|
+
const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['REST Source Catalogs', root], ...extra];
|
|
28
|
+
const when = (d: string | Date | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
|
|
29
|
+
const pretty = (v: unknown) => JSON.stringify(v ?? null, null, 2);
|
|
30
|
+
|
|
31
|
+
/** A catalog of the current workspace, or null (404). */
|
|
32
|
+
async function catalogOf(s: Session, id: string) {
|
|
33
|
+
const c = await loadCatalog(id);
|
|
34
|
+
return c && c.workspace_id === currentWorkspace(s) ? c : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function notesHtml(s: Session): Raw | '' {
|
|
38
|
+
const v = s.state[NOTES];
|
|
39
|
+
if (!v) return '';
|
|
40
|
+
delete s.state[NOTES];
|
|
41
|
+
const notes = JSON.parse(v) as string[];
|
|
42
|
+
return notes.length
|
|
43
|
+
? html`<div class="alert alert-info" role="status"><b>What the document had that the catalog doesn't use:</b><ul>${notes.map((n) => html`<li>${n}</li>`)}</ul></div>`
|
|
44
|
+
: '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The import form: a file, pasted text or a URL; base URL and name optional. */
|
|
48
|
+
function importForm(s: Session, action: string, replacing: boolean): Raw {
|
|
49
|
+
return html`<form method="post" action="${action}" enctype="multipart/form-data">${csrf(s)}
|
|
50
|
+
<p class="muted u-mt0">An <b>OpenAPI 3</b> or <b>Swagger 2.0</b> document in JSON (e.g. <code>openapi.json</code>), or a catalog file downloaded from pgkiln.${replacing ? ' The catalog\'s services are replaced by the document\'s; data sources made from it earlier stay as they are.' : ''}</p>
|
|
51
|
+
<div class="form-grid">
|
|
52
|
+
<div class="field"><label class="label" for="f_file">File</label><input id="f_file" type="file" name="file" accept=".json,application/json"></div>
|
|
53
|
+
${input('url', 'Or read it from a URL', '', { placeholder: 'https://api.example.com/openapi.json', help: 'The server reads it; the host must be on its allow-list (PGKILN_REST_ALLOWED_HOSTS).' })}
|
|
54
|
+
<div class="field" data-wide><label class="label" for="f_text">Or paste it</label><textarea id="f_text" name="text" class="code" rows="4" spellcheck="false"></textarea></div>
|
|
55
|
+
${input('base_url', 'Base URL', '', { placeholder: 'from the document', help: 'Instead of the document\'s server, e.g. https://test.api.example.com/v1.' })}
|
|
56
|
+
${replacing ? '' : input('name', 'Name', '', { placeholder: 'the document\'s title' })}
|
|
57
|
+
</div>
|
|
58
|
+
<div class="buttons"><button class="btn btn-hot">${icon('upload')} ${replacing ? 'Replace the services' : 'Import'}</button></div></form>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The text of an uploaded, pasted or fetched document (and where it came from). */
|
|
62
|
+
async function documentText(req: Req): Promise<{ text: string; from?: string }> {
|
|
63
|
+
let text = '';
|
|
64
|
+
if (req.isMultipart()) {
|
|
65
|
+
const parsed = await readMultipart(req, MAX_DOC_BYTES / 1024 / 1024);
|
|
66
|
+
req.body = parsed.body as Body;
|
|
67
|
+
const f = parsed.files.get('file');
|
|
68
|
+
if (f?.truncated) throw new Error('The file is larger than 5 MB.');
|
|
69
|
+
if (f) text = f.data.toString('utf8');
|
|
70
|
+
}
|
|
71
|
+
if (!text.trim()) text = String(req.body?.text ?? '');
|
|
72
|
+
if (text.trim()) return { text };
|
|
73
|
+
const url = String(req.body?.url ?? '').trim();
|
|
74
|
+
if (!url) throw new Error('Choose a file, paste a document or give its URL.');
|
|
75
|
+
if (!/^https?:\/\//i.test(url)) throw new Error('The URL starts with http:// or https://.');
|
|
76
|
+
const r = await webRequest(url, { headers: { accept: 'application/json' }, timeoutMs: 15_000, maxBytes: MAX_DOC_BYTES });
|
|
77
|
+
if (r.status !== 200) throw new Error(`The URL answered HTTP ${r.status}.`);
|
|
78
|
+
return { text: r.body.toString('utf8'), from: r.url };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A service's fields from a form (JSON fields parsed; problems thrown). */
|
|
82
|
+
function serviceFromBody(b: Body): CatalogService {
|
|
83
|
+
const json = (k: string, empty: unknown) => {
|
|
84
|
+
const v = String(b[k] ?? '').trim();
|
|
85
|
+
if (!v) return empty;
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(v);
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error(`${k.replace('_', ' ')}: not valid JSON.`);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const text = (k: string) => String(b[k] ?? '').trim() || null;
|
|
93
|
+
return {
|
|
94
|
+
name: String(b.name ?? '').trim().toUpperCase(),
|
|
95
|
+
description: text('description'),
|
|
96
|
+
path: String(b.path ?? '').trim(),
|
|
97
|
+
method: String(b.method ?? 'GET').toUpperCase(),
|
|
98
|
+
params: json('params', []) as CatalogService['params'],
|
|
99
|
+
headers: json('headers', {}) as CatalogService['headers'],
|
|
100
|
+
body: text('body'),
|
|
101
|
+
row_selector: text('row_selector'),
|
|
102
|
+
columns: json('columns', []) as CatalogService['columns'],
|
|
103
|
+
key_columns: String(b.key_columns ?? '').split(',').map((x) => x.trim()).filter(Boolean),
|
|
104
|
+
operations: json('operations', {}) as CatalogService['operations'],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function serviceForm(s: Session, action: string, v: Partial<CatalogService>, label: string): Raw {
|
|
109
|
+
const area = (name: string, title: string, value: string, help: string, rows = 4) =>
|
|
110
|
+
html`<div class="field" data-wide><label class="label" for="f_${name}">${title}</label><textarea id="f_${name}" name="${name}" class="code" rows="${rows}" spellcheck="false">${value}</textarea><small class="help">${help}</small></div>`;
|
|
111
|
+
return html`<form method="post" action="${action}">${csrf(s)}
|
|
112
|
+
<div class="form-grid">
|
|
113
|
+
${input('name', 'Name', v.name ?? '', { required: true, placeholder: 'GET_PETS', help: 'The data source\'s name when an application makes one from this service.' })}
|
|
114
|
+
<div class="field"><label class="label" for="f_method">Method</label><select id="f_method" name="method">${['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((m) => html`<option${(v.method ?? 'GET') === m ? raw(' selected') : ''}>${m}</option>`)}</select></div>
|
|
115
|
+
${input('path', 'Path', v.path ?? '', { required: true, placeholder: '/pets/{pet_id}', help: 'After the catalog\'s base URL; {name} is a path parameter.' })}
|
|
116
|
+
${input('row_selector', 'Row selector', v.row_selector ?? '', { placeholder: 'items', help: 'Where the rows are in the response; empty: the response.' })}
|
|
117
|
+
<div class="field" data-wide><label class="label" for="f_description">Description</label><input id="f_description" name="description" value="${v.description ?? ''}"></div>
|
|
118
|
+
${area('params', 'Parameters (JSON)', pretty(v.params ?? []), '[{"name": "page_size", "in": "query", "key": "pageSize", "default": "20"}] · in: path, query, header, body; "key": the service\'s name for it when it isn\'t lower case with _.')}
|
|
119
|
+
${area('columns', 'Columns (JSON)', pretty(v.columns ?? []), '[{"name": "id", "path": "id", "type": "integer"}] · types: text, number, integer, boolean, date, timestamp, json.', 6)}
|
|
120
|
+
${area('headers', 'Headers (JSON)', pretty(v.headers ?? {}), 'Extra request headers, not secrets: an application\'s web credential signs in.', 2)}
|
|
121
|
+
${area('body', 'Body template', v.body ?? '', '{"query": {q}} — empty: the body parameters as a JSON object.', 2)}
|
|
122
|
+
${input('key_columns', 'Key columns', (v.key_columns ?? []).join(', '), { placeholder: 'id' })}
|
|
123
|
+
${area('operations', 'Operations (JSON)', pretty(v.operations ?? {}), '{"update": {"method": "PUT", "path": "/{id}"}, "delete": {"method": "DELETE", "path": "/{id}"}} · write-back for forms and grids.', 3)}
|
|
124
|
+
</div>
|
|
125
|
+
<div class="buttons"><button class="btn btn-hot">${icon('save')} ${label}</button></div></form>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function restCatalogRoutes(app: FastifyInstance) {
|
|
129
|
+
// ---------------------------------------------------------------- the list
|
|
130
|
+
app.get(root, async (req: Req, reply) => {
|
|
131
|
+
const s = await developer(req, reply);
|
|
132
|
+
if (!s) return;
|
|
133
|
+
const rows = (await owner.query(
|
|
134
|
+
`select c.id, c.name, c.description, c.base_url, c.source, c.api_version, c.updated_at,
|
|
135
|
+
(select count(*)::int from meta.rest_catalog_service x where x.catalog_id = c.id) as services
|
|
136
|
+
from meta.rest_catalog c where c.workspace_id = $1 order by lower(c.name)`, [currentWorkspace(s)])).rows;
|
|
137
|
+
const main = html`<div class="title-row"><h1>REST Source Catalogs</h1></div>
|
|
138
|
+
<p class="muted u-mt0">Web services described once for ${s.workspace?.name ?? 'this workspace'}: their base URL and services. An application makes a REST data source from a service in Shared Components → REST data sources → New. The data source is a copy; credentials stay with the application.</p>
|
|
139
|
+
${notesHtml(s)}
|
|
140
|
+
${region('Catalogs', html`<div class="table-wrap"><table class="report report-reflow">
|
|
141
|
+
<thead><tr><th>Catalog</th><th>Base URL</th><th class="num">Services</th><th>From</th><th>Updated (UTC)</th></tr></thead>
|
|
142
|
+
<tbody>${rows.length ? rows.map((c) => html`<tr>
|
|
143
|
+
<td data-label="Catalog"><a href="${root}/${c.id}">${c.name}</a>${c.description ? html`<br><span class="muted small">${String(c.description).slice(0, 160)}</span>` : ''}</td>
|
|
144
|
+
<td data-label="Base URL"><code class="wrap-anywhere">${c.base_url}</code></td>
|
|
145
|
+
<td class="num" data-label="Services">${c.services}</td>
|
|
146
|
+
<td data-label="From">${c.source === 'openapi' ? html`OpenAPI${c.api_version ? html` <span class="muted">${c.api_version}</span>` : ''}` : 'by hand'}</td>
|
|
147
|
+
<td data-label="Updated">${when(c.updated_at)}</td></tr>`)
|
|
148
|
+
: html`<tr><td colspan="5" class="empty">No catalogs yet: import an OpenAPI document or create one below.</td></tr>`}</tbody></table></div>`)}
|
|
149
|
+
${region('Import a catalog', importForm(s, `${root}/import`, false))}
|
|
150
|
+
${region('Create a catalog by hand', html`<form method="post" action="${root}">${csrf(s)}
|
|
151
|
+
<div class="form-grid">${input('name', 'Name', '', { required: true, placeholder: 'e.g. Weather service' })}${input('base_url', 'Base URL', '', { required: true, placeholder: 'https://api.example.com/v1' })}
|
|
152
|
+
<div class="field" data-wide><label class="label" for="f_cdescription">Description</label><input id="f_cdescription" name="description"></div></div>
|
|
153
|
+
<div class="buttons"><button class="btn">${icon('plus')} Create</button></div></form>`)}`;
|
|
154
|
+
return send(reply, s, shell(s, 'REST Source Catalogs', crumbs(), main));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
app.post(root, async (req: Req, reply) => {
|
|
158
|
+
const s = await developer(req, reply);
|
|
159
|
+
if (!s) return;
|
|
160
|
+
const c: Catalog = {
|
|
161
|
+
name: String(req.body?.name ?? '').trim(), description: String(req.body?.description ?? '').trim() || null,
|
|
162
|
+
base_url: trimBase(String(req.body?.base_url ?? '')), source: 'manual', api_version: null, services: [],
|
|
163
|
+
};
|
|
164
|
+
const problems = catalogProblems(c);
|
|
165
|
+
if (problems.length) {
|
|
166
|
+
flash(s, problems.join(' '), 'error');
|
|
167
|
+
return back(reply, s, root);
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const id = await insertCatalog(owner, currentWorkspace(s), c, s.username);
|
|
171
|
+
flash(s, 'Catalog created: add its services.');
|
|
172
|
+
return back(reply, s, `${root}/${id}`);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
flash(s, uniqueError(e), 'error');
|
|
175
|
+
return back(reply, s, root);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
app.post(`${root}/import`, async (req: Req, reply) => {
|
|
180
|
+
let doc: { text: string; from?: string } | Error;
|
|
181
|
+
try {
|
|
182
|
+
doc = await documentText(req);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
doc = e as Error;
|
|
185
|
+
}
|
|
186
|
+
const s = await developer(req, reply);
|
|
187
|
+
if (!s) return;
|
|
188
|
+
try {
|
|
189
|
+
if (doc instanceof Error) throw doc;
|
|
190
|
+
const { catalog, notes } = readCatalogText(doc.text, { baseUrl: req.body?.base_url, from: doc.from, name: req.body?.name });
|
|
191
|
+
const problems = wholeCatalogProblems(catalog);
|
|
192
|
+
if (problems.length) throw new Error(`The catalog can't be used as it is: ${problems.slice(0, 8).join(' ')}${problems.length > 8 ? ` (and ${problems.length - 8} more)` : ''}`);
|
|
193
|
+
const id = await owner.tx((c) => insertCatalog(c, currentWorkspace(s), catalog, s.username));
|
|
194
|
+
s.state[NOTES] = JSON.stringify(notes);
|
|
195
|
+
await logActivity({ appId: null, username: s.username, event: 'rest_catalog', ip: clientIp(req), detail: `imported catalog ${catalog.name} (${catalog.services.length} services)` });
|
|
196
|
+
flash(s, `Catalog ${catalog.name} imported with ${catalog.services.length} service(s).`);
|
|
197
|
+
return back(reply, s, `${root}/${id}`);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
flash(s, `Import failed: ${uniqueError(e)}`, 'error');
|
|
200
|
+
return back(reply, s, root);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------- one catalog
|
|
205
|
+
app.get(`${root}/:cid(^\\d+$)`, async (req: Req, reply) => {
|
|
206
|
+
const s = await developer(req, reply);
|
|
207
|
+
if (!s) return;
|
|
208
|
+
const c = await catalogOf(s, req.params.cid);
|
|
209
|
+
if (!c) return reply.code(404).send('Not found');
|
|
210
|
+
const base = `${root}/${c.id}`;
|
|
211
|
+
const sel = req.query.service ? c.services.find((x) => String(x.id) === req.query.service) : null;
|
|
212
|
+
const main = html`<div class="title-row"><h1>${c.name}</h1>
|
|
213
|
+
<a class="btn" href="${base}/download">${icon('download')} Download</a></div>
|
|
214
|
+
<p class="muted u-mt0"><code class="wrap-anywhere">${c.base_url}</code>${c.source === 'openapi' ? html` · from an OpenAPI document${c.api_version ? html` (version ${c.api_version})` : ''}` : ''} · updated ${when(c.updated_at)} UTC</p>
|
|
215
|
+
${notesHtml(s)}
|
|
216
|
+
${region('Services', html`<div class="table-wrap"><table class="report report-reflow">
|
|
217
|
+
<thead><tr><th>Service</th><th>Method</th><th>Path</th><th class="num">Parameters</th><th class="num">Columns</th><th>Write back</th></tr></thead>
|
|
218
|
+
<tbody>${c.services.length ? c.services.map((x) => html`<tr${sel?.id === x.id ? raw(' aria-current="true"') : ''}>
|
|
219
|
+
<td data-label="Service"><a href="${base}?service=${x.id}#service">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description.slice(0, 140)}</span>` : ''}</td>
|
|
220
|
+
<td data-label="Method"><span class="tag">${x.method}</span></td>
|
|
221
|
+
<td data-label="Path"><code class="wrap-anywhere">${x.path}</code></td>
|
|
222
|
+
<td class="num" data-label="Parameters">${x.params.length}</td>
|
|
223
|
+
<td class="num" data-label="Columns">${x.columns.length}</td>
|
|
224
|
+
<td data-label="Write back">${Object.keys(x.operations).join(', ') || html`<span class="muted">—</span>`}</td></tr>`)
|
|
225
|
+
: html`<tr><td colspan="6" class="empty">No services yet.</td></tr>`}</tbody></table></div>
|
|
226
|
+
<p class="u-m0 u-mt1"><a class="btn" href="${base}?service=new#service">${icon('plus')} Add a service</a></p>`)}
|
|
227
|
+
${req.query.service === 'new'
|
|
228
|
+
? region('New service', serviceForm(s, `${base}/services`, { method: 'GET', path: '/' }, 'Add'), '', 'service')
|
|
229
|
+
: sel
|
|
230
|
+
? region(`Service ${sel.name}`, html`<p class="muted u-mt0">URL: <code class="wrap-anywhere">${serviceUrl(c.base_url, sel.path)}</code></p>
|
|
231
|
+
${serviceForm(s, `${base}/services/${sel.id}`, sel, 'Save')}
|
|
232
|
+
<form method="post" action="${base}/services/${sel.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the service ${sel.name}?">Delete service</button></form>`, '', 'service')
|
|
233
|
+
: ''}
|
|
234
|
+
${region('Catalog', html`<form method="post" action="${base}">${csrf(s)}
|
|
235
|
+
<div class="form-grid">${input('name', 'Name', c.name, { required: true })}${input('base_url', 'Base URL', c.base_url, { required: true, help: 'e.g. a test system\'s URL. Data sources made earlier keep the URL they were made with.' })}
|
|
236
|
+
${input('api_version', 'API version', c.api_version ?? '')}
|
|
237
|
+
<div class="field" data-wide><label class="label" for="f_cdescription">Description</label><textarea id="f_cdescription" name="description" rows="2">${c.description ?? ''}</textarea></div></div>
|
|
238
|
+
<div class="buttons"><button class="btn btn-hot">${icon('save')} Save</button></div></form>`)}
|
|
239
|
+
${region('Replace from a document', importForm(s, `${base}/import`, true))}
|
|
240
|
+
<form method="post" action="${base}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the catalog ${c.name} and its services? Data sources made from it stay.">Delete catalog</button></form>`;
|
|
241
|
+
return send(reply, s, shell(s, c.name, crumbs([[c.name]]), main));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
app.post(`${root}/:cid(^\\d+$)`, async (req: Req, reply) => {
|
|
245
|
+
const s = await developer(req, reply);
|
|
246
|
+
if (!s) return;
|
|
247
|
+
const c = await catalogOf(s, req.params.cid);
|
|
248
|
+
if (!c) return reply.code(404).send('Not found');
|
|
249
|
+
const v = {
|
|
250
|
+
name: String(req.body?.name ?? '').trim(), base_url: trimBase(String(req.body?.base_url ?? '')),
|
|
251
|
+
description: String(req.body?.description ?? '').trim() || null, api_version: String(req.body?.api_version ?? '').trim() || null,
|
|
252
|
+
};
|
|
253
|
+
// the services must still make sense on the new base URL
|
|
254
|
+
const problems = [...catalogProblems(v), ...c.services.flatMap((x) => serviceProblems(v.base_url, x))];
|
|
255
|
+
if (problems.length) flash(s, problems.slice(0, 6).join(' '), 'error');
|
|
256
|
+
else
|
|
257
|
+
try {
|
|
258
|
+
await owner.query('update meta.rest_catalog set name = $2, base_url = $3, description = $4, api_version = $5, updated_at = now() where id = $1', [c.id, v.name, v.base_url, v.description, v.api_version]);
|
|
259
|
+
flash(s, 'Catalog saved.');
|
|
260
|
+
} catch (e) {
|
|
261
|
+
flash(s, uniqueError(e), 'error');
|
|
262
|
+
}
|
|
263
|
+
return back(reply, s, `${root}/${c.id}`);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
app.post(`${root}/:cid(^\\d+$)/import`, async (req: Req, reply) => {
|
|
267
|
+
let doc: { text: string; from?: string } | Error;
|
|
268
|
+
try {
|
|
269
|
+
doc = await documentText(req);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
doc = e as Error;
|
|
272
|
+
}
|
|
273
|
+
const s = await developer(req, reply);
|
|
274
|
+
if (!s) return;
|
|
275
|
+
const c = await catalogOf(s, req.params.cid);
|
|
276
|
+
if (!c) return reply.code(404).send('Not found');
|
|
277
|
+
try {
|
|
278
|
+
if (doc instanceof Error) throw doc;
|
|
279
|
+
const { catalog, notes } = readCatalogText(doc.text, { baseUrl: req.body?.base_url, from: doc.from, name: c.name });
|
|
280
|
+
const problems = wholeCatalogProblems(catalog);
|
|
281
|
+
if (problems.length) throw new Error(problems.slice(0, 8).join(' '));
|
|
282
|
+
await owner.tx((db) => replaceCatalog(db, c.id, catalog));
|
|
283
|
+
s.state[NOTES] = JSON.stringify(notes);
|
|
284
|
+
await logActivity({ appId: null, username: s.username, event: 'rest_catalog', ip: clientIp(req), detail: `replaced catalog ${c.name} (${catalog.services.length} services)` });
|
|
285
|
+
flash(s, `The services of ${c.name} were replaced: ${catalog.services.length} service(s).`);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
flash(s, `Import failed: ${uniqueError(e)}`, 'error');
|
|
288
|
+
}
|
|
289
|
+
return back(reply, s, `${root}/${c.id}`);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
app.get(`${root}/:cid(^\\d+$)/download`, async (req: Req, reply) => {
|
|
293
|
+
const s = await developer(req, reply);
|
|
294
|
+
if (!s) return;
|
|
295
|
+
const c = await catalogOf(s, req.params.cid);
|
|
296
|
+
if (!c) return reply.code(404).send('Not found');
|
|
297
|
+
return reply
|
|
298
|
+
.type('application/json')
|
|
299
|
+
.header('content-disposition', `attachment; filename="${snake(c.name).replace(/_/g, '-')}.catalog.json"`)
|
|
300
|
+
.send(JSON.stringify(catalogDocument(c), null, 2));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
app.post(`${root}/:cid(^\\d+$)/delete`, async (req: Req, reply) => {
|
|
304
|
+
const s = await developer(req, reply);
|
|
305
|
+
if (!s) return;
|
|
306
|
+
const c = await catalogOf(s, req.params.cid);
|
|
307
|
+
if (!c) return reply.code(404).send('Not found');
|
|
308
|
+
await owner.query('delete from meta.rest_catalog where id = $1', [c.id]);
|
|
309
|
+
await logActivity({ appId: null, username: s.username, event: 'rest_catalog', ip: clientIp(req), detail: `deleted catalog ${c.name}` });
|
|
310
|
+
flash(s, `Catalog ${c.name} deleted.`);
|
|
311
|
+
return back(reply, s, root);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------- services
|
|
315
|
+
// a new service (no sid) or a changed one
|
|
316
|
+
const saveService = async (req: Req, reply: FastifyReply) => {
|
|
317
|
+
const s = await developer(req, reply);
|
|
318
|
+
if (!s) return;
|
|
319
|
+
const c = await catalogOf(s, req.params.cid);
|
|
320
|
+
if (!c) return reply.code(404).send('Not found');
|
|
321
|
+
const sid = req.params.sid ? Number(req.params.sid) : null;
|
|
322
|
+
if (sid && !c.services.some((x) => x.id === sid)) return reply.code(404).send('Not found');
|
|
323
|
+
const retry = `${root}/${c.id}?service=${sid ?? 'new'}#service`;
|
|
324
|
+
try {
|
|
325
|
+
const v = serviceFromBody(req.body ?? {});
|
|
326
|
+
const problems = serviceProblems(c.base_url, v);
|
|
327
|
+
if (problems.length) throw new Error(problems.join(' '));
|
|
328
|
+
const vals = [v.name, v.description, v.path, v.method, JSON.stringify(v.params), JSON.stringify(v.headers), v.body, v.row_selector, JSON.stringify(v.columns), v.key_columns, JSON.stringify(v.operations)];
|
|
329
|
+
let id = sid;
|
|
330
|
+
if (sid)
|
|
331
|
+
await owner.query(
|
|
332
|
+
`update meta.rest_catalog_service set name = $3, description = $4, path = $5, method = $6, params = $7, headers = $8, body = $9, row_selector = $10, columns = $11, key_columns = $12, operations = $13
|
|
333
|
+
where id = $1 and catalog_id = $2`, [sid, c.id, ...vals]);
|
|
334
|
+
else
|
|
335
|
+
id = (await owner.one(
|
|
336
|
+
`insert into meta.rest_catalog_service (catalog_id, name, description, path, method, params, headers, body, row_selector, columns, key_columns, operations, position)
|
|
337
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, (select coalesce(max(position), 0) + 10 from meta.rest_catalog_service where catalog_id = $1)) returning id`, [c.id, ...vals])).id;
|
|
338
|
+
await owner.query('update meta.rest_catalog set updated_at = now() where id = $1', [c.id]);
|
|
339
|
+
flash(s, `Service ${v.name} saved.`);
|
|
340
|
+
return back(reply, s, `${root}/${c.id}?service=${id}#service`);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
flash(s, uniqueError(e), 'error');
|
|
343
|
+
return back(reply, s, retry);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
app.post(`${root}/:cid(^\\d+$)/services`, saveService);
|
|
347
|
+
app.post(`${root}/:cid(^\\d+$)/services/:sid(^\\d+$)`, saveService);
|
|
348
|
+
|
|
349
|
+
app.post(`${root}/:cid(^\\d+$)/services/:sid(^\\d+$)/delete`, async (req: Req, reply) => {
|
|
350
|
+
const s = await developer(req, reply);
|
|
351
|
+
if (!s) return;
|
|
352
|
+
const c = await catalogOf(s, req.params.cid);
|
|
353
|
+
if (!c) return reply.code(404).send('Not found');
|
|
354
|
+
await owner.query('delete from meta.rest_catalog_service where id = $1 and catalog_id = $2', [req.params.sid, c.id]);
|
|
355
|
+
await owner.query('update meta.rest_catalog set updated_at = now() where id = $1', [c.id]);
|
|
356
|
+
flash(s, 'Service deleted.');
|
|
357
|
+
return back(reply, s, `${root}/${c.id}`);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------- an application's data source from a service
|
|
361
|
+
app.post(`${BASE}/apps/:id(^\\d+$)/rest-sources/from-catalog`, async (req: Req, reply) => {
|
|
362
|
+
const s = await developer(req, reply);
|
|
363
|
+
if (!s) return;
|
|
364
|
+
const appId = Number(req.params.id);
|
|
365
|
+
if (!(await owner.one('select 1 as ok from meta.app where id = $1', [appId]))) return reply.code(404).send('Not found');
|
|
366
|
+
const retry = `${BASE}/apps/${appId}/shared?new=rest_source`;
|
|
367
|
+
try {
|
|
368
|
+
// the service must belong to a catalog of the application's workspace
|
|
369
|
+
const svc = await owner.one<{ id: number; catalog_id: number }>(
|
|
370
|
+
`select x.id, x.catalog_id from meta.rest_catalog_service x join meta.rest_catalog c on c.id = x.catalog_id
|
|
371
|
+
where x.id = $1 and c.workspace_id = meta.app_workspace($2)`, [/^\d{1,9}$/.test(String(req.body?.service)) ? req.body!.service : null, appId]);
|
|
372
|
+
if (!svc) throw new Error('Choose a service of one of this workspace\'s catalogs.');
|
|
373
|
+
const c = (await loadCatalog(svc.catalog_id))!;
|
|
374
|
+
const v = sourceFromService(c, c.services.find((x) => x.id === svc.id)!, { name: req.body?.name, credential: req.body?.credential });
|
|
375
|
+
if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name)) throw new Error('The name is upper case letters, digits and _, starting with a letter, e.g. COUNTRIES.');
|
|
376
|
+
if (v.credential && !(await owner.one('select 1 as ok from meta.web_credential where app_id = $1 and name = $2', [appId, v.credential])))
|
|
377
|
+
throw new Error(`This application has no web credential ${v.credential}.`);
|
|
378
|
+
const problems = sourceProblems(v);
|
|
379
|
+
if (problems.length) throw new Error(problems.join(' '));
|
|
380
|
+
const r = await owner.one<{ id: number }>(
|
|
381
|
+
`insert into meta.rest_source (app_id, name, description, url, method, credential, params, headers, body, row_selector, columns, key_columns, operations)
|
|
382
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) returning id`,
|
|
383
|
+
[appId, v.name, v.description, v.url, v.method, v.credential, JSON.stringify(v.params), JSON.stringify(v.headers), v.body, v.row_selector, JSON.stringify(v.columns), v.key_columns, JSON.stringify(v.operations)]);
|
|
384
|
+
flash(s, `REST data source ${v.name} made from ${c.name}. Test it below${v.credential ? '' : '; add a web credential if the service needs one'}.`);
|
|
385
|
+
return back(reply, s, `${BASE}/apps/${appId}/shared?c=rest_source-${r!.id}`);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
const m = (e as Error).message;
|
|
388
|
+
flash(s, m.includes('rest_source_app_id_name_key') ? 'This application has a REST data source with that name already: choose another name.' : m, 'error');
|
|
389
|
+
return back(reply, s, retry);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function uniqueError(e: unknown) {
|
|
395
|
+
const m = (e as Error).message;
|
|
396
|
+
if (m.includes('rest_catalog_name_key')) return 'This workspace has a catalog with that name already.';
|
|
397
|
+
if (m.includes('rest_catalog_service_catalog_id_name_key')) return 'The catalog has a service with that name already.';
|
|
398
|
+
return m;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Shared Components → REST data sources → New: a data source from a service of
|
|
403
|
+
* one of the workspace's catalogs ('' when it has none).
|
|
404
|
+
*/
|
|
405
|
+
export async function catalogPicker(appId: number, s: Session): Promise<Raw | ''> {
|
|
406
|
+
const services = (await owner.query(
|
|
407
|
+
`select x.id, x.name, x.method, x.path, c.name as catalog from meta.rest_catalog_service x join meta.rest_catalog c on c.id = x.catalog_id
|
|
408
|
+
where c.workspace_id = meta.app_workspace($1) order by lower(c.name), x.position, x.name`, [appId])).rows;
|
|
409
|
+
if (!services.length)
|
|
410
|
+
return html`<p class="muted u-mt0 u-mb1">${icon('globe')} Services described once for the workspace: <a href="${root}">REST Source Catalogs</a>.</p>`;
|
|
411
|
+
const credentials = (await owner.query('select name from meta.web_credential where app_id = $1 order by name', [appId])).rows;
|
|
412
|
+
const groups = new Map<string, typeof services>();
|
|
413
|
+
for (const x of services) groups.set(x.catalog, [...(groups.get(x.catalog) ?? []), x]);
|
|
414
|
+
return region('From a REST source catalog', html`<form method="post" action="${BASE}/apps/${appId}/rest-sources/from-catalog">${csrf(s)}
|
|
415
|
+
<p class="muted u-mt0">A copy of a service of this workspace's <a href="${root}">catalogs</a>: its URL, parameters, columns and write-back operations.</p>
|
|
416
|
+
<div class="form-grid">
|
|
417
|
+
<div class="field" data-wide><label class="label" for="f_service">Service</label><select id="f_service" name="service" required>
|
|
418
|
+
${[...groups].map(([cat, xs]) => html`<optgroup label="${cat}">${xs.map((x) => html`<option value="${x.id}">${x.name} · ${x.method} ${x.path}</option>`)}</optgroup>`)}</select></div>
|
|
419
|
+
${input('name', 'Name', '', { placeholder: 'the service\'s name' })}
|
|
420
|
+
<div class="field"><label class="label" for="f_credential">Web credential</label><select id="f_credential" name="credential"><option value="">none</option>${credentials.map((c) => html`<option>${c.name}</option>`)}</select></div>
|
|
421
|
+
</div>
|
|
422
|
+
<div class="buttons"><button class="btn btn-hot">${icon('plus')} Create from the catalog</button></div></form>`);
|
|
423
|
+
}
|
|
424
|
+
|
package/src/builder/routes.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
|
|
|
24
24
|
import { checkNewApp, createApp, createAppError, startFromBoilerplate } from './newapp.ts';
|
|
25
25
|
import { appFromFileRoutes } from './appfromfile.ts';
|
|
26
26
|
import { clearDevbar } from '../runtime/devbar.ts';
|
|
27
|
+
import { EMAIL } from './find-workspace.ts';
|
|
27
28
|
|
|
28
29
|
// Builder pages: sign-in, workspace and app home, settings, activity and
|
|
29
30
|
// developers. Shared Components, the page designer and the SQL Workshop
|
|
@@ -79,6 +80,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
79
80
|
<button class="btn btn-hot signin-submit">Sign in</button>
|
|
80
81
|
<p class="muted small signin-hint">Leave the workspace empty to open your last one; you can switch later in your account menu.</p>
|
|
81
82
|
<nav class="signin-actions" aria-label="Quick actions">
|
|
83
|
+
<a href="${BASE}/find-workspace">${icon('search')} Find my workspace</a>
|
|
82
84
|
<a href="${BASE}/login?next=${encodeURIComponent(`${BASE}/utilities`)}">${icon('settings')} Administration</a>
|
|
83
85
|
<a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#workspaces" target="_blank" rel="noopener">${icon('help')} About workspaces</a>
|
|
84
86
|
</nav>
|
|
@@ -619,8 +621,9 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
619
621
|
app.get(`${BASE}/developers`, async (req: Req, reply) => {
|
|
620
622
|
const s = await developer(req, reply);
|
|
621
623
|
if (!s) return;
|
|
622
|
-
const devs = (await owner.query('select username, is_admin from meta.developer order by 1')).rows;
|
|
624
|
+
const devs = (await owner.query('select username, is_admin, email from meta.developer order by 1')).rows;
|
|
623
625
|
const admin = devs.some((d) => d.username === s.username && d.is_admin);
|
|
626
|
+
const me = devs.find((d) => d.username === s.username);
|
|
624
627
|
const main = html`<h1 class="u-mb1">Developers</h1>
|
|
625
628
|
<div class="columns">
|
|
626
629
|
${region('Change your password', html`<form method="post" action="${BASE}/developers/password">${csrf(s)}
|
|
@@ -629,17 +632,23 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
629
632
|
${input('password', 'New password', '', { type: 'password', required: true, auto: 'new-password', help: 'At least 8 characters.' })}
|
|
630
633
|
</div>
|
|
631
634
|
<div class="buttons"><button class="btn btn-hot">Change password</button></div></form>`)}
|
|
635
|
+
${region('Your e-mail address', html`<form method="post" action="${BASE}/developers/email">${csrf(s)}<input type="hidden" name="username" value="${s.username}">
|
|
636
|
+
<div class="form-grid">${input('email', 'E-mail address', me?.email ?? '', { type: 'email', auto: 'email', help: '"Find my workspace" on the sign-in page sends your workspaces to it. Empty: none.' })}</div>
|
|
637
|
+
<div class="buttons"><button class="btn">Save</button></div></form>`)}
|
|
632
638
|
${region('Developer accounts', html`
|
|
633
639
|
<p class="muted u-mt0">Administrators add and remove developers and can break other developers' page and application locks.</p>
|
|
634
|
-
<table class="report"><thead><tr><th>Username</th><th>Role</th><th></th></tr></thead><tbody>
|
|
635
|
-
${devs.map((d) => html`<tr><td>${d.username}${d.username === s.username ? ' (you)' : ''}</td><td>${d.is_admin ? 'Administrator' : 'Developer'}</td
|
|
640
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Username</th><th>Role</th><th>E-mail</th><th></th></tr></thead><tbody>
|
|
641
|
+
${devs.map((d) => html`<tr><td data-label="Username">${d.username}${d.username === s.username ? ' (you)' : ''}</td><td data-label="Role">${d.is_admin ? 'Administrator' : 'Developer'}</td>
|
|
642
|
+
<td data-label="E-mail">${admin && d.username !== s.username
|
|
643
|
+
? html`<form method="post" action="${BASE}/developers/email" class="search u-m0 u-mwnone">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input name="email" type="email" value="${d.email ?? ''}" aria-label="E-mail address of ${d.username}" placeholder="none"><button class="btn">Save</button></form>`
|
|
644
|
+
: html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td><td>${d.username === s.username || !admin ? '' : html`
|
|
636
645
|
<form method="post" action="${BASE}/developers/admin" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input type="hidden" name="is_admin" value="${d.is_admin ? 'false' : 'true'}"><button class="link-button">${d.is_admin ? 'Make developer' : 'Make administrator'}</button></form>
|
|
637
646
|
· <form method="post" action="${BASE}/developers/delete" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><button class="link-button" data-confirm="Remove developer ${d.username}?">Remove</button></form>`}</td></tr>`)}
|
|
638
|
-
</tbody></table>
|
|
647
|
+
</tbody></table></div>
|
|
639
648
|
${admin
|
|
640
649
|
? html`<h3>Add developer</h3>
|
|
641
650
|
<form method="post" action="${BASE}/developers">${csrf(s)}
|
|
642
|
-
<div class="form-grid">${input('username', 'Username', '', { required: true })}${input('password', 'Password', '', { type: 'password', required: true, auto: 'new-password' })}</div>
|
|
651
|
+
<div class="form-grid">${input('username', 'Username', '', { required: true })}${input('password', 'Password', '', { type: 'password', required: true, auto: 'new-password' })}${input('email', 'E-mail address', '', { type: 'email', help: 'Optional: for "Find my workspace".' })}</div>
|
|
643
652
|
<div class="field"><label class="check"><input type="checkbox" name="is_admin" value="true"> Administrator</label></div>
|
|
644
653
|
<div class="buttons"><button class="btn btn-hot">Add developer</button></div>
|
|
645
654
|
</form>`
|
|
@@ -657,7 +666,9 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
657
666
|
if (problem) throw new Error(problem);
|
|
658
667
|
const ws = currentWorkspace(s);
|
|
659
668
|
await owner.tx(async (c) => {
|
|
660
|
-
|
|
669
|
+
const email = String(req.body?.email ?? '').trim();
|
|
670
|
+
if (email && !EMAIL.test(email)) throw new Error('The e-mail address is not valid.');
|
|
671
|
+
await c.query('insert into meta.developer (username, password_hash, is_admin, email) values ($1, meta.hash_password($2), $3, $4)', [req.body?.username?.trim(), req.body?.password, req.body?.is_admin === 'true', email || null]);
|
|
661
672
|
// (064) a new developer works in the current workspace; more on Workspace utilities → Workspaces
|
|
662
673
|
// (the insert trigger put them in Default)
|
|
663
674
|
if (ws > 0 && ws !== 1) {
|
|
@@ -694,6 +705,21 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
694
705
|
return back(reply, s, `${BASE}/developers`);
|
|
695
706
|
});
|
|
696
707
|
|
|
708
|
+
// (096) a developer's own e-mail address; administrators set anyone's
|
|
709
|
+
app.post(`${BASE}/developers/email`, async (req: Req, reply) => {
|
|
710
|
+
const s = await developer(req, reply);
|
|
711
|
+
if (!s) return;
|
|
712
|
+
const who = String(req.body?.username ?? s.username);
|
|
713
|
+
if (who !== s.username && !(await isAdmin(s.username))) return reply.code(403).send('Only administrators change other developers\' addresses.');
|
|
714
|
+
const email = String(req.body?.email ?? '').trim();
|
|
715
|
+
if (email && (!EMAIL.test(email) || email.length > 254)) flash(s, 'The e-mail address is not valid.', 'error');
|
|
716
|
+
else {
|
|
717
|
+
const r = await owner.query('update meta.developer set email = $2 where username = $1', [who, email || null]);
|
|
718
|
+
flash(s, r.rowCount ? 'E-mail address saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
|
|
719
|
+
}
|
|
720
|
+
return back(reply, s, `${BASE}/developers`);
|
|
721
|
+
});
|
|
722
|
+
|
|
697
723
|
app.post(`${BASE}/developers/admin`, async (req: Req, reply) => {
|
|
698
724
|
const s = await developer(req, reply);
|
|
699
725
|
if (!s) return;
|
package/src/builder/shared.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { dataLoadDefExtras } from './dataload.ts';
|
|
|
16
16
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
17
17
|
import { endSessions, grantAccess, roleHints, roleHintsHtml, splitRoles } from './users.ts';
|
|
18
18
|
import { appOr404, componentForm, lookups, saveComponent } from './forms.ts';
|
|
19
|
+
import { catalogPicker } from './rest-catalogs.ts';
|
|
19
20
|
|
|
20
21
|
// Shared Components: navigation, authorization schemes, LOVs, application
|
|
21
22
|
// items and processes, report layouts, and access control.
|
|
@@ -67,6 +68,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
67
68
|
const prefill = newKind === 'list_entry' && req.query.list ? { list_name: String(req.query.list).toUpperCase() } : newKind === 'automation_action' && req.query.automation ? actionPrefill(String(req.query.automation), rows.automation_action) : {};
|
|
68
69
|
editor = region(`New ${spec.label.toLowerCase()}`, componentForm(spec, newKind, { seq: 10, ...spec.defaults, ...prefill }, lk, `${BASE}/apps/${a.id}/shared/${newKind}`, s, 'Create'));
|
|
69
70
|
if (newKind === 'template_component') editor = html`${editor}${templateImport(a.id, s)}`;
|
|
71
|
+
if (newKind === 'rest_source') editor = html`${await catalogPicker(a.id, s)}${editor}`;
|
|
70
72
|
} else if (selKind && SHARED.includes(selKind)) {
|
|
71
73
|
const spec = COMPONENTS[selKind];
|
|
72
74
|
const row = rows[selKind].find((r) => String(r.id) === selId);
|