pgkiln 0.45.0 → 0.47.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/106_form_designer.sql +102 -0
- package/db/migrations/107_app_version.sql +9 -0
- package/db/migrations/108_sql_history.sql +25 -0
- package/docs/apex-feature-parity.md +33 -4
- package/docs/guide/03-builder.md +123 -28
- package/docs/guide/04-pages-and-regions.md +74 -2
- package/docs/guide/05-items.md +14 -2
- package/docs/guide/08-security.md +1 -1
- package/docs/guide/09-reference.md +1 -1
- package/docs/guide/10-tutorial.md +1 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +26 -3
- package/docs/guide/16-files.md +2 -2
- package/examples/hr/hr_61_form_designer.sql +80 -0
- package/package.json +1 -1
- package/public/app.css +62 -2
- package/public/app.js +244 -35
- package/public/builder.css +21 -1
- package/public/builder.js +67 -0
- package/public/sw.js +2 -1
- package/public/worksheet.css +244 -0
- package/public/worksheet.js +665 -0
- package/src/app.ts +9 -1
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/appwizard.ts +22 -6
- package/src/builder/arrange.ts +33 -12
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +1 -1
- package/src/builder/designer.ts +44 -17
- package/src/builder/errors.ts +65 -0
- package/src/builder/form-designer.ts +229 -0
- package/src/builder/locks.ts +6 -0
- package/src/builder/newapp.ts +2 -1
- package/src/builder/object-actions.ts +166 -0
- package/src/builder/object-browser.ts +111 -33
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/region-help.ts +2 -0
- package/src/builder/region-settings.ts +2 -0
- package/src/builder/reporter.ts +1 -1
- package/src/builder/routes.ts +7 -5
- package/src/builder/sampledata.ts +2 -2
- package/src/builder/scripts.ts +3 -3
- package/src/builder/search.ts +3 -3
- package/src/builder/shared.ts +2 -2
- package/src/builder/sql.ts +15 -73
- package/src/builder/table-editor.ts +219 -0
- package/src/builder/ui.ts +5 -3
- package/src/builder/unload.ts +8 -4
- package/src/builder/worksheet.ts +569 -0
- package/src/builder/workspaces.ts +1 -1
- package/src/cli/replace.ts +10 -0
- package/src/i18n/ar.ts +62 -0
- package/src/i18n/cs.ts +62 -0
- package/src/i18n/da.ts +62 -0
- package/src/i18n/de.ts +62 -0
- package/src/i18n/el.ts +62 -0
- package/src/i18n/es.ts +62 -0
- package/src/i18n/fi.ts +62 -0
- package/src/i18n/fr.ts +62 -0
- package/src/i18n/he.ts +62 -0
- package/src/i18n/it.ts +62 -0
- package/src/i18n/ja.ts +62 -0
- package/src/i18n/ko.ts +62 -0
- package/src/i18n/nb.ts +62 -0
- package/src/i18n/pl.ts +62 -0
- package/src/i18n/pt.ts +62 -0
- package/src/i18n/ru.ts +62 -0
- package/src/i18n/sv.ts +62 -0
- package/src/i18n/tr.ts +62 -0
- package/src/i18n/uk.ts +62 -0
- package/src/i18n/zh.ts +62 -0
- package/src/i18n.ts +124 -0
- package/src/metadata.ts +4 -2
- package/src/runtime/devbar.ts +1 -1
- package/src/runtime/facet-state.ts +10 -5
- package/src/runtime/facets.ts +29 -5
- package/src/runtime/form-designer.ts +676 -0
- package/src/runtime/grid.ts +15 -8
- package/src/runtime/items.ts +33 -21
- package/src/runtime/lists.ts +1 -1
- package/src/runtime/pdf.ts +2 -1
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +7 -3
- package/src/runtime/report.ts +2 -2
- package/src/runtime/routes.ts +3 -1
- package/src/runtime/smart-filters.ts +2 -1
- package/src/runtime/tasks.ts +6 -3
- package/src/sqlformat.ts +208 -0
- package/src/sqlplan.ts +133 -0
- package/src/sqlscript.ts +6 -2
- package/src/sqlworksheet.ts +278 -0
- package/src/tabledef.ts +226 -0
- package/src/unload.ts +14 -3
|
@@ -4,11 +4,14 @@ import { html, raw, type Raw } from '../html.ts';
|
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
5
|
import { readSchema } from '../ddl.ts';
|
|
6
6
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
7
|
-
import { sessionSchema, workshopDb } from './workspaces.ts';
|
|
7
|
+
import { currentWorkspace, inWorkspaceSql, sessionSchema, workshopDb } from './workspaces.ts';
|
|
8
|
+
import { objectMenu } from './object-actions.ts';
|
|
9
|
+
import { owner } from '../db.ts';
|
|
10
|
+
import type { Session } from '../session.ts';
|
|
8
11
|
|
|
9
12
|
// (sprint 75) The SQL Workshop's Object Browser like APEX's: a schema in the header (the session's
|
|
10
13
|
// schema by default), a tree with a folder per object type, a filter, a + menu that starts a CREATE
|
|
11
|
-
// statement in SQL
|
|
14
|
+
// statement in the SQL Worksheet, and create tiles when nothing is selected. pgkiln's own schemas (meta,
|
|
12
15
|
// pgkiln_plugins) and objects that belong to an extension are left out unless "Show system objects" is
|
|
13
16
|
// on. A table opens in tabs: Columns, Data, Indexes, Constraints, Triggers, Grants, Row level security,
|
|
14
17
|
// Dependencies, Statistics and DDL; the column actions write the ALTER TABLE statement into SQL
|
|
@@ -17,7 +20,7 @@ import { sessionSchema, workshopDb } from './workspaces.ts';
|
|
|
17
20
|
// in the catalog before it is used, quoted.
|
|
18
21
|
|
|
19
22
|
/** pgkiln's own schemas: shown with "Show system objects" only. */
|
|
20
|
-
const SYSTEM_SCHEMAS = ['meta', 'pgkiln_plugins'];
|
|
23
|
+
export const SYSTEM_SCHEMAS = ['meta', 'pgkiln_plugins'];
|
|
21
24
|
|
|
22
25
|
export const OB_TYPES = [
|
|
23
26
|
['table', 'Tables', 'table'],
|
|
@@ -30,9 +33,9 @@ export const OB_TYPES = [
|
|
|
30
33
|
['procedure', 'Procedures', 'code'],
|
|
31
34
|
['trigger', 'Triggers', 'bolt'],
|
|
32
35
|
] as const;
|
|
33
|
-
type ObType = (typeof OB_TYPES)[number][0];
|
|
36
|
+
export type ObType = (typeof OB_TYPES)[number][0];
|
|
34
37
|
|
|
35
|
-
interface Obj {
|
|
38
|
+
export interface Obj {
|
|
36
39
|
type: ObType;
|
|
37
40
|
name: string;
|
|
38
41
|
/** the object as SQL can name it: regclass, regprocedure or regtype text; a trigger: "table" */
|
|
@@ -47,7 +50,7 @@ const TABLE_TABS = [
|
|
|
47
50
|
['grants', 'Grants'], ['rls', 'Row level security'], ['dependencies', 'Dependencies'], ['statistics', 'Statistics'], ['ddl', 'DDL'],
|
|
48
51
|
] as const;
|
|
49
52
|
|
|
50
|
-
/** CREATE statements the + menu and the tiles start with (SQL
|
|
53
|
+
/** CREATE statements the + menu and the tiles start with (the SQL Worksheet shows them; nothing runs until Run). */
|
|
51
54
|
export function createTemplate(kind: string, schema: string): string | null {
|
|
52
55
|
const S = pg.escapeIdentifier(schema);
|
|
53
56
|
switch (kind) {
|
|
@@ -61,6 +64,43 @@ export function createTemplate(kind: string, schema: string): string | null {
|
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
const PAGE_SIZE = 25;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* (sprint 89, SQL-4) The parsing schema of the application changed last in the developer's workspace: the
|
|
70
|
+
* schema its database role may use (not public, pgkiln's or the system's). APEX opens the Object Browser
|
|
71
|
+
* on the workspace's parsing schema.
|
|
72
|
+
*/
|
|
73
|
+
async function parsingSchema(s: Session): Promise<string | null> {
|
|
74
|
+
const row = await owner.one<{ n: string }>(
|
|
75
|
+
`select n.nspname as n
|
|
76
|
+
from meta.app a join pg_roles r on r.rolname = a.db_role
|
|
77
|
+
join pg_namespace n on has_schema_privilege(r.oid, n.oid, 'USAGE')
|
|
78
|
+
where ${inWorkspaceSql('a', 1)} and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'public') and not (n.nspname = any ($2))
|
|
79
|
+
order by a.updated_at desc, a.id desc, n.nspname limit 1`,
|
|
80
|
+
[currentWorkspace(s), SYSTEM_SCHEMAS],
|
|
81
|
+
).catch(() => undefined);
|
|
82
|
+
return row?.n ?? null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* (sprint 89, SQL-4) The schema to show: the one asked for, else the last one shown (remembered per session, shared
|
|
86
|
+
* by the Object Browser and the worksheet's navigator), else the session's schema of the SQL Worksheet, else the parsing
|
|
87
|
+
* schema of the application changed last, else the first of `schemas`.
|
|
88
|
+
*/
|
|
89
|
+
export async function pickSchema(s: Session, db: Awaited<ReturnType<typeof workshopDb>>, schemas: string[], asked: unknown): Promise<string> {
|
|
90
|
+
const usable = (v: unknown): v is string => typeof v === 'string' && schemas.includes(v);
|
|
91
|
+
if (usable(asked)) {
|
|
92
|
+
s.state.__OBSCHEMA = asked;
|
|
93
|
+
return asked;
|
|
94
|
+
}
|
|
95
|
+
const last = s.state.__OBSCHEMA;
|
|
96
|
+
if (usable(last)) return last;
|
|
97
|
+
const own = await sessionSchema(s, db);
|
|
98
|
+
const parsing = usable(own) ? null : await parsingSchema(s);
|
|
99
|
+
const schema = usable(own) ? own : usable(parsing) ? parsing : (schemas[0] ?? '');
|
|
100
|
+
if (schema) s.state.__OBSCHEMA = schema;
|
|
101
|
+
return schema;
|
|
102
|
+
}
|
|
103
|
+
|
|
64
104
|
/** A column type in an action: a type name with an optional size and array brackets. */
|
|
65
105
|
const TYPE_RE = /^[a-z][a-z0-9_ ]{0,40}(\(\d{1,4}(,\s?\d{1,4})?\))?(\[\])?$/i;
|
|
66
106
|
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
|
|
@@ -81,8 +121,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
81
121
|
[system, SYSTEM_SCHEMAS],
|
|
82
122
|
)
|
|
83
123
|
).rows.map((r) => r.n);
|
|
84
|
-
const
|
|
85
|
-
const schema = typeof q.schema === 'string' && schemas.includes(q.schema) ? q.schema : own && schemas.includes(own) ? own : (schemas[0] ?? '');
|
|
124
|
+
const schema = await pickSchema(s, db, schemas, q.schema);
|
|
86
125
|
const filter = typeof q.q === 'string' ? q.q.trim().slice(0, 100) : '';
|
|
87
126
|
const objects = schema ? await listObjects(db, schema, system) : [];
|
|
88
127
|
const shown = objects.filter((o) => !filter || o.name.toLowerCase().includes(filter.toLowerCase()));
|
|
@@ -97,7 +136,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
97
136
|
if (!list.length && filter) return '';
|
|
98
137
|
return html`<details class="ob-folder"${list.length && (filter || list.length <= 50 || selected?.type === t) ? raw(' open') : ''}>
|
|
99
138
|
<summary>${icon(ic)}<span>${label}</span><span class="kind">${list.length}</span></summary>
|
|
100
|
-
<ul class="tree">${list.map((o) => html`<li><a href="${link({ k: o.type, o: o.ref })}"${selected === o ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}${o.ext ? html`<span class="tag">extension</span>` : ''}</a
|
|
139
|
+
<ul class="tree">${list.map((o) => html`<li class="ob-item"><a href="${link({ k: o.type, o: o.ref })}"${selected === o ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}${o.ext ? html`<span class="tag">extension</span>` : ''}</a>${objectMenu(schema, o)}</li>`)}</ul>
|
|
101
140
|
</details>`;
|
|
102
141
|
})}</div>`;
|
|
103
142
|
|
|
@@ -105,9 +144,10 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
105
144
|
if (!schema) detail = html`<p class="muted">No schema you may use.</p>`;
|
|
106
145
|
else if (selected) detail = await objectDetail(db, s, schema, selected, String(q.tab ?? ''), Number(q.p) || 1, link);
|
|
107
146
|
else
|
|
108
|
-
detail = region(`Create database objects in ${schema}`, html`<div class="ob-tiles"
|
|
147
|
+
detail = region(`Create database objects in ${schema}`, html`<div class="ob-tiles">
|
|
148
|
+
<a class="create-source" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema })}">${icon('table')}<span><b>Table (form)</b><small class="muted">Columns, keys, indexes and security in a form</small></span></a>${[
|
|
109
149
|
['table', 'table', 'Table'], ['view', 'layers', 'View'], ['function', 'code', 'Function'], ['sequence', 'hash', 'Sequence'], ['index', 'list', 'Index'],
|
|
110
|
-
].map(([k, ic, label]) => html`<a class="create-source" href="${BASE}/sql/
|
|
150
|
+
].map(([k, ic, label]) => html`<a class="create-source" href="${BASE}/sql/worksheet?${new URLSearchParams({ template: k, schema })}">${icon(ic)}<span><b>${label}</b><small class="muted">Start a CREATE ${label.toUpperCase()} statement</small></span></a>`)}
|
|
111
151
|
<a class="create-source" href="${BASE}/sql/quick">${icon('bolt')}<span><b>Quick SQL</b><small class="muted">A data model in shorthand</small></span></a>
|
|
112
152
|
<a class="create-source" href="${BASE}/sql/load">${icon('upload')}<span><b>Load data</b><small class="muted">A table from a file</small></span></a></div>
|
|
113
153
|
<p class="muted">Select an object in the tree, or filter it by name.</p>`);
|
|
@@ -121,13 +161,13 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
121
161
|
<button class="btn btn-sm">Show</button></form>
|
|
122
162
|
<details class="menu ob-create"><summary class="btn btn-hot">${icon('plus')} Create</summary>
|
|
123
163
|
<div class="menu-panel align-right"><div class="menu-section menu-links">${['table', 'view', 'function', 'sequence', 'index'].map((k) =>
|
|
124
|
-
html`<a href="${BASE}/sql/
|
|
164
|
+
html`<a href="${BASE}/sql/worksheet?${new URLSearchParams({ template: k, schema })}">${icon('plus')} ${k[0].toUpperCase() + k.slice(1)}</a>`)}</div></div></details>
|
|
125
165
|
</div>
|
|
126
166
|
<div class="designer"><aside class="region region-standard ob-tree" aria-label="Database objects">${tree}</aside><div>${detail}</div></div>`;
|
|
127
167
|
return send(reply, s, shell(s, 'Object Browser', [['SQL Workshop', `${BASE}/sql`], ['Object Browser']], main, 'sql'));
|
|
128
168
|
});
|
|
129
169
|
|
|
130
|
-
// column actions: the ALTER TABLE statement, into SQL
|
|
170
|
+
// column actions: the ALTER TABLE statement, into the SQL Worksheet to review and run
|
|
131
171
|
app.post(`${BASE}/sql/objects/alter`, async (req: Req, reply) => {
|
|
132
172
|
const s = await developer(req, reply);
|
|
133
173
|
if (!s) return;
|
|
@@ -161,6 +201,24 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
161
201
|
if (!cols.includes(col)) problem = 'Choose a column of the table.';
|
|
162
202
|
else if (!NAME_RE.test(to) || cols.includes(to)) problem = 'The new name must be a valid name that the table doesn\'t have yet.';
|
|
163
203
|
else sql = `alter table ${table.q} rename column ${ident(col)} to ${ident(to)};`;
|
|
204
|
+
} else if (b.action === 'modify') {
|
|
205
|
+
// (sprint 89, SQL-4) type, nulls and default in one ALTER TABLE; a default is one expression, never a statement
|
|
206
|
+
const type = String(b.type ?? '').trim();
|
|
207
|
+
const expr = String(b.default ?? '').trim();
|
|
208
|
+
const parts: string[] = [];
|
|
209
|
+
const c = ident(col);
|
|
210
|
+
if (!cols.includes(col)) problem = 'Choose a column of the table.';
|
|
211
|
+
else if (type && !TYPE_RE.test(type)) problem = 'A type is a name with an optional size, e.g. text, numeric(9,2), varchar(40), int[].';
|
|
212
|
+
else if (b.default_action === 'set' && (!expr || expr.length > 200 || /;|--|\/\*/.test(expr))) problem = 'A default is one expression (at most 200 characters, without ; or comments), e.g. now(), 0 or \'new\'.';
|
|
213
|
+
else {
|
|
214
|
+
if (type) parts.push(`alter column ${c} type ${type} using ${c}::${type}`);
|
|
215
|
+
if (b.nullable === 'not_null') parts.push(`alter column ${c} set not null`);
|
|
216
|
+
else if (b.nullable === 'null') parts.push(`alter column ${c} drop not null`);
|
|
217
|
+
if (b.default_action === 'set') parts.push(`alter column ${c} set default ${expr}`);
|
|
218
|
+
else if (b.default_action === 'drop') parts.push(`alter column ${c} drop default`);
|
|
219
|
+
if (!parts.length) problem = 'Nothing to change: choose a new type, nulls or a default.';
|
|
220
|
+
else sql = `alter table ${table.q}\n ${parts.join(',\n ')};`;
|
|
221
|
+
}
|
|
164
222
|
} else if (b.action === 'drop') {
|
|
165
223
|
if (!cols.includes(col)) problem = 'Choose a column of the table.';
|
|
166
224
|
else sql = `alter table ${table.q} drop column ${ident(col)};`;
|
|
@@ -171,11 +229,11 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
171
229
|
}
|
|
172
230
|
s.state.__SQLDRAFT = sql;
|
|
173
231
|
flash(s, 'Review the statement and press Run: nothing has been changed yet.');
|
|
174
|
-
return back(reply, s, `${BASE}/sql/
|
|
232
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
175
233
|
});
|
|
176
234
|
}
|
|
177
235
|
|
|
178
|
-
async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, schema: string, system: boolean): Promise<Obj[]> {
|
|
236
|
+
export async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, schema: string, system: boolean): Promise<Obj[]> {
|
|
179
237
|
const ext = (cls: string, oid: string) => `exists (select 1 from pg_depend d where d.classid = '${cls}'::regclass and d.objid = ${oid} and d.deptype = 'e')`;
|
|
180
238
|
const rows = (
|
|
181
239
|
await db.query<Obj>(
|
|
@@ -206,11 +264,11 @@ async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, schema: s
|
|
|
206
264
|
return system ? rows : rows.filter((o) => !o.ext);
|
|
207
265
|
}
|
|
208
266
|
|
|
209
|
-
async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csrf_token: string }, schema: string, o: Obj, tabAsked: string, page: number, link: (x: Record<string, string>) => string): Promise<Raw> {
|
|
267
|
+
export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csrf_token: string }, schema: string, o: Obj, tabAsked: string, page: number, link: (x: Record<string, string>) => string): Promise<Raw> {
|
|
210
268
|
const table = (rows: Record<string, unknown>[], empty = 'None.') =>
|
|
211
269
|
rows.length
|
|
212
270
|
? html`<div class="table-wrap"><table class="report"><thead><tr>${Object.keys(rows[0]).map((k) => html`<th scope="col">${k}</th>`)}</tr></thead>
|
|
213
|
-
<tbody>${rows.map((r) => html`<tr>${Object.values(r).map((v) => html`<td>${v === null ?
|
|
271
|
+
<tbody>${rows.map((r) => html`<tr>${Object.values(r).map((v) => html`<td>${v === null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v)}</td>`)}</tr>`)}</tbody></table></div>`
|
|
214
272
|
: html`<p class="muted">${empty}</p>`;
|
|
215
273
|
const ddlOf = async (match: (x: { kind: string; name: string; table?: string }) => boolean) => {
|
|
216
274
|
const objects = await readSchema(db.pool as pg.Pool, schema).catch(() => []);
|
|
@@ -247,7 +305,14 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
|
|
|
247
305
|
const isTable = o.type === 'table';
|
|
248
306
|
const tabs = TABLE_TABS.filter(([k]) => isTable || !['indexes', 'constraints', 'triggers', 'rls'].includes(k) || (o.type === 'matview' && k === 'indexes'));
|
|
249
307
|
const tab = tabs.some(([k]) => k === tabAsked) ? tabAsked : 'columns';
|
|
250
|
-
|
|
308
|
+
// (sprint 89, SQL-4) the tabs on one row: the main ones and the current one, the rest under More
|
|
309
|
+
const MAIN = ['columns', 'data', 'indexes', 'constraints', 'ddl'];
|
|
310
|
+
const tabLink = ([k, label]: readonly [string, string]) => html`<a href="${link({ k: o.type, o: o.ref, tab: k })}"${k === tab ? raw(' aria-current="page"') : ''}>${label}</a>`;
|
|
311
|
+
const shownTabs = tabs.filter(([k]) => MAIN.includes(k) || k === tab);
|
|
312
|
+
const moreTabs = tabs.filter(([k]) => !shownTabs.some(([x]) => x === k));
|
|
313
|
+
const nav = html`<nav class="settings-tabs ob-tabs" aria-label="${o.name}">${shownTabs.map(tabLink)}${moreTabs.length
|
|
314
|
+
? html`<details class="menu ob-more"><summary>More ${icon('chevron-down')}</summary><div class="menu-panel"><div class="menu-section menu-links">${moreTabs.map(tabLink)}</div></div></details>`
|
|
315
|
+
: ''}</nav>`;
|
|
251
316
|
let body: Raw;
|
|
252
317
|
switch (tab) {
|
|
253
318
|
case 'columns': {
|
|
@@ -262,20 +327,32 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
|
|
|
262
327
|
).rows;
|
|
263
328
|
const names = cols.map((c) => String(c.column));
|
|
264
329
|
const hidden = html`${raw(`<input type="hidden" name="__csrf" value="${s.csrf_token}">`)}<input type="hidden" name="table" value="${o.ref}"><input type="hidden" name="schema" value="${schema}">`;
|
|
265
|
-
const pick = (id: string) => html`<label class="
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
<
|
|
275
|
-
<
|
|
276
|
-
|
|
330
|
+
const pick = (id: string) => html`<label class="label" for="${id}">Column</label><select id="${id}" name="column">${names.map((n) => html`<option>${n}</option>`)}</select>`;
|
|
331
|
+
// (sprint 89, SQL-4; APEX: the Columns toolbar) Add, Modify, Rename and Drop above the columns, each a small form
|
|
332
|
+
body = html`${isTable
|
|
333
|
+
? html`<div class="ob-toolbar" role="toolbar" aria-label="Columns">
|
|
334
|
+
<details class="menu ob-tool"><summary class="btn btn-sm">${icon('plus')} Add column</summary><div class="menu-panel">
|
|
335
|
+
<form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="add">
|
|
336
|
+
<label class="label" for="ob-add-name">Name</label><input id="ob-add-name" name="column" placeholder="name" required>
|
|
337
|
+
<label class="label" for="ob-add-type">Type</label><input id="ob-add-type" name="type" placeholder="text" required>
|
|
338
|
+
<label class="check"><input type="checkbox" name="not_null" value="true"> not null</label><button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
|
|
339
|
+
<details class="menu ob-tool"><summary class="btn btn-sm">${icon('edit')} Modify column</summary><div class="menu-panel">
|
|
340
|
+
<form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="modify">
|
|
341
|
+
${pick('ob-mod-col')}
|
|
342
|
+
<label class="label" for="ob-mod-type">New type</label><input id="ob-mod-type" name="type" placeholder="the same">
|
|
343
|
+
<label class="label" for="ob-mod-null">Nulls</label><select id="ob-mod-null" name="nullable"><option value="">the same</option><option value="null">allowed</option><option value="not_null">not null</option></select>
|
|
344
|
+
<label class="label" for="ob-mod-def">Default</label><select id="ob-mod-def" name="default_action"><option value="">the same</option><option value="set">set to</option><option value="drop">none</option></select>
|
|
345
|
+
<label class="sr-only" for="ob-mod-expr">Default expression</label><input id="ob-mod-expr" name="default" placeholder="now(), 0, 'x'">
|
|
346
|
+
<button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
|
|
347
|
+
<details class="menu ob-tool"><summary class="btn btn-sm">${icon('tag')} Rename column</summary><div class="menu-panel">
|
|
348
|
+
<form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="rename">
|
|
349
|
+
${pick('ob-ren-col')}<label class="label" for="ob-ren-to">New name</label><input id="ob-ren-to" name="new_name" placeholder="new name" required><button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
|
|
350
|
+
<details class="menu ob-tool"><summary class="btn btn-sm">${icon('trash')} Drop column</summary><div class="menu-panel">
|
|
351
|
+
<form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="drop">
|
|
352
|
+
${pick('ob-drop-col')}<button class="btn btn-sm btn-danger">Show the SQL</button></form></div></details>
|
|
353
|
+
<span class="muted ob-toolbar-note">Each writes the ALTER TABLE statement into the SQL Worksheet: review it there and run it.</span>
|
|
277
354
|
</div>`
|
|
278
|
-
: ''}`;
|
|
355
|
+
: ''}${table(cols)}`;
|
|
279
356
|
break;
|
|
280
357
|
}
|
|
281
358
|
case 'data': {
|
|
@@ -288,7 +365,7 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
|
|
|
288
365
|
const p = offset / PAGE_SIZE + 1;
|
|
289
366
|
body = html`${table(rows, 'No rows.')}<nav class="pager" aria-label="Pages">${p > 1 ? html`<a href="${link({ k: o.type, o: o.ref, tab: 'data', p: String(p - 1) })}">‹ Previous</a>` : ''}
|
|
290
367
|
<span>Rows ${rows.length ? offset + 1 : 0}–${offset + rows.length}</span>${more ? html`<a href="${link({ k: o.type, o: o.ref, tab: 'data', p: String(p + 1) })}">Next ›</a>` : ''}</nav>
|
|
291
|
-
<p class="muted">To change rows, use SQL
|
|
368
|
+
<p class="muted">To change rows, use the SQL Worksheet, or an interactive grid on the table in an application.</p>`;
|
|
292
369
|
}
|
|
293
370
|
break;
|
|
294
371
|
}
|
|
@@ -338,5 +415,6 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
|
|
|
338
415
|
default:
|
|
339
416
|
body = await ddlOf((x) => (x.kind === 'table' || x.kind === 'view' || x.kind === 'materialized view') ? x.name === relname : x.table === relname);
|
|
340
417
|
}
|
|
341
|
-
return html`<h2 class="ob-title">${icon(OB_TYPES.find(([t]) => t === o.type)![2])} ${o.ref}${o.rls ? html` <span class="tag">RLS</span>` : ''}
|
|
418
|
+
return html`<h2 class="ob-title">${icon(OB_TYPES.find(([t]) => t === o.type)![2])} ${o.ref}${o.rls ? html` <span class="tag">RLS</span>` : ''}${o.type === 'table'
|
|
419
|
+
? html` <a class="btn btn-sm" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}">${icon('edit')} Edit table</a>` : ''}</h2>${nav}${region(tabs.find(([k]) => k === tab)![1], body)}`;
|
|
342
420
|
}
|
|
@@ -363,7 +363,7 @@ export async function queryBuilderRoutes(app: FastifyInstance) {
|
|
|
363
363
|
const output = built
|
|
364
364
|
? html`<div class="u-spacer"></div>${region('SQL', html`<pre class="source" tabindex="0" aria-label="Generated SQL">${built.sql}</pre>
|
|
365
365
|
<form method="post" action="${BASE}/sql">${csrf(s)}<input type="hidden" name="sql" value="${built.sql}">
|
|
366
|
-
<div class="buttons"><button class="btn btn-hot">${icon('play')} Run in SQL
|
|
366
|
+
<div class="buttons"><button class="btn btn-hot">${icon('play')} Run in the SQL Worksheet</button></div></form>`)}`
|
|
367
367
|
: '';
|
|
368
368
|
return send(reply, s, shell(s, 'Query Builder', [['SQL Workshop', `${BASE}/sql`], ['Query Builder']], html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('query')}${form}${output}`, 'sql'));
|
|
369
369
|
});
|
|
@@ -21,6 +21,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
|
|
|
21
21
|
[['*'], '{"display_selector":true} puts it in the page\'s display selector'],
|
|
22
22
|
[['report', 'chart', 'cards', 'dynamic', 'tree', 'template_component'], '{"lazy":true} loads it after the page shows, {"cache":{"scope":"user"|"session"|"all","seconds":300}} keeps its HTML (a submit of the page empties it)'],
|
|
23
23
|
[['data_reporter'], '{"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"order_v","columns":[{"name":"total","label":"Total","format":"FML999G990D00"}]}],"sharing":true,"share_authz":"SCHEME","page_size":25} (the Data Reporter settings below edit it)'],
|
|
24
|
+
[['form_designer'], '{"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"orders","key":"id","columns":[{"name":"customer","label":"Customer"},{"name":"status","lov":"LOV:ORDER_STATUS"}],"insert":true,"update":true,"delete":false}],"design_authz":"SCHEME","page_size":25} (the Form Designer region\'s Settings; users that pass design_authz design forms)'],
|
|
24
25
|
[['ai_assistant'], '{"service":"NAME","system":"…","welcome":"…","context":[{"name":"policy","sql":"select … :AI_PROMPT …"}],"tools":[{"name":"my_leave","description":"…","sql":"select … where status = :STATUS","parameters":{"STATUS":{"type":"string","enum":["PENDING"]}}},{"name":"dept","type":"rest","source":"DEPARTMENT","description":"…"}],"max_rounds":5,"max_turns":20} (the AI assistant settings below edit it)'],
|
|
25
26
|
[['report'], '{"ai_filter":{"service":"NAME"}} adds an "Ask in your own words" box (natural-language filters)'],
|
|
26
27
|
[['plugin'], '{"plugin":"name","attributes":{"NAME":"value"}} (a region plug-in; a Source SELECT feeds its template)'],
|
|
@@ -45,6 +46,7 @@ const ANCHORS: Record<string, string> = {
|
|
|
45
46
|
tree: 'tree',
|
|
46
47
|
list: 'list-lists',
|
|
47
48
|
data_reporter: 'data-reporter-data-reporter',
|
|
49
|
+
form_designer: 'form_designer-form-designer',
|
|
48
50
|
ai_assistant: 'ai-assistant-ai-assistant',
|
|
49
51
|
template_component: 'template-components',
|
|
50
52
|
static: 'static-and-dynamic-content',
|
|
@@ -16,6 +16,7 @@ import { LIST_TEMPLATES } from '../runtime/lists.ts';
|
|
|
16
16
|
import { MAX_EXTRA_LAYERS } from '../runtime/maps.ts';
|
|
17
17
|
import { postgis } from '../runtime/spatial.ts';
|
|
18
18
|
import { reporterSettingsForm } from './reporter.ts';
|
|
19
|
+
import { formDesignerSettingsForm } from './form-designer.ts';
|
|
19
20
|
|
|
20
21
|
// Page designer → a region → Settings: the region's "config" JSON as a form
|
|
21
22
|
// for grid, chart, cards, calendar and faceted search regions (report
|
|
@@ -732,6 +733,7 @@ export async function regionSettingsForm(pageId: number, appId: number, r: Regio
|
|
|
732
733
|
if (r.type === 'ai_assistant') return assistantSettingsForm(pageId, appId, r, s);
|
|
733
734
|
if (r.type === 'template_component') return templateRegionForm(pageId, appId, r, s);
|
|
734
735
|
if (r.type === 'data_reporter') return reporterSettingsForm(pageId, appId, r, s);
|
|
736
|
+
if (r.type === 'form_designer') return formDesignerSettingsForm(pageId, appId, r, s);
|
|
735
737
|
if (!(SETTINGS_TYPES as readonly string[]).includes(r.type)) return '';
|
|
736
738
|
const cfg = r.config ?? {};
|
|
737
739
|
const id = (n: string) => `rg_${r.id}_${n}`;
|
package/src/builder/reporter.ts
CHANGED
|
@@ -43,7 +43,7 @@ async function objectByOid(oid: number): Promise<DbObject | null> {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/** The tables and views to choose from, with whether the application's role may read them. */
|
|
46
|
-
async function objectList(dbRole: string | null) {
|
|
46
|
+
export async function objectList(dbRole: string | null) {
|
|
47
47
|
return (
|
|
48
48
|
await owner.query<{ oid: number; name: string; access: boolean }>(
|
|
49
49
|
`select c.oid::int as oid, format('%I.%I', n.nspname, c.relname) as name,
|
package/src/builder/routes.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { documentShell } from '../layout.ts';
|
|
|
13
13
|
import { passwordProblem } from '../accounts.ts';
|
|
14
14
|
import { clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash } from '../session.ts';
|
|
15
15
|
import { loginSettingsFields, saveLoginSettings } from './login-page.ts';
|
|
16
|
-
import { APP_COLORS, appHeader, back, BASE, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
|
|
16
|
+
import { APP_COLORS, appHeader, back, BASE, designerUrl, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
|
|
17
17
|
import { appOr404 } from './forms.ts';
|
|
18
18
|
import { docToFiles, filesToZip } from '../appfiles.ts';
|
|
19
19
|
import { homeRoutes, rememberApp } from './home.ts';
|
|
@@ -278,11 +278,11 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
278
278
|
<select id="f_group" name="group">${[['', 'All pages'], ...groups.map((g) => [g, g]), ['-', 'Without a group']].map(([v, l]) => html`<option value="${v}"${v === groupFilter ? raw(' selected') : ''}>${l}</option>`)}</select></div>
|
|
279
279
|
<button class="btn u-selfend">Show</button></form>`
|
|
280
280
|
: ''}${icons
|
|
281
|
-
? html`<ul class="page-icons" id="pages-rows">${shownPages.map((p) => html`<li><a href="${
|
|
281
|
+
? html`<ul class="page-icons" id="pages-rows">${shownPages.map((p) => html`<li><a href="${designerUrl(a.id, p.page_no)}" class="page-icon"><span class="page-icon-no">${p.page_no}</span><b>${p.name}</b><small class="muted">${p.mode}, ${p.regions} region${p.regions === 1 ? '' : 's'}</small></a></li>`)}</ul>`
|
|
282
282
|
: html`<div class="table-wrap"><table class="report">
|
|
283
283
|
<thead><tr><th class="num">Page</th><th>Name</th>${grouped ? html`<th>Group</th>` : ''}<th>Mode</th><th class="num">Regions</th><th class="num">Items</th><th class="num">DAs</th><th class="num">Processes</th><th>Authorization</th><th>Protection</th><th>Lock</th><th></th></tr></thead>
|
|
284
284
|
<tbody id="pages-rows">${shownPages.map((p) => html`<tr>
|
|
285
|
-
<td class="num">${p.page_no}</td><td><a href="${
|
|
285
|
+
<td class="num">${p.page_no}</td><td><a href="${designerUrl(a.id, p.page_no)}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
|
|
286
286
|
<td class="num">${p.regions}</td><td class="num">${p.items}</td><td class="num">${p.das}</td><td class="num">${p.processes}</td>
|
|
287
287
|
<td>${p.requires_auth ? (p.authz ?? '—') : 'public'}</td><td>${p.protection}</td>
|
|
288
288
|
<td>${((l) => (l ? html`<span class="lock-tag" title="${lockText(l)}">${icon('key')}<span>${l.locked_by === s.username ? 'you' : l.locked_by}</span></span>` : '—'))(locks.get(p.page_no))}</td>
|
|
@@ -401,6 +401,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
401
401
|
<div class="form-grid">
|
|
402
402
|
${input('name', 'Name', a.name, { required: true })}
|
|
403
403
|
${input('alias', 'Alias', a.alias, { required: true })}
|
|
404
|
+
${input('version', 'Version', a.version ?? '', { placeholder: 'Release 1.0', help: 'Shown in the footer of the application\'s pages; empty: none.' })}
|
|
404
405
|
${input('home_page', 'Home page', a.home_page, { type: 'number' })}
|
|
405
406
|
${select('app_type', 'Application type', a.app_type ?? 'standard', APP_TYPES.map((t): [string, string] => [t, APP_TYPE_LABELS[t]]),
|
|
406
407
|
'Theme and library applications offer their theme or shared components to other applications (Shared Components → Subscriptions); Create application can start from a boilerplate application.')}
|
|
@@ -546,7 +547,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
546
547
|
date_format = $14, timestamp_format = $15, remember_me_days = $16, ldap_directories = $17,
|
|
547
548
|
header_name = $18, header_auto_create = $19, logout_url = $20, db_auth_roles = $21, db_auth_member_of = $22,
|
|
548
549
|
custom_auth_function = $23, custom_auth_code = $24, custom_auth_post_code = $25, nav_list = $26, navbar_list = $27,
|
|
549
|
-
app_type = $28, session_group = $29, updated_at = now() where id = $1`,
|
|
550
|
+
app_type = $28, session_group = $29, version = $30, updated_at = now() where id = $1`,
|
|
550
551
|
[req.params.id, b.name?.trim(), b.alias?.trim().toLowerCase(), Number(b.home_page) || 1, b.authentication, b.db_role?.trim() || null, b.debug === 'true',
|
|
551
552
|
JSON.stringify({
|
|
552
553
|
base: baseStyleOf({ base: b.base }) === 'standard' ? undefined : baseStyleOf({ base: b.base }),
|
|
@@ -579,7 +580,8 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
579
580
|
b.nav_list?.trim().toUpperCase() || null,
|
|
580
581
|
b.navbar_list?.trim().toUpperCase() || null,
|
|
581
582
|
(APP_TYPES as readonly string[]).includes(b.app_type ?? '') ? b.app_type : 'standard',
|
|
582
|
-
/^[a-z][a-z0-9_]{0,29}$/.test(b.session_group?.trim().toLowerCase() ?? '') ? b.session_group!.trim().toLowerCase() : null
|
|
583
|
+
/^[a-z][a-z0-9_]{0,29}$/.test(b.session_group?.trim().toLowerCase() ?? '') ? b.session_group!.trim().toLowerCase() : null,
|
|
584
|
+
(b.version ?? '').trim().slice(0, 40) || null],
|
|
583
585
|
);
|
|
584
586
|
await saveTimeZoneSettings(req.params.id, b);
|
|
585
587
|
await saveLoginSettings(req.params.id, b);
|
|
@@ -40,7 +40,7 @@ import { currentWorkspace, workshopDb } from './workspaces.ts';
|
|
|
40
40
|
// nulls, all proposed from the catalog. The form posts (CSRF) to preview
|
|
41
41
|
// (insert and roll back), insert (one transaction, parents first), download
|
|
42
42
|
// as SQL or CSV, or save the definition (meta.data_generator) to rerun.
|
|
43
|
-
// Runs on a connection of its own as the owner, like SQL
|
|
43
|
+
// Runs on a connection of its own as the owner, like the SQL Worksheet, with a
|
|
44
44
|
// statement timeout; the connection is closed afterwards. No JavaScript needed.
|
|
45
45
|
|
|
46
46
|
/** The statement timeout of a run (PostgreSQL interval syntax). */
|
|
@@ -109,7 +109,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
109
109
|
|
|
110
110
|
const intro = html`<p class="muted">Generate realistic rows for the tables of a schema: names, e-mail addresses, dates and numbers in a range, values from a list,
|
|
111
111
|
foreign keys that pick existing parent rows. Preview them, insert them in one transaction (parents first) or download them as SQL or CSV.
|
|
112
|
-
Runs as the builder's owner connection (an isolated workspace's developer login for its developers), like <a href="${BASE}/sql">SQL
|
|
112
|
+
Runs as the builder's owner connection (an isolated workspace's developer login for its developers), like the <a href="${BASE}/sql/worksheet">SQL Worksheet</a>.</p>`;
|
|
113
113
|
|
|
114
114
|
// ------------------------------------------------------------ step 1 and 2: schema and tables
|
|
115
115
|
const start = async (s: Session, reply: FastifyReply, schema: string | undefined) => {
|
package/src/builder/scripts.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
|
|
|
12
12
|
// SQL Workshop → SQL Scripts: saved scripts (create, edit, upload, download,
|
|
13
13
|
// delete), run with a result per statement (stop at the first error or go on,
|
|
14
14
|
// optionally in one transaction), and the run history. Scripts run on the
|
|
15
|
-
// owner connection, like SQL
|
|
15
|
+
// owner connection, like the SQL Worksheet, on a connection of their own that is
|
|
16
16
|
// closed afterwards (a script may SET ROLE or change settings). Scripts and runs belong
|
|
17
17
|
// to the current workspace; an isolated workspace's run on its developer login (090).
|
|
18
18
|
|
|
@@ -139,11 +139,11 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
139
139
|
).rows;
|
|
140
140
|
const runs = (await owner.query('select * from meta.sql_script_run where workspace_id = $1 order by id desc limit 20', [currentWorkspace(s)])).rows;
|
|
141
141
|
const list = scripts.length
|
|
142
|
-
? html`<div class="table-wrap"><table class="report"><thead><tr><th>Script</th><th>Updated</th><th class="num">Size</th><th>Last run</th><th>
|
|
142
|
+
? html`<div class="table-wrap"><table class="report"><thead><tr><th>Script</th><th>Updated</th><th class="num">Size</th><th>Last run</th><th>Open</th></tr></thead><tbody>
|
|
143
143
|
${scripts.map((x) => html`<tr><td><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description}</span>` : ''}</td>
|
|
144
144
|
<td>${when(x.updated_at)}${x.updated_by ? html` <span class="muted small">by ${x.updated_by}</span>` : ''}</td><td class="num">${bytes(Number(x.size))}</td>
|
|
145
145
|
<td>${x.last_run ? html`${when(x.last_run)} ${x.last_failed ? html`<span class="tag tag-error">Errors</span>` : html`<span class="tag tag-ok">OK</span>`}` : html`<span class="muted">never</span>`}</td>
|
|
146
|
-
<td><a class="btn btn-sm" href="${BASE}/sql/scripts/${x.id}/download">${icon('download')} Download</a></td></tr>`)}
|
|
146
|
+
<td><a class="btn btn-sm" href="${BASE}/sql/worksheet?script=${x.id}">${icon('code')} Worksheet</a> <a class="btn btn-sm" href="${BASE}/sql/scripts/${x.id}/download">${icon('download')} Download</a></td></tr>`)}
|
|
147
147
|
</tbody></table></div>`
|
|
148
148
|
: html`<p class="muted">No scripts yet. Create one, upload a .sql file, or generate one with Quick SQL.</p>`;
|
|
149
149
|
return page(
|
package/src/builder/search.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { owner } from '../db.ts';
|
|
|
3
3
|
import { html, type Raw } from '../html.ts';
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
5
|
import { COMPONENTS, type FieldKind } from './components.ts';
|
|
6
|
-
import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
|
|
6
|
+
import { appHeader, BASE, designerUrl, developer, region, send, shell, type Req } from './ui.ts';
|
|
7
7
|
|
|
8
8
|
// App Builder → Search and "Where used": every component of an application
|
|
9
9
|
// (pages, regions, items, buttons, dynamic actions, validations, processes
|
|
@@ -46,7 +46,7 @@ export async function appEntries(appId: number, query: (sql: string, params?: un
|
|
|
46
46
|
const pageById = new Map(pages.map((p) => [p.id, p]));
|
|
47
47
|
const out: Entry[] = pages.map((p) => ({
|
|
48
48
|
kind: 'page', id: p.id, label: `Page ${p.page_no}: ${p.name}`, kindLabel: 'Page', icon: 'file', pageNo: p.page_no,
|
|
49
|
-
url:
|
|
49
|
+
url: designerUrl(appId, p.page_no, 'c=page'),
|
|
50
50
|
fields: PAGE_FIELDS.map((f) => ({ ...f, value: asText(p[f.name]) })),
|
|
51
51
|
}));
|
|
52
52
|
for (const [kind, spec] of Object.entries(COMPONENTS)) {
|
|
@@ -57,7 +57,7 @@ export async function appEntries(appId: number, query: (sql: string, params?: un
|
|
|
57
57
|
const page = spec.scope === 'page' ? pageById.get(row.page_id) : null;
|
|
58
58
|
out.push({
|
|
59
59
|
kind, id: row.id, label: spec.summary(row), kindLabel: spec.label, icon: spec.icon, pageNo: page?.page_no ?? null,
|
|
60
|
-
url: page ?
|
|
60
|
+
url: page ? designerUrl(appId, page.page_no, `c=${kind}-${row.id}`) : `${BASE}/apps/${appId}/shared?c=${kind}-${row.id}`,
|
|
61
61
|
fields: spec.fields.filter((f) => TEXT_KINDS.has(f.kind)).map((f) => ({ name: f.name, label: f.label, kind: f.kind, value: asText(row[f.name]) })),
|
|
62
62
|
});
|
|
63
63
|
}
|
package/src/builder/shared.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { layoutExtras } from './layouts.ts';
|
|
|
13
13
|
import { templateExtras, templateImport } from './templates.ts';
|
|
14
14
|
import { credentialExtras, needsSecret, restSourceExtras } from './websources.ts';
|
|
15
15
|
import { dataLoadDefExtras } from './dataload.ts';
|
|
16
|
-
import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
16
|
+
import { appHeader, back, BASE, designerUrl, 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
19
|
import { catalogPicker } from './rest-catalogs.ts';
|
|
@@ -89,7 +89,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
89
89
|
if (!kids.length) return html``;
|
|
90
90
|
return html`<ul class="tree">${kids.map((p) => {
|
|
91
91
|
const next = new Set(seen).add(p.page_no);
|
|
92
|
-
return html`<li><a href="${
|
|
92
|
+
return html`<li><a href="${designerUrl(a.id, p.page_no)}">${icon('file')}<span>${p.name}</span><span class="kind">p${p.page_no}</span></a>${tree(p.page_no, next)}</li>`;
|
|
93
93
|
})}</ul>`;
|
|
94
94
|
};
|
|
95
95
|
const main = html`${appHeader(a, 'shared')}
|