pgkiln 0.41.0 → 0.43.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 (68) hide show
  1. package/db/migrations/097_generated_app_quality.sql +911 -0
  2. package/db/migrations/098_app_activity.sql +37 -0
  3. package/db/migrations/099_layout_positions.sql +29 -0
  4. package/docs/apex-feature-parity.md +38 -11
  5. package/docs/guide/03-builder.md +133 -31
  6. package/docs/guide/04-pages-and-regions.md +46 -3
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/09-reference.md +10 -3
  9. package/docs/guide/11-from-apex.md +1 -1
  10. package/docs/guide/12-development.md +3 -2
  11. package/docs/guide/14-globalization.md +7 -3
  12. package/docs/guide/17-mobile.md +1 -1
  13. package/package.json +1 -1
  14. package/public/app.css +233 -33
  15. package/public/app.js +263 -0
  16. package/public/builder.css +142 -2
  17. package/public/builder.js +311 -3
  18. package/src/builder/appfromfile.ts +5 -4
  19. package/src/builder/appsheets.ts +23 -9
  20. package/src/builder/appwizard.ts +215 -70
  21. package/src/builder/arrange.ts +133 -17
  22. package/src/builder/blueprints.ts +2 -1
  23. package/src/builder/components.ts +29 -23
  24. package/src/builder/designer.ts +67 -18
  25. package/src/builder/forms.ts +4 -1
  26. package/src/builder/home.ts +2 -32
  27. package/src/builder/newapp.ts +182 -5
  28. package/src/builder/object-browser.ts +342 -0
  29. package/src/builder/pwa.ts +3 -3
  30. package/src/builder/routes.ts +86 -51
  31. package/src/builder/shared.ts +103 -1
  32. package/src/builder/sql.ts +13 -65
  33. package/src/builder/ui.ts +21 -6
  34. package/src/i18n/ar.ts +17 -0
  35. package/src/i18n/cs.ts +17 -0
  36. package/src/i18n/da.ts +17 -0
  37. package/src/i18n/de.ts +17 -0
  38. package/src/i18n/el.ts +17 -0
  39. package/src/i18n/es.ts +17 -0
  40. package/src/i18n/fi.ts +17 -0
  41. package/src/i18n/fr.ts +17 -0
  42. package/src/i18n/he.ts +17 -0
  43. package/src/i18n/it.ts +17 -0
  44. package/src/i18n/ja.ts +17 -0
  45. package/src/i18n/ko.ts +17 -0
  46. package/src/i18n/nb.ts +17 -0
  47. package/src/i18n/pl.ts +17 -0
  48. package/src/i18n/pt.ts +17 -0
  49. package/src/i18n/ru.ts +17 -0
  50. package/src/i18n/sv.ts +17 -0
  51. package/src/i18n/tr.ts +17 -0
  52. package/src/i18n/uk.ts +17 -0
  53. package/src/i18n/zh.ts +17 -0
  54. package/src/i18n.ts +34 -0
  55. package/src/icons.ts +2 -1
  56. package/src/layout.ts +2 -2
  57. package/src/metadata.ts +5 -1
  58. package/src/runtime/charts.ts +8 -3
  59. package/src/runtime/devbar.ts +35 -14
  60. package/src/runtime/engine.ts +13 -4
  61. package/src/runtime/format.ts +52 -0
  62. package/src/runtime/items.ts +27 -0
  63. package/src/runtime/locale.ts +5 -2
  64. package/src/runtime/regions.ts +61 -4
  65. package/src/runtime/render.ts +38 -8
  66. package/src/runtime/report.ts +149 -88
  67. package/src/runtime/routes.ts +34 -3
  68. package/src/runtime/styles.ts +5 -2
@@ -5,11 +5,17 @@ import { html, raw, type Raw } from '../html.ts';
5
5
  import { icon } from '../icons.ts';
6
6
  import { LoadError } from '../dataload.ts';
7
7
  import type { Session } from '../session.ts';
8
- import { aliasFor, check, createdHtml, ITEM, type Created } from './appfromfile.ts';
8
+ import { aliasFor, check, ITEM, type Created } from './appfromfile.ts';
9
9
  import { addDashboard, parseBook } from './appsheets.ts';
10
- import { checkNewApp, createApp, createAppError, reservedSchema, type CheckedApp } from './newapp.ts';
10
+ import { finishGeneratedApp, checkNewApp, createApp, createAppError, featuresFrom, FEATURES, reservedSchema, startFromBoilerplate, DEFAULT_APP_ICON, type CheckedApp, type Features } from './newapp.ts';
11
11
  import { currentWorkspace } from './workspaces.ts';
12
- import { BASE, csrf, developer, input, region, select, send, shell, type Body, type Req } from './ui.ts';
12
+ import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Body, type Req } from './ui.ts';
13
+ import { iconPicker } from './forms.ts';
14
+ import { inWorkspaceSql } from './workspaces.ts';
15
+ import { BASE_STYLES } from '../runtime/styles.ts';
16
+ import { LANGUAGE_NAMES } from '../i18n.ts';
17
+ import { secretKeyConfigured } from '../secrets.ts';
18
+ import { appPublicKey } from '../push.ts';
13
19
 
14
20
  // Two more ways into the create application wizard:
15
21
  // - From pasted data (APEX: Create App from a File → Copy and Paste): CSV or
@@ -38,6 +44,10 @@ interface Relation {
38
44
  pk: string | null;
39
45
  /** the planner's estimate (null: never analyzed) */
40
46
  rows: string | null;
47
+ /** (sprint 73) a date or timestamp column for a calendar */
48
+ calendar: boolean;
49
+ /** (sprint 73) a table with a foreign key to this one, for master-detail */
50
+ detail: boolean;
41
51
  }
42
52
 
43
53
  /** The tables, partitioned tables, views and materialized views of a schema (not partitions). */
