pgkiln 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY.md +11 -9
- package/db/migrations/087_login_page.sql +175 -0
- package/db/migrations/088_region_templates.sql +18 -0
- package/db/migrations/089_page_groups.sql +330 -0
- package/db/migrations/090_workspace_isolation.sql +512 -0
- package/docs/apex-feature-parity.md +23 -21
- package/docs/guide/03-builder.md +85 -8
- package/docs/guide/04-pages-and-regions.md +42 -3
- package/docs/guide/08-security.md +46 -5
- package/docs/guide/11-from-apex.md +4 -1
- package/docs/guide/12-development.md +13 -3
- package/docs/guide/17-mobile.md +3 -1
- package/docs/guide/18-cli.md +2 -0
- package/docs/guide/20-ai-agents.md +2 -0
- package/examples/hr/hr_40_sample_data.sql +1 -1
- package/examples/hr/hr_54_region_templates.sql +67 -0
- package/examples/hr/hr_55_page_groups.sql +24 -0
- package/package.json +1 -1
- package/public/app.css +60 -3
- package/public/app.js +89 -3
- package/public/builder.css +11 -0
- package/public/builder.js +50 -0
- package/public/code-editor.css +3 -3
- package/public/sw.js +5 -3
- package/src/app.ts +20 -0
- package/src/appfiles.ts +1 -0
- package/src/automations.ts +3 -2
- package/src/builder/advisor.ts +25 -5
- package/src/builder/ai-builder.ts +27 -18
- package/src/builder/blueprints.ts +7 -7
- package/src/builder/code-editor.ts +3 -2
- package/src/builder/components.ts +15 -2
- package/src/builder/dataload.ts +24 -14
- package/src/builder/ddl-tools.ts +117 -0
- package/src/builder/designer.ts +29 -3
- package/src/builder/documents.ts +2 -1
- package/src/builder/forms.ts +6 -4
- package/src/builder/home.ts +6 -2
- package/src/builder/login-page.ts +82 -0
- package/src/builder/newapp.ts +21 -3
- package/src/builder/quicksql.ts +3 -2
- package/src/builder/region-help.ts +4 -2
- package/src/builder/region-settings.ts +19 -3
- package/src/builder/report-settings.ts +4 -2
- package/src/builder/routes.ts +18 -6
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +27 -16
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +18 -10
- package/src/builder/ui.ts +2 -2
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +175 -15
- package/src/cli/main.ts +45 -0
- package/src/cli/mcp.ts +42 -0
- package/src/cli/replace.ts +4 -0
- package/src/db.ts +36 -6
- package/src/ddl.ts +510 -0
- package/src/debug.ts +3 -2
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +3 -2
- package/src/metadata.ts +10 -4
- package/src/migrate.ts +3 -0
- package/src/process-jobs.ts +7 -2
- package/src/restsync.ts +3 -2
- package/src/runtime/devbar.ts +3 -1
- package/src/runtime/display-selector.ts +12 -4
- package/src/runtime/grid.ts +170 -26
- package/src/runtime/lists.ts +11 -1
- package/src/runtime/password-reset.ts +171 -0
- package/src/runtime/pwa.ts +7 -10
- package/src/runtime/regions.ts +33 -0
- package/src/runtime/render.ts +15 -0
- package/src/runtime/report-views.ts +4 -2
- package/src/runtime/report.ts +36 -22
- package/src/runtime/routes.ts +39 -33
- package/src/runtime/signin.ts +80 -0
- package/src/sampledata.ts +3 -3
- package/src/session.ts +5 -2
- package/src/static-version.ts +34 -0
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- package/src/workspace-db.ts +166 -0
|
@@ -9,6 +9,7 @@ import type { Session } from '../session.ts';
|
|
|
9
9
|
import { appOr404 } from './forms.ts';
|
|
10
10
|
import { isAdmin } from './locks.ts';
|
|
11
11
|
import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
12
|
+
import { workshopDb } from './workspaces.ts';
|
|
12
13
|
import { WIZARD_KINDS, wizardTables } from './wizards.ts';
|
|
13
14
|
|
|
14
15
|
// App Builder AI (migration 062), with the AI service an administrator
|
|
@@ -43,10 +44,20 @@ async function ask(s: Session, req: Parameters<typeof generate>[1]) {
|
|
|
43
44
|
return generate(svc.name, req, { appId: null, user: s.username, source: 'builder' });
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
/** Schemas a developer may describe or ask about (not pgkiln's, not the system's). */
|
|
47
|
-
async function schemas() {
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
/** Schemas a developer may describe or ask about (not pgkiln's, not the system's; in an isolated workspace, its own). */
|
|
48
|
+
async function schemas(s: Session) {
|
|
49
|
+
const db = await workshopDb(s);
|
|
50
|
+
return (await db.query<{ n: string }>(
|
|
51
|
+
`select nspname as n from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta')
|
|
52
|
+
${db === owner ? '' : `and has_schema_privilege(oid, 'USAGE')`} order by 1`)).rows.map((r) => r.n);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A table's descriptions as database comments. */
|
|
56
|
+
async function comment(c: { query: (sql: string) => Promise<unknown> }, t: TableInfo, notes: [string, string][]) {
|
|
57
|
+
const rel = `${pg.escapeIdentifier(t.schema)}.${pg.escapeIdentifier(t.table)}`;
|
|
58
|
+
const what = t.kind === 'view' ? 'view' : t.kind === 'materialized view' ? 'materialized view' : 'table';
|
|
59
|
+
for (const [col, note] of notes)
|
|
60
|
+
await c.query(col ? `comment on column ${rel}.${pg.escapeIdentifier(col)} is ${note ? pg.escapeLiteral(note) : 'null'}` : `comment on ${what} ${rel} is ${note ? pg.escapeLiteral(note) : 'null'}`);
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
interface TableInfo {
|
|
@@ -130,7 +141,7 @@ const SQL_SCHEMA = {
|
|
|
130
141
|
};
|
|
131
142
|
|
|
132
143
|
async function workshopPage(s: Session, reply: FastifyReply, out: { question?: string; schema?: string; sql?: string; error?: string; result?: Raw }) {
|
|
133
|
-
const list = await schemas();
|
|
144
|
+
const list = await schemas(s);
|
|
134
145
|
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ai')}
|
|
135
146
|
${await serviceBox(s)}
|
|
136
147
|
<p><a href="${BASE}/sql/ai/describe">Describe tables for AI ▸</a> <span class="muted">(better answers: say what tables and columns mean)</span></p>
|
|
@@ -162,7 +173,7 @@ async function workshopPage(s: Session, reply: FastifyReply, out: { question?: s
|
|
|
162
173
|
const isName = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 63;
|
|
163
174
|
|
|
164
175
|
async function describePage(s: Session, reply: FastifyReply, schema: string, table: string, draft: { table?: string; columns?: Record<string, string> } | null, message?: Raw) {
|
|
165
|
-
const list = await schemas();
|
|
176
|
+
const list = await schemas(s);
|
|
166
177
|
const tables = schema && list.includes(schema) ? await tableInfo({ schema }) : [];
|
|
167
178
|
const t = table ? tables.find((x) => x.table === table) : undefined;
|
|
168
179
|
const noted = new Set((await owner.query<{ t: string }>(`select distinct table_name as t from meta.ai_table_note where schema_name = $1`, [schema])).rows.map((r) => r.t));
|
|
@@ -299,7 +310,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
299
310
|
let result: Raw;
|
|
300
311
|
try {
|
|
301
312
|
if (!question) throw new AiError('config', 'Type a question.');
|
|
302
|
-
if (!(await schemas()).includes(schema)) throw new AiError('config', 'Choose a schema.');
|
|
313
|
+
if (!(await schemas(s)).includes(schema)) throw new AiError('config', 'Choose a schema.');
|
|
303
314
|
const res = await ask(s, {
|
|
304
315
|
system: `You write PostgreSQL ${(await owner.one<{ v: number }>(`select current_setting('server_version_num')::int / 10000 as v`))!.v} SQL for a developer, for these tables and views:\n\n${describeTables(await tableInfo({ schema }))}\n\n` +
|
|
305
316
|
'Use only those tables and columns, qualified with their schema. Write a SELECT unless the question clearly asks to change data. The developer reviews the SQL before anything runs.',
|
|
@@ -331,7 +342,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
331
342
|
let result: Raw;
|
|
332
343
|
try {
|
|
333
344
|
if (!sql.trim() && !error.trim()) throw new AiError('config', 'Paste a query, an error message or both.');
|
|
334
|
-
const tables = schema && (await schemas()).includes(schema) ? describeTables(await tableInfo({ schema })) : '';
|
|
345
|
+
const tables = schema && (await schemas(s)).includes(schema) ? describeTables(await tableInfo({ schema })) : '';
|
|
335
346
|
const res = await ask(s, {
|
|
336
347
|
system: 'You explain PostgreSQL to a developer: what a query does, why an error happens and how to fix it. Be concise; show corrected SQL when it helps.' +
|
|
337
348
|
(tables ? `\n\nThe tables and views of the schema:\n\n${tables}` : ''),
|
|
@@ -352,10 +363,10 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
352
363
|
});
|
|
353
364
|
|
|
354
365
|
/** The posted table, checked against the catalog (null: unknown or reserved). */
|
|
355
|
-
const postedTable = async (b: Record<string, any>) => {
|
|
366
|
+
const postedTable = async (s: Session, b: Record<string, any>) => {
|
|
356
367
|
const schema = String(b.schema ?? '');
|
|
357
368
|
const table = String(b.table ?? '');
|
|
358
|
-
if (!isName(schema) || !isName(table) || RESERVED.test(schema)) return null;
|
|
369
|
+
if (!isName(schema) || !isName(table) || RESERVED.test(schema) || !(await schemas(s)).includes(schema)) return null;
|
|
359
370
|
return (await tableInfo({ schema, table }))[0] ?? null;
|
|
360
371
|
};
|
|
361
372
|
|
|
@@ -363,11 +374,14 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
363
374
|
const s = await developer(req, reply);
|
|
364
375
|
if (!s) return;
|
|
365
376
|
const b = (req.body ?? {}) as Record<string, any>;
|
|
366
|
-
const t = await postedTable(b);
|
|
377
|
+
const t = await postedTable(s, b);
|
|
367
378
|
if (!t) return reply.code(404).send('Not found');
|
|
368
379
|
const notes: [string, string][] = [['', String(b['note:'] ?? '')], ...t.columns.map((c): [string, string] => [c.name, String(b[`note:${c.name}`] ?? '')])]
|
|
369
380
|
.map(([k, v]) => [k, v.replace(/\s+/g, ' ').trim().slice(0, 2000)]);
|
|
370
381
|
try {
|
|
382
|
+
// database comments as the developer's SQL connection (an isolated workspace: its own login), then the notes
|
|
383
|
+
const db = await workshopDb(s);
|
|
384
|
+
if (b.comments === 'true' && db !== owner) await db.tx((c) => comment(c, t, notes));
|
|
371
385
|
await owner.tx(async (c) => {
|
|
372
386
|
for (const [col, note] of notes) {
|
|
373
387
|
if (note) await c.query(
|
|
@@ -376,12 +390,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
376
390
|
[t.schema, t.table, col, note, s.username]);
|
|
377
391
|
else await c.query('delete from meta.ai_table_note where schema_name = $1 and table_name = $2 and column_name = $3', [t.schema, t.table, col]);
|
|
378
392
|
}
|
|
379
|
-
if (b.comments === 'true')
|
|
380
|
-
const rel = `${pg.escapeIdentifier(t.schema)}.${pg.escapeIdentifier(t.table)}`;
|
|
381
|
-
const what = t.kind === 'view' ? 'view' : t.kind === 'materialized view' ? 'materialized view' : 'table';
|
|
382
|
-
for (const [col, note] of notes)
|
|
383
|
-
await c.query(col ? `comment on column ${rel}.${pg.escapeIdentifier(col)} is ${note ? pg.escapeLiteral(note) : 'null'}` : `comment on ${what} ${rel} is ${note ? pg.escapeLiteral(note) : 'null'}`);
|
|
384
|
-
}
|
|
393
|
+
if (b.comments === 'true' && db === owner) await comment(c, t, notes);
|
|
385
394
|
});
|
|
386
395
|
flash(s, `Descriptions of ${t.schema}.${t.table} saved${b.comments === 'true' ? ', also as database comments' : ''}.`);
|
|
387
396
|
} catch (e) {
|
|
@@ -394,7 +403,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
394
403
|
const s = await developer(req, reply);
|
|
395
404
|
if (!s) return;
|
|
396
405
|
const b = (req.body ?? {}) as Record<string, any>;
|
|
397
|
-
const t = await postedTable(b);
|
|
406
|
+
const t = await postedTable(s, b);
|
|
398
407
|
if (!t) return reply.code(404).send('Not found');
|
|
399
408
|
const names = t.columns.map((c) => c.name);
|
|
400
409
|
try {
|
|
@@ -133,7 +133,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
133
133
|
app.get(`${BASE}/blueprints`, async (req: Req, reply) => {
|
|
134
134
|
const s = await developer(req, reply);
|
|
135
135
|
if (!s) return;
|
|
136
|
-
const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id order by lower(b.name), b.id
|
|
136
|
+
const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id where b.workspace_id = $1 order by lower(b.name), b.id`, [currentWorkspace(s)])).rows;
|
|
137
137
|
return page(s, reply, '', html`<h1>Blueprints</h1>
|
|
138
138
|
<p class="muted">Describe an application as a blueprint (tables, pages, menu, sample rows), review it and create it. <a class="btn btn-hot" href="${BASE}/blueprints/new">${icon('plus')} New blueprint</a></p>
|
|
139
139
|
${list.length
|
|
@@ -154,7 +154,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
154
154
|
app.get(`${BASE}/blueprints/:id(^\\d+$)`, async (req: Req, reply) => {
|
|
155
155
|
const s = await developer(req, reply);
|
|
156
156
|
if (!s) return;
|
|
157
|
-
const b = await owner.one('select id, name, spec from meta.blueprint where id = $1', [req.params.id]);
|
|
157
|
+
const b = await owner.one('select id, name, spec from meta.blueprint where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]);
|
|
158
158
|
if (!b) return reply.code(404).send('Not found');
|
|
159
159
|
return editor(s, reply, { id: b.id, name: b.name, spec: JSON.stringify(b.spec, null, 2) });
|
|
160
160
|
});
|
|
@@ -178,8 +178,8 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
178
178
|
if (error || !json || typeof json !== 'object' || Array.isArray(json))
|
|
179
179
|
return editor(s, reply, { ...o, message: html`<div class="alert alert-error" role="alert">${error ?? 'A blueprint is a JSON object.'}</div>` });
|
|
180
180
|
const id = o.id
|
|
181
|
-
? (await owner.one('update meta.blueprint set name = $2, spec = $3, updated_at = now() where id = $1 returning id', [o.id, o.name, JSON.stringify(json)]))?.id
|
|
182
|
-
: (await owner.one('insert into meta.blueprint (name, spec, created_by) values ($1, $2, $3) returning id', [o.name, JSON.stringify(json), s.username])).id;
|
|
181
|
+
? (await owner.one('update meta.blueprint set name = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4 returning id', [o.id, o.name, JSON.stringify(json), currentWorkspace(s)]))?.id
|
|
182
|
+
: (await owner.one('insert into meta.blueprint (name, spec, created_by, workspace_id) values ($1, $2, $3, $4) returning id', [o.name, JSON.stringify(json), s.username, currentWorkspace(s)])).id;
|
|
183
183
|
if (!id) return reply.code(404).send('Not found');
|
|
184
184
|
flash(s, `Blueprint ${o.name} saved.`);
|
|
185
185
|
return back(reply, s, `${BASE}/blueprints/${id}`);
|
|
@@ -240,8 +240,8 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
240
240
|
throw new Error(createAppError(e, checked.alias));
|
|
241
241
|
});
|
|
242
242
|
const r = await buildBlueprint(c, blueprint, checked.role, { dashboard: (tables, no) => addDashboard(c, checked.alias, tables, no) });
|
|
243
|
-
if (o.id) await c.query('update meta.blueprint set app_id = $2, spec = $3, updated_at = now() where id = $1', [o.id, a.id, JSON.stringify(json)]);
|
|
244
|
-
else await c.query('insert into meta.blueprint (name, spec, created_by, app_id) values ($1, $2, $3, $4)', [o.name, JSON.stringify(json), s.username, a.id]);
|
|
243
|
+
if (o.id) await c.query('update meta.blueprint set app_id = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4', [o.id, a.id, JSON.stringify(json), currentWorkspace(s)]);
|
|
244
|
+
else await c.query('insert into meta.blueprint (name, spec, created_by, app_id, workspace_id) values ($1, $2, $3, $4, $5)', [o.name, JSON.stringify(json), s.username, a.id, currentWorkspace(s)]);
|
|
245
245
|
return { ...r, existing: a.existingAccount };
|
|
246
246
|
});
|
|
247
247
|
await logActivity({ username: s.username, event: 'blueprint', ip: clientIp(req), detail: `created ${blueprint.alias} from blueprint ${o.name}` });
|
|
@@ -255,7 +255,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
255
255
|
app.post(`${BASE}/blueprints/:id(^\\d+$)/delete`, async (req: Req, reply) => {
|
|
256
256
|
const s = await developer(req, reply);
|
|
257
257
|
if (!s) return;
|
|
258
|
-
const r = await owner.query('delete from meta.blueprint where id = $1', [req.params.id]);
|
|
258
|
+
const r = await owner.query('delete from meta.blueprint where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]);
|
|
259
259
|
flash(s, r.rowCount ? 'Blueprint deleted (applications created from it stay).' : 'There is no such blueprint.');
|
|
260
260
|
return back(reply, s, `${BASE}/blueprints`);
|
|
261
261
|
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import pg from 'pg';
|
|
3
3
|
import { owner, runtime } from '../db.ts';
|
|
4
|
+
import { devDbForApp } from '../workspace-db.ts';
|
|
4
5
|
import { html, raw, type Raw } from '../html.ts';
|
|
5
6
|
import { checkSql, type SqlShape } from './advisor.ts';
|
|
6
7
|
import { BASE, developer, type Req } from './ui.ts';
|
|
7
|
-
import { appAllowed } from './workspaces.ts';
|
|
8
|
+
import { appAllowed, workshopDb } from './workspaces.ts';
|
|
8
9
|
|
|
9
10
|
// The builder's code editor (public/code-editor.js) and its SQL completions.
|
|
10
11
|
//
|
|
@@ -215,7 +216,7 @@ export async function codeEditorRoutes(app: FastifyInstance) {
|
|
|
215
216
|
if (!sql.trim()) return reply.send({ ok: true, message: 'Nothing to check.' });
|
|
216
217
|
if (/^\s*(STATIC|LOV):/i.test(sql)) return reply.send({ ok: true, message: 'Not SQL: a static or shared list of values.' });
|
|
217
218
|
const role = target ? (await owner.one('select db_role from meta.app where id = $1', [target.id]))?.db_role : null;
|
|
218
|
-
const c = await
|
|
219
|
+
const c = await (target ? await devDbForApp(target.id) : await workshopDb(s)).pool.connect();
|
|
219
220
|
try {
|
|
220
221
|
await c.query('begin');
|
|
221
222
|
await c.query(`set local statement_timeout = '5s'`);
|
|
@@ -104,7 +104,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
104
104
|
{ name: 'title', label: 'Title', kind: 'text', group: 'Identification' },
|
|
105
105
|
{ name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
|
|
106
106
|
help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
|
|
107
|
-
{ name: 'type', label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
|
|
107
|
+
{ name: 'type', label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
|
|
108
108
|
{ name: 'source', label: 'Source', kind: 'code', wide: true, group: 'Source',
|
|
109
109
|
help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
|
|
110
110
|
{ name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
|
|
@@ -114,7 +114,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
114
114
|
{ name: 'pk_item', label: 'Primary key item (form)', kind: 'upper', group: 'Source' },
|
|
115
115
|
{ name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
|
|
116
116
|
{ name: 'columns', label: 'Column span (1-12)', kind: 'int', group: 'Layout' },
|
|
117
|
-
{ name: 'template', label: 'Template', kind: 'select', options: ['standard', 'plain', 'collapsible'], group: 'Layout' },
|
|
117
|
+
{ name: 'template', label: 'Template', kind: 'select', options: ['standard', 'plain', 'collapsible', 'hero', 'title_bar', 'buttons_container', 'inline_dialog', 'inline_drawer', 'wizard'], group: 'Layout', help: 'hero: a large banner (title, text, {"icon":"…"} in the attributes, buttons). title_bar: title and buttons on one line. buttons_container: the buttons only. inline_dialog, inline_drawer: hidden until a dynamic action Open region (or a link to #R<id>) opens it over the page. wizard: a narrow card for one step (a list region with template wizard_progress shows the steps).' },
|
|
118
118
|
{ name: 'template_options', label: 'Template options', kind: 'options', choices: REGION_OPTIONS, group: 'Appearance',
|
|
119
119
|
help: 'CSS classes from a fixed list, added to the region (APEX: Template Options). "Hide the header" applies to the standard template.' },
|
|
120
120
|
{ name: 'condition', label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'Boolean expression; the region renders only when true.' },
|
|
@@ -448,6 +448,19 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
448
448
|
buildOption(),
|
|
449
449
|
],
|
|
450
450
|
},
|
|
451
|
+
page_group: {
|
|
452
|
+
table: 'meta.page_group',
|
|
453
|
+
scope: 'app',
|
|
454
|
+
label: 'Page group',
|
|
455
|
+
plural: 'Page groups',
|
|
456
|
+
icon: 'layers',
|
|
457
|
+
summary: (o) => o.name,
|
|
458
|
+
defaults: {},
|
|
459
|
+
fields: [
|
|
460
|
+
{ name: 'name', label: 'Name', kind: 'text', help: 'e.g. Administration. Pages join it in their Page group property; renaming the group renames it on its pages.' },
|
|
461
|
+
{ name: 'description', label: 'Description', kind: 'text', wide: true },
|
|
462
|
+
],
|
|
463
|
+
},
|
|
451
464
|
build_option: {
|
|
452
465
|
table: 'meta.build_option',
|
|
453
466
|
scope: 'app',
|
package/src/builder/dataload.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { readMultipart } from '../runtime/files.ts';
|
|
|
26
26
|
import type { Session } from '../session.ts';
|
|
27
27
|
import type { ComponentSpec } from './components.ts';
|
|
28
28
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
29
|
+
import { appWorkspace, currentWorkspace, workshopDb } from './workspaces.ts';
|
|
29
30
|
|
|
30
31
|
// SQL Workshop → Load Data: upload a CSV/TSV/XLSX/JSON/XML file, preview it,
|
|
31
32
|
// and load it into a new table (column types inferred) or an existing one
|
|
@@ -73,17 +74,23 @@ export const DATA_LOAD_DEF_SPEC: ComponentSpec = {
|
|
|
73
74
|
export const dataLoadDefExtras = (row: { id: number }) =>
|
|
74
75
|
html`<p class="u-mt1"><a class="btn" href="${BASE}/sql/load?definition=${row.id}">${icon('upload')} Load a file with this definition</a></p>`;
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
/** The data load definitions of the current workspace's applications. */
|
|
78
|
+
async function definitions(s: Session) {
|
|
77
79
|
return (
|
|
78
80
|
await owner.query<{ id: number; app_id: number; app_name: string; name: string; table_name: string }>(
|
|
79
|
-
|
|
81
|
+
`select d.id, d.app_id, a.name as app_name, d.name, d.table_name from meta.data_load_def d join meta.app a on a.id = d.app_id
|
|
82
|
+
where meta.app_workspace(a.id) = $1 order by a.name, d.name`,
|
|
83
|
+
[currentWorkspace(s)],
|
|
80
84
|
)
|
|
81
85
|
).rows;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
async function definition(id: unknown) {
|
|
88
|
+
async function definition(s: Session, id: unknown) {
|
|
85
89
|
if (!/^\d+$/.test(String(id ?? ''))) return undefined;
|
|
86
|
-
return owner.one<DataLoadDefinition & { id: number; app_id: number }>(
|
|
90
|
+
return owner.one<DataLoadDefinition & { id: number; app_id: number }>(
|
|
91
|
+
'select * from meta.data_load_def where id = $1 and meta.app_workspace(app_id) = $2',
|
|
92
|
+
[id, currentWorkspace(s)],
|
|
93
|
+
);
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
export const MAX_MB = Number(process.env.DATA_LOAD_MAX_MB ?? 50);
|
|
@@ -142,7 +149,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
142
149
|
});
|
|
143
150
|
|
|
144
151
|
const uploadForm = async (s: Session, error?: string, chosen?: string) => {
|
|
145
|
-
const defs = await definitions();
|
|
152
|
+
const defs = await definitions(s);
|
|
146
153
|
return region(
|
|
147
154
|
'Load data from a file',
|
|
148
155
|
html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
@@ -180,7 +187,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
180
187
|
if (!s) return;
|
|
181
188
|
if (!file) return page(s, reply, await uploadForm(s, 'Choose a file.'));
|
|
182
189
|
if (file.truncated) return page(s, reply, await uploadForm(s, `The file is larger than ${MAX_MB} MB.`));
|
|
183
|
-
const def = await definition(req.body?.definition);
|
|
190
|
+
const def = await definition(s, req.body?.definition);
|
|
184
191
|
const rowTag = (req.body?.row_tag ?? '').trim();
|
|
185
192
|
try {
|
|
186
193
|
if (def) await parseFile(file.filename, file.data, { headers: def.headers, format: def.format, rowTag: def.row_tag });
|
|
@@ -203,7 +210,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
203
210
|
if (!f) return reply.redirect(`${BASE}/sql/load`);
|
|
204
211
|
const headers = (req.query.h ?? req.body?.h) !== '0';
|
|
205
212
|
const rowTag = (req.query.rt ?? req.body?.rt ?? '').trim();
|
|
206
|
-
const def = await definition(req.query.d ?? req.body?.d);
|
|
213
|
+
const def = await definition(s, req.query.d ?? req.body?.d);
|
|
207
214
|
let sheet: Sheet;
|
|
208
215
|
try {
|
|
209
216
|
sheet = def
|
|
@@ -256,17 +263,19 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
256
263
|
${skip}
|
|
257
264
|
<div class="buttons"><button class="btn btn-hot">Create table and load</button></div></form>`;
|
|
258
265
|
|
|
266
|
+
const db = await workshopDb(s);
|
|
259
267
|
const tables = (
|
|
260
|
-
await
|
|
268
|
+
await db.query(
|
|
261
269
|
`select c.oid::regclass::text as name from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
262
|
-
where c.relkind in ('r', 'p') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')
|
|
270
|
+
where c.relkind in ('r', 'p') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')
|
|
271
|
+
and has_table_privilege(c.oid, 'INSERT') order by 1`,
|
|
263
272
|
)
|
|
264
273
|
).rows.map((r) => r.name as string);
|
|
265
274
|
const chosen = tables.find((t) => t === (req.query.table ?? req.body?.table));
|
|
266
|
-
const apps = (await owner.query<{ id: number; name: string }>('select id, name from meta.app order by name')).rows;
|
|
275
|
+
const apps = (await owner.query<{ id: number; name: string }>('select id, name from meta.app where meta.app_workspace(id) = $1 order by name', [currentWorkspace(s)])).rows;
|
|
267
276
|
let mapping: Raw | '' = '';
|
|
268
277
|
if (chosen) {
|
|
269
|
-
const cols = await
|
|
278
|
+
const cols = await db.tx((c) => tableColumns(c, chosen));
|
|
270
279
|
const auto = autoMap(sheet.headers, cols);
|
|
271
280
|
const hasPk = cols.some((c) => c.pk);
|
|
272
281
|
mapping = html`<form method="post" action="${action}">${hidden}<input type="hidden" name="target" value="existing"><input type="hidden" name="table" value="${chosen}">
|
|
@@ -335,6 +344,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
335
344
|
const mode = ['append', 'replace', 'merge'].includes(b.mode ?? '') ? b.mode : 'append';
|
|
336
345
|
const format = sheet instanceof Error ? 'auto' : sheet.format;
|
|
337
346
|
try {
|
|
347
|
+
if (!/^\d{1,9}$/.test(String(b.def_app ?? '')) || (await appWorkspace(String(b.def_app))) !== currentWorkspace(s)) throw new LoadError('Choose an application of this workspace.');
|
|
338
348
|
const r = await owner.one(
|
|
339
349
|
`insert into meta.data_load_def (app_id, name, table_name, format, headers, row_tag, mode, skip_errors, columns)
|
|
340
350
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id, app_id`,
|
|
@@ -344,7 +354,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
344
354
|
return back(reply, s, `${BASE}/apps/${r!.app_id}/shared?c=data_load_def-${r!.id}`);
|
|
345
355
|
} catch (e) {
|
|
346
356
|
const code = (e as { code?: string }).code;
|
|
347
|
-
if (!code?.startsWith('23') && code !== '22P02') throw e;
|
|
357
|
+
if (!(e instanceof LoadError) && !code?.startsWith('23') && code !== '22P02') throw e;
|
|
348
358
|
reply.code(422);
|
|
349
359
|
return targetPage(req, reply, s, html`<div class="alert alert-error" role="alert">The definition could not be saved: ${
|
|
350
360
|
code === '23505' ? `the application already has a definition named ${name}.` : (e as Error).message}</div>`);
|
|
@@ -354,10 +364,10 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
354
364
|
let error: string | undefined;
|
|
355
365
|
let failed = false;
|
|
356
366
|
try {
|
|
357
|
-
const def = b.target === 'definition' ? await definition(b.d) : undefined;
|
|
367
|
+
const def = b.target === 'definition' ? await definition(s, b.d) : undefined;
|
|
358
368
|
const sheet = def ? null : await parseFile(f.filename, f.content, { headers, rowTag });
|
|
359
369
|
const opts = { skipErrors: b.skip_errors === 'true', firstRow: headers && sheet && (sheet.format === 'csv' || sheet.format === 'xlsx') ? 2 : 1 };
|
|
360
|
-
result = await
|
|
370
|
+
result = await (await workshopDb(s)).tx(async (c) => {
|
|
361
371
|
if (def) return loadWithDefinition(c, def, f);
|
|
362
372
|
if (!sheet) throw new LoadError('The data load definition no longer exists.');
|
|
363
373
|
if (b.target === 'new') {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { unifiedDiff } from '../cli/diff.ts';
|
|
3
|
+
import { owner } from '../db.ts';
|
|
4
|
+
import { changeScript, compare, ddlScript, OBJECT_TYPES, pick, readSchema, userSchemas, type Difference } from '../ddl.ts';
|
|
5
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
6
|
+
import { icon } from '../icons.ts';
|
|
7
|
+
import { clientIp, logActivity } from '../session.ts';
|
|
8
|
+
import { BASE, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
9
|
+
import { workshopDb } from './workspaces.ts';
|
|
10
|
+
|
|
11
|
+
// SQL Workshop → Generate DDL and Schema Comparison (APEX: SQL Workshop →
|
|
12
|
+
// Utilities). Both only read the catalog (src/ddl.ts); the scripts they
|
|
13
|
+
// write are shown and downloaded, never run.
|
|
14
|
+
|
|
15
|
+
const TYPE_LABELS: Record<(typeof OBJECT_TYPES)[number], string> = {
|
|
16
|
+
types: 'Types and domains', sequences: 'Sequences', tables: 'Tables (constraints, indexes)', views: 'Views and materialized views',
|
|
17
|
+
functions: 'Functions and procedures', triggers: 'Triggers', policies: 'Row level security and policies',
|
|
18
|
+
};
|
|
19
|
+
const STATE_LABELS: Record<Difference['state'], string> = { source: 'Only in the source', target: 'Only in the target', different: 'Different' };
|
|
20
|
+
|
|
21
|
+
const many = (v: unknown) => ([] as string[]).concat((v as string | string[] | undefined) ?? []);
|
|
22
|
+
const schemaSelect = (name: string, label: string, schemas: string[], current: string) =>
|
|
23
|
+
html`<div class="field"><label class="label" for="f_${name}">${label}</label>
|
|
24
|
+
<select id="f_${name}" name="${name}" required><option value="">- choose a schema -</option>${schemas.map((x) => html`<option value="${x}"${x === current ? raw(' selected') : ''}>${x}</option>`)}</select></div>`;
|
|
25
|
+
|
|
26
|
+
/** A unified diff with added and removed lines marked. */
|
|
27
|
+
const diffBlock = (d: Difference, source: string, target: string) => {
|
|
28
|
+
const text = unifiedDiff(d.name, Buffer.from(`${d.target ?? ''}\n`), Buffer.from(`${d.source ?? ''}\n`), [target, source]);
|
|
29
|
+
return html`<pre class="diff">${text.trimEnd().split('\n').map((l, i) => html`${i ? '\n' : ''}<span class="${l.startsWith('+') && !l.startsWith('+++') ? 'diff-add' : l.startsWith('-') && !l.startsWith('---') ? 'diff-del' : null}">${l}</span>`)}</pre>`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function ddlToolRoutes(app: FastifyInstance) {
|
|
33
|
+
app.get(`${BASE}/sql/ddl`, async (req: Req, reply) => {
|
|
34
|
+
const s = await developer(req, reply);
|
|
35
|
+
if (!s) return;
|
|
36
|
+
const db = await workshopDb(s);
|
|
37
|
+
const schemas = await userSchemas(db, { usable: db !== owner });
|
|
38
|
+
const q = req.query ?? {};
|
|
39
|
+
const schema = schemas.includes(q.schema ?? '') ? q.schema! : '';
|
|
40
|
+
// the first visit: every type, comments and grants
|
|
41
|
+
const fresh = !q.schema;
|
|
42
|
+
const types = new Set(fresh ? OBJECT_TYPES : many(q.t).filter((x) => (OBJECT_TYPES as readonly string[]).includes(x)));
|
|
43
|
+
const opts = { comments: fresh || q.comments === '1', grants: fresh || q.grants === '1' };
|
|
44
|
+
let result: Raw | '' = '';
|
|
45
|
+
if (schema) {
|
|
46
|
+
try {
|
|
47
|
+
const objects = pick(await readSchema(db.pool, schema), types, opts);
|
|
48
|
+
const script = ddlScript(schema, objects);
|
|
49
|
+
await logActivity({ username: s.username, event: 'generate_ddl', ip: clientIp(req), detail: `${schema}: ${objects.length} objects` });
|
|
50
|
+
if (q.download === '1')
|
|
51
|
+
return reply.header('content-disposition', `attachment; filename="${schema}.sql"`).type('application/sql; charset=utf-8').send(script);
|
|
52
|
+
const qs = new URLSearchParams([['schema', schema], ...[...types].map((x) => ['t', x]), ...(opts.comments ? [['comments', '1']] : []), ...(opts.grants ? [['grants', '1']] : []), ['download', '1']]);
|
|
53
|
+
result = region(`DDL of ${schema}`, html`<p class="muted">${objects.length} object(s). Review the script before you run it (SQL Workshop → SQL Scripts).</p>
|
|
54
|
+
<textarea class="code" rows="24" spellcheck="false" readonly aria-label="DDL of ${schema}" data-code="plpgsql">${script}</textarea>`,
|
|
55
|
+
html`<a class="btn" href="${BASE}/sql/ddl?${qs.toString()}">${icon('download')} Download ${schema}.sql</a>`);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
result = html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ddl')}
|
|
61
|
+
<p class="muted">The DDL of a schema's objects, from the database catalog, in an order that runs: types, sequences, tables, functions, views, constraints, indexes, triggers, row level security, comments and grants (APEX: Generate DDL). Objects of extensions are left out.</p>
|
|
62
|
+
<form method="get" class="u-mb1">
|
|
63
|
+
<div class="form-grid">${schemaSelect('schema', 'Schema', schemas, schema)}</div>
|
|
64
|
+
<fieldset class="prop-group"><legend>Objects</legend><div class="form-grid">
|
|
65
|
+
${OBJECT_TYPES.map((x) => html`<label class="check"><input type="checkbox" name="t" value="${x}"${types.has(x) ? raw(' checked') : ''}> ${TYPE_LABELS[x]}</label>`)}
|
|
66
|
+
<label class="check"><input type="checkbox" name="comments" value="1"${opts.comments ? raw(' checked') : ''}> Comments</label>
|
|
67
|
+
<label class="check"><input type="checkbox" name="grants" value="1"${opts.grants ? raw(' checked') : ''}> Grants</label>
|
|
68
|
+
</div></fieldset>
|
|
69
|
+
<div class="buttons"><button class="btn btn-hot">${icon('code')} Generate</button></div>
|
|
70
|
+
</form>
|
|
71
|
+
${result}`;
|
|
72
|
+
return send(reply, s, shell(s, 'Generate DDL', [['SQL Workshop', `${BASE}/sql`], ['Generate DDL']], main, 'sql'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
app.get(`${BASE}/sql/compare`, async (req: Req, reply) => {
|
|
76
|
+
const s = await developer(req, reply);
|
|
77
|
+
if (!s) return;
|
|
78
|
+
const db = await workshopDb(s);
|
|
79
|
+
const schemas = await userSchemas(db, { usable: db !== owner });
|
|
80
|
+
const q = req.query ?? {};
|
|
81
|
+
const source = schemas.includes(q.source ?? '') ? q.source! : '';
|
|
82
|
+
const target = schemas.includes(q.target ?? '') ? q.target! : '';
|
|
83
|
+
let result: Raw | '' = '';
|
|
84
|
+
if (source && target && source === target) result = html`<div class="alert alert-error" role="alert">Choose two different schemas.</div>`;
|
|
85
|
+
else if (source && target) {
|
|
86
|
+
try {
|
|
87
|
+
const [a, b] = await Promise.all([readSchema(db.pool, source), readSchema(db.pool, target)]);
|
|
88
|
+
const { differences, same } = compare(a, b);
|
|
89
|
+
const script = changeScript(source, target, a, b);
|
|
90
|
+
await logActivity({ username: s.username, event: 'schema_compare', ip: clientIp(req), detail: `${source} → ${target}: ${differences.length} differences` });
|
|
91
|
+
if (q.download === '1')
|
|
92
|
+
return reply.header('content-disposition', `attachment; filename="${target}-like-${source}.sql"`).type('application/sql; charset=utf-8').send(script);
|
|
93
|
+
const groups = (['source', 'target', 'different'] as const).map((st) => [st, differences.filter((d) => d.state === st)] as const).filter(([, list]) => list.length);
|
|
94
|
+
result = html`${region('Summary', html`<p>${differences.length
|
|
95
|
+
? `${differences.length} difference(s): ${groups.map(([st, list]) => `${list.length} ${STATE_LABELS[st].toLowerCase()}`).join(', ')}; ${same} object(s) the same.`
|
|
96
|
+
: `The schemas are the same (${same} objects).`}</p>`)}
|
|
97
|
+
${groups.map(([st, list]) => html`<div class="u-spacer"></div>${region(`${STATE_LABELS[st]} (${st === 'source' ? source : st === 'target' ? target : `${source} and ${target}`})`,
|
|
98
|
+
html`<ul class="compare-list">${list.map((d) => html`<li><details><summary><span class="tag">${d.kind}</span> ${d.name}</summary>
|
|
99
|
+
${st === 'different' ? diffBlock(d, source, target) : html`<pre class="diff">${d.source ?? d.target}</pre>`}</details></li>`)}</ul>`)}`)}
|
|
100
|
+
<div class="u-spacer"></div>
|
|
101
|
+
${region(`Change script: make ${target} like ${source}`, html`<p class="muted">Creates what only ${source} has, changes what differs, and lists what only ${target} has as commented-out drops; columns that change identity or generation, and types, domains and sequences that differ, are left for review. Nothing is run here.</p>
|
|
102
|
+
<textarea class="code" rows="20" spellcheck="false" readonly aria-label="Change script" data-code="plpgsql">${script}</textarea>`,
|
|
103
|
+
html`<a class="btn" href="${BASE}/sql/compare?${new URLSearchParams({ source, target, download: '1' }).toString()}">${icon('download')} Download</a>`)}`;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
result = html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('compare')}
|
|
109
|
+
<p class="muted">Compares the objects of two schemas of this database by their DDL (APEX: Schema Comparison), e.g. a development copy with the schema it came from, and writes a script that makes the target like the source. The order of a table's columns is not compared.</p>
|
|
110
|
+
<form method="get" class="u-mb1">
|
|
111
|
+
<div class="form-grid">${schemaSelect('source', 'Source', schemas, source)}${schemaSelect('target', 'Target (to make like the source)', schemas, target)}</div>
|
|
112
|
+
<div class="buttons"><button class="btn btn-hot">${icon('layers')} Compare</button></div>
|
|
113
|
+
</form>
|
|
114
|
+
${result}`;
|
|
115
|
+
return send(reply, s, shell(s, 'Schema Comparison', [['SQL Workshop', `${BASE}/sql`], ['Schema Comparison']], main, 'sql'));
|
|
116
|
+
});
|
|
117
|
+
}
|
package/src/builder/designer.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { icon } from '../icons.ts';
|
|
|
5
5
|
import { COMPONENTS } from './components.ts';
|
|
6
6
|
import { back, BASE, bicon, csrf, developer, flash, input, select, send, shell, type Req } from './ui.ts';
|
|
7
7
|
import { buildOptionChoices, componentForm, lookups, saveComponent } from './forms.ts';
|
|
8
|
+
import { regionConfigHelp } from './region-help.ts';
|
|
8
9
|
import { parseIncludes } from './static-files.ts';
|
|
9
10
|
import { regionSettingsForm } from './region-settings.ts';
|
|
10
11
|
import { usedInPanel } from './search.ts';
|
|
@@ -301,6 +302,8 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
301
302
|
</div></fieldset>
|
|
302
303
|
<fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
|
|
303
304
|
${select('mode', 'Page mode', p.mode, [['normal', 'Normal'], ['modal', 'Modal dialog']])}
|
|
305
|
+
${select('page_group', 'Page group', p.page_group ?? '', [['', '- none -'], ...lk.pageGroups.map((g): [string, string] => [g, g]), ...(p.page_group && !lk.pageGroups.includes(p.page_group) ? [[p.page_group, `${p.page_group} (no such group)`] as [string, string]] : [])], 'Organises the pages in the App Builder (Shared Components → Page groups). Changes nothing when the application runs.')}
|
|
306
|
+
${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['minimal', 'Minimal (no navigation)']], 'Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
|
|
304
307
|
${select('dialog_position', 'Dialog position', p.dialog_position ?? 'center', [['center', 'Centred dialog'], ['right', 'Drawer from the right'], ['left', 'Drawer from the left'], ['top', 'Drawer from the top'], ['bottom', 'Drawer from the bottom']], 'Modal pages only. On phones dialogs and side drawers fill the screen.')}
|
|
305
308
|
${select('dialog_size', 'Dialog size', p.dialog_size ?? 'medium', [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large']], 'The width (the height of top and bottom drawers).')}
|
|
306
309
|
${input('static_includes', 'JavaScript and CSS files', (p.static_includes ?? []).join(', '), { placeholder: 'e.g. page-3.js, page-3.css', help: 'Static application files (.js, .css) this page loads after the application\'s own, in this order.' })}
|
|
@@ -322,12 +325,24 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
322
325
|
<form method="post" action="${BASE}/pages/${p.id}/delete" class="danger-zone">${csrf(s)}
|
|
323
326
|
<button class="btn btn-sm btn-danger" data-confirm="Delete page ${p.page_no} and all its components?">Delete page</button></form>`;
|
|
324
327
|
}
|
|
328
|
+
// (sprint 59) the help pane: the guide section of the selected component's type
|
|
329
|
+
const guide = (() => {
|
|
330
|
+
const GUIDE = 'https://pgkiln.vargar.eu/docs';
|
|
331
|
+
const k = newKind || kind;
|
|
332
|
+
if (k === 'region') return regionConfigHelp((newKind ? req.query.type : rows.region.find((r: any) => String(r.id) === cid)?.type) ?? null).url ?? `${GUIDE}/pages-and-regions#region-types`;
|
|
333
|
+
return ({ item: `${GUIDE}/items`, button: `${GUIDE}/processing#buttons`, dynamic_action: `${GUIDE}/dynamic-actions`, validation: `${GUIDE}/processing#validations`,
|
|
334
|
+
process: `${GUIDE}/processing#processes`, computation: `${GUIDE}/processing#computations`, branch: `${GUIDE}/processing#branches` } as Record<string, string>)[k] ?? `${GUIDE}/pages-and-regions`;
|
|
335
|
+
})();
|
|
325
336
|
const right = html`
|
|
326
337
|
<div class="pe-head"><div class="pe-title">${heading}</div>
|
|
327
338
|
<div class="pe-filter-row" hidden><label class="sr-only" for="pe-filter">Filter properties</label>${icon('filter')}<input id="pe-filter" class="pe-filter" type="search" placeholder="Filter" autocomplete="off"></div>
|
|
328
339
|
</div>
|
|
329
340
|
${arrange}
|
|
330
|
-
<div class="pe">${props}</div
|
|
341
|
+
<div class="pe">${props}</div>
|
|
342
|
+
<details class="pe-help" open${guide ? raw(` data-guide="${guide}"`) : ''}>
|
|
343
|
+
<summary>${icon('help')} Help</summary>
|
|
344
|
+
<div class="pe-help-body" aria-live="polite" data-pe-help><p class="muted">Select a property to see its help here.</p></div>
|
|
345
|
+
</details>`;
|
|
331
346
|
|
|
332
347
|
// ------------------------------------------------------------ toolbar
|
|
333
348
|
const idx = pages.findIndex((x) => x.id === p.id);
|
|
@@ -380,6 +395,17 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
380
395
|
return send(reply, s, shell(s, `Page ${p.page_no}`, [['App Builder', BASE], [p.app_name, `${BASE}/apps/${p.app_id}`], [`Page ${p.page_no}: ${p.name}`]], main, 'apps', { toolbar, full: true }));
|
|
381
396
|
});
|
|
382
397
|
|
|
398
|
+
// (sprint 59) Quick edit from the developer toolbar: an item or button of the page by its name
|
|
399
|
+
app.get(`${BASE}/pages/:pid/quick`, async (req: Req, reply) => {
|
|
400
|
+
const s = await developer(req, reply);
|
|
401
|
+
if (!s) return;
|
|
402
|
+
if (!/^\d{1,9}$/.test(req.params.pid)) return reply.code(404).send('Not found');
|
|
403
|
+
const q = req.query ?? {};
|
|
404
|
+
const [kind, table, name] = q.item ? ['item', 'meta.item', String(q.item).toUpperCase()] : q.button ? ['button', 'meta.button', String(q.button).toUpperCase()] : [null, null, null];
|
|
405
|
+
const row = kind ? await owner.one(`select id from ${table} where page_id = $1 and name = $2 order by id limit 1`, [req.params.pid, name]) : null;
|
|
406
|
+
return reply.redirect(`${BASE}/pages/${req.params.pid}${row ? `?c=${kind}-${row.id}` : ''}`);
|
|
407
|
+
});
|
|
408
|
+
|
|
383
409
|
app.post(`${BASE}/pages/:pid`, async (req: Req, reply) => {
|
|
384
410
|
const s = await developer(req, reply);
|
|
385
411
|
if (!s) return;
|
|
@@ -388,9 +414,9 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
388
414
|
const before = await owner.one('select app_id, page_no from meta.page where id = $1', [req.params.pid]);
|
|
389
415
|
await owner.query(
|
|
390
416
|
`update meta.page set page_no = $2, name = $3, title = $4, requires_auth = $5, mode = $6, parent_page = $7, authz = $8, protection = $9, build_option = $10,
|
|
391
|
-
dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14 where id = $1`,
|
|
417
|
+
dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14, page_template = $15, page_group = $16 where id = $1`,
|
|
392
418
|
[req.params.pid, Number(b.page_no), b.name?.trim(), b.title?.trim() || null, b.requires_auth === 'true', b.mode, b.parent_page ? Number(b.parent_page) : null, b.authz || null, b.protection, b.build_option?.trim().toUpperCase() || null,
|
|
393
|
-
['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true'],
|
|
419
|
+
['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', b.page_template === 'minimal' ? 'minimal' : 'standard', b.page_group?.trim() || null],
|
|
394
420
|
);
|
|
395
421
|
// the page's lock and comments follow a new page number
|
|
396
422
|
if (before && before.page_no !== Number(b.page_no))
|
package/src/builder/documents.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|
|
2
2
|
import pg from 'pg';
|
|
3
3
|
import { applyBinds } from '../binds.ts';
|
|
4
4
|
import { owner } from '../db.ts';
|
|
5
|
+
import { devDbForApp } from '../workspace-db.ts';
|
|
5
6
|
import { html } from '../html.ts';
|
|
6
7
|
import { english } from '../i18n.ts';
|
|
7
8
|
import { documentData, documentPdf, fillTemplate, TemplateError } from '../runtime/document.ts';
|
|
@@ -42,7 +43,7 @@ export async function documentRoutes(app: FastifyInstance) {
|
|
|
42
43
|
const tpl = a && /^\d+$/.test(tid) ? await owner.one('select * from meta.document_template where id = $1 and app_id = $2', [tid, a.id]) : undefined;
|
|
43
44
|
if (!a || !tpl) return reply.code(404).send('Not found');
|
|
44
45
|
const binds = previewBinds(String(req.query.binds ?? ''));
|
|
45
|
-
const c = await
|
|
46
|
+
const c = await (await devDbForApp(a.id)).pool.connect();
|
|
46
47
|
try {
|
|
47
48
|
await c.query('begin');
|
|
48
49
|
await c.query(`set local statement_timeout = '10s'`);
|
package/src/builder/forms.ts
CHANGED
|
@@ -19,10 +19,11 @@ export interface Lookups {
|
|
|
19
19
|
lists: string[];
|
|
20
20
|
listEntries: { id: number; list_name: string; label: string }[];
|
|
21
21
|
automations: string[];
|
|
22
|
+
pageGroups: string[];
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export async function lookups(appId: number, pageId?: number): Promise<Lookups> {
|
|
25
|
-
const [regions, pages, authz, nav, buildOptions, rest, lists, listEntries, automations] = await Promise.all([
|
|
26
|
+
const [regions, pages, authz, nav, buildOptions, rest, lists, listEntries, automations, pageGroups] = await Promise.all([
|
|
26
27
|
pageId ? owner.query('select id, title, type from meta.region where page_id = $1 order by seq, id', [pageId]) : Promise.resolve({ rows: [] }),
|
|
27
28
|
owner.query('select page_no, name from meta.page where app_id = $1 order by page_no', [appId]),
|
|
28
29
|
owner.query('select name from meta.authz_scheme where app_id = $1 order by name', [appId]),
|
|
@@ -32,8 +33,9 @@ export async function lookups(appId: number, pageId?: number): Promise<Lookups>
|
|
|
32
33
|
owner.query('select name from meta.list where app_id = $1 order by name', [appId]),
|
|
33
34
|
owner.query('select id, list_name, label from meta.list_entry where app_id = $1 order by list_name, seq, id', [appId]),
|
|
34
35
|
owner.query('select name from meta.automation where app_id = $1 order by name', [appId]),
|
|
36
|
+
owner.query('select name from meta.page_group where app_id = $1 order by name', [appId]),
|
|
35
37
|
]);
|
|
36
|
-
return { regions: regions.rows, pages: pages.rows, authz: authz.rows.map((r) => r.name), nav: nav.rows, buildOptions: buildOptions.rows, restSources: rest.rows.map((r) => r.name), lists: lists.rows.map((r) => r.name), listEntries: listEntries.rows, automations: automations.rows.map((r) => r.name) };
|
|
38
|
+
return { regions: regions.rows, pages: pages.rows, authz: authz.rows.map((r) => r.name), nav: nav.rows, buildOptions: buildOptions.rows, restSources: rest.rows.map((r) => r.name), lists: lists.rows.map((r) => r.name), listEntries: listEntries.rows, automations: automations.rows.map((r) => r.name), pageGroups: pageGroups.rows.map((r) => r.name) };
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** Choices of a build option field: each option and its negation, plus a missing current value. */
|
|
@@ -84,7 +86,7 @@ export function componentForm(spec: ComponentSpec, kind: string, row: any, lk: L
|
|
|
84
86
|
html`<select id="${id}" name="${f.name}">${list.map(([val, label]) => html`<option value="${val}"${String(v ?? '') === val ? raw(' selected') : ''}>${label}</option>`)}</select>`;
|
|
85
87
|
switch (f.kind) {
|
|
86
88
|
case 'bool':
|
|
87
|
-
return html`<div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="${f.name}" value="true"${v ? raw(' checked') : ''}> ${f.label}</label>${help}</div>`;
|
|
89
|
+
return html`<div class="field" data-prop="${f.name}"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="${f.name}" value="true"${v ? raw(' checked') : ''}> ${f.label}</label>${help}</div>`;
|
|
88
90
|
case 'options': {
|
|
89
91
|
const chosen: unknown[] = Array.isArray(v) ? v : [];
|
|
90
92
|
return html`<fieldset class="field options-field"${f.wide ? raw(' data-wide') : ''}><legend class="label">${f.label}</legend>
|
|
@@ -159,7 +161,7 @@ export function componentForm(spec: ComponentSpec, kind: string, row: any, lk: L
|
|
|
159
161
|
default:
|
|
160
162
|
control = html`<input id="${id}" name="${f.name}" type="${f.kind === 'int' ? 'number' : 'text'}" value="${v ?? ''}">`;
|
|
161
163
|
}
|
|
162
|
-
return html`<div class="field"${f.wide ? raw(' data-wide') : ''}><label class="label" for="${id}">${f.label}</label>${control}${help}</div>`;
|
|
164
|
+
return html`<div class="field" data-prop="${f.name}"${f.wide ? raw(' data-wide') : ''}><label class="label" for="${id}">${f.label}</label>${control}${help}</div>`;
|
|
163
165
|
};
|
|
164
166
|
const groups = [...new Set(spec.fields.map((f) => f.group ?? ''))];
|
|
165
167
|
return html`<form method="post" action="${action}" class="component-form"${opts.id ? html` id="${opts.id}"` : ''}>
|