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.
- package/db/migrations/097_generated_app_quality.sql +911 -0
- package/db/migrations/098_app_activity.sql +37 -0
- package/docs/apex-feature-parity.md +5 -5
- package/docs/guide/03-builder.md +66 -18
- package/docs/guide/09-reference.md +6 -2
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/12-development.md +1 -1
- package/docs/guide/14-globalization.md +7 -3
- package/docs/guide/17-mobile.md +1 -1
- package/package.json +1 -1
- package/public/app.css +175 -33
- package/public/app.js +80 -0
- package/public/builder.css +30 -2
- package/public/builder.js +31 -0
- package/src/builder/appfromfile.ts +5 -4
- package/src/builder/appsheets.ts +23 -9
- package/src/builder/appwizard.ts +215 -70
- package/src/builder/blueprints.ts +2 -1
- package/src/builder/components.ts +1 -1
- package/src/builder/home.ts +2 -32
- package/src/builder/newapp.ts +182 -5
- package/src/builder/routes.ts +4 -28
- package/src/i18n/ar.ts +1 -0
- package/src/i18n/cs.ts +1 -0
- package/src/i18n/da.ts +1 -0
- package/src/i18n/de.ts +1 -0
- package/src/i18n/el.ts +1 -0
- package/src/i18n/es.ts +1 -0
- package/src/i18n/fi.ts +1 -0
- package/src/i18n/fr.ts +1 -0
- package/src/i18n/he.ts +1 -0
- package/src/i18n/it.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/nb.ts +1 -0
- package/src/i18n/pl.ts +1 -0
- package/src/i18n/pt.ts +1 -0
- package/src/i18n/ru.ts +1 -0
- package/src/i18n/sv.ts +1 -0
- package/src/i18n/tr.ts +1 -0
- package/src/i18n/uk.ts +1 -0
- package/src/i18n/zh.ts +1 -0
- package/src/i18n.ts +2 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +2 -2
- package/src/runtime/charts.ts +8 -3
- package/src/runtime/devbar.ts +35 -14
- package/src/runtime/engine.ts +7 -4
- package/src/runtime/locale.ts +2 -2
- package/src/runtime/regions.ts +25 -2
- package/src/runtime/render.ts +18 -4
- package/src/runtime/report.ts +15 -9
- package/src/runtime/styles.ts +5 -2
package/src/builder/appwizard.ts
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
64
|
-
export
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
|
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',
|
|
89
|
-
|
|
90
|
-
|
|
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,
|
|
93
|
-
pages.push({ page: next, label: `${
|
|
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
|
|
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
|
-
// ----------------------------------------------------------------
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
235
|
+
return createPage(s, reply, schema, null);
|
|
205
236
|
});
|
|
206
237
|
|
|
207
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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: {
|
package/src/builder/home.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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);
|