@@ -45,7 +55,9 @@ export async function schemaRelations(schema: string): Promise<Relation[]> {
45
55
  return (
46
56
  await owner.query<Relation>(
47
57
  `select c.relname, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
48
- meta.wizard_pk(c.oid) as pk, case when c.reltuples >= 0 then c.reltuples::bigint end as rows
58
+ meta.wizard_pk(c.oid) as pk, case when c.reltuples >= 0 then c.reltuples::bigint end as rows,
59
+ meta.wizard_defaults('calendar', c.oid) ->> 'start' is not null as calendar,
60
+ meta.wizard_pk(c.oid) is not null and meta.wizard_defaults('master_detail', c.oid) ->> 'detail' is not null as detail
49
61
  from pg_class c join pg_namespace n on n.oid = c.relnamespace
50
62
  where n.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm') and not c.relispartition
51
63
  order by c.relkind in ('v', 'm'), c.relname`,
@@ -60,10 +72,40 @@ const appSchemas = async () =>
60
72
  (r) => r.nspname,
61
73
  );
62
74
 
63
- /** Create the application on existing tables and views, in one transaction. */
64
- export async function buildAppFromTables(checked: CheckedApp, relations: Relation[], chart: boolean): Promise<Created> {
65
- if (!relations.length) throw new LoadError('Choose at least one table or view.');
66
- if (relations.length > MAX_TABLES) throw new LoadError(`Choose at most ${MAX_TABLES} tables and views.`);
75
+ /** (sprint 73) The page types of the Create Application wizard (APEX: Add Page), per table. */
76
+ export const PAGE_KINDS: Record<string, string> = {
77
+ report_form: 'Interactive report with form',
78
+ facets: 'Faceted search',
79
+ cards: 'Cards',
80
+ calendar: 'Calendar',
81
+ master_detail: 'Master detail',
82
+ grid: 'Interactive grid',
83
+ report: 'Report',
84
+ '': 'No page',
85
+ };
86
+ /** More rows than this: an interactive grid (paged) instead of a report and form by default. */
87
+ export const LARGE_TABLE = 100_000;
88
+
89
+ /** The page types a table or view can have. */
90
+ export function kindsFor(r: Relation): string[] {
91
+ const keyed = r.kind === 'table' && !!r.pk;
92
+ return Object.keys(PAGE_KINDS).filter((k) =>
93
+ k === 'report_form' || k === 'grid' ? keyed : k === 'master_detail' ? keyed && r.detail : k === 'calendar' ? r.calendar : true,
94
+ );
95
+ }
96
+ /** The proposed page type: a report and form; a grid for a large table; a report for a view or a table without a single-column key. */
97
+ export const defaultKind = (r: Relation) => (r.kind === 'table' && r.pk ? (Number(r.rows ?? 0) > LARGE_TABLE ? 'grid' : 'report_form') : 'report');
98
+
99
+ export interface Plan {
100
+ rel: Relation;
101
+ kind: string;
102
+ }
103
+
104
+ /** Create the application on existing tables and views (each with its page type) and the features, in one transaction. */
105
+ export async function buildAppFromTables(checked: CheckedApp, plans: Plan[], chart: boolean, features: Partial<Features> = {}): Promise<Created> {
106
+ plans = plans.filter((p) => p.kind);
107
+ if (plans.length > MAX_TABLES) throw new LoadError(`Choose at most ${MAX_TABLES} tables and views.`);
108
+ for (const p of plans) if (!kindsFor(p.rel).includes(p.kind)) throw new LoadError(`${p.rel.relname} can't have the page type ${PAGE_KINDS[p.kind] ?? p.kind}.`);
67
109
  return owner.tx(async (c) => {
68
110
  const app = await createApp(c, checked).catch((e) => {
69
111
  throw new LoadError(createAppError(e, checked.alias));
@@ -72,33 +114,48 @@ export async function buildAppFromTables(checked: CheckedApp, relations: Relatio
72
114
  const notes: string[] = [];
73
115
  const tables: string[] = [];
74
116
  let next = 2;
75
- for (const r of relations) {
117
+ const generate = (kind: string, T: string, page: number, options: Record<string, unknown>) =>
118
+ c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, kind, T, page, JSON.stringify(options)]);
119
+ for (const { rel: r, kind } of plans) {
76
120
  const T = `${pg.escapeIdentifier(checked.schema)}.${pg.escapeIdentifier(r.relname)}`;
77
121
  tables.push(T);
78
- const label = (await c.query('select meta.wizard_label($1) as l', [r.relname])).rows[0].l as string;
79
- if (r.kind === 'table' && r.pk) {
80
- await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, 'report_form', T, next, JSON.stringify({ form_page: next + 1, label })]);
81
- pages.push({ page: next, label }, { page: next + 1, label: `${label} form` });
122
+ // (097) Employees for the report and the menu, Employee for the form
123
+ const { plural, singular } = (await c.query('select meta.wizard_table_label($1::regclass) as plural, meta.wizard_table_label($1::regclass, false) as singular', [T])).rows[0];
124
+ const withForm = !!r.pk && r.kind === 'table';
125
+ if (kind === 'report_form' || (withForm && ['facets', 'cards', 'calendar'].includes(kind))) {
126
+ await generate(kind, T, next, { form_page: next + 1 });
127
+ pages.push({ page: next, label: `${plural} (${PAGE_KINDS[kind].toLowerCase()})` }, { page: next + 1, label: `${singular} form` });
82
128
  next += 2;
129
+ } else if (kind !== 'report') {
130
+ await generate(kind, T, next, {});
131
+ pages.push({ page: next, label: `${plural} (${PAGE_KINDS[kind].toLowerCase()})` });
132
+ next += 1;
83
133
  } else {
84
134
  // a report of the rows (no form: a view, or no single-column primary key)
85
- const page = (await c.query(`select meta.wizard_new_page(a, $2, $3, a.home_page) as id from meta.app a where a.alias = $1`, [checked.alias, next, label])).rows[0].id;
135
+ const page = (await c.query(`select meta.wizard_new_page(a, $2, $3, a.home_page) as id from meta.app a where a.alias = $1`, [checked.alias, next, plural])).rows[0].id;
86
136
  await c.query(
87
- `insert into meta.region (page_id, seq, title, type, source)
88
- select $1, 10, $2, 'report', format(E'select %s\\n from %s', string_agg(quote_ident(column_name), ', ' order by ordinal), meta.wizard_qname($3::regclass))
89
- from meta.wizard_catalog($3::regclass) where kind <> 'binary'`,
90
- [page, label, T],
137
+ `insert into meta.region (page_id, seq, title, type, source, config)
138
+ select $1, 10, $2, 'report', w ->> 'source',
139
+ jsonb_build_object('headings', w -> 'headings')
140
+ || case when jsonb_array_length(w -> 'hidden') > 0 then jsonb_build_object('hidden', w -> 'hidden') else '{}' end
141
+ || case when w -> 'formats' <> '{}' then jsonb_build_object('formats', w -> 'formats') else '{}' end
142
+ from meta.wizard_report($3::regclass) w`,
143
+ [page, plural, T],
91
144
  );
92
- await c.query(`select meta.wizard_nav(a, $2, 'table', $3) from meta.app a where a.alias = $1`, [checked.alias, label, next]);
93
- pages.push({ page: next, label: `${label} (report)` });
145
+ await c.query(`select meta.wizard_nav(a, $2, 'table', $3) from meta.app a where a.alias = $1`, [checked.alias, plural, next]);
146
+ pages.push({ page: next, label: `${plural} (report)` });
94
147
  next += 1;
95
148
  }
96
149
  }
97
- if (chart) {
98
- const dash = await addDashboard(c, checked.alias, tables, next);
150
+ if (chart && tables.length) {
151
+ const skipped: string[] = [];
152
+ const dash = await addDashboard(c, checked.alias, tables, next, skipped);
99
153
  if (dash) pages.push(dash);
100
154
  else notes.push('No dashboard: no table has a foreign key, or a text, yes/no or date column whose statistics show a few values that repeat (ANALYZE the tables first).');
155
+ if (skipped.length)
156
+ notes.push(`Not on the dashboard: ${skipped.map((t) => t.replace(/^.*\./, '').replace(/"/g, '')).join(', ')} (row level security: the application's role may see none of the rows).`);
101
157
  }
158
+ await finishGeneratedApp(c, app.id, { description: checked.description, features, notes });
102
159
  return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, pages, notes };
103
160
  });
104
161
  }
@@ -159,75 +216,163 @@ export async function appWizardRoutes(app: FastifyInstance) {
159
216
  return reply.redirect(`${BASE}/create/file/${r!.id}?h=${headers ? '1' : '0'}`, 303);
160
217
  });
161
218
 
162
- // ---------------------------------------------------------------- existing tables
163
- const tablesForm = async (s: Session, schema: string, b: Body | null, message: Raw | '' = '') => {
164
- const schemas = await appSchemas();
165
- const known = schemas.includes(schema);
166
- const relations = known ? await schemaRelations(schema) : [];
167
- const v = (k: string, d: string) => (b ? String(b[k] ?? '') : d);
168
- const chosen = (r: Relation, i: number) => (b ? b[`t_${i}`] === r.relname : r.kind === 'table');
169
- return html`<h1>Create an application from existing tables</h1>
170
- <p class="muted">Pick the tables and views of a schema. The application gets the schema as its parsing schema and a database role app_&lt;alias&gt; with access to the schema's tables; each table gets an interactive report with a modal form (a view, or a table without a single-column primary key, gets a report), and a navigation entry.</p>
171
- ${message}
172
- ${region('Schema', html`<form method="get" action="${BASE}/create/tables">
173
- <div class="form-grid">${select('schema', 'Schema', schema, [['', '- choose a schema -'], ...schemas.map((x): [string, string] => [x, x])])}</div>
174
- <div class="buttons"><button class="btn">${icon('search')} Show its tables</button></div></form>`)}
175
- ${known
176
- ? html`<div class="u-spacer"></div><form method="post" action="${BASE}/create/tables">${csrf(s)}<input type="hidden" name="schema" value="${schema}">
177
- ${region(`Tables and views of ${schema}`, relations.length
178
- ? html`<ul class="aw-tables">${relations.map(
179
- (r, i) => html`<li><label class="check"><input type="checkbox" name="t_${i}" value="${r.relname}"${chosen(r, i) ? raw(' checked') : ''}> ${r.relname}</label>
180
- <small class="muted">${r.kind === 'view' ? 'view: a report' : `${r.rows === null ? '' : `about ${r.rows} row(s), `}${r.pk ? 'a report and form' : 'no single-column primary key: a report'}`}</small></li>`,
181
- )}</ul>`
182
- : html`<p class="muted">This schema has no tables or views.</p>`)}
183
- <div class="u-spacer"></div>
184
- ${region('Application', html`<div class="form-grid">
185
- ${input('name', 'Name', v('name', schema.replace(/_/g, ' ').replace(/^./, (x) => x.toUpperCase())), { required: true })}
186
- ${input('alias', 'Alias (URL)', v('alias', await aliasFor(schema)), { required: true, help: 'lowercase, e.g. inventory → /a/inventory' })}
187
- ${select('authentication', 'Authentication', v('authentication', 'app_users'), [['app_users', 'App users (login page)'], ['none', 'None (public)']])}
188
- ${input('admin_user', 'First user', v('admin_user', ''), { placeholder: 'e.g. your name', help: 'Gets the admin role. An existing account in Users is reused.' })}
189
- ${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'For a new account; at least 8 characters.' })}
190
- </div>`)}
191
- <div class="u-spacer"></div>
192
- ${region('Pages', html`${check('chart', 'Dashboard: a chart per table with the number of rows per parent row (a foreign key) or per value of a column whose values repeat', b ? b.chart === 'true' : true)}`)}
193
- <div class="buttons"><a class="btn" href="${BASE}/create">Cancel</a><button class="btn btn-hot">Create application</button></div>
194
- </form>`
195
- : schema
196
- ? html`<div class="alert alert-error" role="alert">Choose one of the schemas in the list.</div>`
197
- : ''}`;
198
- };
219
+ // ---------------------------------------------------------------- the Create Application wizard
220
+ // (sprint 73) One page like APEX's: name, icon and description; appearance; the pages (with a schema:
221
+ // a page type per table and view); features; settings; a boilerplate under Advanced. Choosing a schema
222
+ // reloads the page with its tables (a GET form of its own, so nothing typed is sent in a URL).
223
+ app.get(`${BASE}/create`, async (req: Req, reply) => {
224
+ const s = await developer(req, reply);
225
+ if (!s) return;
226
+ const schema = typeof req.query.schema === 'string' ? req.query.schema : '';
227
+ return createPage(s, reply, schema, null);
228
+ });
199
229
 
230
+ // the earlier address of "From existing tables"
200
231
  app.get(`${BASE}/create/tables`, async (req: Req, reply) => {
201
232
  const s = await developer(req, reply);
202
233
  if (!s) return;
203
234
  const schema = typeof req.query.schema === 'string' ? req.query.schema : '';
204
- return page(s, reply, 'Create application from existing tables', 'Existing tables', await tablesForm(s, schema, null));
235
+ return createPage(s, reply, schema, null);
205
236
  });
206
237
 
207
- app.post(`${BASE}/create/tables`, async (req: Req, reply) => {
238
+ const create = async (req: Req, reply: FastifyReply, fromTables = false) => {
208
239
  const s = await developer(req, reply);
209
240
  if (!s) return;
210
241
  const b = req.body ?? {};
211
242
  const schema = String(b.schema ?? '');
212
243
  const failed = async (message: string) => {
213
244
  reply.code(422);
214
- return page(s, reply, 'Create application from existing tables', 'Existing tables',
215
- await tablesForm(s, schema, b, html`<div class="alert alert-error" role="alert">${message}</div>`));
245
+ return createPage(s, reply, schema, b, html`<div class="alert alert-error" role="alert">${message}</div>`);
216
246
  };
217
247
  let built: Created;
248
+ let pushKey = false;
218
249
  try {
219
- if (!schema || reservedSchema(schema) || !(await appSchemas()).includes(schema)) throw new LoadError('Choose one of the schemas in the list.');
250
+ if (schema && (reservedSchema(schema) || !(await appSchemas()).includes(schema))) throw new LoadError('Choose one of the schemas in the list.');
220
251
  const checked = await checkNewApp({ ...b, schema }, currentWorkspace(s)).catch((e) => {
221
252
  throw new LoadError((e as Error).message);
222
253
  });
223
- // only names of this schema's tables and views count (looked up again, never used as SQL)
224
- const relations = await schemaRelations(schema);
225
- const picked = new Set(Object.entries(b).filter(([k]) => /^t_\d+$/.test(k)).map(([, x]) => String(x)));
226
- built = await buildAppFromTables(checked, relations.filter((r) => picked.has(r.relname)), b.chart === 'true');
254
+ const features = featuresFrom(b, b.features === '1');
255
+ const boilerplate = /^\d{1,9}$/.test(b.boilerplate ?? '') ? Number(b.boilerplate) : null;
256
+ if (boilerplate && !(await boilerplates(s)).some((x) => x.id === boilerplate)) throw new LoadError('Choose a boilerplate application (an application of type Boilerplate).');
257
+ // the tables of the form: only names of this schema's tables and views count (looked up again, never used as SQL)
258
+ const relations = schema ? await schemaRelations(schema) : [];
259
+ const plans: Plan[] = [];
260
+ for (const [k, v] of Object.entries(b)) {
261
+ const m = /^t_(\d+)$/.exec(k);
262
+ const rel = m && relations.find((r) => r.relname === String(v));
263
+ if (!rel) continue;
264
+ // a page type per table; a ticked table of the earlier form gets the proposed one
265
+ const kind = b[`k_${m![1]}`] === undefined ? defaultKind(rel) : String(b[`k_${m![1]}`]);
266
+ if (kind) plans.push({ rel, kind });
267
+ }
268
+ const askedTables = fromTables || b.tables === '1' || Object.keys(b).some((k) => /^t_\d+$/.test(k));
269
+ if (askedTables && !plans.length) throw new LoadError('Choose at least one table or view.');
270
+ if (plans.length && boilerplate) throw new LoadError('A boilerplate application brings its own pages: choose no tables, or no boilerplate.');
271
+ if (plans.length) built = await buildAppFromTables(checked, plans, b.chart === 'true', features);
272
+ else {
273
+ built = await owner.tx(async (c) => {
274
+ const made = await createApp(c, checked).catch((e) => {
275
+ throw new LoadError(createAppError(e, checked.alias));
276
+ });
277
+ const notes: string[] = [];
278
+ if (boilerplate) await startFromBoilerplate(c, made.id, checked, boilerplate);
279
+ else await finishGeneratedApp(c, made.id, { description: checked.description, features, notes });
280
+ return { app: { id: made.id, alias: checked.alias, existingAccount: made.existingAccount }, pages: [], notes };
281
+ });
282
+ }
283
+ pushKey = features.push;
227
284
  } catch (e) {
228
285
  if (e instanceof LoadError || (e as { code?: string }).code) return failed((e as Error).message);
229
286
  throw e;
230
287
  }
231
- return page(s, reply, 'Create application from existing tables', 'Existing tables', createdHtml(built, String(b.admin_user ?? '').trim(), `${BASE}/create/tables`));
232
- });
288
+ // the push key of the new application, after the commit
289
+ if (pushKey && secretKeyConfigured()) await appPublicKey(built.app.id).catch(() => undefined);
290
+ // (sprint 73, APEX: straight to the new application's home) the result as a message on its page
291
+ const pages = built.pages.length ? ` with ${built.pages.length} page(s) besides Home` : '';
292
+ const account = built.app.existingAccount ? ` The existing account ${String(b.admin_user ?? '').trim()} got the admin role (its password was not changed).` : '';
293
+ flash(s, `Application ${built.app.alias} created${pages}.${account}${built.notes.length ? ` ${built.notes.join(' ')}` : ''}`);
294
+ return back(reply, s, `${BASE}/apps/${built.app.id}`);
295
+ };
296
+ app.post(`${BASE}/apps`, (req: Req, reply) => create(req, reply));
297
+ app.post(`${BASE}/create/tables`, (req: Req, reply) => create(req, reply, true));
298
+ }
299
+
300
+ /** The workspace's boilerplate applications (Advanced → Start from). */
301
+ const boilerplates = async (s: Session) =>
302
+ (await owner.query<{ id: number; name: string }>(`select id, name from meta.app a where app_type = 'boilerplate' and ${inWorkspaceSql('a', 1)} order by lower(name), id`, [currentWorkspace(s)])).rows;
303
+
304
+ /** Other ways to start (APEX: the Create Application wizard's sources). */
305
+ const SOURCES: [string, string, string, string][] = [
306
+ ['create/file', 'upload', 'From a file', 'CSV, Excel, JSON or XML: a table per sheet with its rows'],
307
+ ['create/paste', 'file', 'From pasted data', 'Rows copied from a spreadsheet, or CSV text'],
308
+ ['blueprints', 'layers', 'From a blueprint', 'Tables, pages, menu and sample rows described once (or drafted by AI)'],
309
+ ['gallery', 'grid', 'From the App Gallery', 'A starter application'],
310
+ ];
311
+
312
+ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Body | null, message: Raw | '' = '') {
313
+ const schemas = await appSchemas();
314
+ const known = schemas.includes(schema);
315
+ const relations = known ? await schemaRelations(schema) : [];
316
+ const bp = await boilerplates(s);
317
+ const v = (k: string, d: string) => (b ? String(b[k] ?? '') : d);
318
+ const features = featuresFrom(b ?? {}, !!b && b.features === '1');
319
+ // (sprint 73) the name proposed from the schema is selected on first focus, so typing replaces it (app.js data-select-once)
320
+ const suggested = known ? schema.replace(/_/g, ' ').replace(/^./, (x) => x.toUpperCase()) : '';
321
+ const kindOf = (r: Relation, i: number) => (b ? (b[`t_${i}`] === r.relname ? (b[`k_${i}`] === undefined ? defaultKind(r) : String(b[`k_${i}`])) : '') : defaultKind(r));
322
+ const rows = (r: Relation) => (r.rows === null ? 'not analyzed' : `about ${Number(r.rows).toLocaleString('en')} row(s)`);
323
+ const main = html`<div class="ab-narrow create-app">
324
+ <h1>Create an application</h1>
325
+ <nav class="create-sources" aria-label="Other ways to start">${SOURCES.map(
326
+ ([path, ic, label, help]) => html`<a class="create-source" href="${BASE}/${path}">${icon(ic)}<span><b>${label}</b><small class="muted">${help}</small></span></a>`,
327
+ )}</nav>
328
+ ${message}
329
+ ${schema && !known && !message ? html`<div class="alert alert-error" role="alert">Choose one of the schemas in the list.</div>` : ''}
330
+ ${region('Data', html`<form method="get" action="${BASE}/create" class="create-schema">
331
+ <div class="form-grid">${select('schema', 'Pages on the tables of', schema, [['', '- a new, empty schema (Home only) -'], ...schemas.map((x): [string, string] => [x, x])],
332
+ 'The application\'s parsing schema. Choose a schema to get a page per table and view; a new schema is named after the alias.')}</div>
333
+ <div class="buttons"><button class="btn">${icon('search')} Show its tables</button></div></form>`)}
334
+ <form method="post" action="${BASE}/apps" data-progress="Creating the application and its pages…">${csrf(s)}
335
+ <input type="hidden" name="schema" value="${known ? schema : ''}"><input type="hidden" name="features" value="1">
336
+ ${known ? html`<input type="hidden" name="tables" value="1">` : ''}
337
+ ${region('Application', html`<div class="form-grid">
338
+ <div class="field"><label class="label" for="f_name">Name</label>
339
+ <input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once></div>
340
+ ${input('description', 'Description', v('description', ''), { placeholder: 'One line for the home and About pages' })}
341
+ ${iconPicker('f_icon', 'icon', 'Icon', v('icon', DEFAULT_APP_ICON))}
342
+ </div>`)}
343
+ ${region('Appearance', html`<div class="form-grid">
344
+ ${select('base', 'Theme style', v('base', 'iris'), [['iris', 'Iris (light body, dark header and menu)'], ['lavender', 'Lavender (rounded, indigo)'], ['standard', 'Standard (blue, navy header)']])}
345
+ ${select('nav', 'Navigation', v('nav', 'side'), [['side', 'Side menu'], ['top', 'Top menu']])}
346
+ ${input('accent', 'Accent colour', v('accent', BASE_STYLES.iris.accent), { type: 'color', help: 'Of the theme style; the default stores nothing.' })}
347
+ </div>`)}
348
+ ${region('Pages', html`<ul class="aw-pages">
349
+ <li><span class="aw-page-kind">${icon('home')} Home</span><span class="muted">a hero with the name and icon, and a card per page</span></li>
350
+ ${known
351
+ ? relations.length
352
+ ? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
353
+ <label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.relname}</label>
354
+ <select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${PAGE_KINDS[k]}</option>`)}</select>
355
+ <small class="muted">${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small></li>`)
356
+ : html`<li class="muted">${schema} has no tables or views.</li>`
357
+ : html`<li class="muted">Choose a schema under Data for a page per table, or add pages with the page wizards later.</li>`}
358
+ </ul>
359
+ ${known ? check('chart', 'Dashboard: a chart per table with the number of rows per parent row (a foreign key) or per value of a column whose values repeat', b ? b.chart === 'true' : true) : ''}`)}
360
+ ${region('Features', html`<div class="aw-features">${FEATURES.map(([k, label, help]) => check(`f_${k}`, label, features[k], help))}</div>`)}
361
+ ${region('Settings', html`<div class="form-grid">
362
+ ${input('alias', 'Alias (URL)', v('alias', known ? await aliasFor(schema) : ''), { required: true, help: 'lowercase, e.g. inventory → /a/inventory' })}
363
+ ${select('authentication', 'Authentication', v('authentication', 'app_users'), [['app_users', 'App users (login page)'], ['none', 'None (public)']])}
364
+ ${input('admin_user', 'First user', v('admin_user', ''), { placeholder: 'e.g. your name', help: 'Gets the admin role. An existing account in Users is reused.' })}
365
+ ${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'For a new account; at least 8 characters.' })}
366
+ ${select('language', 'Language', v('language', 'en'), Object.entries(LANGUAGE_NAMES).map(([code, name]): [string, string] => [code, `${name} (${code})`]))}
367
+ </div>`)}
368
+ ${!known && bp.length
369
+ ? html`<details class="region region-standard aw-advanced"><summary class="region-header"><h2>Advanced</h2></summary><div class="region-body"><div class="form-grid">
370
+ ${select('boilerplate', 'Start from', v('boilerplate', ''), [['', '- a blank application -'], ...bp.map((x): [string, string] => [String(x.id), `${x.name} (boilerplate)`])],
371
+ 'A boilerplate application\'s pages, shared components and settings are copied; the new application keeps its own name, alias, role and authentication.')}
372
+ </div></div></details>`
373
+ : ''}
374
+ <div class="buttons"><a class="btn" href="${BASE}">Cancel</a><button class="btn btn-hot">Create application</button></div>
375
+ </form>
376
+ </div>`;
377
+ return send(reply, s, shell(s, 'Create application', [['App Builder', BASE], ['Create']], main));
233
378
  }
