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.
Files changed (93) hide show
  1. package/db/migrations/106_form_designer.sql +102 -0
  2. package/db/migrations/107_app_version.sql +9 -0
  3. package/db/migrations/108_sql_history.sql +25 -0
  4. package/docs/apex-feature-parity.md +33 -4
  5. package/docs/guide/03-builder.md +123 -28
  6. package/docs/guide/04-pages-and-regions.md +74 -2
  7. package/docs/guide/05-items.md +14 -2
  8. package/docs/guide/08-security.md +1 -1
  9. package/docs/guide/09-reference.md +1 -1
  10. package/docs/guide/10-tutorial.md +1 -1
  11. package/docs/guide/11-from-apex.md +2 -1
  12. package/docs/guide/12-development.md +26 -3
  13. package/docs/guide/16-files.md +2 -2
  14. package/examples/hr/hr_61_form_designer.sql +80 -0
  15. package/package.json +1 -1
  16. package/public/app.css +62 -2
  17. package/public/app.js +244 -35
  18. package/public/builder.css +21 -1
  19. package/public/builder.js +67 -0
  20. package/public/sw.js +2 -1
  21. package/public/worksheet.css +244 -0
  22. package/public/worksheet.js +665 -0
  23. package/src/app.ts +9 -1
  24. package/src/builder/ai-builder.ts +3 -3
  25. package/src/builder/appwizard.ts +22 -6
  26. package/src/builder/arrange.ts +33 -12
  27. package/src/builder/code-editor.ts +1 -1
  28. package/src/builder/components.ts +1 -1
  29. package/src/builder/designer.ts +44 -17
  30. package/src/builder/errors.ts +65 -0
  31. package/src/builder/form-designer.ts +229 -0
  32. package/src/builder/locks.ts +6 -0
  33. package/src/builder/newapp.ts +2 -1
  34. package/src/builder/object-actions.ts +166 -0
  35. package/src/builder/object-browser.ts +111 -33
  36. package/src/builder/querybuilder.ts +1 -1
  37. package/src/builder/region-help.ts +2 -0
  38. package/src/builder/region-settings.ts +2 -0
  39. package/src/builder/reporter.ts +1 -1
  40. package/src/builder/routes.ts +7 -5
  41. package/src/builder/sampledata.ts +2 -2
  42. package/src/builder/scripts.ts +3 -3
  43. package/src/builder/search.ts +3 -3
  44. package/src/builder/shared.ts +2 -2
  45. package/src/builder/sql.ts +15 -73
  46. package/src/builder/table-editor.ts +219 -0
  47. package/src/builder/ui.ts +5 -3
  48. package/src/builder/unload.ts +8 -4
  49. package/src/builder/worksheet.ts +569 -0
  50. package/src/builder/workspaces.ts +1 -1
  51. package/src/cli/replace.ts +10 -0
  52. package/src/i18n/ar.ts +62 -0
  53. package/src/i18n/cs.ts +62 -0
  54. package/src/i18n/da.ts +62 -0
  55. package/src/i18n/de.ts +62 -0
  56. package/src/i18n/el.ts +62 -0
  57. package/src/i18n/es.ts +62 -0
  58. package/src/i18n/fi.ts +62 -0
  59. package/src/i18n/fr.ts +62 -0
  60. package/src/i18n/he.ts +62 -0
  61. package/src/i18n/it.ts +62 -0
  62. package/src/i18n/ja.ts +62 -0
  63. package/src/i18n/ko.ts +62 -0
  64. package/src/i18n/nb.ts +62 -0
  65. package/src/i18n/pl.ts +62 -0
  66. package/src/i18n/pt.ts +62 -0
  67. package/src/i18n/ru.ts +62 -0
  68. package/src/i18n/sv.ts +62 -0
  69. package/src/i18n/tr.ts +62 -0
  70. package/src/i18n/uk.ts +62 -0
  71. package/src/i18n/zh.ts +62 -0
  72. package/src/i18n.ts +124 -0
  73. package/src/metadata.ts +4 -2
  74. package/src/runtime/devbar.ts +1 -1
  75. package/src/runtime/facet-state.ts +10 -5
  76. package/src/runtime/facets.ts +29 -5
  77. package/src/runtime/form-designer.ts +676 -0
  78. package/src/runtime/grid.ts +15 -8
  79. package/src/runtime/items.ts +33 -21
  80. package/src/runtime/lists.ts +1 -1
  81. package/src/runtime/pdf.ts +2 -1
  82. package/src/runtime/regions.ts +4 -0
  83. package/src/runtime/render.ts +7 -3
  84. package/src/runtime/report.ts +2 -2
  85. package/src/runtime/routes.ts +3 -1
  86. package/src/runtime/smart-filters.ts +2 -1
  87. package/src/runtime/tasks.ts +6 -3
  88. package/src/sqlformat.ts +208 -0
  89. package/src/sqlplan.ts +133 -0
  90. package/src/sqlscript.ts +6 -2
  91. package/src/sqlworksheet.ts +278 -0
  92. package/src/tabledef.ts +226 -0
  93. 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 Commands, and create tiles when nothing is selected. pgkiln's own schemas (meta,
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 Commands shows them; nothing runs until Run). */
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 own = await sessionSchema(s, db);
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></li>`)}</ul>
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/commands?${new URLSearchParams({ template: k, schema })}">${icon(ic)}<span><b>${label}</b><small class="muted">Start a CREATE ${label.toUpperCase()} statement</small></span></a>`)}
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/commands?${new URLSearchParams({ template: k, schema })}">${icon('plus')} ${k[0].toUpperCase() + k.slice(1)}</a>`)}</div></div></details>
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 Commands to review and run
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/commands`);
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 ? html`<span class="null">null</span>` : typeof v === 'object' ? JSON.stringify(v) : String(v)}</td>`)}</tr>`)}</tbody></table></div>`
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
- const nav = html`<nav class="settings-tabs ob-tabs" aria-label="${o.name}">${tabs.map(([k, label]) => html`<a href="${link({ k: o.type, o: o.ref, tab: k })}"${k === tab ? raw(' aria-current="page"') : ''}>${label}</a>`)}</nav>`;
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="sr-only" for="${id}">Column</label><select id="${id}" name="column">${names.map((n) => html`<option>${n}</option>`)}</select>`;
266
- body = html`${table(cols)}${isTable
267
- ? html`<div class="ob-actions">
268
- <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="add">
269
- <b>Add column</b><label class="sr-only" for="ob-add-name">Name</label><input id="ob-add-name" name="column" placeholder="name" required>
270
- <label class="sr-only" for="ob-add-type">Type</label><input id="ob-add-type" name="type" placeholder="text" required>
271
- <label class="check"><input type="checkbox" name="not_null" value="true"> not null</label><button class="btn btn-sm">Show the SQL</button></form>
272
- <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="rename">
273
- <b>Rename column</b>${pick('ob-ren-col')}<label class="sr-only" for="ob-ren-to">New name</label><input id="ob-ren-to" name="new_name" placeholder="new name" required><button class="btn btn-sm">Show the SQL</button></form>
274
- <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="drop">
275
- <b>Drop column</b>${pick('ob-drop-col')}<button class="btn btn-sm">Show the SQL</button></form>
276
- <p class="muted u-m0">Each action writes the ALTER TABLE statement into SQL Commands: review it there and press Run.</p>
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 Commands, or an interactive grid on the table in an application.</p>`;
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>` : ''}</h2>${nav}${region(tabs.find(([k]) => k === tab)![1], body)}`;
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 Commands</button></div></form>`)}`
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}`;
@@ -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,
@@ -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="${BASE}/pages/${p.id}" 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>`
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="${BASE}/pages/${p.id}">${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>
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 Commands, with a
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 Commands</a>.</p>`;
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) => {
@@ -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 Commands, on a connection of their own that is
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>File</th></tr></thead><tbody>
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(
@@ -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: `${BASE}/pages/${p.id}?c=page`,
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 ? `${BASE}/pages/${page.id}?c=${kind}-${row.id}` : `${BASE}/apps/${appId}/shared?c=${kind}-${row.id}`,
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
  }
@@ -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="${BASE}/pages/${p.id}">${icon('file')}<span>${p.name}</span><span class="kind">p${p.page_no}</span></a>${tree(p.page_no, next)}</li>`;
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')}