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
|
@@ -5,6 +5,7 @@ import { icon } from '../icons.ts';
|
|
|
5
5
|
import { applicationRoles, applyRemote, disconnect, dropRemote, fdwStatus, MAX_TABLES, remoteDbProblems, serverNameOf, type RemoteDb } from '../remote-db.ts';
|
|
6
6
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
7
7
|
import { administrator, back, BASE, csrf, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
8
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
8
9
|
|
|
9
10
|
// (sprint 150, AP-7 leftover; APEX: REST Enabled SQL services) Workspace utilities → Remote databases
|
|
10
11
|
// (administrators): another PostgreSQL database's tables as foreign tables in a local schema (src/remote-db.ts).
|
|
@@ -16,8 +17,8 @@ import { administrator, back, BASE, csrf, flash, input, region, send, shell, typ
|
|
|
16
17
|
// POST /builder/remote-databases/extension create extension postgres_fdw (a superuser connection)
|
|
17
18
|
|
|
18
19
|
const PAGE = `${BASE}/remote-databases`;
|
|
19
|
-
const TITLE =
|
|
20
|
-
const crumbs: [string, string?][]
|
|
20
|
+
const TITLE = () => tx('rdb.title');
|
|
21
|
+
const crumbs = (): [string, string?][] => [[tx('rdb.app_builder'), BASE], [tx('rdb.workspace_utilities'), `${BASE}/utilities`], [TITLE()]];
|
|
21
22
|
const rid = (v: unknown) => (/^\d{1,9}$/.test(String(v ?? '')) ? Number(v) : null);
|
|
22
23
|
const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
|
|
23
24
|
const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : typeof v === 'string' && v ? [v] : []);
|
|
@@ -37,72 +38,70 @@ function fromBody(b: Record<string, unknown>): RemoteDb {
|
|
|
37
38
|
|
|
38
39
|
function form(s: Session, x: Partial<Row>, roles: string[]) {
|
|
39
40
|
const editing = !!x.id;
|
|
40
|
-
return region(editing ?
|
|
41
|
+
return region(editing ? tx('rdb.edit_named', { name: x.name }) : tx('rdb.add_a_remote_database'), html`<form method="post" action="${PAGE}" id="rdb-form">${csrf(s)}
|
|
41
42
|
${editing ? html`<input type="hidden" name="id" value="${x.id}">` : ''}
|
|
42
43
|
<div class="form-grid">
|
|
43
|
-
${input('name', '
|
|
44
|
-
${input('description', '
|
|
45
|
-
${input('host', '
|
|
46
|
-
${input('port', '
|
|
47
|
-
${input('dbname', '
|
|
48
|
-
${input('user_name', '
|
|
49
|
-
${input('password', '
|
|
50
|
-
${input('remote_schema', '
|
|
51
|
-
${input('local_schema', '
|
|
52
|
-
${input('tables', '
|
|
44
|
+
${input('name', tx('rdb.name'), x.name ?? '', { help: tx('rdb.a_static_id_e_g'), required: true })}
|
|
45
|
+
${input('description', tx('rdb.description'), x.description ?? '')}
|
|
46
|
+
${input('host', tx('rdb.host'), x.host ?? '', { help: tx('rdb.as_the_database_server_reaches'), required: true })}
|
|
47
|
+
${input('port', tx('rdb.port'), String(x.port ?? 5432), { type: 'number' })}
|
|
48
|
+
${input('dbname', tx('rdb.database'), x.dbname ?? '', { required: true })}
|
|
49
|
+
${input('user_name', tx('rdb.user'), x.user_name ?? '', { required: true, help: tx('rdb.a_user_of_the_remote') })}
|
|
50
|
+
${input('password', tx('rdb.password'), '', { type: 'password', auto: 'new-password', help: editing ? tx('rdb.kept_by_postgresql_in_the') : tx('rdb.kept_by_postgresql_in_the_2') })}
|
|
51
|
+
${input('remote_schema', tx('rdb.remote_schema'), x.remote_schema ?? 'public')}
|
|
52
|
+
${input('local_schema', tx('rdb.local_schema'), x.local_schema ?? '', { required: true, help: tx('rdb.a_new_schema_here_for') })}
|
|
53
|
+
${input('tables', tx('rdb.tables'), (x.tables ?? []).join(', '), { help: tx('rdb.tables_help', { n: MAX_TABLES }) })}
|
|
53
54
|
</div>
|
|
54
|
-
<fieldset class="prop-group u-mt1"><legend
|
|
55
|
+
<fieldset class="prop-group u-mt1"><legend>${tx('rdb.grant_to_the_applications_database')}</legend>
|
|
55
56
|
${roles.length
|
|
56
57
|
? html`<div class="check-list">${roles.map((r) => html`<label><input type="checkbox" name="grant_roles" value="${r}"${(x.grant_roles ?? []).includes(r) ? raw(' checked') : ''}> ${r}</label>`)}</div>`
|
|
57
|
-
: html`<p class="muted"
|
|
58
|
-
<label class="u-mt1"><input type="checkbox" name="writable" value="true"${x.writable ? raw(' checked') : ''}>
|
|
58
|
+
: html`<p class="muted">${tx('rdb.no_application_has_a_database')}</p>`}
|
|
59
|
+
<label class="u-mt1"><input type="checkbox" name="writable" value="true"${x.writable ? raw(' checked') : ''}> ${tx('rdb.also_insert_update_and_delete')}</label>
|
|
59
60
|
</fieldset>
|
|
60
61
|
<div class="buttons u-mt1">
|
|
61
|
-
<button class="btn btn-hot" name="mode" value="save">${editing ? '
|
|
62
|
-
<button class="btn" name="mode" value="test"
|
|
63
|
-
<a class="btn" href="${PAGE}"
|
|
62
|
+
<button class="btn btn-hot" name="mode" value="save">${editing ? tx('rdb.save_and_import_again') : tx('rdb.add_and_import')}</button>
|
|
63
|
+
<button class="btn" name="mode" value="test">${tx('rdb.test_the_connection')}</button>
|
|
64
|
+
<a class="btn" href="${PAGE}">${tx('rdb.cancel')}</a>
|
|
64
65
|
</div></form>`);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
export async function remoteDatabaseRoutes(app: FastifyInstance) {
|
|
68
69
|
app.get(PAGE, async (req: Req, reply) => {
|
|
69
|
-
const s = await administrator(req, reply, TITLE);
|
|
70
|
+
const s = await administrator(req, reply, TITLE());
|
|
70
71
|
if (!s) return;
|
|
71
72
|
const q = (req.query ?? {}) as Record<string, string>;
|
|
72
73
|
const st = await fdwStatus(owner);
|
|
73
74
|
const all = await rows();
|
|
74
75
|
const roles = await applicationRoles(owner);
|
|
75
76
|
const edit = rid(q.edit) ? all.find((r) => r.id === rid(q.edit)) : undefined;
|
|
76
|
-
const main = html`<h1 class="u-mb1">${TITLE}</h1>
|
|
77
|
-
<p class="muted u-mt0"
|
|
78
|
-
of values use them like the application's own tables, as its database role, with the grants given here.</p>
|
|
77
|
+
const main = html`<h1 class="u-mb1">${TITLE()}</h1>
|
|
78
|
+
<p class="muted u-mt0">${tx('rdb.another_postgresql_database_s_tables')}</p>
|
|
79
79
|
${st.installed
|
|
80
|
-
? html`<p class="muted">${icon('check')}
|
|
81
|
-
: html`<div class="alert alert-info" role="status"><p><strong
|
|
80
|
+
? html`<p class="muted">${icon('check')} ${tx('rdb.fdw_installed')}</p>`
|
|
81
|
+
: html`<div class="alert alert-info" role="status"><p><strong>${tx('rdb.postgres_fdw_is_not_installed')}</strong></p>
|
|
82
82
|
${st.available && st.canCreate
|
|
83
|
-
? html`<form method="post" action="${PAGE}/extension">${csrf(s)}<button class="btn"
|
|
84
|
-
: html`<p>${st.available ? '
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<th class="num">Tables</th><th>Granted to</th><th>Imported</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
|
83
|
+
? html`<form method="post" action="${PAGE}/extension">${csrf(s)}<button class="btn">${tx('rdb.create_extension_postgres_fdw')}</button></form>`
|
|
84
|
+
: html`<p>${tx.rich(st.available ? 'rdb.fdw_superuser' : 'rdb.fdw_missing', { create: html`<code>create extension postgres_fdw;</code>`, grant: html`<code>grant usage on foreign data wrapper postgres_fdw to</code>` })}</p>`}</div>`}
|
|
85
|
+
${region(tx('rdb.remote_databases'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rdb.name')}</th><th>${tx('rdb.connection')}</th><th>${tx('rdb.local_schema')}</th>
|
|
86
|
+
<th class="num">${tx('rdb.tables')}</th><th>${tx('rdb.granted_to')}</th><th>${tx('rdb.imported')}</th><th><span class="sr-only">${tx('rdb.actions')}</span></th></tr></thead><tbody>
|
|
88
87
|
${all.length ? all.map((r) => html`<tr>
|
|
89
|
-
<td data-label="
|
|
90
|
-
<td data-label="
|
|
91
|
-
<td data-label="
|
|
92
|
-
<td class="num" data-label="
|
|
93
|
-
<td data-label="
|
|
94
|
-
<td data-label="
|
|
95
|
-
<td data-label="
|
|
96
|
-
<form method="post" action="${`${PAGE}/${r.id}/refresh`}" class="inline">${csrf(s)}<button class="btn btn-sm"
|
|
97
|
-
<form method="post" action="${`${PAGE}/${r.id}/delete`}" class="inline">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${
|
|
98
|
-
</tr>`) : html`<tr><td colspan="7" class="empty"
|
|
88
|
+
<td data-label="${tx('rdb.name')}"><strong>${r.name}</strong>${r.description ? html`<br><small class="muted">${r.description}</small>` : ''}</td>
|
|
89
|
+
<td data-label="${tx('rdb.connection')}"><code>${r.user_name}@${r.host}:${r.port}/${r.dbname}</code> (${r.remote_schema})</td>
|
|
90
|
+
<td data-label="${tx('rdb.local_schema')}"><code>${r.local_schema}</code></td>
|
|
91
|
+
<td class="num" data-label="${tx('rdb.tables')}">${r.imported ?? '—'}</td>
|
|
92
|
+
<td data-label="${tx('rdb.granted_to')}">${r.grant_roles.join(', ') || '—'}${r.writable ? ' (read and write)' : ''}</td>
|
|
93
|
+
<td data-label="${tx('rdb.imported')}">${when(r.imported_at)}${r.error ? html`<br><span class="wf-problem">${r.error}</span>` : ''}</td>
|
|
94
|
+
<td data-label="${tx('rdb.actions')}"><a class="btn btn-sm" href="${PAGE}?edit=${r.id}#rdb-form">${tx('rdb.edit')}</a>
|
|
95
|
+
<form method="post" action="${`${PAGE}/${r.id}/refresh`}" class="inline">${csrf(s)}<button class="btn btn-sm">${tx('rdb.import_again')}</button></form>
|
|
96
|
+
<form method="post" action="${`${PAGE}/${r.id}/delete`}" class="inline">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('rdb.delete_confirm', { name: r.name, schema: r.local_schema })}">${tx('rdb.delete')}</button></form></td>
|
|
97
|
+
</tr>`) : html`<tr><td colspan="7" class="empty">${tx('rdb.no_remote_databases_yet')}</td></tr>`}
|
|
99
98
|
</tbody></table></div>`)}
|
|
100
99
|
${st.installed ? form(s, edit ?? {}, roles) : ''}`;
|
|
101
|
-
return send(reply, s, shell(s, TITLE, crumbs, main));
|
|
100
|
+
return send(reply, s, shell(s, TITLE(), crumbs(), main));
|
|
102
101
|
});
|
|
103
102
|
|
|
104
103
|
app.post(PAGE, async (req: Req, reply) => {
|
|
105
|
-
const s = await administrator(req, reply, TITLE);
|
|
104
|
+
const s = await administrator(req, reply, TITLE());
|
|
106
105
|
if (!s) return;
|
|
107
106
|
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
108
107
|
const x = fromBody(b);
|
|
@@ -110,7 +109,7 @@ export async function remoteDatabaseRoutes(app: FastifyInstance) {
|
|
|
110
109
|
const password = typeof b.password === 'string' && b.password ? b.password : null;
|
|
111
110
|
const retry = editId ? `${PAGE}?edit=${editId}#rdb-form` : `${PAGE}#rdb-form`;
|
|
112
111
|
const problems = remoteDbProblems(x, await applicationRoles(owner));
|
|
113
|
-
if (password && password.length > 1000) problems.push('
|
|
112
|
+
if (password && password.length > 1000) problems.push(tx('rdb.the_password_has_at_most'));
|
|
114
113
|
if (problems.length) {
|
|
115
114
|
flash(s, problems.join(' '), 'error');
|
|
116
115
|
return back(reply, s, retry);
|
|
@@ -119,14 +118,14 @@ export async function remoteDatabaseRoutes(app: FastifyInstance) {
|
|
|
119
118
|
try {
|
|
120
119
|
const count = await owner.tx(async (c) => {
|
|
121
120
|
const old = editId ? (await c.query<Row>('select * from meta.remote_database where id = $1 for update', [editId])).rows[0] : undefined;
|
|
122
|
-
if (editId && !old) throw new Error('
|
|
121
|
+
if (editId && !old) throw new Error(tx('rdb.no_such_remote_database'));
|
|
123
122
|
// a renamed one, or a new local schema: the old server and schema go first
|
|
124
123
|
if (old && (old.name !== x.name || old.local_schema !== x.local_schema)) {
|
|
125
|
-
if (!password) throw new Error('
|
|
124
|
+
if (!password) throw new Error(tx('rdb.renaming_it_or_moving_it'));
|
|
126
125
|
await dropRemote(c, old);
|
|
127
126
|
}
|
|
128
127
|
const n = await applyRemote(c, x, password, old && old.name === x.name && old.local_schema === x.local_schema ? old.grant_roles : []);
|
|
129
|
-
if (test) throw Object.assign(new Error('
|
|
128
|
+
if (test) throw Object.assign(new Error(tx('rdb.rolled_back')), { tested: n });
|
|
130
129
|
if (old)
|
|
131
130
|
await c.query(
|
|
132
131
|
`update meta.remote_database set name = $2, description = $3, host = $4, port = $5, dbname = $6, user_name = $7, remote_schema = $8, local_schema = $9,
|
|
@@ -149,22 +148,22 @@ export async function remoteDatabaseRoutes(app: FastifyInstance) {
|
|
|
149
148
|
} catch (e) {
|
|
150
149
|
const tested = (e as { tested?: number }).tested;
|
|
151
150
|
if (tested !== undefined) {
|
|
152
|
-
flash(s,
|
|
151
|
+
flash(s, tx('rdb.test_ok', { n: tested, schema: x.local_schema }));
|
|
153
152
|
} else {
|
|
154
|
-
const msg = (e as Error).message.includes('remote_database_name_key') ?
|
|
155
|
-
: (e as Error).message.includes('remote_database_local_schema_key') ?
|
|
156
|
-
flash(s, `${test ? '
|
|
153
|
+
const msg = (e as Error).message.includes('remote_database_name_key') ? tx('rdb.name_taken', { name: x.name })
|
|
154
|
+
: (e as Error).message.includes('remote_database_local_schema_key') ? tx('rdb.schema_taken', { schema: x.local_schema }) : (e as Error).message;
|
|
155
|
+
flash(s, `${test ? tx('rdb.the_test_failed') : tx('rdb.nothing_was_changed')}: ${msg}`, 'error');
|
|
157
156
|
}
|
|
158
157
|
return back(reply, s, retry);
|
|
159
158
|
}
|
|
160
159
|
});
|
|
161
160
|
|
|
162
161
|
app.post(`${PAGE}/:rid/refresh`, async (req: Req, reply) => {
|
|
163
|
-
const s = await administrator(req, reply, TITLE);
|
|
162
|
+
const s = await administrator(req, reply, TITLE());
|
|
164
163
|
if (!s) return;
|
|
165
164
|
const r = (await rows()).find((x) => x.id === rid(req.params.rid));
|
|
166
165
|
if (!r) {
|
|
167
|
-
flash(s, '
|
|
166
|
+
flash(s, tx('rdb.no_such_remote_database'), 'error');
|
|
168
167
|
return back(reply, s, PAGE);
|
|
169
168
|
}
|
|
170
169
|
try {
|
|
@@ -180,11 +179,11 @@ export async function remoteDatabaseRoutes(app: FastifyInstance) {
|
|
|
180
179
|
});
|
|
181
180
|
|
|
182
181
|
app.post(`${PAGE}/:rid/delete`, async (req: Req, reply) => {
|
|
183
|
-
const s = await administrator(req, reply, TITLE);
|
|
182
|
+
const s = await administrator(req, reply, TITLE());
|
|
184
183
|
if (!s) return;
|
|
185
184
|
const r = (await rows()).find((x) => x.id === rid(req.params.rid));
|
|
186
185
|
if (!r) {
|
|
187
|
-
flash(s, '
|
|
186
|
+
flash(s, tx('rdb.no_such_remote_database'), 'error');
|
|
188
187
|
return back(reply, s, PAGE);
|
|
189
188
|
}
|
|
190
189
|
try {
|
|
@@ -201,12 +200,12 @@ export async function remoteDatabaseRoutes(app: FastifyInstance) {
|
|
|
201
200
|
});
|
|
202
201
|
|
|
203
202
|
app.post(`${PAGE}/extension`, async (req: Req, reply) => {
|
|
204
|
-
const s = await administrator(req, reply, TITLE);
|
|
203
|
+
const s = await administrator(req, reply, TITLE());
|
|
205
204
|
if (!s) return;
|
|
206
205
|
try {
|
|
207
206
|
await owner.query('create extension if not exists postgres_fdw');
|
|
208
207
|
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: 'create extension if not exists postgres_fdw' });
|
|
209
|
-
flash(s, '
|
|
208
|
+
flash(s, tx('rdb.the_postgres_fdw_extension_is'));
|
|
210
209
|
} catch (e) {
|
|
211
210
|
flash(s, `create extension postgres_fdw failed: ${(e as Error).message}`, 'error');
|
|
212
211
|
}
|
|
@@ -160,7 +160,7 @@ async function reportSettingsFormInner(pageId: number, appId: number, r: { id: n
|
|
|
160
160
|
<fieldset class="prop-group"><legend>${tx('rpt.json_path_columns')}</legend><div class="form-grid">
|
|
161
161
|
<div class="field" data-wide><label class="label" for="${id('json_columns')}">${tx('rpt.columns_read_from_a_json')}</label>
|
|
162
162
|
<input id="${id('json_columns')}" name="json_columns" value="${jsonColumnsText(cfg.json_columns)}" placeholder="city=profile:address.city; first_skill=profile:skills.0.name; since=profile:since:date" class="code">
|
|
163
|
-
<small class="help"
|
|
163
|
+
<small class="help">${tx('rs.json_columns_help', { types: JSON_TYPES.join(', ') })}</small></div>
|
|
164
164
|
</div></fieldset>
|
|
165
165
|
<fieldset class="prop-group"><legend>${tx('rpt.columns')}</legend>
|
|
166
166
|
${all.length
|
package/src/builder/reporter.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { heading } from '../runtime/items.ts';
|
|
|
5
5
|
import { RESERVED_SCHEMAS, SOURCE_ID, sourcesOf, type Source, type SourceColumn } from '../runtime/data-reporter.ts';
|
|
6
6
|
import type { Session } from '../session.ts';
|
|
7
7
|
import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
|
|
8
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
8
9
|
|
|
9
10
|
// Page designer → a Data Reporter region → Settings: the data sources the
|
|
10
11
|
// region offers (tables and views, which of their columns, labels and format
|
|
@@ -113,7 +114,7 @@ export async function mergeReporterSettings(
|
|
|
113
114
|
const format = clip(b[`s${i}_fmt_${j}`], 40);
|
|
114
115
|
columns.push({ name, ...(label ? { label } : {}), ...(format ? { format } : {}) });
|
|
115
116
|
}
|
|
116
|
-
if (!columns.length) errors.push(
|
|
117
|
+
if (!columns.length) errors.push(tx('drep.no_columns', { id: s.id }));
|
|
117
118
|
const label = clip(b[`s${i}_label`], 80);
|
|
118
119
|
const description = clip(b[`s${i}_description`], 300);
|
|
119
120
|
sources.push({ id: s.id, ...(label ? { label } : {}), ...(description ? { description } : {}), schema: s.schema, table: s.table, columns });
|
|
@@ -121,7 +122,7 @@ export async function mergeReporterSettings(
|
|
|
121
122
|
|
|
122
123
|
if (b.new_object) {
|
|
123
124
|
const obj = /^\d{1,10}$/.test(b.new_object) ? await lookup.byOid(Number(b.new_object)) : null;
|
|
124
|
-
if (!obj || RESERVED_SCHEMAS.test(obj.schema)) errors.push('
|
|
125
|
+
if (!obj || RESERVED_SCHEMAS.test(obj.schema)) errors.push(tx('drep.choose_a_table_or_view'));
|
|
125
126
|
else if (sources.length >= MAX_SOURCES) errors.push(`A region offers at most ${MAX_SOURCES} data sources.`);
|
|
126
127
|
else {
|
|
127
128
|
let id = clip(b.new_id, 40).toLowerCase();
|
|
@@ -130,7 +131,7 @@ export async function mergeReporterSettings(
|
|
|
130
131
|
id = '';
|
|
131
132
|
}
|
|
132
133
|
if (id && sources.some((s) => s.id === id)) {
|
|
133
|
-
errors.push(
|
|
134
|
+
errors.push(tx('drep.exists', { id }));
|
|
134
135
|
id = '';
|
|
135
136
|
}
|
|
136
137
|
if (!id) {
|
|
@@ -146,7 +147,7 @@ export async function mergeReporterSettings(
|
|
|
146
147
|
// (sprint 110) a dataset of the workspace as a new source
|
|
147
148
|
if (b.new_dataset) {
|
|
148
149
|
const name = b.new_dataset.trim().toLowerCase();
|
|
149
|
-
if (!a.datasets?.has(name)) errors.push('
|
|
150
|
+
if (!a.datasets?.has(name)) errors.push(tx('drep.choose_one_of_the_workspace'));
|
|
150
151
|
else if (sources.length >= MAX_SOURCES) errors.push(`A region offers at most ${MAX_SOURCES} data sources.`);
|
|
151
152
|
else {
|
|
152
153
|
let id = name.slice(0, 36);
|
|
@@ -179,12 +180,12 @@ export async function reporterSettingsForm(pageId: number, appId: number, r: { i
|
|
|
179
180
|
<input type="hidden" name="s${i}_key" value="${src.id}">
|
|
180
181
|
${!d ? html`<div class="alert alert-error" role="alert">The workspace has no dataset ${src.dataset} (any more).</div>` : ''}
|
|
181
182
|
<div class="form-grid">
|
|
182
|
-
<div class="field"><label class="label" for="${id(`s${i}_id`)}"
|
|
183
|
-
<div class="field"><label class="label" for="${id(`s${i}_label`)}"
|
|
184
|
-
<div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}"
|
|
183
|
+
<div class="field"><label class="label" for="${id(`s${i}_id`)}">${tx('drep.static_id')}</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
|
|
184
|
+
<div class="field"><label class="label" for="${id(`s${i}_label`)}">${tx('drep.label')}</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${d?.label ?? ''}"></div>
|
|
185
|
+
<div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">${tx('drep.description_shown_to_users')}</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}" placeholder="${tx('drep.the_dataset_s')}"></div>
|
|
185
186
|
</div>
|
|
186
|
-
<p class="muted"
|
|
187
|
-
<div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true">
|
|
187
|
+
<p class="muted">${tx.rich('drep.dataset_note', { link: html`<a href="${BASE}/reporter">${tx('drep.app_builder_data_reporter')}</a>` })}</p>
|
|
188
|
+
<div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> ${tx('drep.remove_this_data_source_users')}</label></div>
|
|
188
189
|
</fieldset>`;
|
|
189
190
|
}
|
|
190
191
|
const obj = await findObject(src.schema, src.table);
|
|
@@ -194,56 +195,56 @@ export async function reporterSettingsForm(pageId: number, appId: number, r: { i
|
|
|
194
195
|
const readable = obj ? objects.find((o) => o.oid === obj.oid)?.access : undefined;
|
|
195
196
|
return html`<fieldset class="prop-group"><legend>${src.label || src.id} <span class="muted">(${src.schema}.${src.table})</span></legend>
|
|
196
197
|
<input type="hidden" name="s${i}_key" value="${src.id}">
|
|
197
|
-
${!obj ? html`<div class="alert alert-error" role="alert"
|
|
198
|
-
${obj && readable === false ? html`<div class="alert alert-error" role="alert"
|
|
198
|
+
${!obj ? html`<div class="alert alert-error" role="alert">${tx('drep.no_object', { object: `${src.schema}.${src.table}` })}</div>` : ''}
|
|
199
|
+
${obj && readable === false ? html`<div class="alert alert-error" role="alert">${tx('drep.no_select', { object: `${src.schema}.${src.table}` })}</div>` : ''}
|
|
199
200
|
<div class="form-grid">
|
|
200
|
-
<div class="field"><label class="label" for="${id(`s${i}_id`)}"
|
|
201
|
-
<div class="field"><label class="label" for="${id(`s${i}_label`)}"
|
|
202
|
-
<div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}"
|
|
201
|
+
<div class="field"><label class="label" for="${id(`s${i}_id`)}">${tx('drep.static_id')}</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
|
|
202
|
+
<div class="field"><label class="label" for="${id(`s${i}_label`)}">${tx('drep.label')}</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${heading(src.table)}"></div>
|
|
203
|
+
<div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">${tx('drep.description_shown_to_users')}</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}"></div>
|
|
203
204
|
</div>
|
|
204
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col"
|
|
205
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('drep.offer')}</th><th scope="col">${tx('drep.column')}</th><th scope="col">${tx('drep.label')}</th><th scope="col">${tx('drep.format_mask')}</th></tr></thead><tbody>
|
|
205
206
|
${all.map((c, j) => {
|
|
206
207
|
const o = offered.get(c.name);
|
|
207
208
|
return html`<tr>
|
|
208
|
-
<td data-label="
|
|
209
|
-
<td data-label="
|
|
210
|
-
<td data-label="
|
|
211
|
-
<td data-label="
|
|
209
|
+
<td data-label="${tx('drep.offer')}"><input type="hidden" name="s${i}_col_${j}" value="${c.name}"><input type="checkbox" name="s${i}_on_${j}" value="true"${o ? raw(' checked') : ''} aria-label="${tx('drep.aria_offer', { column: c.name })}"></td>
|
|
210
|
+
<td data-label="${tx('drep.column')}"><code>${c.name}</code> <span class="muted">${c.type}</span>${c.missing ? html` <span class="tag tag-error">${tx('drep.not_in_the_table')}</span>` : ''}</td>
|
|
211
|
+
<td data-label="${tx('drep.label')}"><input name="s${i}_label_${j}" maxlength="80" value="${o?.label ?? ''}" placeholder="${heading(c.name)}" aria-label="${tx('drep.aria_label', { column: c.name })}"></td>
|
|
212
|
+
<td data-label="${tx('drep.format_mask')}"><input name="s${i}_fmt_${j}" maxlength="40" value="${o?.format ?? ''}" placeholder="FML999G990D00" aria-label="${tx('drep.aria_format', { column: c.name })}"></td>
|
|
212
213
|
</tr>`;
|
|
213
214
|
})}
|
|
214
215
|
</tbody></table></div>
|
|
215
|
-
<div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true">
|
|
216
|
+
<div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> ${tx('drep.remove_this_data_source_users')}</label></div>
|
|
216
217
|
</fieldset>`;
|
|
217
218
|
}),
|
|
218
219
|
);
|
|
219
|
-
return html`<h3 class="u-mt15"
|
|
220
|
-
<p class="muted u-mt0"
|
|
220
|
+
return html`<h3 class="u-mt15">${tx('drep.data_reporter_settings')}</h3>
|
|
221
|
+
<p class="muted u-mt0">${tx('drep.signed_in_users_build_their')}</p>
|
|
221
222
|
<form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/reporter" class="component-form">${csrf(s)}
|
|
222
|
-
<fieldset class="prop-group"><legend
|
|
223
|
-
<div class="field"><label class="label" for="${id('sharing')}"
|
|
224
|
-
<select id="${id('sharing')}" name="sharing">${opt('', '
|
|
225
|
-
<div class="field"><label class="label" for="${id('page_size')}"
|
|
223
|
+
<fieldset class="prop-group"><legend>${tx('drep.reports')}</legend><div class="form-grid">
|
|
224
|
+
<div class="field"><label class="label" for="${id('sharing')}">${tx('drep.who_may_share_reports')}</label>
|
|
225
|
+
<select id="${id('sharing')}" name="sharing">${opt('', tx('drep.every_signed_in_user'), sharing)}${opt('off', tx('drep.nobody_private_reports_only'), sharing)}${schemes.map((n) => opt(`authz:${n}`, `Authorization: ${n}`, sharing))}</select></div>
|
|
226
|
+
<div class="field"><label class="label" for="${id('page_size')}">${tx('drep.rows_per_page')}</label>
|
|
226
227
|
<input id="${id('page_size')}" name="page_size" type="number" min="5" max="200" value="${cfg.page_size ?? ''}" placeholder="25"></div>
|
|
227
|
-
<div class="field"><label class="label" for="${id('edit_authz')}"
|
|
228
|
-
<select id="${id('edit_authz')}" name="edit_authz">${opt('', '
|
|
229
|
-
<small class="help"
|
|
230
|
-
<div class="field"><label class="label" for="${id('admin_authz')}"
|
|
231
|
-
<select id="${id('admin_authz')}" name="admin_authz">${opt('', '
|
|
232
|
-
<div class="field"><label class="check"><input type="checkbox" name="publishing" value="true"${cfg.publishing === true ? raw(' checked') : ''}>
|
|
233
|
-
<div class="field" data-wide><label class="label" for="${id('empty')}"
|
|
234
|
-
<input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="
|
|
228
|
+
<div class="field"><label class="label" for="${id('edit_authz')}">${tx('drep.who_may_build_and_save')}</label>
|
|
229
|
+
<select id="${id('edit_authz')}" name="edit_authz">${opt('', tx('drep.every_user'), cfg.edit_authz ?? '')}${schemes.map((n) => opt(n, `Authorization: ${n}`, cfg.edit_authz ?? ''))}</select>
|
|
230
|
+
<small class="help">${tx('drep.others_only_run_shared_reports')}</small></div>
|
|
231
|
+
<div class="field"><label class="label" for="${id('admin_authz')}">${tx('drep.who_may_unpublish_and_delete')}</label>
|
|
232
|
+
<select id="${id('admin_authz')}" name="admin_authz">${opt('', tx('drep.nobody'), cfg.admin_authz ?? '')}${schemes.map((n) => opt(n, `Authorization: ${n}`, cfg.admin_authz ?? ''))}</select></div>
|
|
233
|
+
<div class="field"><label class="check"><input type="checkbox" name="publishing" value="true"${cfg.publishing === true ? raw(' checked') : ''}> ${tx('drep.shared_reports_are_published_a')}</label></div>
|
|
234
|
+
<div class="field" data-wide><label class="label" for="${id('empty')}">${tx('drep.text_when_there_are_no')}</label>
|
|
235
|
+
<input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="${tx('drep.no_data_found')}"></div>
|
|
235
236
|
</div></fieldset>
|
|
236
237
|
${sourceSets}
|
|
237
|
-
<fieldset class="prop-group"><legend
|
|
238
|
-
<div class="field" data-wide><label class="label" for="${id('new_object')}"
|
|
238
|
+
<fieldset class="prop-group"><legend>${tx('drep.add_a_data_source')}</legend><div class="form-grid">
|
|
239
|
+
<div class="field" data-wide><label class="label" for="${id('new_object')}">${tx('drep.table_or_view')}</label>
|
|
239
240
|
<select id="${id('new_object')}" name="new_object"><option value="">- none -</option>${objects.map((o) => html`<option value="${o.oid}">${o.name}${o.access ? '' : ' (no access for the app role)'}</option>`)}</select></div>
|
|
240
|
-
<div class="field"><label class="label" for="${id('new_id')}"
|
|
241
|
-
<div class="field"><label class="label" for="${id('new_label')}"
|
|
241
|
+
<div class="field"><label class="label" for="${id('new_id')}">${tx('drep.static_id')}</label><input id="${id('new_id')}" name="new_id" maxlength="40" pattern="[a-z][a-z0-9_]*" placeholder="${tx('drep.from_the_name')}"></div>
|
|
242
|
+
<div class="field"><label class="label" for="${id('new_label')}">${tx('drep.label')}</label><input id="${id('new_label')}" name="new_label" maxlength="80"></div>
|
|
242
243
|
</div>
|
|
243
|
-
<small class="help"
|
|
244
|
-
${datasets.length ? html`<div class="form-grid"><div class="field" data-wide><label class="label" for="${id('new_dataset')}"
|
|
244
|
+
<small class="help">${tx('drep.all_columns_are_offered_at')}</small>
|
|
245
|
+
${datasets.length ? html`<div class="form-grid"><div class="field" data-wide><label class="label" for="${id('new_dataset')}">${tx('drep.or_a_dataset_of_the')}</label>
|
|
245
246
|
<select id="${id('new_dataset')}" name="new_dataset"><option value="">- none -</option>${datasets.map((d) => html`<option value="${d.name}">${d.label} (${d.name})</option>`)}</select></div></div>` : ''}</fieldset>
|
|
246
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
247
|
+
<div class="buttons"><button class="btn btn-hot">${tx('drep.save_data_reporter_settings')}</button></div>
|
|
247
248
|
</form>`;
|
|
248
249
|
}
|
|
249
250
|
|
|
@@ -262,7 +263,7 @@ export async function reporterBuilderRoutes(app: FastifyInstance) {
|
|
|
262
263
|
const { config, errors } = await mergeReporterSettings(r.config ?? {}, (req.body ?? {}) as Body, { authz: new Set(schemes.map((x) => x.name.toUpperCase())), datasets: new Set(datasets.map((x) => x.name)) });
|
|
263
264
|
await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
|
|
264
265
|
if (errors.length) flash(s, errors.join(' '), 'error');
|
|
265
|
-
else flash(s, '
|
|
266
|
+
else flash(s, tx('drep.settings_saved'));
|
|
266
267
|
return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
|
|
267
268
|
});
|
|
268
269
|
}
|