pgkiln 0.41.0 → 0.42.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 (53) 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/docs/apex-feature-parity.md +5 -5
  4. package/docs/guide/03-builder.md +66 -18
  5. package/docs/guide/09-reference.md +6 -2
  6. package/docs/guide/11-from-apex.md +1 -1
  7. package/docs/guide/12-development.md +1 -1
  8. package/docs/guide/14-globalization.md +7 -3
  9. package/docs/guide/17-mobile.md +1 -1
  10. package/package.json +1 -1
  11. package/public/app.css +175 -33
  12. package/public/app.js +80 -0
  13. package/public/builder.css +30 -2
  14. package/public/builder.js +31 -0
  15. package/src/builder/appfromfile.ts +5 -4
  16. package/src/builder/appsheets.ts +23 -9
  17. package/src/builder/appwizard.ts +215 -70
  18. package/src/builder/blueprints.ts +2 -1
  19. package/src/builder/components.ts +1 -1
  20. package/src/builder/home.ts +2 -32
  21. package/src/builder/newapp.ts +182 -5
  22. package/src/builder/routes.ts +4 -28
  23. package/src/i18n/ar.ts +1 -0
  24. package/src/i18n/cs.ts +1 -0
  25. package/src/i18n/da.ts +1 -0
  26. package/src/i18n/de.ts +1 -0
  27. package/src/i18n/el.ts +1 -0
  28. package/src/i18n/es.ts +1 -0
  29. package/src/i18n/fi.ts +1 -0
  30. package/src/i18n/fr.ts +1 -0
  31. package/src/i18n/he.ts +1 -0
  32. package/src/i18n/it.ts +1 -0
  33. package/src/i18n/ja.ts +1 -0
  34. package/src/i18n/ko.ts +1 -0
  35. package/src/i18n/nb.ts +1 -0
  36. package/src/i18n/pl.ts +1 -0
  37. package/src/i18n/pt.ts +1 -0
  38. package/src/i18n/ru.ts +1 -0
  39. package/src/i18n/sv.ts +1 -0
  40. package/src/i18n/tr.ts +1 -0
  41. package/src/i18n/uk.ts +1 -0
  42. package/src/i18n/zh.ts +1 -0
  43. package/src/i18n.ts +2 -0
  44. package/src/icons.ts +2 -1
  45. package/src/layout.ts +2 -2
  46. package/src/runtime/charts.ts +8 -3
  47. package/src/runtime/devbar.ts +35 -14
  48. package/src/runtime/engine.ts +7 -4
  49. package/src/runtime/locale.ts +2 -2
  50. package/src/runtime/regions.ts +25 -2
  51. package/src/runtime/render.ts +18 -4
  52. package/src/runtime/report.ts +15 -9
  53. 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
  }
@@ -9,7 +9,7 @@ import { icon } from '../icons.ts';
9
9
  import { clientIp, logActivity, type Session } from '../session.ts';
10
10
  import { builderService } from './ai-builder.ts';
11
11
  import { addDashboard } from './appsheets.ts';
12
- import { checkNewApp, createApp, createAppError } from './newapp.ts';
12
+ import { finishGeneratedApp, checkNewApp, createApp, createAppError } from './newapp.ts';
13
13
  import { currentWorkspace } from './workspaces.ts';
14
14
  import { blueprintFromApp } from '../blueprint-from-app.ts';
15
15
  import { devDbForApp } from '../workspace-db.ts';
@@ -284,6 +284,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
284
284
  throw new Error(createAppError(e, checked.alias));
285
285
  });
286
286
  const r = await buildBlueprint(c, blueprint, checked.role, { dashboard: (tables, no) => addDashboard(c, checked.alias, tables, no) });
287
+ await finishGeneratedApp(c, a.id, { description: checked.description });
287
288
  if (o.id) await c.query('update meta.blueprint set app_id = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4', [o.id, a.id, JSON.stringify(json), currentWorkspace(s)]);
288
289
  else await c.query('insert into meta.blueprint (name, spec, created_by, app_id, workspace_id) values ($1, $2, $3, $4, $5)', [o.name, JSON.stringify(json), s.username, a.id, currentWorkspace(s)]);
289
290
  return { ...r, existing: a.existingAccount };
@@ -149,7 +149,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
149
149
  { name: 'readonly_condition', label: 'Read-only condition (SQL)', kind: 'code', group: 'Security', help: 'When true the item is shown read-only and ignored on submit.' },
