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
|
@@ -5,18 +5,19 @@ import { icon } from '../icons.ts';
|
|
|
5
5
|
import { clientIp, logActivity } from '../session.ts';
|
|
6
6
|
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
7
7
|
import { currentWorkspace } from './workspaces.ts';
|
|
8
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
8
9
|
|
|
9
10
|
// Application groups (092; APEX: Workspace Utilities → Application Groups): a
|
|
10
11
|
// workspace's named groups of applications. The App Builder home filters and
|
|
11
12
|
// labels applications by group. Every developer of the workspace manages them.
|
|
12
13
|
|
|
13
14
|
const NAME = /^\S(.{0,58}\S)?$/;
|
|
14
|
-
const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['
|
|
15
|
+
const crumbs = (extra: [string, string?][] = []): [string, string?][] => [[tx('rail.app_builder'), BASE], [tx('wu.title'), `${BASE}/utilities`], [tx('wu.app_groups'), `${BASE}/app-groups`], ...extra];
|
|
15
16
|
|
|
16
17
|
const groupError = (e: unknown) => {
|
|
17
18
|
const m = (e as Error).message;
|
|
18
|
-
if (m.includes('app_group_workspace_id_name_key')) return '
|
|
19
|
-
if (m.includes('app_group_name_check')) return '
|
|
19
|
+
if (m.includes('app_group_workspace_id_name_key')) return tx('agr.exists');
|
|
20
|
+
if (m.includes('app_group_name_check')) return tx('agr.bad_name');
|
|
20
21
|
return m;
|
|
21
22
|
};
|
|
22
23
|
|
|
@@ -37,36 +38,36 @@ export async function appGroupRoutes(app: FastifyInstance) {
|
|
|
37
38
|
[ws],
|
|
38
39
|
),
|
|
39
40
|
]);
|
|
40
|
-
const main = html`<div class="title-row"><h1
|
|
41
|
-
<p class="muted u-mt0"
|
|
41
|
+
const main = html`<div class="title-row"><h1>${tx('wu.app_groups')}</h1></div>
|
|
42
|
+
<p class="muted u-mt0">${tx('agr.intro', { workspace: s.workspace?.name ?? '' })}</p>
|
|
42
43
|
<div class="columns wide-left">
|
|
43
|
-
${region('
|
|
44
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
44
|
+
${region(tx('agr.groups'), groups.rows.length
|
|
45
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('agr.group')}</th><th class="num">${tx('agr.applications')}</th><th>${tx('agr.rename')}</th><th></th></tr></thead><tbody>
|
|
45
46
|
${groups.rows.map((g) => html`<tr>
|
|
46
|
-
<td data-label="
|
|
47
|
-
<td class="num" data-label="
|
|
48
|
-
<td data-label="
|
|
49
|
-
<label class="sr-only" for="g-${g.id}"
|
|
50
|
-
<button class="btn btn-sm"
|
|
51
|
-
<td><form method="post" action="${BASE}/app-groups/${g.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="
|
|
47
|
+
<td data-label="${tx('agr.group')}"><a href="${BASE}?group=${g.id}">${g.name}</a>${g.description ? html`<br><span class="muted small">${g.description}</span>` : ''}</td>
|
|
48
|
+
<td class="num" data-label="${tx('agr.applications')}">${g.apps}</td>
|
|
49
|
+
<td data-label="${tx('agr.rename')}"><form method="post" action="${BASE}/app-groups/${g.id}" class="search u-mwnone">${csrf(s)}
|
|
50
|
+
<label class="sr-only" for="g-${g.id}">${tx('agr.new_name_for', { name: g.name })}</label><input id="g-${g.id}" name="name" value="${g.name}" required maxlength="60">
|
|
51
|
+
<button class="btn btn-sm">${tx('agr.rename')}</button></form></td>
|
|
52
|
+
<td><form method="post" action="${BASE}/app-groups/${g.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('agr.delete_confirm', { name: g.name })}">${icon('trash')}<span class="sr-only">${tx('agr.delete_name', { name: g.name })}</span></button></form></td></tr>`)}
|
|
52
53
|
</tbody></table></div>`
|
|
53
|
-
: html`<p class="muted"
|
|
54
|
-
${region('
|
|
55
|
-
${input('name', '
|
|
56
|
-
${input('description', '
|
|
57
|
-
<div class="buttons"><button class="btn btn-hot">${icon('plus')}
|
|
54
|
+
: html`<p class="muted">${tx('agr.no_groups')}</p>`)}
|
|
55
|
+
${region(tx('agr.add_title'), html`<form method="post" action="${BASE}/app-groups">${csrf(s)}
|
|
56
|
+
${input('name', tx('agr.name'), '', { required: true, placeholder: tx('agr.name_ph') })}
|
|
57
|
+
${input('description', tx('agr.description'), '')}
|
|
58
|
+
<div class="buttons"><button class="btn btn-hot">${icon('plus')} ${tx('agr.add')}</button></div></form>`)}
|
|
58
59
|
</div>
|
|
59
60
|
<div class="u-spacer"></div>
|
|
60
|
-
${region('
|
|
61
|
+
${region(tx('agr.applications'), apps.rows.length
|
|
61
62
|
? html`<form method="post" action="${BASE}/app-groups/assign">${csrf(s)}
|
|
62
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
63
|
-
${apps.rows.map((a) => html`<tr><td data-label="
|
|
64
|
-
<td data-label="
|
|
65
|
-
<select id="a-${a.id}" name="app_${a.id}"><option value=""
|
|
63
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('agr.application')}</th><th>${tx('agr.group')}</th></tr></thead><tbody>
|
|
64
|
+
${apps.rows.map((a) => html`<tr><td data-label="${tx('agr.application')}"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
|
|
65
|
+
<td data-label="${tx('agr.group')}"><label class="sr-only" for="a-${a.id}">${tx('agr.group_of', { name: a.name })}</label>
|
|
66
|
+
<select id="a-${a.id}" name="app_${a.id}"><option value="">${tx('agr.no_group')}</option>${groups.rows.map((g) => html`<option value="${g.id}"${a.group_id === g.id ? raw(' selected') : ''}>${g.name}</option>`)}</select></td></tr>`)}
|
|
66
67
|
</tbody></table></div>
|
|
67
|
-
<div class="buttons"><button class="btn"${groups.rows.length ? '' : raw(' disabled')}
|
|
68
|
-
: html`<p class="muted"
|
|
69
|
-
return send(reply, s, shell(s, '
|
|
68
|
+
<div class="buttons"><button class="btn"${groups.rows.length ? '' : raw(' disabled')}>${tx('agr.save')}</button></div></form>`
|
|
69
|
+
: html`<p class="muted">${tx('agr.no_apps')}</p>`)}`;
|
|
70
|
+
return send(reply, s, shell(s, tx('wu.app_groups'), crumbs(), main));
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
app.post(`${BASE}/app-groups`, async (req: Req, reply) => {
|
|
@@ -74,10 +75,10 @@ export async function appGroupRoutes(app: FastifyInstance) {
|
|
|
74
75
|
if (!s) return;
|
|
75
76
|
const name = String(req.body?.name ?? '').trim();
|
|
76
77
|
try {
|
|
77
|
-
if (!NAME.test(name)) throw new Error('
|
|
78
|
+
if (!NAME.test(name)) throw new Error(tx('agr.bad_name'));
|
|
78
79
|
await owner.query('insert into meta.app_group (workspace_id, name, description) values ($1, $2, $3)', [currentWorkspace(s), name, String(req.body?.description ?? '').trim().slice(0, 2000) || null]);
|
|
79
80
|
await logActivity({ username: s.username, event: 'app_group', ip: clientIp(req), detail: `added ${name}` });
|
|
80
|
-
flash(s,
|
|
81
|
+
flash(s, tx('agr.added', { name }));
|
|
81
82
|
} catch (e) {
|
|
82
83
|
flash(s, groupError(e), 'error');
|
|
83
84
|
}
|
|
@@ -89,10 +90,10 @@ export async function appGroupRoutes(app: FastifyInstance) {
|
|
|
89
90
|
if (!s) return;
|
|
90
91
|
const name = String(req.body?.name ?? '').trim();
|
|
91
92
|
try {
|
|
92
|
-
if (!NAME.test(name)) throw new Error('
|
|
93
|
+
if (!NAME.test(name)) throw new Error(tx('agr.bad_name'));
|
|
93
94
|
const r = await owner.query('update meta.app_group set name = $3 where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s), name]);
|
|
94
95
|
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
95
|
-
flash(s,
|
|
96
|
+
flash(s, tx('agr.renamed', { name }));
|
|
96
97
|
} catch (e) {
|
|
97
98
|
flash(s, groupError(e), 'error');
|
|
98
99
|
}
|
|
@@ -105,7 +106,7 @@ export async function appGroupRoutes(app: FastifyInstance) {
|
|
|
105
106
|
const r = await owner.one<{ name: string }>('delete from meta.app_group where id = $1 and workspace_id = $2 returning name', [req.params.id, currentWorkspace(s)]);
|
|
106
107
|
if (!r) return reply.code(404).send('Not found');
|
|
107
108
|
await logActivity({ username: s.username, event: 'app_group', ip: clientIp(req), detail: `deleted ${r.name}` });
|
|
108
|
-
flash(s,
|
|
109
|
+
flash(s, tx('agr.deleted', { name: r.name }));
|
|
109
110
|
return back(reply, s, `${BASE}/app-groups`);
|
|
110
111
|
});
|
|
111
112
|
|
|
@@ -126,7 +127,7 @@ export async function appGroupRoutes(app: FastifyInstance) {
|
|
|
126
127
|
await c.query('insert into meta.app_group_member (app_id, group_id) values ($1, $2) on conflict (app_id) do update set group_id = excluded.group_id', [appId, groupId]);
|
|
127
128
|
}
|
|
128
129
|
});
|
|
129
|
-
flash(s, '
|
|
130
|
+
flash(s, tx('agr.saved'));
|
|
130
131
|
return back(reply, s, `${BASE}/app-groups`);
|
|
131
132
|
});
|
|
132
133
|
}
|
|
@@ -24,6 +24,7 @@ import { appWizardRoutes, PASTED } from './appwizard.ts';
|
|
|
24
24
|
import { finishGeneratedApp, checkNewApp, createApp, createAppError, reservedSchema, type CheckedApp } from './newapp.ts';
|
|
25
25
|
import { currentWorkspace } from './workspaces.ts';
|
|
26
26
|
import { BASE, csrf, developer, input, region, select, send, shell, type Body, type Req } from './ui.ts';
|
|
27
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
27
28
|
|
|
28
29
|
// Create → From a file (APEX: Create App from a File). Step 1 uploads a
|
|
29
30
|
// CSV/TSV, Excel .xlsx, JSON or XML file (parsed by src/dataload.ts, the
|
|
@@ -116,9 +117,9 @@ interface Built {
|
|
|
116
117
|
export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Body): Promise<Built> {
|
|
117
118
|
const tableName = String(b.table ?? '').trim();
|
|
118
119
|
const cols = chosenColumns(sheet, b);
|
|
119
|
-
if (!cols.length) throw new LoadError('
|
|
120
|
+
if (!cols.length) throw new LoadError(tx('aff.give_at_least_one_column'));
|
|
120
121
|
// the table goes into the app's schema; createTableSql checks the names and the types
|
|
121
|
-
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(tableName)) throw new LoadError(
|
|
122
|
+
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(tableName)) throw new LoadError(tx('aff.table_name_a_lower_case'));
|
|
122
123
|
const table = `${checked.schema}.${tableName}`;
|
|
123
124
|
const ddl = createTableSql(table, cols);
|
|
124
125
|
const headers = b.h !== '0';
|
|
@@ -127,7 +128,7 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
|
|
|
127
128
|
throw new LoadError(createAppError(e, checked.alias));
|
|
128
129
|
});
|
|
129
130
|
await c.query(ddl).catch((e) => {
|
|
130
|
-
throw new LoadError((e as { code?: string }).code === '42P07' ?
|
|
131
|
+
throw new LoadError((e as { code?: string }).code === '42P07' ? tx('aff.table_exists', { table }) : (e as Error).message);
|
|
131
132
|
});
|
|
132
133
|
// explicit grants too: an existing schema may have other default privileges
|
|
133
134
|
const T = (await c.query('select $1::regclass::text as t', [table])).rows[0].t as string;
|
|
@@ -159,7 +160,7 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
|
|
|
159
160
|
const repeated = (col: CatalogColumn) => col.distinct_values !== null && col.distinct_values >= 2 && col.distinct_values <= 50 && col.distinct_values < rows;
|
|
160
161
|
if (b.chart === 'true') {
|
|
161
162
|
const group = groupColumn(catalog, rows);
|
|
162
|
-
if (!group) notes.push('
|
|
163
|
+
if (!group) notes.push(tx('aff.no_dashboard'));
|
|
163
164
|
else {
|
|
164
165
|
await generate('chart', next, { label: 'Dashboard', icon: 'chart', nav: true, label_column: group, function: 'count', value_column: null });
|
|
165
166
|
pages.push({ page: next++, label: `Dashboard (rows per ${group})` });
|
|
@@ -172,7 +173,7 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
|
|
|
172
173
|
const col = catalog.find((x) => x.column_name === f);
|
|
173
174
|
return col && (col.kind !== 'text' || repeated(col));
|
|
174
175
|
});
|
|
175
|
-
if (!facets.length) notes.push('
|
|
176
|
+
if (!facets.length) notes.push(tx('aff.no_facets'));
|
|
176
177
|
else {
|
|
177
178
|
await generate('facets', next, { label: `Search ${label.toLowerCase()}`, icon: 'filter', nav: true, facets });
|
|
178
179
|
pages.push({ page: next++, label: `Faceted search (${facets.join(', ')})` });
|
|
@@ -195,43 +196,41 @@ export interface Created {
|
|
|
195
196
|
export function createdHtml(built: Created | Built, adminUser: string, another: string) {
|
|
196
197
|
const tables = 'table' in built ? [{ table: built.table, result: built.result }] : (built.tables ?? []);
|
|
197
198
|
const errors = tables.flatMap((t) => t.result.errors.map((e) => ({ ...e, table: t.table })));
|
|
198
|
-
const loaded = tables.map((t) =>
|
|
199
|
-
return html`<h1
|
|
200
|
-
<div class="alert alert-success" role="status"
|
|
201
|
-
built.app.existingAccount ? `
|
|
199
|
+
const loaded = tables.map((t) => (t.result.failed ? tx('aff.loaded_skipped', { table: t.table, n: t.result.inserted, skipped: t.result.failed }) : tx('aff.loaded', { table: t.table, n: t.result.inserted }))).join(' ');
|
|
200
|
+
return html`<h1>${tx('aff.application_created')}</h1>
|
|
201
|
+
<div class="alert alert-success" role="status">${tx('aff.created', { alias: built.app.alias })} ${loaded}${
|
|
202
|
+
built.app.existingAccount ? ` ${tx('aff.existing_account', { user: adminUser })}` : ''}</div>
|
|
202
203
|
${errors.length
|
|
203
|
-
? region('
|
|
204
|
+
? region(tx('aff.skipped_rows'), html`<div class="table-wrap"><table class="report"><thead><tr>${tables.length > 1 ? html`<th>${tx('aff.table')}</th>` : ''}<th class="num">${tx('aff.row')}</th><th>${tx('aff.error')}</th></tr></thead>
|
|
204
205
|
<tbody>${errors.map((e) => html`<tr>${tables.length > 1 ? html`<td>${e.table}</td>` : ''}<td class="num">${e.row}</td><td>${e.message}</td></tr>`)}</tbody></table></div>
|
|
205
|
-
${tables.some((t) => t.result.failed > t.result.errors.length) ? html`<p class="muted"
|
|
206
|
+
${tables.some((t) => t.result.failed > t.result.errors.length) ? html`<p class="muted">${tx('aff.the_first_errors_are_shown')}</p>` : ''}`)
|
|
206
207
|
: ''}
|
|
207
|
-
${'foreignKeys' in built && built.foreignKeys?.length ? region('
|
|
208
|
-
${region('
|
|
208
|
+
${'foreignKeys' in built && built.foreignKeys?.length ? region(tx('aff.foreign_keys'), html`<ul>${built.foreignKeys.map((x) => html`<li>${x}</li>`)}</ul>`) : ''}
|
|
209
|
+
${region(tx('aff.pages'), html`<ul>${built.pages.map((p) => html`<li>${tx('aff.page_item', { n: p.page, label: p.label })}</li>`)}</ul>
|
|
209
210
|
${built.notes.map((n) => html`<p class="muted">${n}</p>`)}`)}
|
|
210
211
|
<div class="buttons u-mt1">
|
|
211
|
-
<a class="btn btn-hot" href="${BASE}/apps/${built.app.id}">${icon('edit')}
|
|
212
|
-
<a class="btn btn-run" href="/a/${built.app.alias}" target="_blank" rel="noopener">${icon('play')}
|
|
213
|
-
<a class="btn" href="${another}">${icon('plus')}
|
|
212
|
+
<a class="btn btn-hot" href="${BASE}/apps/${built.app.id}">${icon('edit')} ${tx('aff.edit_the_application')}</a>
|
|
213
|
+
<a class="btn btn-run" href="/a/${built.app.alias}" target="_blank" rel="noopener">${icon('play')} ${tx('aff.run')}</a>
|
|
214
|
+
<a class="btn" href="${another}">${icon('plus')} ${tx('aff.create_another')}</a>
|
|
214
215
|
</div>`;
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
export async function appFromFileRoutes(app: FastifyInstance) {
|
|
218
219
|
const page = (s: Session, reply: FastifyReply, main: Raw) =>
|
|
219
|
-
send(reply, s, shell(s, '
|
|
220
|
+
send(reply, s, shell(s, tx('aff.create_application_from_a_file'), [[tx('aff.app_builder'), BASE], [tx('aff.create'), `${BASE}/create`], [tx('aff.from_a_file')]], html`<div class="ab-narrow">${main}</div>`));
|
|
220
221
|
|
|
221
222
|
const uploadForm = (s: Session, error?: string) =>
|
|
222
|
-
html`<h1
|
|
223
|
-
<p class="muted"
|
|
224
|
-
|
|
225
|
-
<a href="${BASE}/create/paste">Paste data</a> instead, or <a href="${BASE}/create/tables">start from existing tables</a>.</p>
|
|
226
|
-
${region('File', html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
223
|
+
html`<h1>${tx('aff.create_an_application_from_a')}</h1>
|
|
224
|
+
<p class="muted">${tx.rich('aff.intro', { sheets: MAX_SHEETS, mb: MAX_MB, paste: html`<a href="${BASE}/create/paste">${tx('aff.paste_data')}</a>`, tables: html`<a href="${BASE}/create/tables">${tx('aff.start_from_existing_tables')}</a>` })}</p>
|
|
225
|
+
${region(tx('aff.file'), html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
227
226
|
<form method="post" action="${BASE}/create/file" enctype="multipart/form-data">${csrf(s)}
|
|
228
227
|
<div class="form-grid">
|
|
229
|
-
<div class="field"><label class="label" for="f_file"
|
|
228
|
+
<div class="field"><label class="label" for="f_file">${tx('aff.file')}</label>
|
|
230
229
|
<input type="file" id="f_file" name="file" accept=".csv,.tsv,.txt,.xlsx,.json,.jsonl,.xml,text/csv,application/json,application/xml,text/xml" required></div>
|
|
231
230
|
<div class="field"><span class="label" aria-hidden="true"></span>
|
|
232
|
-
<label class="check"><input type="checkbox" name="headers" value="true" checked>
|
|
231
|
+
<label class="check"><input type="checkbox" name="headers" value="true" checked> ${tx('aff.first_row_contains_column_names')}</label></div>
|
|
233
232
|
</div>
|
|
234
|
-
<div class="buttons"><a class="btn" href="${BASE}/create"
|
|
233
|
+
<div class="buttons"><a class="btn" href="${BASE}/create">${tx('aff.cancel')}</a><button class="btn btn-hot">${icon('upload')} ${tx('aff.next')}</button></div>
|
|
235
234
|
</form>`)}`;
|
|
236
235
|
|
|
237
236
|
// step 1: the file
|
|
@@ -254,12 +253,12 @@ export async function appFromFileRoutes(app: FastifyInstance) {
|
|
|
254
253
|
reply.code(422);
|
|
255
254
|
return page(s, reply, uploadForm(s, message));
|
|
256
255
|
};
|
|
257
|
-
if (!file) return fail('
|
|
258
|
-
if (file.truncated) return fail(
|
|
256
|
+
if (!file) return fail(tx('aff.choose_file'));
|
|
257
|
+
if (file.truncated) return fail(tx('aff.too_large', { mb: MAX_MB }));
|
|
259
258
|
const headers = req.body?.headers === 'true';
|
|
260
259
|
try {
|
|
261
260
|
const sheets = await parseBook(file.filename, file.data, { headers });
|
|
262
|
-
if (!sheets.some((x) => x.rows.length)) throw new LoadError('
|
|
261
|
+
if (!sheets.some((x) => x.rows.length)) throw new LoadError(tx('aff.the_file_has_column_names'));
|
|
263
262
|
} catch (e) {
|
|
264
263
|
if (e instanceof LoadError) return fail(e.message);
|
|
265
264
|
throw e;
|
|
@@ -284,36 +283,36 @@ export async function appFromFileRoutes(app: FastifyInstance) {
|
|
|
284
283
|
throw e;
|
|
285
284
|
}
|
|
286
285
|
const sheet = sheets[0];
|
|
287
|
-
const again = f.mime_type === PASTED ? html`<a href="${BASE}/create/paste"
|
|
286
|
+
const again = f.mime_type === PASTED ? html`<a href="${BASE}/create/paste">${tx('aff.paste_other_data')}</a>` : html`<a href="${BASE}/create/file">${tx('aff.choose_another_file')}</a>`;
|
|
288
287
|
const schemas = (await owner.query(`select nspname from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta') order by 1`)).rows;
|
|
289
288
|
const v = (k: string, d: string) => (b ? (b[k] ?? '') : d);
|
|
290
|
-
const appRegion = region('
|
|
291
|
-
${input('name', '
|
|
292
|
-
${input('alias', '
|
|
293
|
-
${select('schema', '
|
|
294
|
-
|
|
295
|
-
${select('authentication', '
|
|
296
|
-
${input('admin_user', '
|
|
297
|
-
${input('admin_password', '
|
|
289
|
+
const appRegion = region(tx('aff.application'), html`<div class="form-grid">
|
|
290
|
+
${input('name', tx('aff.name'), v('name', appNameFor(f.filename)), { required: true })}
|
|
291
|
+
${input('alias', tx('aff.alias_url'), v('alias', await aliasFor(f.filename)), { required: true, help: tx('aff.lowercase_e_g_inventory_a') })}
|
|
292
|
+
${select('schema', tx('aff.parsing_schema'), v('schema', ''), [['', tx('aff.new_schema_named_after_the')], ...schemas.map((r): [string, string] => [r.nspname, r.nspname])],
|
|
293
|
+
sheets.length > 1 ? tx('aff.schema_help_n') : tx('aff.schema_help_1'))}
|
|
294
|
+
${select('authentication', tx('aff.authentication'), v('authentication', 'app_users'), [['app_users', tx('aff.app_users_login_page')], ['none', tx('aff.none_public')]])}
|
|
295
|
+
${input('admin_user', tx('aff.first_user'), v('admin_user', ''), { placeholder: tx('aff.e_g_your_name'), help: tx('aff.gets_the_admin_role_an') })}
|
|
296
|
+
${input('admin_password', tx('aff.password'), '', { type: 'password', auto: 'new-password', help: tx('aff.for_a_new_account_at') })}
|
|
298
297
|
</div>`);
|
|
299
298
|
if (sheets.length > 1) {
|
|
300
|
-
return page(s, reply, html`<h1
|
|
299
|
+
return page(s, reply, html`<h1>${tx('aff.create_an_application_from_a')}</h1>
|
|
301
300
|
${message}
|
|
302
|
-
<p class="muted">${
|
|
301
|
+
<p class="muted">${tx(sheet.format === 'json' ? 'aff.arrays_info' : 'aff.sheets_info', { file: f.filename, n: sheets.length, list: sheets.map((x) => tx('aff.sheet_rows', { name: x.name, n: x.rows.length })).join(', ') })} ${again}</p>
|
|
303
302
|
<form method="post" action="${BASE}/create/file/${req.params.id}">${csrf(s)}<input type="hidden" name="h" value="${headers ? '1' : '0'}">
|
|
304
303
|
${appRegion}
|
|
305
304
|
<div class="u-spacer"></div>
|
|
306
305
|
${sheetsForm(sheets, b)}
|
|
307
306
|
<div class="u-spacer"></div>
|
|
308
|
-
${region('
|
|
309
|
-
${check('chart', '
|
|
310
|
-
${check('skip_errors', '
|
|
311
|
-
<div class="buttons"><a class="btn" href="${BASE}/create"
|
|
307
|
+
${region(tx('aff.pages'), html`<p class="muted u-mt0">${tx('aff.page_1_is_the_home')}</p>
|
|
308
|
+
${check('chart', tx('aff.dashboard_a_chart_per_table'), b ? b.chart === 'true' : true)}
|
|
309
|
+
${check('skip_errors', tx('aff.skip_rows_with_errors_otherwise'), v('skip_errors', '') === 'true')}`)}
|
|
310
|
+
<div class="buttons"><a class="btn" href="${BASE}/create">${tx('aff.cancel')}</a><button class="btn btn-hot" name="action" value="create">${tx('aff.create_application')}</button></div>
|
|
312
311
|
</form>`);
|
|
313
312
|
}
|
|
314
313
|
const suggested = suggestColumns(sheet);
|
|
315
|
-
const info = `${f.filename
|
|
316
|
-
sheet.format === 'xlsx' ? ', Excel' : sheet.format === 'json' ? ', JSON' : sheet.format === 'xml' ?
|
|
314
|
+
const info = `${tx('aff.info', { file: f.filename, rows: sheet.rows.length, cols: sheet.headers.length })}${
|
|
315
|
+
sheet.format === 'xlsx' ? ', Excel' : sheet.format === 'json' ? ', JSON' : sheet.format === 'xml' ? tx('aff.xml_rows', { path: `<${sheet.rowPath}>` }) : tx('aff.delimiter', { d: sheet.delimiter === '\t' ? tx('aff.tab') : `“${sheet.delimiter}”` })
|
|
317
316
|
}`;
|
|
318
317
|
const sample = (i: number) =>
|
|
319
318
|
sheet.rows
|
|
@@ -322,30 +321,30 @@ export async function appFromFileRoutes(app: FastifyInstance) {
|
|
|
322
321
|
.slice(0, SAMPLE)
|
|
323
322
|
.map((x) => (x.length > 30 ? `${x.slice(0, 30)}…` : x))
|
|
324
323
|
.join(' · ');
|
|
325
|
-
const main = html`<h1
|
|
324
|
+
const main = html`<h1>${tx('aff.create_an_application_from_a')}</h1>
|
|
326
325
|
${message}
|
|
327
|
-
${region('
|
|
326
|
+
${region(tx('aff.preview'), html`<p class="muted">${info}${sheet.rows.length > 10 ? ` ${tx('aff.first_10')}` : ''}. ${again}</p>${preview(sheet)}`)}
|
|
328
327
|
<div class="u-spacer"></div>
|
|
329
328
|
<form method="post" action="${BASE}/create/file/${req.params.id}">${csrf(s)}<input type="hidden" name="h" value="${headers ? '1' : '0'}">
|
|
330
329
|
${appRegion}
|
|
331
330
|
<div class="u-spacer"></div>
|
|
332
|
-
${region('
|
|
333
|
-
${input('table', '
|
|
331
|
+
${region(tx('aff.table'), html`<div class="form-grid">
|
|
332
|
+
${input('table', tx('aff.table_name'), v('table', tableNameFor(f.filename)), { required: true, help: tx('aff.in_the_application_s_schema') })}
|
|
334
333
|
</div>
|
|
335
|
-
<div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col"
|
|
334
|
+
<div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col">${tx('aff.file_column')}</th><th scope="col">${tx('aff.values')}</th><th scope="col">${tx('aff.col_name_head')}</th><th scope="col">${tx('aff.type')}</th></tr></thead><tbody>
|
|
336
335
|
${suggested.map((c) => {
|
|
337
336
|
const type = v(`type_${c.index}`, c.type);
|
|
338
337
|
return html`<tr><td>${sheet.headers[c.index]}</td><td class="muted small ff-sample">${sample(c.index)}</td>
|
|
339
|
-
<td><input name="name_${c.index}" value="${v(`name_${c.index}`, c.name)}" aria-label="
|
|
340
|
-
<td><select name="type_${c.index}" aria-label="
|
|
338
|
+
<td><input name="name_${c.index}" value="${v(`name_${c.index}`, c.name)}" aria-label="${tx('aff.col_name_for', { col: sheet.headers[c.index] })}"></td>
|
|
339
|
+
<td><select name="type_${c.index}" aria-label="${tx('aff.type_of', { col: sheet.headers[c.index] })}">${COLUMN_TYPES.map((t) => html`<option value="${t}"${t === type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`;
|
|
341
340
|
})}
|
|
342
341
|
</tbody></table></div>
|
|
343
|
-
${check('skip_errors', '
|
|
342
|
+
${check('skip_errors', tx('aff.skip_rows_with_errors_otherwise'), v('skip_errors', '') === 'true')}`)}
|
|
344
343
|
<div class="u-spacer"></div>
|
|
345
|
-
${region('
|
|
346
|
-
${check('chart', '
|
|
347
|
-
${check('facets', '
|
|
348
|
-
<div class="buttons"><a class="btn" href="${BASE}/create"
|
|
344
|
+
${region(tx('aff.pages'), html`<p class="muted u-mt0">${tx('aff.page_1_is_the_home_2')}</p>
|
|
345
|
+
${check('chart', tx('aff.dashboard_a_chart_with_the'), b ? b.chart === 'true' : true)}
|
|
346
|
+
${check('facets', tx('aff.faceted_search_a_report_with'), b ? b.facets === 'true' : true)}`)}
|
|
347
|
+
<div class="buttons"><a class="btn" href="${BASE}/create">${tx('aff.cancel')}</a><button class="btn btn-hot">${tx('aff.create_application')}</button></div>
|
|
349
348
|
</form>`;
|
|
350
349
|
return page(s, reply, main);
|
|
351
350
|
};
|
|
@@ -370,7 +369,7 @@ export async function appFromFileRoutes(app: FastifyInstance) {
|
|
|
370
369
|
if (b.action === 'preview') return settings(req, reply, s, b);
|
|
371
370
|
let built: Built | BuiltBook;
|
|
372
371
|
try {
|
|
373
|
-
if (reservedSchema(String(b.schema ?? ''))) throw new LoadError(
|
|
372
|
+
if (reservedSchema(String(b.schema ?? ''))) throw new LoadError(tx('aff.bad_schema', { schema: String(b.schema) }));
|
|
374
373
|
const checked = await checkNewApp(b, currentWorkspace(s)).catch((e) => {
|
|
375
374
|
throw new LoadError((e as Error).message);
|
|
376
375
|
});
|
package/src/builder/appsheets.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { preview } from './dataload.ts';
|
|
|
22
22
|
import { finishGeneratedApp, createApp, createAppError, type CheckedApp } from './newapp.ts';
|
|
23
23
|
import { groupColumn } from './appfromfile.ts';
|
|
24
24
|
import { region, type Body } from './ui.ts';
|
|
25
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
25
26
|
|
|
26
27
|
// Create → From a file with several sheets or tables (APEX: Create App from a
|
|
27
28
|
// File, several sheets). An Excel workbook with several sheets, or a JSON
|
|
@@ -66,7 +67,7 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
|
|
|
66
67
|
try {
|
|
67
68
|
book = await readXlsxFile<string>(data, { parseNumber: (s) => s, trim: true });
|
|
68
69
|
} catch (e) {
|
|
69
|
-
throw new LoadError(
|
|
70
|
+
throw new LoadError(tx('ash.bad_xlsx', { error: (e as Error).message }));
|
|
70
71
|
}
|
|
71
72
|
const sheets: NamedSheet[] = [];
|
|
72
73
|
let total = 0;
|
|
@@ -77,16 +78,16 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
|
|
|
77
78
|
try {
|
|
78
79
|
sheet = tableSheet('xlsx', rows, headers);
|
|
79
80
|
} catch (e) {
|
|
80
|
-
if (e instanceof LoadError) throw new LoadError(
|
|
81
|
+
if (e instanceof LoadError) throw new LoadError(tx('ash.sheet_error', { sheet: s.sheet, error: e.message }));
|
|
81
82
|
throw e;
|
|
82
83
|
}
|
|
83
84
|
if (!sheet.rows.length) continue;
|
|
84
85
|
total += sheet.rows.length;
|
|
85
86
|
sheets.push({ ...sheet, name: s.sheet });
|
|
86
87
|
}
|
|
87
|
-
if (sheets.length > MAX_SHEETS) throw new LoadError(
|
|
88
|
-
if (total > MAX_ROWS) throw new LoadError(
|
|
89
|
-
if (!sheets.length) throw new LoadError('
|
|
88
|
+
if (sheets.length > MAX_SHEETS) throw new LoadError(tx('ash.too_many_sheets', { n: sheets.length, max: MAX_SHEETS }));
|
|
89
|
+
if (total > MAX_ROWS) throw new LoadError(tx('ash.too_many_rows_wb', { n: total, max: MAX_ROWS }));
|
|
90
|
+
if (!sheets.length) throw new LoadError(tx('ash.the_file_has_column_names'));
|
|
90
91
|
return sheets;
|
|
91
92
|
}
|
|
92
93
|
if (/\.json$/i.test(filename) || /^\s*\{/.test(data.subarray(0, 64).toString('utf8').replace(/^/, ''))) {
|
|
@@ -101,9 +102,9 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
|
|
|
101
102
|
([, v]) => Array.isArray(v) && v.length && v.every((r) => r && typeof r === 'object' && !Array.isArray(r)),
|
|
102
103
|
) as [string, unknown[]][];
|
|
103
104
|
if (arrays.length > 1) {
|
|
104
|
-
if (arrays.length > MAX_SHEETS) throw new LoadError(
|
|
105
|
+
if (arrays.length > MAX_SHEETS) throw new LoadError(tx('ash.too_many_arrays', { n: arrays.length, max: MAX_SHEETS }));
|
|
105
106
|
const total = arrays.reduce((n, [, v]) => n + v.length, 0);
|
|
106
|
-
if (total > MAX_ROWS) throw new LoadError(
|
|
107
|
+
if (total > MAX_ROWS) throw new LoadError(tx('ash.too_many_rows', { n: total, max: MAX_ROWS }));
|
|
107
108
|
return arrays.map(([name, v]) => ({ ...parseJson(JSON.stringify(v)), name }));
|
|
108
109
|
}
|
|
109
110
|
}
|
|
@@ -235,18 +236,18 @@ export function fkChecked(f: ForeignKey, b: Body | null) {
|
|
|
235
236
|
/** CREATE TABLE for a planned table: an integer key column becomes an identity primary key; another key is unique and not null next to a new identity id. */
|
|
236
237
|
export function plannedTableSql(schema: string, p: TablePlan) {
|
|
237
238
|
const valid = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
238
|
-
const where =
|
|
239
|
-
if (!valid.test(p.table)) throw new LoadError(
|
|
240
|
-
if (!p.cols.length) throw new LoadError(
|
|
239
|
+
const where = tx('ash.sheet', { name: p.sheet.name });
|
|
240
|
+
if (!valid.test(p.table)) throw new LoadError(tx('ash.bad_table_name', { where }));
|
|
241
|
+
if (!p.cols.length) throw new LoadError(tx('ash.no_columns', { where }));
|
|
241
242
|
for (const c of p.cols) {
|
|
242
|
-
if (!valid.test(c.name)) throw new LoadError(
|
|
243
|
-
if (!COLUMN_TYPES.includes(c.type)) throw new LoadError(
|
|
243
|
+
if (!valid.test(c.name)) throw new LoadError(tx('ash.bad_column', { where, col: c.name }));
|
|
244
|
+
if (!COLUMN_TYPES.includes(c.type)) throw new LoadError(tx('ash.bad_type', { where, type: c.type }));
|
|
244
245
|
}
|
|
245
|
-
if (new Set(p.cols.map((c) => c.name)).size !== p.cols.length) throw new LoadError(
|
|
246
|
+
if (new Set(p.cols.map((c) => c.name)).size !== p.cols.length) throw new LoadError(tx('ash.dup_columns', { where }));
|
|
246
247
|
const ident = pg.escapeIdentifier;
|
|
247
248
|
const defs: string[] = [];
|
|
248
249
|
if (!identityKey(p)) {
|
|
249
|
-
if (p.cols.some((c) => c.name === 'id')) throw new LoadError(
|
|
250
|
+
if (p.cols.some((c) => c.name === 'id')) throw new LoadError(tx('ash.id_taken', { where }));
|
|
250
251
|
defs.push('id bigint generated by default as identity primary key');
|
|
251
252
|
}
|
|
252
253
|
for (const c of p.cols) {
|
|
@@ -366,9 +367,9 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
|
|
|
366
367
|
export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet[], b: Body): Promise<BuiltBook> {
|
|
367
368
|
const v = (name: string) => String(b[name] ?? '');
|
|
368
369
|
const plans = planFrom(sheets, v).filter((p) => p.on);
|
|
369
|
-
if (!plans.length) throw new LoadError('
|
|
370
|
+
if (!plans.length) throw new LoadError(tx('ash.choose_at_least_one_sheet'));
|
|
370
371
|
const ddl = plans.map((p) => plannedTableSql(checked.schema, p));
|
|
371
|
-
if (new Set(plans.map((p) => p.table)).size !== plans.length) throw new LoadError('
|
|
372
|
+
if (new Set(plans.map((p) => p.table)).size !== plans.length) throw new LoadError(tx('ash.each_sheet_needs_a_table'));
|
|
372
373
|
// only proposed foreign keys can be chosen (recomputed from the posted names and types)
|
|
373
374
|
const fks = proposeForeignKeys(plans).filter((f) => v(fkName(f)) === String(f.to));
|
|
374
375
|
const headers = b.h !== '0';
|
|
@@ -381,7 +382,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
|
|
|
381
382
|
const tables: BuiltBook['tables'] = [];
|
|
382
383
|
for (const [n, p] of plans.entries()) {
|
|
383
384
|
await c.query(ddl[n]).catch((e) => {
|
|
384
|
-
throw new LoadError((e as { code?: string }).code === '42P07' ?
|
|
385
|
+
throw new LoadError((e as { code?: string }).code === '42P07' ? tx('ash.table_exists', { table: `${checked.schema}.${p.table}` }) : (e as Error).message);
|
|
385
386
|
});
|
|
386
387
|
const T = (await c.query('select $1::regclass::text as t', [`${pg.escapeIdentifier(checked.schema)}.${pg.escapeIdentifier(p.table)}`])).rows[0].t as string;
|
|
387
388
|
qualified.set(p.k, T);
|
|
@@ -413,7 +414,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
|
|
|
413
414
|
const ident = pg.escapeIdentifier;
|
|
414
415
|
await c.query(`create index on ${A} (${ident(col)})`);
|
|
415
416
|
await c.query(`alter table ${A} add foreign key (${ident(col)}) references ${B} (${ident(key)})`).catch((e) => {
|
|
416
|
-
throw new LoadError(
|
|
417
|
+
throw new LoadError(tx('ash.fk_failed', { fk: `${a.table}.${col} → ${to.table}.${key}`, error: (e as { detail?: string }).detail ?? (e as Error).message }));
|
|
417
418
|
});
|
|
418
419
|
foreignKeys.push(`${a.table}.${col} → ${to.table}.${key}`);
|
|
419
420
|
}
|
|
@@ -432,7 +433,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
|
|
|
432
433
|
if (b.chart === 'true') {
|
|
433
434
|
const dash = await addDashboard(c, checked.alias, [...qualified.values()], next);
|
|
434
435
|
if (dash) pages.push(dash);
|
|
435
|
-
else notes.push('
|
|
436
|
+
else notes.push(tx('ash.no_dashboard'));
|
|
436
437
|
}
|
|
437
438
|
await finishGeneratedApp(c, app.id, { description: checked.description });
|
|
438
439
|
return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, tables, foreignKeys, pages, notes };
|
|
@@ -462,22 +463,22 @@ export function sheetsForm(sheets: NamedSheet[], b: Body | null): Raw {
|
|
|
462
463
|
const sections = sheets.map((sheet, k) => {
|
|
463
464
|
const id = field(k, 'table');
|
|
464
465
|
return region(
|
|
465
|
-
|
|
466
|
-
html`<div class="field">${checkbox(field(k, 'on'), 'true', v(field(k, 'on')) === 'true',
|
|
466
|
+
tx('ash.sheet', { name: sheet.name }),
|
|
467
|
+
html`<div class="field">${checkbox(field(k, 'on'), 'true', v(field(k, 'on')) === 'true', tx('ash.create_from_sheet', { rows: sheet.rows.length, cols: sheet.headers.length }))}</div>
|
|
467
468
|
<div class="form-grid">
|
|
468
|
-
<div class="field"><label class="label" for="f_${id}"
|
|
469
|
-
<div class="field"><label class="label" for="f_${field(k, 'key')}"
|
|
470
|
-
<select id="f_${field(k, 'key')}" name="${field(k, 'key')}">${[['', '
|
|
469
|
+
<div class="field"><label class="label" for="f_${id}">${tx('ash.table_name')}</label><input id="f_${id}" name="${id}" value="${v(id)}"></div>
|
|
470
|
+
<div class="field"><label class="label" for="f_${field(k, 'key')}">${tx('ash.primary_key')}</label>
|
|
471
|
+
<select id="f_${field(k, 'key')}" name="${field(k, 'key')}">${[['', tx('ash.a_new_column_id_identity')], ...sheet.headers.map((h, i): [string, string] => [String(i), h])].map(
|
|
471
472
|
([value, label]) => html`<option value="${value}"${v(field(k, 'key')) === value ? raw(' selected') : ''}>${label}</option>`,
|
|
472
473
|
)}</select>
|
|
473
|
-
<small class="help"
|
|
474
|
+
<small class="help">${tx('ash.a_whole_number_key_column')}</small></div>
|
|
474
475
|
</div>
|
|
475
|
-
<div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col"
|
|
476
|
+
<div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col">${tx('ash.file_column')}</th><th scope="col">${tx('ash.values')}</th><th scope="col">${tx('ash.col_name_head')}</th><th scope="col">${tx('ash.type')}</th></tr></thead><tbody>
|
|
476
477
|
${sheet.headers.map((h, i) => {
|
|
477
478
|
const type = v(field(k, `type_${i}`));
|
|
478
479
|
return html`<tr><td>${h}</td><td class="muted small ff-sample">${sample(sheet, i)}</td>
|
|
479
|
-
<td><input name="${field(k, `name_${i}`)}" value="${v(field(k, `name_${i}`))}" aria-label="
|
|
480
|
-
<td><select name="${field(k, `type_${i}`)}" aria-label="
|
|
480
|
+
<td><input name="${field(k, `name_${i}`)}" value="${v(field(k, `name_${i}`))}" aria-label="${tx('ash.col_name_for', { col: h, sheet: sheet.name })}"></td>
|
|
481
|
+
<td><select name="${field(k, `type_${i}`)}" aria-label="${tx('ash.type_of', { col: h, sheet: sheet.name })}">${COLUMN_TYPES.map((t) => html`<option value="${t}"${t === type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`;
|
|
481
482
|
})}
|
|
482
483
|
</tbody></table></div>
|
|
483
484
|
<details class="ff-preview"><summary>Preview${sheet.rows.length > 10 ? ' (first 10 rows)' : ''}</summary>${preview(sheet)}</details>`,
|
|
@@ -491,11 +492,11 @@ export function sheetsForm(sheets: NamedSheet[], b: Body | null): Raw {
|
|
|
491
492
|
const fkList = fks.length
|
|
492
493
|
? html`<ul class="ff-fks">${fks.map(
|
|
493
494
|
(f) => html`<li><input type="hidden" name="fkp_${f.from}_${f.col}" value="${String(f.to)}">${checkbox(fkName(f), String(f.to), fkChecked(f, b), html`${name(f.from, f.col)} → ${keyName(f.to)}`)}
|
|
494
|
-
${f.missing ? html`<small class="help">${
|
|
495
|
+
${f.missing ? html`<small class="help">${tx('ash.missing', { n: f.missing, key: keyName(f.to) })}</small>` : ''}</li>`,
|
|
495
496
|
)}</ul>`
|
|
496
|
-
: html`<p class="muted"
|
|
497
|
+
: html`<p class="muted">${tx('ash.no_column_matches_another_table')}</p>`;
|
|
497
498
|
return html`${sections.map((x) => html`${x}<div class="u-spacer"></div>`)}
|
|
498
|
-
${region('
|
|
499
|
+
${region(tx('ash.foreign_keys'), html`<p class="muted u-mt0">${tx('ash.fk_intro')}</p>
|
|
499
500
|
${fkList}
|
|
500
|
-
<div class="buttons"><button class="btn" name="action" value="preview"
|
|
501
|
+
<div class="buttons"><button class="btn" name="action" value="preview">${tx('ash.update_the_proposals')}</button></div>`)}`;
|
|
501
502
|
}
|