pgkiln 0.83.0 → 0.85.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/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +3 -2
- package/docs/guide/03-builder.md +16 -5
- package/docs/guide/04-pages-and-regions.md +42 -0
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +1 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +13 -1
- package/package.json +1 -1
- package/public/app.css +29 -0
- package/public/app.js +78 -0
- package/public/sw.js +5 -1
- package/src/app.ts +11 -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/code-editor.ts +1 -1
- package/src/builder/components.ts +10 -6
- 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 +3461 -0
- package/src/builder/i18n/en.ts +3461 -0
- package/src/builder/i18n/es.ts +3461 -0
- package/src/builder/i18n/fr.ts +3461 -0
- package/src/builder/i18n/it.ts +3461 -0
- package/src/builder/i18n/ja.ts +3461 -0
- package/src/builder/i18n/ko.ts +3461 -0
- package/src/builder/i18n/nl.ts +3461 -0
- package/src/builder/i18n/pt.ts +3461 -0
- package/src/builder/i18n/zh-cn.ts +3461 -0
- package/src/builder/i18n/zh-tw.ts +3461 -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/notification-settings.ts +27 -0
- 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 +30 -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/cli/replace.ts +2 -0
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +9 -3
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +12 -3
- package/src/runtime/routes.ts +12 -4
- package/src/runtime/signin.ts +2 -1
- package/src/session.ts +13 -4
package/src/builder/api.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { publicUrl } from '../sso.ts';
|
|
|
7
7
|
import { icon } from '../icons.ts';
|
|
8
8
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
9
9
|
import { roleHints, roleHintsHtml, splitRoles } from './users.ts';
|
|
10
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
10
11
|
|
|
11
12
|
// Per-application REST API page: the database role API tokens use, what
|
|
12
13
|
// PostgREST exposes to it, and developer-issued tokens for trying it out.
|
|
@@ -58,43 +59,42 @@ async function clientsRegion(s: Session, a: any, secret?: NewSecret, usable = tr
|
|
|
58
59
|
html`<form method="post" action="${BASE}/apps/${a.id}/api/clients/${c.id}/${path}" class="inline-form">${csrf(s)}${extra}<button class="${cls}">${label}</button></form>`;
|
|
59
60
|
const createForm = html`<form method="post" action="${BASE}/apps/${a.id}/api/clients">${csrf(s)}
|
|
60
61
|
<div class="form-grid">
|
|
61
|
-
${input('name', '
|
|
62
|
-
${input('token_minutes', '
|
|
63
|
-
<div class="field"><label class="label" for="f_roles"
|
|
64
|
-
${input('description', '
|
|
62
|
+
${input('name', tx('apip.name'), '', { required: true, placeholder: tx('apip.e_g_payroll_sync'), help: tx('apip.lower_case_letters_digits') })}
|
|
63
|
+
${input('token_minutes', tx('apip.token_lifetime_minutes'), 60, { type: 'number', help: tx('apip.5_to_1440') })}
|
|
64
|
+
<div class="field"><label class="label" for="f_roles">${tx('apip.roles')}</label><input id="f_roles" name="roles" placeholder="${tx('apip.comma_separated_or_pick_below')}">${roleHintsHtml(hints)}</div>
|
|
65
|
+
${input('description', tx('apip.description'), '', { placeholder: tx('apip.who_uses_it') })}
|
|
65
66
|
</div>
|
|
66
|
-
<div class="buttons"><button class="btn btn-hot"${usable ? '' : raw(' disabled')}
|
|
67
|
+
<div class="buttons"><button class="btn btn-hot"${usable ? '' : raw(' disabled')}>${tx('apip.create_client')}</button></div>
|
|
67
68
|
</form>`;
|
|
68
69
|
return region(
|
|
69
70
|
'OAuth clients',
|
|
70
|
-
html`<p class="muted u-mt0"
|
|
71
|
-
<code>${tokenUrl}</code> (<code>grant_type=client_credentials</code>) and acts as <code>client:<name></code> with its roles, read at every request.</p>
|
|
71
|
+
html`<p class="muted u-mt0">${tx.rich('apip.oauth_intro', { ords: html`<code>oauth.create_client</code>`, url: html`<code>${tokenUrl}</code>`, grant: html`<code>grant_type=client_credentials</code>`, client: html`<code>client:<name></code>` })}</p>
|
|
72
72
|
${secret
|
|
73
|
-
? html`<div class="alert alert-success" role="status">${secret.rotated ?
|
|
74
|
-
secret.rotated && secret.graceHours ? `
|
|
73
|
+
? html`<div class="alert alert-success" role="status">${secret.rotated ? tx('apip.new_secret_for', { name: secret.name }) : tx('apip.client_created', { name: secret.name })}. ${tx('apip.copy_now')}${
|
|
74
|
+
secret.rotated && secret.graceHours ? ` ${tx('apip.grace', { n: secret.graceHours })}` : ''}</div>
|
|
75
75
|
<div class="form-grid">
|
|
76
|
-
<div class="field"><label class="label" for="oauth_id"
|
|
77
|
-
<div class="field"><label class="label" for="oauth_secret"
|
|
76
|
+
<div class="field"><label class="label" for="oauth_id">${tx('apip.client_id')}</label><input id="oauth_id" class="code" readonly value="${secret.clientId}"></div>
|
|
77
|
+
<div class="field"><label class="label" for="oauth_secret">${tx('apip.client_secret')}</label><input id="oauth_secret" class="code" readonly value="${secret.secret}"></div>
|
|
78
78
|
</div>
|
|
79
79
|
<pre class="source">curl -u '${secret.clientId}:${secret.secret}' -d grant_type=client_credentials ${tokenUrl}</pre>`
|
|
80
80
|
: ''}
|
|
81
81
|
${clients.length
|
|
82
|
-
? html`<div class="table-wrap"><table class="report report-reflow api-clients"><thead><tr><th
|
|
82
|
+
? html`<div class="table-wrap"><table class="report report-reflow api-clients"><thead><tr><th>${tx('apip.client')}</th><th>${tx('apip.roles')}</th><th class="num">${tx('apip.token')}</th><th>${tx('apip.secret_changed')}</th><th>${tx('apip.last_used')}</th><th><span class="sr-only">${tx('apip.actions')}</span></th></tr></thead><tbody>
|
|
83
83
|
${clients.map((c) => html`<tr${c.active ? '' : raw(' class="muted"')}>
|
|
84
|
-
<td data-label="
|
|
85
|
-
<td data-label="
|
|
86
|
-
<input name="roles" value="${c.roles.join(', ')}" aria-label="
|
|
87
|
-
<td class="num" data-label="
|
|
88
|
-
<td data-label="
|
|
89
|
-
<td data-label="
|
|
84
|
+
<td data-label="${tx('apip.client')}"><strong>${c.name}</strong>${c.active ? '' : html` <span class="tag">revoked</span>`}<br><code class="small">${c.client_id}</code>${c.description ? html`<br><small class="help">${c.description}</small>` : ''}</td>
|
|
85
|
+
<td data-label="${tx('apip.roles')}"><form method="post" action="${BASE}/apps/${a.id}/api/clients/${c.id}/roles" class="search roles-form u-m0 u-mwnone">${csrf(s)}
|
|
86
|
+
<input name="roles" value="${c.roles.join(', ')}" aria-label="${tx('apip.aria_roles', { name: c.name })}" placeholder="${tx('apip.no_roles')}"><button class="btn btn-sm">${tx('apip.save')}</button>${roleHintsHtml(hints, tx('apip.add'))}</form></td>
|
|
87
|
+
<td class="num" data-label="${tx('apip.token')}">${tx('apip.minutes', { n: c.token_minutes })}</td>
|
|
88
|
+
<td data-label="${tx('apip.secret_changed')}">${when(c.secret_changed_at)}${c.grace ? html`<br><small class="help">${tx('apip.old_valid_until', { when: when(c.previous_valid_until) })}</small>` : ''}</td>
|
|
89
|
+
<td data-label="${tx('apip.last_used')}">${when(c.last_used_at) || html`<span class="muted">${tx('apip.never')}</span>`}</td>
|
|
90
90
|
<td class="actions"><div class="api-client-actions">
|
|
91
|
-
${action(c, 'rotate', '
|
|
92
|
-
${action(c, 'active', c.active ? '
|
|
93
|
-
${action(c, 'delete', '
|
|
91
|
+
${action(c, 'rotate', tx('apip.new_secret'), html`<select name="grace" aria-label="${tx('apip.keep_the_old_secret')}"><option value="24">${tx('apip.old_24h')}</option><option value="168">${tx('apip.old_7d')}</option><option value="0">${tx('apip.old_now')}</option></select>`, 'btn btn-sm')}
|
|
92
|
+
${action(c, 'active', c.active ? tx('apip.revoke') : tx('apip.reactivate'), html`<input type="hidden" name="active" value="${c.active ? 'false' : 'true'}">`, 'btn btn-sm')}
|
|
93
|
+
${action(c, 'delete', tx('apip.delete'), '', 'btn btn-sm btn-danger')}
|
|
94
94
|
</div></td></tr>`)}
|
|
95
95
|
</tbody></table></div>
|
|
96
|
-
<details class="api-new u-mt1"><summary class="btn">${icon('plus')}
|
|
97
|
-
: html`<p class="muted"
|
|
96
|
+
<details class="api-new u-mt1"><summary class="btn">${icon('plus')} ${tx('apip.new_client')}</summary><div class="api-new-body">${createForm}</div></details>`
|
|
97
|
+
: html`<p class="muted">${tx('apip.no_clients_yet')}</p>${createForm}`}`,
|
|
98
98
|
html`<span class="count">${clients.length}</span>`,
|
|
99
99
|
);
|
|
100
100
|
}
|
|
@@ -117,42 +117,42 @@ async function apiPage(s: Session, a: any, issued?: { token: string; username: s
|
|
|
117
117
|
`curl -X POST ${url}/rpc/${e.name} -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" -d '{ … }'`),
|
|
118
118
|
].join('\n');
|
|
119
119
|
|
|
120
|
-
const pill = (ok: boolean, text: Raw | string) => html`<li class="${ok ? 'is-ok' : 'is-bad'}"><span aria-hidden="true">${ok ? '✓' : '✗'}</span> <span class="sr-only">${ok ? '
|
|
120
|
+
const pill = (ok: boolean, text: Raw | string) => html`<li class="${ok ? 'is-ok' : 'is-bad'}"><span aria-hidden="true">${ok ? '✓' : '✗'}</span> <span class="sr-only">${ok ? tx('apip.ok') : tx('apip.problem')}</span>${text}</li>`;
|
|
121
121
|
const main = html`${appHeader(a, 'api', s)}
|
|
122
|
-
<p class="muted api-intro"
|
|
123
|
-
<ul class="api-status" aria-label="
|
|
124
|
-
${pill(status.ok, html
|
|
125
|
-
${role ? pill(!problem, problem ?? html`<code>${role}</code
|
|
126
|
-
${role && !problem ? pill(!!granted?.ok, granted?.ok ?
|
|
122
|
+
<p class="muted api-intro">${tx.rich('apip.intro', { schema: html`<code>${API_SCHEMA}</code>`, role: html`<code>role</code>`, user: html`<code>meta.app_user()</code>`, has: html`<code>meta.has_role()</code>` })}</p>
|
|
123
|
+
<ul class="api-status" aria-label="${tx('apip.status')}">
|
|
124
|
+
${pill(status.ok, html`${tx.rich('apip.at', { url: html`<code>${url}</code>` })} ${status.ok ? status.detail : html`<b>${tx('apip.not_reachable')}</b> (${status.detail})`}`)}
|
|
125
|
+
${role ? pill(!problem, problem ?? tx.rich('apip.dedicated', { role: html`<code>${role}</code>` })) : pill(false, tx('apip.no_role'))}
|
|
126
|
+
${role && !problem ? pill(!!granted?.ok, granted?.ok ? tx.rich('apip.may_switch', { role: html`<code>${role}</code>` }) : tx.rich('apip.run_grant', { run: html`<b>${tx('apip.run')}</b>`, sql: html`<code>grant ${role} to pgkiln_authenticator;</code>` })) : ''}
|
|
127
127
|
</ul>
|
|
128
128
|
<div class="columns api-top">
|
|
129
|
-
${region('
|
|
130
|
-
${input('api_role', '
|
|
131
|
-
<div class="field"><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}>
|
|
132
|
-
<small class="help"
|
|
133
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
129
|
+
${region(tx('apip.setup'), html`<form method="post" action="${BASE}/apps/${a.id}/api">${csrf(s)}
|
|
130
|
+
${input('api_role', tx('apip.api_database_role'), role, { placeholder: tx('apip.e_g_myapp_api'), help: tx('apip.grant_it_only_the_api') })}
|
|
131
|
+
<div class="field"><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}> ${tx('apip.users_create_their_own_tokens')}</label>
|
|
132
|
+
<small class="help">${tx.rich('apip.self_help', { account: html`<em>${tx('apip.my_account')}</em>` })}</small></div>
|
|
133
|
+
<div class="buttons"><button class="btn btn-hot">${tx('apip.save')}</button></div>
|
|
134
134
|
</form>`)}
|
|
135
|
-
${region('
|
|
135
|
+
${region(tx('apip.issue_a_token'), html`<p class="muted u-mt0">${tx.code('apip.for_development_and_trusted_scripts')}</p>
|
|
136
136
|
<form method="post" action="${BASE}/apps/${a.id}/api/token">${csrf(s)}
|
|
137
137
|
<div class="form-grid">
|
|
138
|
-
${input('username', '
|
|
139
|
-
${input('hours', '
|
|
138
|
+
${input('username', tx('apip.account'), issued?.username ?? '', { required: true, auto: 'off' })}
|
|
139
|
+
${input('hours', tx('apip.valid_for_hours'), issued?.expiresInHours ?? 8, { type: 'number', help: `1 to ${MAX_TOKEN_HOURS}.` })}
|
|
140
140
|
</div>
|
|
141
|
-
<div class="buttons"><button class="btn btn-hot"${!problem ? '' : raw(' disabled')}
|
|
141
|
+
<div class="buttons"><button class="btn btn-hot"${!problem ? '' : raw(' disabled')}>${tx('apip.issue_token')}</button></div>
|
|
142
142
|
</form>
|
|
143
143
|
${issued ? html`<div class="field u-mt1" data-wide><label class="label" for="api_token">Token for ${issued.username} (shown once, valid ${issued.expiresInHours} h)</label>
|
|
144
144
|
<textarea id="api_token" class="code" rows="4" readonly spellcheck="false">${issued.token}</textarea></div>` : ''}`)}
|
|
145
145
|
</div>
|
|
146
|
-
${region('
|
|
147
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
148
|
-
<tbody>${eps.map((e) => html`<tr><td data-label="
|
|
149
|
-
: html`<p class="muted u-mt0"
|
|
150
|
-
<h3 class="api-sub"
|
|
146
|
+
${region(tx('apip.endpoints'), html`${eps.length
|
|
147
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('apip.path')}</th><th>${tx('apip.kind')}</th><th>${tx('apip.methods_for', { role: role ?? '' })}</th></tr></thead>
|
|
148
|
+
<tbody>${eps.map((e) => html`<tr><td data-label="${tx('apip.path')}"><code>/${e.kind === 'function' ? 'rpc/' : ''}${e.name}</code></td><td data-label="${tx('apip.kind')}">${e.kind}</td><td data-label="${tx('apip.methods')}">${e.methods || html`<span class="muted">${tx('apip.none')}</span>`}</td></tr>`)}</tbody></table></div>`
|
|
149
|
+
: html`<p class="muted u-mt0">${tx.rich(role ? 'apip.nothing_yet' : 'apip.nothing_yet_no_role', { schema: html`<code>${API_SCHEMA}</code>` })}</p>`}
|
|
150
|
+
<h3 class="api-sub">${tx('apip.try_it')}</h3>
|
|
151
151
|
<pre class="source">${examples}</pre>
|
|
152
|
-
<p class="muted small"
|
|
152
|
+
<p class="muted small">${tx.rich('apip.reload', { sql: html`<code>notify pgrst, 'reload schema';</code>`, link: html`<a href="${url}/" target="_blank" rel="noopener">${url}/</a>` })}</p>`,
|
|
153
153
|
html`<span class="count">${eps.length}</span>`)}
|
|
154
154
|
${await clientsRegion(s, a, secret, !problem)}`;
|
|
155
|
-
return shell(s, `${a.name} REST API`, [['
|
|
155
|
+
return shell(s, `${a.name} REST API`, [[tx('apip.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('apip.rest_api')]], main);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
export async function apiRoutes(app: FastifyInstance) {
|
|
@@ -214,7 +214,7 @@ export async function apiRoutes(app: FastifyInstance) {
|
|
|
214
214
|
return send(reply.header('cache-control', 'no-store'), s, await apiPage(s, a, undefined, { name, clientId: r.client_id, secret: r.client_secret, rotated: false }));
|
|
215
215
|
} catch (e) {
|
|
216
216
|
const err = e as { code?: string; message: string };
|
|
217
|
-
flash(s, err.code === '23505' ? 'A client with this name already exists.' : err.code === '23514' ? '
|
|
217
|
+
flash(s, err.code === '23505' ? 'A client with this name already exists.' : err.code === '23514' ? tx('apip.check_the_name_lower_case') : err.message, 'error');
|
|
218
218
|
return back(reply, s, `${BASE}/apps/${a.id}/api`);
|
|
219
219
|
}
|
|
220
220
|
});
|
|
@@ -246,7 +246,7 @@ export async function apiRoutes(app: FastifyInstance) {
|
|
|
246
246
|
flash(s, active ? `${c.name} can get tokens again.` : `${c.name} is revoked: its tokens stop working now.`);
|
|
247
247
|
} else if (op === 'roles') {
|
|
248
248
|
await owner.query('update meta.api_client set roles = $2 where id = $1', [c.id, splitRoles(req.body?.roles ?? '')]);
|
|
249
|
-
flash(s,
|
|
249
|
+
flash(s, tx('apip.roles_saved', { name: c.name }));
|
|
250
250
|
} else if (op === 'delete') {
|
|
251
251
|
await owner.query('delete from meta.api_client where id = $1', [c.id]);
|
|
252
252
|
flash(s, `${c.name} deleted.`);
|
|
@@ -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
|
});
|