150
150
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
151
151
  buildOption('Security'),
152
- { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
152
+ { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
153
153
  ],
154
154
  },
155
155
  button: {
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
- import { APP_COLORS, BASE, csrf, developer, input, region, select, send, shell, type Req } from './ui.ts';
5
+ import { APP_COLORS, BASE, csrf, developer, input, region, send, shell, type Req } from './ui.ts';
6
6
  import { crossAppLinks } from './cross-app.ts';
7
7
  import type { Session } from '../session.ts';
8
8
  import { isAdmin } from './locks.ts';
@@ -190,37 +190,7 @@ export async function homeRoutes(app: FastifyInstance) {
190
190
  });
191
191
 
192
192
  // ---------------------------------------------------------------- create and import
193
- app.get(`${BASE}/create`, async (req: Req, reply) => {
194
- const s = await developer(req, reply);
195
- if (!s) return;
196
- const schemas = await owner.query(`select nspname from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta') order by 1`);
197
- const boilerplates = (await owner.query(`select id, name from meta.app a where app_type = 'boilerplate' and ${inWorkspaceSql('a', 1)} order by lower(name), id`, [currentWorkspace(s)])).rows;
198
- const main = html`<div class="ab-narrow">
199
- <h1>Create an application</h1>
200
- <p class="muted">A blank application with a Home page, its own database role and a parsing schema. Add pages with the page wizards afterwards.</p>
201
- <p><a class="btn" href="${BASE}/create/file">${icon('upload')} From a file</a> <span class="muted small">Or start from a spreadsheet (CSV, Excel, JSON or XML): a table per sheet with its rows, a report and form per table, foreign keys, a dashboard and a faceted search.</span></p>
202
- <p><a class="btn" href="${BASE}/create/paste">${icon('file')} From pasted data</a> <span class="muted small">Paste rows copied from a spreadsheet, or CSV or TSV text.</span></p>
203
- <p><a class="btn" href="${BASE}/blueprints">${icon('layers')} From a blueprint</a> <span class="muted small">Describe tables, pages, menu and sample rows as a blueprint (or let AI draft one), review it, create it.</span></p>
204
- <p><a class="btn" href="${BASE}/create/tables">${icon('table')} From existing tables</a> <span class="muted small">Pick the tables and views of a schema: a report and form per table, navigation and a dashboard.</span></p>
205
- ${region('Application', html`<form method="post" action="${BASE}/apps">${csrf(s)}
206
- <div class="form-grid">
207
- ${input('name', 'Name', '', { required: true })}
208
- ${input('alias', 'Alias (URL)', '', { required: true, help: 'lowercase, e.g. inventory → /a/inventory' })}
209
- ${boilerplates.length
210
- ? select('boilerplate', 'Start from', /^\d{1,9}$/.test(String(req.query?.boilerplate ?? '')) ? String(req.query!.boilerplate) : '', [['', '- a blank application (a Home page) -'], ...boilerplates.map((x): [string, string] => [String(x.id), `${x.name} (boilerplate)`])],
211
- 'A boilerplate application\'s pages, shared components and settings are copied; the new application keeps its own name, alias, role and authentication.')
212
- : ''}
213
- ${select('schema', 'Parsing schema', '', [['', '- new schema named after the alias -'], ...schemas.rows.map((r): [string, string] => [r.nspname, r.nspname])],
214
- 'A database role app_<alias> is created with access to this schema only; the app runs as that role.')}
215
- ${select('authentication', 'Authentication', 'app_users', [['app_users', 'App users (login page)'], ['none', 'None (public)']])}
216
- ${input('admin_user', 'First user', '', { placeholder: 'e.g. your name', help: 'Gets the admin role. An existing account in Users is reused.' })}
217
- ${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'For a new account; at least 8 characters.' })}
218
- </div>
219
- <div class="buttons"><a class="btn" href="${BASE}">Cancel</a><button class="btn btn-hot">Create application</button></div>
220
- </form>`)}
221
- </div>`;
222
- return send(reply, s, shell(s, 'Create application', [['App Builder', BASE], ['Create']], main));
223
- });
193
+ // (sprint 73) GET /create, the Create Application wizard, is in appwizard.ts
224
194
 
225
195
  app.get(`${BASE}/import`, async (req: Req, reply) => {
226
196
  const s = await developer(req, reply);