pgkiln 0.84.0 → 0.86.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/155_mail_available.sql +23 -0
- package/docs/apex-feature-parity.md +2 -2
- package/docs/guide/03-builder.md +14 -5
- package/docs/guide/04-pages-and-regions.md +1 -1
- package/docs/guide/06-processing.md +7 -3
- package/docs/guide/07-dynamic-actions.md +3 -3
- package/docs/guide/09-reference.md +1 -0
- package/docs/guide/12-development.md +10 -1
- package/examples/gallery/README.md +23 -6
- package/examples/gallery/gallery.json +4 -0
- package/examples/gallery/samples/sample-approvals.sql +102 -11
- package/examples/gallery/samples/sample-calendar.sql +40 -5
- package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
- package/examples/gallery/samples/sample-grids.sql +35 -3
- package/examples/gallery/samples/sample-maps.sql +65 -7
- package/examples/gallery/samples/sample-processes.sql +466 -0
- package/examples/gallery/samples/sample-validations.sql +407 -0
- package/examples/gallery/samples/ux-patterns.sql +154 -14
- package/examples/hr/README.md +2 -0
- package/examples/hr/hr_80_notifications.sql +125 -0
- package/examples/hr/hr_81_vector_search_type.sql +33 -0
- package/package.json +1 -1
- package/public/app.css +8 -1
- package/public/app.js +28 -9
- package/src/app.ts +9 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3453 -0
- package/src/builder/i18n/en.ts +3453 -0
- package/src/builder/i18n/es.ts +3453 -0
- package/src/builder/i18n/fr.ts +3453 -0
- package/src/builder/i18n/it.ts +3453 -0
- package/src/builder/i18n/ja.ts +3453 -0
- package/src/builder/i18n/ko.ts +3453 -0
- package/src/builder/i18n/nl.ts +3453 -0
- package/src/builder/i18n/pt.ts +3453 -0
- package/src/builder/i18n/zh-cn.ts +3453 -0
- package/src/builder/i18n/zh-tw.ts +3453 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +27 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/runtime/engine.ts +6 -3
- package/src/runtime/lists.ts +2 -2
- package/src/runtime/push.ts +7 -1
- package/src/runtime/regions.ts +9 -4
package/src/builder/appwizard.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { LoadError } from '../dataload.ts';
|
|
|
7
7
|
import type { Session } from '../session.ts';
|
|
8
8
|
import { aliasFor, check, ITEM, type Created } from './appfromfile.ts';
|
|
9
9
|
import { addDashboard, parseBook } from './appsheets.ts';
|
|
10
|
-
import { finishGeneratedApp, checkNewApp, createApp, createAppError, featuresFrom, FEATURES, reservedSchema, startFromBoilerplate, DEFAULT_APP_ICON, type CheckedApp, type Features } from './newapp.ts';
|
|
10
|
+
import { finishGeneratedApp, checkNewApp, createApp, createAppError, featuresFrom, featureText, FEATURES, reservedSchema, startFromBoilerplate, DEFAULT_APP_ICON, type CheckedApp, type Features } from './newapp.ts';
|
|
11
11
|
import { currentWorkspace } from './workspaces.ts';
|
|
12
12
|
import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Body, type Req } from './ui.ts';
|
|
13
13
|
import { iconPicker } from './forms.ts';
|
|
@@ -16,6 +16,7 @@ import { BASE_STYLES } from '../runtime/styles.ts';
|
|
|
16
16
|
import { LANGUAGE_NAMES } from '../i18n.ts';
|
|
17
17
|
import { secretKeyConfigured } from '../secrets.ts';
|
|
18
18
|
import { appPublicKey } from '../push.ts';
|
|
19
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
19
20
|
|
|
20
21
|
// Two more ways into the create application wizard:
|
|
21
22
|
// - From pasted data (APEX: Create App from a File → Copy and Paste): CSV or
|
|
@@ -110,7 +111,7 @@ export interface Plan {
|
|
|
110
111
|
/** Create the application on existing tables and views (each with its page type) and the features, in one transaction. */
|
|
111
112
|
export async function buildAppFromTables(checked: CheckedApp, plans: Plan[], chart: boolean, features: Partial<Features> = {}): Promise<Created> {
|
|
112
113
|
plans = plans.filter((p) => p.kind);
|
|
113
|
-
if (plans.length > MAX_TABLES) throw new LoadError(
|
|
114
|
+
if (plans.length > MAX_TABLES) throw new LoadError(tx('aw.at_most_tables', { n: MAX_TABLES }));
|
|
114
115
|
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}.`);
|
|
115
116
|
return owner.tx(async (c) => {
|
|
116
117
|
const app = await createApp(c, checked).catch((e) => {
|
|
@@ -157,9 +158,9 @@ export async function buildAppFromTables(checked: CheckedApp, plans: Plan[], cha
|
|
|
157
158
|
const skipped: string[] = [];
|
|
158
159
|
const dash = await addDashboard(c, checked.alias, tables, next, skipped);
|
|
159
160
|
if (dash) pages.push(dash);
|
|
160
|
-
else notes.push('
|
|
161
|
+
else notes.push(tx('aw.no_dashboard'));
|
|
161
162
|
if (skipped.length)
|
|
162
|
-
notes.push(
|
|
163
|
+
notes.push(tx('aw.not_on_dashboard', { tables: skipped.map((t) => t.replace(/^.*\./, '').replace(/"/g, '')).join(', ') }));
|
|
163
164
|
}
|
|
164
165
|
await finishGeneratedApp(c, app.id, { description: checked.description, features, notes });
|
|
165
166
|
return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, pages, notes };
|
|
@@ -168,28 +169,27 @@ export async function buildAppFromTables(checked: CheckedApp, plans: Plan[], cha
|
|
|
168
169
|
|
|
169
170
|
export async function appWizardRoutes(app: FastifyInstance) {
|
|
170
171
|
const page = (s: Session, reply: FastifyReply, title: string, crumb: string, main: Raw) =>
|
|
171
|
-
send(reply, s, shell(s, title, [['
|
|
172
|
+
send(reply, s, shell(s, title, [[tx('aw.app_builder'), BASE], [tx('aw.create'), `${BASE}/create`], [crumb]], html`<div class="ab-narrow">${main}</div>`));
|
|
172
173
|
|
|
173
174
|
// ---------------------------------------------------------------- pasted data
|
|
174
175
|
const pasteForm = (s: Session, b: Body = {}, error?: string) =>
|
|
175
|
-
html`<h1
|
|
176
|
-
<p class="muted"
|
|
177
|
-
|
|
178
|
-
${region('Data', html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
176
|
+
html`<h1>${tx('aw.create_an_application_from_pasted')}</h1>
|
|
177
|
+
<p class="muted">${tx.rich('aw.paste_intro', { link: html`<a href="${BASE}/create/file">${tx('aw.upload_a_file')}</a>` })}</p>
|
|
178
|
+
${region(tx('aw.data'), html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
179
179
|
<form method="post" action="${BASE}/create/paste">${csrf(s)}
|
|
180
180
|
<div class="form-grid">
|
|
181
|
-
${input('title', '
|
|
182
|
-
<div class="field" data-wide><label class="label" for="f_data"
|
|
181
|
+
${input('title', tx('aw.name'), b.title ?? tx('aw.pasted_data'), { help: tx('aw.names_the_application_and_the') })}
|
|
182
|
+
<div class="field" data-wide><label class="label" for="f_data">${tx('aw.data')}</label>
|
|
183
183
|
<textarea id="f_data" name="data" class="code" rows="12" required spellcheck="false" placeholder="name,city,joined Ann,Utrecht,2026-01-05">${b.data ?? ''}</textarea></div>
|
|
184
|
-
<div class="field"><label class="check"><input type="checkbox" name="headers" value="true"${b.headers === undefined || b.headers === 'true' ? raw(' checked') : ''}>
|
|
184
|
+
<div class="field"><label class="check"><input type="checkbox" name="headers" value="true"${b.headers === undefined || b.headers === 'true' ? raw(' checked') : ''}> ${tx('aw.first_row_contains_column_names')}</label></div>
|
|
185
185
|
</div>
|
|
186
|
-
<div class="buttons"><a class="btn" href="${BASE}/create"
|
|
186
|
+
<div class="buttons"><a class="btn" href="${BASE}/create">${tx('aw.cancel')}</a><button class="btn btn-hot">${icon('upload')} ${tx('aw.next')}</button></div>
|
|
187
187
|
</form>`)}`;
|
|
188
188
|
|
|
189
189
|
app.get(`${BASE}/create/paste`, async (req: Req, reply) => {
|
|
190
190
|
const s = await developer(req, reply);
|
|
191
191
|
if (!s) return;
|
|
192
|
-
return page(s, reply, '
|
|
192
|
+
return page(s, reply, tx('aw.paste_title'), tx('aw.pasted_data'), pasteForm(s));
|
|
193
193
|
});
|
|
194
194
|
|
|
195
195
|
app.post(`${BASE}/create/paste`, async (req: Req, reply) => {
|
|
@@ -198,19 +198,19 @@ export async function appWizardRoutes(app: FastifyInstance) {
|
|
|
198
198
|
const b = req.body ?? {};
|
|
199
199
|
const fail = (message: string) => {
|
|
200
200
|
reply.code(422);
|
|
201
|
-
return page(s, reply, '
|
|
201
|
+
return page(s, reply, tx('aw.paste_title'), tx('aw.pasted_data'), pasteForm(s, { ...b, headers: b.headers === 'true' ? 'true' : 'false' }, message));
|
|
202
202
|
};
|
|
203
203
|
const text = typeof b.data === 'string' ? b.data : '';
|
|
204
|
-
if (!text.trim()) return fail('
|
|
204
|
+
if (!text.trim()) return fail(tx('aw.paste_the_data'));
|
|
205
205
|
const data = Buffer.from(text, 'utf8');
|
|
206
|
-
if (data.length > MAX_PASTE) return fail(
|
|
206
|
+
if (data.length > MAX_PASTE) return fail(tx('aw.paste_max', { mb: MAX_PASTE / 1024 / 1024 }));
|
|
207
207
|
const filename = pastedName(String(b.title ?? ''));
|
|
208
208
|
const headers = b.headers === 'true';
|
|
209
209
|
try {
|
|
210
210
|
// read as delimited text whatever it looks like
|
|
211
211
|
const sheets = await parseBook(filename, data, { headers });
|
|
212
|
-
if (sheets[0].format !== 'csv') throw new LoadError('
|
|
213
|
-
if (!sheets[0].rows.length) throw new LoadError('
|
|
212
|
+
if (sheets[0].format !== 'csv') throw new LoadError(tx('aw.paste_delimited_text_csv_or'));
|
|
213
|
+
if (!sheets[0].rows.length) throw new LoadError(tx('aw.the_data_has_column_names'));
|
|
214
214
|
} catch (e) {
|
|
215
215
|
if (e instanceof LoadError) return fail(e.message);
|
|
216
216
|
throw e;
|
|
@@ -253,13 +253,13 @@ export async function appWizardRoutes(app: FastifyInstance) {
|
|
|
253
253
|
let built: Created;
|
|
254
254
|
let pushKey = false;
|
|
255
255
|
try {
|
|
256
|
-
if (schema && (reservedSchema(schema) || !(await appSchemas()).includes(schema))) throw new LoadError('
|
|
256
|
+
if (schema && (reservedSchema(schema) || !(await appSchemas()).includes(schema))) throw new LoadError(tx('aw.choose_one_of_the_schemas'));
|
|
257
257
|
const checked = await checkNewApp({ ...b, schema }, currentWorkspace(s)).catch((e) => {
|
|
258
258
|
throw new LoadError((e as Error).message);
|
|
259
259
|
});
|
|
260
260
|
const features = featuresFrom(b, b.features === '1');
|
|
261
261
|
const boilerplate = /^\d{1,9}$/.test(b.boilerplate ?? '') ? Number(b.boilerplate) : null;
|
|
262
|
-
if (boilerplate && !(await boilerplates(s)).some((x) => x.id === boilerplate)) throw new LoadError('
|
|
262
|
+
if (boilerplate && !(await boilerplates(s)).some((x) => x.id === boilerplate)) throw new LoadError(tx('aw.choose_a_boilerplate_application_an'));
|
|
263
263
|
// the tables of the form: only names of this schema's tables and views count (looked up again, never used as SQL)
|
|
264
264
|
const relations = schema ? await schemaRelations(schema) : [];
|
|
265
265
|
const plans: Plan[] = [];
|
|
@@ -272,8 +272,8 @@ export async function appWizardRoutes(app: FastifyInstance) {
|
|
|
272
272
|
if (kind) plans.push({ rel, kind });
|
|
273
273
|
}
|
|
274
274
|
const askedTables = fromTables || b.tables === '1' || Object.keys(b).some((k) => /^t_\d+$/.test(k));
|
|
275
|
-
if (askedTables && !plans.length) throw new LoadError('
|
|
276
|
-
if (plans.length && boilerplate) throw new LoadError('
|
|
275
|
+
if (askedTables && !plans.length) throw new LoadError(tx('aw.choose_at_least_one_table'));
|
|
276
|
+
if (plans.length && boilerplate) throw new LoadError(tx('aw.a_boilerplate_application_brings_its'));
|
|
277
277
|
if (plans.length) built = await buildAppFromTables(checked, plans, b.chart === 'true', features);
|
|
278
278
|
else {
|
|
279
279
|
built = await owner.tx(async (c) => {
|
|
@@ -294,9 +294,9 @@ export async function appWizardRoutes(app: FastifyInstance) {
|
|
|
294
294
|
// the push key of the new application, after the commit
|
|
295
295
|
if (pushKey && secretKeyConfigured()) await appPublicKey(built.app.id).catch(() => undefined);
|
|
296
296
|
// (sprint 73, APEX: straight to the new application's home) the result as a message on its page
|
|
297
|
-
const
|
|
298
|
-
const account = built.app.existingAccount ? `
|
|
299
|
-
flash(s,
|
|
297
|
+
const created = built.pages.length ? tx('aw.created_pages', { alias: built.app.alias, n: built.pages.length }) : tx('aw.created', { alias: built.app.alias });
|
|
298
|
+
const account = built.app.existingAccount ? ` ${tx('aw.existing_account', { user: String(b.admin_user ?? '').trim() })}` : '';
|
|
299
|
+
flash(s, `${created}${account}${built.notes.length ? ` ${built.notes.join(' ')}` : ''}`);
|
|
300
300
|
return back(reply, s, `${BASE}/apps/${built.app.id}`);
|
|
301
301
|
};
|
|
302
302
|
app.post(`${BASE}/apps`, (req: Req, reply) => create(req, reply));
|
|
@@ -322,8 +322,8 @@ const SOURCES: [string, string, string, string][] = [
|
|
|
322
322
|
const rlsBadge = (r: Relation) =>
|
|
323
323
|
!r.rls ? ''
|
|
324
324
|
: r.policies > 0
|
|
325
|
-
? html` <span class="tag aw-rls" title="
|
|
326
|
-
: html` <span class="tag tag-error aw-rls" title="
|
|
325
|
+
? html` <span class="tag aw-rls" title="${r.policies === 1 ? tx('aw.rls_on_1') : tx('aw.rls_on', { n: r.policies })}">${icon('shield')} RLS · ${r.policies}</span>`
|
|
326
|
+
: html` <span class="tag tag-error aw-rls" title="${tx('aw.row_level_security_is_on')}">${icon('shield')} ${tx('aw.rls_no_policies')}</span>`;
|
|
327
327
|
|
|
328
328
|
async function createPage(s: Session, reply: FastifyReply, schema: string, b: Body | null, message: Raw | '' = '') {
|
|
329
329
|
const schemas = await appSchemas();
|
|
@@ -335,62 +335,62 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
|
|
|
335
335
|
// (sprint 73) the name proposed from the schema is selected on first focus, so typing replaces it (app.js data-select-once)
|
|
336
336
|
const suggested = known ? schema.replace(/_/g, ' ').replace(/^./, (x) => x.toUpperCase()) : '';
|
|
337
337
|
const kindOf = (r: Relation, i: number) => (b ? (b[`t_${i}`] === r.relname ? (b[`k_${i}`] === undefined ? defaultKind(r) : String(b[`k_${i}`])) : '') : defaultKind(r));
|
|
338
|
-
const rows = (r: Relation) => (r.rows === null ? '
|
|
338
|
+
const rows = (r: Relation) => (r.rows === null ? tx('aw.not_analyzed') : tx('aw.about_rows', { n: Number(r.rows).toLocaleString('en') }));
|
|
339
339
|
const main = html`<div class="ab-narrow create-app">
|
|
340
|
-
<h1
|
|
341
|
-
<nav class="create-sources" aria-label="
|
|
342
|
-
([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>`,
|
|
340
|
+
<h1>${tx('aw.create_an_application')}</h1>
|
|
341
|
+
<nav class="create-sources" aria-label="${tx('aw.other_ways_to_start')}">${SOURCES.map(
|
|
342
|
+
([path, ic, label, help]) => html`<a class="create-source" href="${BASE}/${path}">${icon(ic)}<span><b>${tx.dyn(`aw.src.${path.replace(/^create\//, '')}`, label)}</b><small class="muted">${tx.dyn(`aw.srch.${path.replace(/^create\//, '')}`, help)}</small></span></a>`,
|
|
343
343
|
)}</nav>
|
|
344
344
|
${message}
|
|
345
|
-
${schema && !known && !message ? html`<div class="alert alert-error" role="alert"
|
|
346
|
-
${region('
|
|
347
|
-
<div class="form-grid">${select('schema', '
|
|
348
|
-
'
|
|
349
|
-
<div class="buttons"><button class="btn">${icon('search')}
|
|
345
|
+
${schema && !known && !message ? html`<div class="alert alert-error" role="alert">${tx('aw.choose_one_of_the_schemas')}</div>` : ''}
|
|
346
|
+
${region(tx('aw.data'), html`<form method="get" action="${BASE}/create" class="create-schema">
|
|
347
|
+
<div class="form-grid">${select('schema', tx('aw.pages_on_the_tables_of'), schema, [['', tx('aw.a_new_empty_schema_home')], ...schemas.map((x): [string, string] => [x, x])],
|
|
348
|
+
tx('aw.schema_help'))}</div>
|
|
349
|
+
<div class="buttons"><button class="btn">${icon('search')} ${tx('aw.show_its_tables')}</button></div></form>`)}
|
|
350
350
|
<form method="post" action="${BASE}/apps" data-progress="Creating the application and its pages…">${csrf(s)}
|
|
351
351
|
<input type="hidden" name="features" value="1">
|
|
352
352
|
<div id="aw-schema-fields"><input type="hidden" name="schema" value="${known ? schema : ''}">${known ? html`<input type="hidden" name="tables" value="1">` : ''}</div>
|
|
353
|
-
${region('
|
|
354
|
-
<div class="field"><label class="label" for="f_name"
|
|
353
|
+
${region(tx('aw.application'), html`<div class="form-grid">
|
|
354
|
+
<div class="field"><label class="label" for="f_name">${tx('aw.name')}</label>
|
|
355
355
|
<input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once></div>
|
|
356
|
-
${input('description', '
|
|
357
|
-
${iconPicker('f_icon', 'icon', '
|
|
356
|
+
${input('description', tx('aw.description'), v('description', ''), { placeholder: tx('aw.one_line_for_the_home') })}
|
|
357
|
+
${iconPicker('f_icon', 'icon', tx('aw.icon'), v('icon', DEFAULT_APP_ICON))}
|
|
358
358
|
</div>`)}
|
|
359
|
-
${region('
|
|
360
|
-
${select('base', '
|
|
361
|
-
${select('nav', '
|
|
362
|
-
${input('accent', '
|
|
359
|
+
${region(tx('aw.appearance'), html`<div class="form-grid">
|
|
360
|
+
${select('base', tx('aw.theme_style'), v('base', 'iris'), [['iris', tx('aw.iris_light_body_dark_header')], ['lavender', tx('aw.lavender_rounded_indigo')], ['standard', tx('aw.standard_blue_navy_header')]])}
|
|
361
|
+
${select('nav', tx('aw.navigation'), v('nav', 'side'), [['side', tx('aw.side_menu')], ['top', tx('aw.top_menu')]])}
|
|
362
|
+
${input('accent', tx('aw.accent_colour'), v('accent', BASE_STYLES.iris.accent), { type: 'color', help: tx('aw.of_the_theme_style_the') })}
|
|
363
363
|
</div>`)}
|
|
364
|
-
${region('
|
|
365
|
-
<li><span class="aw-page-kind">${icon('home')}
|
|
364
|
+
${region(tx('aw.pages'), html`<ul class="aw-pages">
|
|
365
|
+
<li><span class="aw-page-kind">${icon('home')} ${tx('aw.home')}</span><span class="muted">${tx('aw.a_hero_with_the_name')}</span></li>
|
|
366
366
|
${known
|
|
367
367
|
? relations.length
|
|
368
368
|
? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
|
|
369
369
|
<label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
|
|
370
|
-
<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>
|
|
371
|
-
<small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? '
|
|
372
|
-
: html`<li class="muted">${
|
|
373
|
-
: html`<li class="muted"
|
|
370
|
+
<select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${tx.dyn(`aw.k.${k || 'none'}`, PAGE_KINDS[k])}</option>`)}</select>
|
|
371
|
+
<small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? tx('aw.view') : rows(r)}${r.kind === 'table' && !r.pk ? tx('aw.no_key') : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? tx('aw.paged_grid') : ''}</small>${rlsBadge(r)}</li>`)
|
|
372
|
+
: html`<li class="muted">${tx('aw.no_relations', { schema })}</li>`
|
|
373
|
+
: html`<li class="muted">${tx('aw.choose_a_schema_under_data')}</li>`}
|
|
374
374
|
</ul>
|
|
375
|
-
${known ? check('chart', '
|
|
376
|
-
${region('
|
|
377
|
-
${region('
|
|
378
|
-
<div class="field"><label class="label" for="f_alias"
|
|
375
|
+
${known ? check('chart', tx('aw.dashboard_a_chart_per_table'), b ? b.chart === 'true' : true) : ''}`, '', 'aw-pages')}
|
|
376
|
+
${region(tx('aw.features'), html`<div class="aw-features">${FEATURES.map(([k, label, help]) => { const [l, h] = featureText(k, label, help); return check(`f_${k}`, l, features[k], h); })}</div>`)}
|
|
377
|
+
${region(tx('aw.settings'), html`<div class="form-grid">
|
|
378
|
+
<div class="field"><label class="label" for="f_alias">${tx('aw.alias_url')}</label>
|
|
379
379
|
<input id="f_alias" name="alias" value="${v('alias', known ? await aliasFor(schema) : '')}" required pattern="[a-z][a-z0-9_\-]*" maxlength="50" data-derive-from="f_name"${b && b.alias ? raw(' data-edited') : ''}>
|
|
380
|
-
<small class="help"
|
|
381
|
-
${select('authentication', '
|
|
382
|
-
${input('admin_user', '
|
|
383
|
-
${input('admin_password', '
|
|
384
|
-
${select('language', '
|
|
380
|
+
<small class="help">${tx('aw.lowercase_e_g_inventory_a')}</small></div>
|
|
381
|
+
${select('authentication', tx('aw.authentication'), v('authentication', 'app_users'), [['app_users', tx('aw.app_users_login_page')], ['none', tx('aw.none_public')]])}
|
|
382
|
+
${input('admin_user', tx('aw.first_user'), v('admin_user', ''), { placeholder: tx('aw.e_g_your_name'), help: tx('aw.gets_the_admin_role_an') })}
|
|
383
|
+
${input('admin_password', tx('aw.password'), '', { type: 'password', auto: 'new-password', help: tx('aw.for_a_new_account_at') })}
|
|
384
|
+
${select('language', tx('aw.language'), v('language', 'en'), Object.entries(LANGUAGE_NAMES).map(([code, name]): [string, string] => [code, `${name} (${code})`]))}
|
|
385
385
|
</div>`)}
|
|
386
386
|
${!known && bp.length
|
|
387
|
-
? html`<details class="region region-standard aw-advanced"><summary class="region-header"><h2
|
|
388
|
-
${select('boilerplate', '
|
|
389
|
-
'
|
|
387
|
+
? html`<details class="region region-standard aw-advanced"><summary class="region-header"><h2>${tx('aw.advanced')}</h2></summary><div class="region-body"><div class="form-grid">
|
|
388
|
+
${select('boilerplate', tx('aw.start_from'), v('boilerplate', ''), [['', tx('aw.a_blank_application')], ...bp.map((x): [string, string] => [String(x.id), tx('aw.bp_option', { name: x.name })])],
|
|
389
|
+
tx('aw.bp_help'))}
|
|
390
390
|
</div></div></details>`
|
|
391
391
|
: ''}
|
|
392
|
-
<div class="buttons"><a class="btn" href="${BASE}"
|
|
392
|
+
<div class="buttons"><a class="btn" href="${BASE}">${tx('aw.cancel')}</a><button class="btn btn-hot">${tx('aw.create_application')}</button></div>
|
|
393
393
|
</form>
|
|
394
394
|
</div>`;
|
|
395
|
-
return send(reply, s, shell(s, '
|
|
395
|
+
return send(reply, s, shell(s, tx('aw.create_application'), [[tx('aw.app_builder'), BASE], [tx('aw.create')]], main));
|
|
396
396
|
}
|
package/src/builder/arrange.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
|
|
|
3
3
|
import { saveState, type Session } from '../session.ts';
|
|
4
4
|
import { COMPONENTS } from './components.ts';
|
|
5
5
|
import { BASE, developer, flash, type Req } from './ui.ts';
|
|
6
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
6
7
|
|
|
7
8
|
// Page designer layout: moving regions, items and buttons (drag and drop on
|
|
8
9
|
// the Layout tab, or the Move up / Move down / column span buttons), creating
|
|
@@ -142,7 +143,7 @@ async function targetRegion(c: Q, pid: number, value: string): Promise<number |
|
|
|
142
143
|
if (value === '') return null;
|
|
143
144
|
const id = int(value);
|
|
144
145
|
if (id === null || !(await c.query('select 1 from meta.region where id = $1 and page_id = $2', [id, pid])).rowCount)
|
|
145
|
-
throw new LayoutError(400, '
|
|
146
|
+
throw new LayoutError(400, tx('arr.that_region_is_not_on'));
|
|
146
147
|
return id;
|
|
147
148
|
}
|
|
148
149
|
|
|
@@ -165,8 +166,8 @@ export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: num
|
|
|
165
166
|
if (kind === 'region' && o.position !== undefined && !o.dir) place = o.position || 'body';
|
|
166
167
|
if (kind === 'button' && o.slot !== undefined && !o.dir) place = o.slot || 'region';
|
|
167
168
|
if (kind === 'button' && region === null) place = '';
|
|
168
|
-
if (kind === 'region' && !POSITIONS().includes(place)) throw new LayoutError(400, '
|
|
169
|
-
if (kind === 'button' && place && !SLOTS().includes(place)) throw new LayoutError(400, '
|
|
169
|
+
if (kind === 'region' && !POSITIONS().includes(place)) throw new LayoutError(400, tx('arr.unknown_position'));
|
|
170
|
+
if (kind === 'button' && place && !SLOTS().includes(place)) throw new LayoutError(400, tx('arr.unknown_button_position'));
|
|
170
171
|
const scope = kind === 'region' ? " and coalesce(position, 'body') = $2"
|
|
171
172
|
: kind === 'button' ? " and region_id is not distinct from $2 and (region_id is null or coalesce(slot, 'region') = $3)"
|
|
172
173
|
: ' and region_id is not distinct from $2';
|
|
@@ -178,13 +179,13 @@ export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: num
|
|
|
178
179
|
const now = list.indexOf(id);
|
|
179
180
|
at = Math.max(0, Math.min(others.length, now + (o.dir === 'up' ? -1 : 1)));
|
|
180
181
|
} else if (o.dir) {
|
|
181
|
-
throw new LayoutError(400, '
|
|
182
|
+
throw new LayoutError(400, tx('arr.unknown_direction'));
|
|
182
183
|
} else if (!o.before) {
|
|
183
184
|
at = others.length;
|
|
184
185
|
} else {
|
|
185
186
|
const before = int(o.before);
|
|
186
187
|
at = before === null ? -1 : others.indexOf(before);
|
|
187
|
-
if (at < 0) throw new LayoutError(400, '
|
|
188
|
+
if (at < 0) throw new LayoutError(400, tx('arr.the_place_to_move_to'));
|
|
188
189
|
}
|
|
189
190
|
others.splice(at, 0, id);
|
|
190
191
|
const stored = place === 'body' || place === 'region' || place === '' ? null : place;
|
|
@@ -198,9 +199,9 @@ export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: num
|
|
|
198
199
|
/** Sets a region's column span (1–12), or widens / narrows it by one column. */
|
|
199
200
|
export async function spanRegion(c: Q, pid: number, id: number, o: { columns?: string; delta?: string }) {
|
|
200
201
|
const row = (await c.query('select columns from meta.region where id = $1 and page_id = $2', [id, pid])).rows[0];
|
|
201
|
-
if (!row) throw new LayoutError(404, '
|
|
202
|
+
if (!row) throw new LayoutError(404, tx('arr.region_not_found_on_this'));
|
|
202
203
|
const n = o.delta === '1' ? row.columns + 1 : o.delta === '-1' ? row.columns - 1 : int(o.columns);
|
|
203
|
-
if (n === null || n < 1 || n > 12) throw new LayoutError(400, '
|
|
204
|
+
if (n === null || n < 1 || n > 12) throw new LayoutError(400, tx('arr.the_column_span_is_1'));
|
|
204
205
|
await c.query('update meta.region set columns = $3 where id = $1 and page_id = $2', [id, pid, n]);
|
|
205
206
|
return n;
|
|
206
207
|
}
|
|
@@ -208,10 +209,10 @@ export async function spanRegion(c: Q, pid: number, id: number, o: { columns?: s
|
|
|
208
209
|
/** A new component from the gallery, with a working default; placed like moveComponent. Returns its id. */
|
|
209
210
|
export async function createComponent(c: Q, pid: number, kind: LayoutKind, type: string, o: { region?: string; before?: string; position?: string; slot?: string }) {
|
|
210
211
|
const page = (await c.query('select page_no from meta.page where id = $1', [pid])).rows[0];
|
|
211
|
-
if (!page) throw new LayoutError(404, '
|
|
212
|
+
if (!page) throw new LayoutError(404, tx('arr.page_not_found'));
|
|
212
213
|
let id: number;
|
|
213
214
|
if (kind === 'region') {
|
|
214
|
-
if (!REGION_TYPES().includes(type)) throw new LayoutError(400, '
|
|
215
|
+
if (!REGION_TYPES().includes(type)) throw new LayoutError(400, tx('arr.unknown_region_type'));
|
|
215
216
|
id = (await c.query(`insert into meta.region (page_id, seq, title, type, source) values ($1, 999999, $2, $3, $4) returning id`,
|
|
216
217
|
[pid, `New ${REGION_LABELS[type]?.[0].toLowerCase() ?? type}`, type, NEW_REGION_SOURCE[type] ?? null])).rows[0].id;
|
|
217
218
|
// (140) a breadcrumb goes in the breadcrumb bar, without a frame of its own (unless it is dropped elsewhere)
|
|
@@ -222,14 +223,14 @@ export async function createComponent(c: Q, pid: number, kind: LayoutKind, type:
|
|
|
222
223
|
} else {
|
|
223
224
|
const region = await targetRegion(c, pid, o.region ?? '');
|
|
224
225
|
if (kind === 'item') {
|
|
225
|
-
if (!ITEM_TYPES().includes(type)) throw new LayoutError(400, '
|
|
226
|
+
if (!ITEM_TYPES().includes(type)) throw new LayoutError(400, tx('arr.unknown_item_type'));
|
|
226
227
|
const taken = new Set((await c.query('select name from meta.item where page_id = $1', [pid])).rows.map((r) => r.name));
|
|
227
228
|
const base = `P${page.page_no}_NEW`;
|
|
228
229
|
let name = base;
|
|
229
230
|
for (let i = 2; taken.has(name); i++) name = `${base}_${i}`;
|
|
230
231
|
id = (await c.query(`insert into meta.item (page_id, region_id, seq, name, label, type) values ($1, $2, 999999, $3, 'New item', $4) returning id`, [pid, region, name, type])).rows[0].id;
|
|
231
232
|
} else {
|
|
232
|
-
if (!BUTTON_ACTIONS().includes(type)) throw new LayoutError(400, '
|
|
233
|
+
if (!BUTTON_ACTIONS().includes(type)) throw new LayoutError(400, tx('arr.unknown_button_action'));
|
|
233
234
|
const taken = new Set((await c.query('select name from meta.button where page_id = $1', [pid])).rows.map((r) => r.name));
|
|
234
235
|
let name = 'NEW_BUTTON';
|
|
235
236
|
for (let i = 2; taken.has(name); i++) name = `NEW_BUTTON_${i}`;
|
|
@@ -268,7 +269,7 @@ export async function duplicateComponent(c: Q, pid: number, kind: LayoutKind, id
|
|
|
268
269
|
const row = (await c.query(`select * from ${table} where id = $1 and page_id = $2`, [id, pid])).rows[0];
|
|
269
270
|
if (!row) throw new LayoutError(404, `${COMPONENTS[kind].label} not found on this page.`);
|
|
270
271
|
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;
|
|
271
|
-
if (!pages.length) throw new LayoutError(400, '
|
|
272
|
+
if (!pages.length) throw new LayoutError(400, tx('arr.that_page_is_not_in'));
|
|
272
273
|
const same = toPid === pid;
|
|
273
274
|
const rename = (name: string) => (same ? name : name.replace(/^P\d+_/, `P${pages[0].page_no}_`));
|
|
274
275
|
const copyChild = async (k: 'item' | 'button', x: any, regionId: number | null) =>
|
|
@@ -336,24 +337,24 @@ export async function arrangeRoutes(app: FastifyInstance) {
|
|
|
336
337
|
route('move', async (s, pid, b) => {
|
|
337
338
|
const kind = b.kind;
|
|
338
339
|
const id = int(b.id);
|
|
339
|
-
if (!isKind(kind) || id === null) throw new LayoutError(400, '
|
|
340
|
+
if (!isKind(kind) || id === null) throw new LayoutError(400, tx('arr.unknown_component'));
|
|
340
341
|
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 }));
|
|
341
342
|
return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} moved.` };
|
|
342
343
|
});
|
|
343
344
|
|
|
344
345
|
route('span', async (s, pid, b) => {
|
|
345
346
|
const id = int(b.id);
|
|
346
|
-
if (id === null) throw new LayoutError(400, '
|
|
347
|
+
if (id === null) throw new LayoutError(400, tx('arr.unknown_region'));
|
|
347
348
|
let n = 0;
|
|
348
349
|
await recorded(s, pid, `Resize ${await describe('region', id)}`, async (c) => {
|
|
349
350
|
n = await spanRegion(c, pid, id, { columns: b.columns, delta: b.delta });
|
|
350
351
|
});
|
|
351
|
-
return { sel: `region-${id}`, message:
|
|
352
|
+
return { sel: `region-${id}`, message: tx('arr.spans', { n }) };
|
|
352
353
|
});
|
|
353
354
|
|
|
354
355
|
route('create', async (s, pid, b) => {
|
|
355
356
|
const kind = b.kind;
|
|
356
|
-
if (!isKind(kind)) throw new LayoutError(400, '
|
|
357
|
+
if (!isKind(kind)) throw new LayoutError(400, tx('arr.unknown_component_type'));
|
|
357
358
|
const id = await owner.tx((c) => createComponent(c, pid, kind, b.type ?? '', { region: b.region, before: b.before, position: b.position, slot: b.slot }));
|
|
358
359
|
keep(s, { pid, undo: [], redo: [] }); // earlier layout steps no longer line up with the new component
|
|
359
360
|
return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} created; set its properties on the right.` };
|
|
@@ -365,7 +366,7 @@ export async function arrangeRoutes(app: FastifyInstance) {
|
|
|
365
366
|
route('comment', async (_s, pid, b) => {
|
|
366
367
|
const kind = b.kind ?? '';
|
|
367
368
|
const id = int(b.id);
|
|
368
|
-
if (!COMMENTABLE.has(kind) || id === null) throw new LayoutError(400, '
|
|
369
|
+
if (!COMMENTABLE.has(kind) || id === null) throw new LayoutError(400, tx('arr.unknown_component'));
|
|
369
370
|
const on = b.on !== 'false';
|
|
370
371
|
const done = await owner.tx(async (c) => {
|
|
371
372
|
if (on)
|
|
@@ -375,14 +376,14 @@ export async function arrangeRoutes(app: FastifyInstance) {
|
|
|
375
376
|
on conflict (app_id, name) do nothing`, [pid]);
|
|
376
377
|
return (await c.query(`update ${COMPONENTS[kind].table} set build_option = $3 where id = $1 and page_id = $2 and ${on ? 'true' : `build_option = 'COMMENTED_OUT'`}`, [id, pid, on ? 'COMMENTED_OUT' : null])).rowCount;
|
|
377
378
|
});
|
|
378
|
-
if (!done) throw new LayoutError(404, '
|
|
379
|
+
if (!done) throw new LayoutError(404, tx('arr.unknown_component'));
|
|
379
380
|
return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} ${on ? 'commented out: it stays in the page designer and is left out of the page (build option COMMENTED_OUT)' : 'back on the page'}.` };
|
|
380
381
|
});
|
|
381
382
|
|
|
382
383
|
route('duplicate', async (s, pid, b) => {
|
|
383
384
|
const kind = b.kind;
|
|
384
385
|
const id = int(b.id);
|
|
385
|
-
if (!isKind(kind) || id === null) throw new LayoutError(400, '
|
|
386
|
+
if (!isKind(kind) || id === null) throw new LayoutError(400, tx('arr.unknown_component'));
|
|
386
387
|
const copy = await owner.tx((c) => duplicateComponent(c, pid, kind, id));
|
|
387
388
|
keep(s, { pid, undo: [], redo: [] });
|
|
388
389
|
return { sel: `${kind}-${copy}`, message: `${COMPONENTS[kind].label} duplicated.` };
|
|
@@ -398,7 +399,7 @@ export async function arrangeRoutes(app: FastifyInstance) {
|
|
|
398
399
|
const id = int(b.id);
|
|
399
400
|
try {
|
|
400
401
|
if (pid === null || !(await owner.one('select 1 as ok from meta.page where id = $1', [pid]))) throw new LayoutError(404, 'Page not found');
|
|
401
|
-
if (!isKind(kind) || id === null || to === null) throw new LayoutError(400, '
|
|
402
|
+
if (!isKind(kind) || id === null || to === null) throw new LayoutError(400, tx('arr.unknown_component_or_page'));
|
|
402
403
|
const copy = await owner.tx((c) => duplicateComponent(c, pid, kind, id, to));
|
|
403
404
|
if (to === pid) keep(s, { pid, undo: [], redo: [] });
|
|
404
405
|
flash(s, `${COMPONENTS[kind].label} copied.`);
|
|
@@ -415,10 +416,10 @@ export async function arrangeRoutes(app: FastifyInstance) {
|
|
|
415
416
|
route(op, async (s, pid) => {
|
|
416
417
|
const h = history(s, pid);
|
|
417
418
|
const step = (op === 'undo' ? h.undo : h.redo).pop();
|
|
418
|
-
if (!step) return { sel: '', message:
|
|
419
|
+
if (!step) return { sel: '', message: op === 'undo' ? tx('arr.nothing_undo') : tx('arr.nothing_redo') };
|
|
419
420
|
await owner.tx((c) => restore(c, pid, op === 'undo' ? step.before : step.after));
|
|
420
421
|
(op === 'undo' ? h.redo : h.undo).push(step);
|
|
421
422
|
keep(s, h);
|
|
422
|
-
return { sel: '', message: `${op === 'undo' ? '
|
|
423
|
+
return { sel: '', message: `${op === 'undo' ? tx('arr.undone') : tx('arr.redone')}: ${step.label}.` };
|
|
423
424
|
});
|
|
424
425
|
}
|