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.
- package/db/migrations/097_generated_app_quality.sql +911 -0
- package/db/migrations/098_app_activity.sql +37 -0
- package/db/migrations/099_layout_positions.sql +29 -0
- package/docs/apex-feature-parity.md +38 -11
- package/docs/guide/03-builder.md +133 -31
- package/docs/guide/04-pages-and-regions.md +46 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/09-reference.md +10 -3
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/12-development.md +3 -2
- 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 +233 -33
- package/public/app.js +263 -0
- package/public/builder.css +142 -2
- package/public/builder.js +311 -3
- package/src/builder/appfromfile.ts +5 -4
- package/src/builder/appsheets.ts +23 -9
- package/src/builder/appwizard.ts +215 -70
- package/src/builder/arrange.ts +133 -17
- package/src/builder/blueprints.ts +2 -1
- package/src/builder/components.ts +29 -23
- package/src/builder/designer.ts +67 -18
- package/src/builder/forms.ts +4 -1
- package/src/builder/home.ts +2 -32
- package/src/builder/newapp.ts +182 -5
- package/src/builder/object-browser.ts +342 -0
- package/src/builder/pwa.ts +3 -3
- package/src/builder/routes.ts +86 -51
- package/src/builder/shared.ts +103 -1
- package/src/builder/sql.ts +13 -65
- package/src/builder/ui.ts +21 -6
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +2 -2
- package/src/metadata.ts +5 -1
- package/src/runtime/charts.ts +8 -3
- package/src/runtime/devbar.ts +35 -14
- package/src/runtime/engine.ts +13 -4
- package/src/runtime/format.ts +52 -0
- package/src/runtime/items.ts +27 -0
- package/src/runtime/locale.ts +5 -2
- package/src/runtime/regions.ts +61 -4
- package/src/runtime/render.ts +38 -8
- package/src/runtime/report.ts +149 -88
- package/src/runtime/routes.ts +34 -3
- 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
|
}
|
package/src/builder/arrange.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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);
|