@@ -22,6 +22,19 @@ const options = (kind: string, field: string) => COMPONENTS[kind].fields.find((f
22
22
  export const REGION_TYPES = () => options('region', 'type');
23
23
  export const ITEM_TYPES = () => options('item', 'type');
24
24
  export const BUTTON_ACTIONS = () => options('button', 'action');
25
+ /** (099) display positions of regions and button positions (slots) of region buttons; body and region are the defaults (null) */
26
+ export const POSITIONS = () => options('region', 'position');
27
+ export const SLOTS = () => options('button', 'slot');
28
+ export const POSITION_LABELS: Record<string, string> = {
29
+ banner: 'Banner', breadcrumb: 'Breadcrumb bar', left: 'Left column', body: 'Body', right: 'Right column', footer: 'Footer',
30
+ };
31
+ export const SLOT_LABELS: Record<string, string> = {
32
+ region: 'Region buttons', create: 'Create', edit: 'Edit', change: 'Change', copy: 'Copy', help: 'Help', up: 'Up',
33
+ previous: 'Previous', close: 'Close', delete: 'Delete', next: 'Next',
34
+ };
35
+ /** The button positions in a region's header and in its bottom bar (previous, close, delete at the left; next at the right). */
36
+ export const TOP_SLOTS = ['create', 'edit', 'change', 'copy', 'help', 'up'];
37
+ export const BOTTOM_SLOTS = ['previous', 'close', 'delete', 'next'];
25
38
 
26
39
  /** Gallery names and icons. */
27
40
  export const REGION_LABELS: Record<string, [string, string]> = {
@@ -58,9 +71,10 @@ const NEW_REGION_SOURCE: Record<string, string | null> = {
58
71
  // ---------------------------------------------------------------- snapshots (undo / redo)
59
72
 
60
73
  interface Snap {
61
- region: { id: number; seq: number; columns: number }[];
74
+ // position and slot (099) are missing from snapshots taken before them: those leave them as they are
75
+ region: { id: number; seq: number; columns: number; position?: string | null }[];
62
76
  item: { id: number; seq: number; region_id: number | null }[];
63
- button: { id: number; seq: number; region_id: number | null }[];
77
+ button: { id: number; seq: number; region_id: number | null; slot?: string | null }[];
64
78
  }
65
79
  interface History {
66
80
  pid: number;
@@ -74,19 +88,22 @@ type Q = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[]; ro
74
88
 
75
89
  async function snapshot(c: Q, pid: number): Promise<Snap> {
76
90
  // one after the other: a transaction's client runs one query at a time
77
- const region = await c.query('select id, seq, columns from meta.region where page_id = $1 order by id', [pid]);
91
+ const region = await c.query('select id, seq, columns, position from meta.region where page_id = $1 order by id', [pid]);
78
92
  const item = await c.query('select id, seq, region_id from meta.item where page_id = $1 order by id', [pid]);
79
- const button = await c.query('select id, seq, region_id from meta.button where page_id = $1 order by id', [pid]);
93
+ const button = await c.query('select id, seq, region_id, slot from meta.button where page_id = $1 order by id', [pid]);
80
94
  return { region: region.rows, item: item.rows, button: button.rows };
81
95
  }
82
96
 
83
97
  /** Puts the layout of a snapshot back; components deleted since are skipped, regions that are gone become "no region". */
84
98
  async function restore(c: Q, pid: number, snap: Snap) {
85
99
  const regions = new Set((await c.query('select id from meta.region where page_id = $1', [pid])).rows.map((r) => r.id));
86
- for (const r of snap.region) await c.query('update meta.region set seq = $3, columns = $4 where id = $1 and page_id = $2', [r.id, pid, r.seq, r.columns]);
87
- for (const kind of ['item', 'button'] as const)
88
- for (const r of snap[kind])
89
- await c.query(`update ${COMPONENTS[kind].table} set seq = $3, region_id = $4 where id = $1 and page_id = $2`, [r.id, pid, r.seq, r.region_id !== null && regions.has(r.region_id) ? r.region_id : null]);
100
+ for (const r of snap.region)
101
+ await c.query('update meta.region set seq = $3, columns = $4, position = case when $5 then $6 else position end where id = $1 and page_id = $2', [r.id, pid, r.seq, r.columns, 'position' in r, r.position ?? null]);
102
+ for (const r of snap.item)
103
+ await c.query('update meta.item set seq = $3, region_id = $4 where id = $1 and page_id = $2', [r.id, pid, r.seq, r.region_id !== null && regions.has(r.region_id) ? r.region_id : null]);
104
+ for (const r of snap.button)
105
+ await c.query('update meta.button set seq = $3, region_id = $4, slot = case when $5 then $6 else slot end where id = $1 and page_id = $2',
106
+ [r.id, pid, r.seq, r.region_id !== null && regions.has(r.region_id) ? r.region_id : null, 'slot' in r, r.slot ?? null]);
90
107
  }
91
108
 
92
109
  function history(s: Session, pid: number): History {
@@ -128,17 +145,29 @@ async function targetRegion(c: Q, pid: number, value: string): Promise<number |
128
145
  /**
129
146
  * Moves a region, item or button. `dir` moves it one place up or down among
130
147
  * its neighbours; otherwise it goes before `before` (an id in the target
131
- * container, or '' for the end) in `region` (items and buttons; '' = page level).
148
+ * container, or '' for the end) in `region` (items and buttons; '' = page level),
149
+ * `position` (regions: a display position, '' = body) and `slot` (buttons: a
150
+ * button position, '' = with the region's other buttons).
132
151
  * Sequences in the target container are renumbered 10, 20, 30…
133
152
  */
134
- export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: number, o: { dir?: string; region?: string; before?: string }) {
153
+ export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: number, o: { dir?: string; region?: string; before?: string; position?: string; slot?: string }) {
135
154
  const table = COMPONENTS[kind].table;
136
155
  const row = (await c.query(`select * from ${table} where id = $1 and page_id = $2`, [id, pid])).rows[0];
137
156
  if (!row) throw new LayoutError(404, `${COMPONENTS[kind].label} not found on this page.`);
138
157
  let region: number | null = kind === 'region' ? null : row.region_id;
139
158
  if (kind !== 'region' && o.region !== undefined && !o.dir) region = await targetRegion(c, pid, o.region);
140
- const scope = kind === 'region' ? '' : ' and region_id is not distinct from $2';
141
- const list: number[] = (await c.query(`select id from ${table} where page_id = $1${scope} order by seq, id`, kind === 'region' ? [pid] : [pid, region])).rows.map((r) => r.id);
159
+ // the container: a region's display position; a button's region and position in it (page-level buttons have none)
160
+ let place: string = kind === 'region' ? (row.position ?? 'body') : kind === 'button' && region !== null ? (row.slot ?? 'region') : '';
161
+ if (kind === 'region' && o.position !== undefined && !o.dir) place = o.position || 'body';
162
+ if (kind === 'button' && o.slot !== undefined && !o.dir) place = o.slot || 'region';
163
+ if (kind === 'button' && region === null) place = '';
164
+ if (kind === 'region' && !POSITIONS().includes(place)) throw new LayoutError(400, 'Unknown position.');
165
+ if (kind === 'button' && place && !SLOTS().includes(place)) throw new LayoutError(400, 'Unknown button position.');
166
+ const scope = kind === 'region' ? " and coalesce(position, 'body') = $2"
167
+ : kind === 'button' ? " and region_id is not distinct from $2 and (region_id is null or coalesce(slot, 'region') = $3)"
168
+ : ' and region_id is not distinct from $2';
169
+ const params = kind === 'region' ? [pid, place] : kind === 'button' ? [pid, region, place] : [pid, region];
170
+ const list: number[] = (await c.query(`select id from ${table} where page_id = $1${scope} order by seq, id`, params)).rows.map((r) => r.id);
142
171
  const others = list.filter((x) => x !== id);
143
172
  let at: number;
144
173
  if (o.dir === 'up' || o.dir === 'down') {
@@ -154,8 +183,10 @@ export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: num
154
183
  if (at < 0) throw new LayoutError(400, 'The place to move to is not in that region.');
155
184
  }
156
185
  others.splice(at, 0, id);
186
+ const stored = place === 'body' || place === 'region' || place === '' ? null : place;
157
187
  for (const [i, x] of others.entries()) {
158
- if (kind === 'region') await c.query(`update ${table} set seq = $3 where id = $1 and page_id = $2`, [x, pid, (i + 1) * 10]);
188
+ if (kind === 'region') await c.query(`update ${table} set seq = $3, position = case when id = $4 then $5::text else position end where id = $1 and page_id = $2`, [x, pid, (i + 1) * 10, id, stored]);
189
+ else if (kind === 'button') await c.query(`update ${table} set seq = $3, region_id = case when id = $4 then $5::int else region_id end, slot = case when id = $4 then $6::text else slot end where id = $1 and page_id = $2`, [x, pid, (i + 1) * 10, id, region, stored]);
159
190
  else await c.query(`update ${table} set seq = $3, region_id = case when id = $4 then $5::int else region_id end where id = $1 and page_id = $2`, [x, pid, (i + 1) * 10, id, region]);
160
191
  }
161
192
  }
@@ -171,7 +202,7 @@ export async function spanRegion(c: Q, pid: number, id: number, o: { columns?: s
171
202
  }
172
203
 
173
204
  /** A new component from the gallery, with a working default; placed like moveComponent. Returns its id. */
174
- export async function createComponent(c: Q, pid: number, kind: LayoutKind, type: string, o: { region?: string; before?: string }) {
205
+ export async function createComponent(c: Q, pid: number, kind: LayoutKind, type: string, o: { region?: string; before?: string; position?: string; slot?: string }) {
175
206
  const page = (await c.query('select page_no from meta.page where id = $1', [pid])).rows[0];
176
207
  if (!page) throw new LayoutError(404, 'Page not found.');
177
208
  let id: number;
@@ -196,10 +227,62 @@ export async function createComponent(c: Q, pid: number, kind: LayoutKind, type:
196
227
  id = (await c.query(`insert into meta.button (page_id, region_id, seq, name, label, action) values ($1, $2, 999999, $3, 'New button', $4) returning id`, [pid, region, name, type])).rows[0].id;
197
228
  }
198
229
  }
199
- await moveComponent(c, pid, kind, id, { region: o.region ?? '', before: o.before ?? '' });
230
+ await moveComponent(c, pid, kind, id, { region: o.region ?? '', before: o.before ?? '', position: o.position ?? '', slot: o.slot ?? '' });
200
231
  return id;
201
232
  }
202
233
 
234
+ /** A copy of a row of a page component table with some columns changed; returns its id. */
235
+ async function copyRow(c: Q, kind: LayoutKind, id: number, over: Record<string, unknown>): Promise<number> {
236
+ const table = COMPONENTS[kind].table;
237
+ return (await c.query(
238
+ `insert into ${table} select (jsonb_populate_record(null::${table}, to_jsonb(x) || jsonb_build_object('id', nextval('${table}_id_seq')) || $2::jsonb)).* from ${table} x where id = $1 returning id`,
239
+ [id, JSON.stringify(over)],
240
+ )).rows[0].id;
241
+ }
242
+
243
+ /** NAME, or NAME_2, NAME_3… when the page has an item (or button) of that name already. */
244
+ async function freeName(c: Q, kind: 'item' | 'button', pid: number, name: string) {
245
+ const taken = new Set((await c.query(`select name from ${COMPONENTS[kind].table} where page_id = $1`, [pid])).rows.map((r) => r.name));
246
+ let out = name;
247
+ for (let i = 2; taken.has(out); i++) out = `${name}_${i}`;
248
+ return out;
249
+ }
250
+
251
+ /**
252
+ * (sprint 77, APEX: Duplicate and Copy to other Page in the rendering tree's menu) A copy of a region (with its
253
+ * items and buttons), an item or a button: on the same page right after the original, or at the end of another
254
+ * page of the same application (items named P<n>_ get that page's number; components of a region go to page level
255
+ * there). Item and button names are made unique on the page. Returns the copy's id.
256
+ */
257
+ export async function duplicateComponent(c: Q, pid: number, kind: LayoutKind, id: number, toPid: number = pid) {
258
+ const table = COMPONENTS[kind].table;
259
+ const row = (await c.query(`select * from ${table} where id = $1 and page_id = $2`, [id, pid])).rows[0];
260
+ if (!row) throw new LayoutError(404, `${COMPONENTS[kind].label} not found on this page.`);
261
+ const pages = (await c.query('select t.page_no from meta.page t join meta.page f on f.app_id = t.app_id where t.id = $1 and f.id = $2', [toPid, pid])).rows;
262
+ if (!pages.length) throw new LayoutError(400, 'That page is not in this application.');
263
+ const same = toPid === pid;
264
+ const rename = (name: string) => (same ? name : name.replace(/^P\d+_/, `P${pages[0].page_no}_`));
265
+ const copyChild = async (k: 'item' | 'button', x: any, regionId: number | null) =>
266
+ copyRow(c, k, x.id, { page_id: toPid, region_id: regionId, name: await freeName(c, k, toPid, rename(x.name)), ...(k === 'button' && regionId === null ? { slot: null } : {}) });
267
+ // where the copy goes: after the original in its container, or at the end of the other page
268
+ let place: { region?: string; position?: string; slot?: string; before: string } = { before: '' };
269
+ if (same) {
270
+ const scope = kind === 'region' ? " and coalesce(position, 'body') = coalesce($2, 'body')" : ' and region_id is not distinct from $2';
271
+ const list: number[] = (await c.query(`select id from ${table} where page_id = $1${scope} order by seq, id`, [pid, kind === 'region' ? row.position : row.region_id])).rows.map((r) => r.id);
272
+ const next = list[list.indexOf(id) + 1];
273
+ place = { region: String(row.region_id ?? ''), position: row.position ?? '', slot: row.slot ?? '', before: next ? String(next) : '' };
274
+ } else place = { region: '', position: row.position ?? '', slot: '', before: '' };
275
+ let copy: number;
276
+ if (kind === 'region') {
277
+ const staticId = row.static_id && !same && !(await c.query('select 1 from meta.region where page_id = $1 and static_id = $2', [toPid, row.static_id])).rowCount ? row.static_id : null;
278
+ copy = await copyRow(c, 'region', id, { page_id: toPid, static_id: staticId });
279
+ for (const k of ['item', 'button'] as const)
280
+ for (const x of (await c.query(`select id, name from ${COMPONENTS[k].table} where region_id = $1 order by seq, id`, [id])).rows) await copyChild(k, x, copy);
281
+ } else copy = await copyChild(kind, row, same ? row.region_id : null);
282
+ await moveComponent(c, toPid, kind, copy, place);
283
+ return copy;
284
+ }
285
+
203
286
  // ---------------------------------------------------------------- routes
204
287
 
205
288
  const wantsJson = (req: Req) => String(req.headers.accept ?? '').includes('application/json');
@@ -245,7 +328,7 @@ export async function arrangeRoutes(app: FastifyInstance) {
245
328
  const kind = b.kind;
246
329
  const id = int(b.id);
247
330
  if (!isKind(kind) || id === null) throw new LayoutError(400, 'Unknown component.');
248
- await recorded(s, pid, `Move ${await describe(kind, id)}`, (c) => moveComponent(c, pid, kind, id, { dir: b.dir, region: b.region, before: b.before }));
331
+ await recorded(s, pid, `Move ${await describe(kind, id)}`, (c) => moveComponent(c, pid, kind, id, { dir: b.dir, region: b.region, before: b.before, position: b.position, slot: b.slot }));
249
332
  return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} moved.` };
250
333
  });
251
334
 
@@ -262,11 +345,44 @@ export async function arrangeRoutes(app: FastifyInstance) {
262
345
  route('create', async (s, pid, b) => {
263
346
  const kind = b.kind;
264
347
  if (!isKind(kind)) throw new LayoutError(400, 'Unknown component type.');
265
- const id = await owner.tx((c) => createComponent(c, pid, kind, b.type ?? '', { region: b.region, before: b.before }));
348
+ const id = await owner.tx((c) => createComponent(c, pid, kind, b.type ?? '', { region: b.region, before: b.before, position: b.position, slot: b.slot }));
266
349
  keep(s, { pid, undo: [], redo: [] }); // earlier layout steps no longer line up with the new component
267
350
  return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} created; set its properties on the right.` };
268
351
  });
269
352
 
353
+ // (sprint 77) the rendering tree's menu: Duplicate, Copy to other Page
354
+ route('duplicate', async (s, pid, b) => {
355
+ const kind = b.kind;
356
+ const id = int(b.id);
357
+ if (!isKind(kind) || id === null) throw new LayoutError(400, 'Unknown component.');
358
+ const copy = await owner.tx((c) => duplicateComponent(c, pid, kind, id));
359
+ keep(s, { pid, undo: [], redo: [] });
360
+ return { sel: `${kind}-${copy}`, message: `${COMPONENTS[kind].label} duplicated.` };
361
+ });
362
+
363
+ app.post(`${BASE}/pages/:pid/layout/copy`, async (req: Req, reply) => {
364
+ const s = await developer(req, reply);
365
+ if (!s) return;
366
+ const b = (req.body ?? {}) as Record<string, string | undefined>;
367
+ const pid = int(req.params.pid);
368
+ const to = int(b.page);
369
+ const kind = b.kind;
370
+ const id = int(b.id);
371
+ try {
372
+ if (pid === null || !(await owner.one('select 1 as ok from meta.page where id = $1', [pid]))) throw new LayoutError(404, 'Page not found');
373
+ if (!isKind(kind) || id === null || to === null) throw new LayoutError(400, 'Unknown component or page.');
374
+ const copy = await owner.tx((c) => duplicateComponent(c, pid, kind, id, to));
375
+ if (to === pid) keep(s, { pid, undo: [], redo: [] });
376
+ flash(s, `${COMPONENTS[kind].label} copied.`);
377
+ await saveState(s);
378
+ const location = `${BASE}/pages/${to}?c=${kind}-${copy}`;
379
+ return wantsJson(req) ? reply.send({ ok: true, location }) : reply.redirect(location, 303);
380
+ } catch (e) {
381
+ if (!(e instanceof LayoutError)) throw e;
382
+ return wantsJson(req) ? reply.code(e.status).send({ ok: false, error: e.message }) : reply.code(e.status).type('text/plain').send(e.message);
383
+ }
384
+ });
385
+
270
386
  for (const op of ['undo', 'redo'] as const)
271
387
  route(op, async (s, pid) => {
272
388
  const h = history(s, pid);