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/feedback.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { appOr404 } from './forms.ts';
|
|
|
6
6
|
import { issuesFrom } from '../team.ts';
|
|
7
7
|
import { toIssueButton } from './team.ts';
|
|
8
8
|
import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
|
|
9
|
+
import { scopedTx as tx, type BuilderKey } from './i18n.ts';
|
|
9
10
|
|
|
10
11
|
// Application → Feedback (APEX: Team Development → Feedback, migration 084):
|
|
11
12
|
// switch the application's Feedback link on or off, read what users sent
|
|
@@ -13,8 +14,10 @@ import { appHeader, back, BASE, csrf, developer, flash, region, select, send, sh
|
|
|
13
14
|
// and a response the user sees on the feedback page. Answers are data, not
|
|
14
15
|
// a change of the application: allowed while it is locked or Run only.
|
|
15
16
|
|
|
16
|
-
const STATUSES: [string,
|
|
17
|
-
const KINDS: [string,
|
|
17
|
+
const STATUSES: [string, BuilderKey][] = [['open', 'fbk.status.open'], ['in_progress', 'fbk.status.in_progress'], ['done', 'fbk.status.done'], ['declined', 'fbk.status.declined']];
|
|
18
|
+
const KINDS: [string, BuilderKey][] = [['comment', 'fbk.kind.comment'], ['idea', 'fbk.kind.idea'], ['problem', 'fbk.kind.problem']];
|
|
19
|
+
/** Options with their labels in the developer's language. */
|
|
20
|
+
const options = (list: [string, BuilderKey][]): [string, string][] => list.map(([v, k]) => [v, tx(k)]);
|
|
18
21
|
const when = (d: string | Date) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
|
|
19
22
|
|
|
20
23
|
export async function feedbackRoutes(app: FastifyInstance) {
|
|
@@ -33,39 +36,39 @@ export async function feedbackRoutes(app: FastifyInstance) {
|
|
|
33
36
|
// (sprint 113) feedback turned into a Team Development issue links to it
|
|
34
37
|
const issues = await issuesFrom('feedback', rows.map((f) => Number(f.id)));
|
|
35
38
|
const body = html`
|
|
36
|
-
${region('
|
|
39
|
+
${region(tx('fbk.title'), html`
|
|
37
40
|
<form method="post" action="${BASE}/apps/${a.id}/feedback/settings" class="u-mb1">${csrf(s)}
|
|
38
|
-
<label class="check"><input type="checkbox" name="feedback" value="true"${a.feedback ? raw(' checked') : ''}>
|
|
39
|
-
<small class="help"
|
|
40
|
-
<div class="buttons"><button class="btn btn-sm"
|
|
41
|
-
<p class="muted">${count('open')
|
|
41
|
+
<label class="check"><input type="checkbox" name="feedback" value="true"${a.feedback ? raw(' checked') : ''}> ${tx('fbk.enable')}</label>
|
|
42
|
+
<small class="help">${tx.rich('fbk.enable_help', { link: html`<b>${tx('fbk.title')}</b>` })}</small>
|
|
43
|
+
<div class="buttons"><button class="btn btn-sm">${tx('common.save')}</button></div></form>
|
|
44
|
+
<p class="muted">${tx('fbk.counts', { open: count('open'), in_progress: count('in_progress'), done: count('done'), declined: count('declined') })}</p>
|
|
42
45
|
<form method="get" class="filter-row">
|
|
43
|
-
${select('status', '
|
|
44
|
-
${select('kind', '
|
|
45
|
-
<button class="btn btn-sm">${icon('filter')}
|
|
46
|
+
${select('status', tx('fbk.status'), status, [['', tx('fbk.all')], ...options(STATUSES)])}
|
|
47
|
+
${select('kind', tx('fbk.kind'), kind, [['', tx('fbk.all')], ...options(KINDS)])}
|
|
48
|
+
<button class="btn btn-sm">${icon('filter')} ${tx('common.show')}</button></form>
|
|
46
49
|
${rows.length ? html`<ul class="feedback-admin">${rows.map((f) => html`<li class="feedback-item" id="feedback-${f.id}">
|
|
47
|
-
<div class="feedback-meta"><span class="tag">${KINDS.find(([v]) => v === f.kind)?.[1]}</span>
|
|
48
|
-
<b>${f.username}</b> <span class="muted">${when(f.created_at)}${f.page_no ? html` · <a href="/a/${a.alias}/${f.page_no}" target="_blank" rel="noopener"
|
|
50
|
+
<div class="feedback-meta"><span class="tag">${options(KINDS).find(([v]) => v === f.kind)?.[1]}</span>
|
|
51
|
+
<b>${f.username}</b> <span class="muted">${when(f.created_at)}${f.page_no ? html` · <a href="/a/${a.alias}/${f.page_no}" target="_blank" rel="noopener">${tx('fbk.page', { n: f.page_no })}</a>` : ''}${f.rating ? ` · ${'★'.repeat(f.rating)}` : ''}${f.user_agent ? ` · ${f.user_agent}` : ''}</span></div>
|
|
49
52
|
<p class="feedback-body">${f.body}</p>
|
|
50
53
|
<p class="feedback-issue">${toIssueButton(s, 'feedback', Number(f.id), issues.get(Number(f.id)))}</p>
|
|
51
54
|
<form method="post" action="${BASE}/apps/${a.id}/feedback/${f.id}">${csrf(s)}
|
|
52
55
|
<div class="form-grid">
|
|
53
|
-
${select('status', '
|
|
54
|
-
<div class="field" data-wide><label class="label" for="f_response_${f.id}"
|
|
56
|
+
${select('status', tx('fbk.status'), f.status, options(STATUSES))}
|
|
57
|
+
<div class="field" data-wide><label class="label" for="f_response_${f.id}">${tx('fbk.answer')}</label>
|
|
55
58
|
<textarea id="f_response_${f.id}" name="response" rows="2" maxlength="4000">${f.response ?? ''}</textarea>
|
|
56
|
-
${f.responded_by ? html`<small class="help"
|
|
59
|
+
${f.responded_by ? html`<small class="help">${tx('fbk.answered_by', { user: f.responded_by, when: when(f.responded_at) })}</small>` : ''}</div>
|
|
57
60
|
</div>
|
|
58
|
-
<div class="buttons"><button class="btn btn-sm btn-hot"
|
|
59
|
-
</li>`)}</ul>` : html`<p class="muted"
|
|
61
|
+
<div class="buttons"><button class="btn btn-sm btn-hot">${tx('common.save')}</button></div></form>
|
|
62
|
+
</li>`)}</ul>` : html`<p class="muted">${tx(status || kind ? 'fbk.none_filtered' : 'fbk.none')}</p>`}`)}`;
|
|
60
63
|
const main = html`${appHeader(a, 'activity', s)}<div class="ide-body">${body}</div>`;
|
|
61
|
-
return send(reply, s, shell(s,
|
|
64
|
+
return send(reply, s, shell(s, tx('fbk.page_title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.tab.activity'), `${BASE}/apps/${a.id}/activity`], [tx('fbk.title')]], main));
|
|
62
65
|
});
|
|
63
66
|
|
|
64
67
|
app.post(`${BASE}/apps/:id(^\\d+$)/feedback/settings`, async (req: Req, reply) => {
|
|
65
68
|
const s = await developer(req, reply);
|
|
66
69
|
if (!s) return;
|
|
67
70
|
await owner.query('update meta.app set feedback = $2, updated_at = now() where id = $1', [req.params.id, req.body?.feedback === 'true']);
|
|
68
|
-
flash(s, '
|
|
71
|
+
flash(s, tx('fbk.saved'));
|
|
69
72
|
return back(reply, s, `${BASE}/apps/${req.params.id}/feedback`);
|
|
70
73
|
});
|
|
71
74
|
|
|
@@ -81,7 +84,7 @@ export async function feedbackRoutes(app: FastifyInstance) {
|
|
|
81
84
|
responded_at = case when $4::text is distinct from response then now() else responded_at end
|
|
82
85
|
where id = $2 and app_id = $1`,
|
|
83
86
|
[req.params.id, req.params.fid, status, response, s.username]);
|
|
84
|
-
flash(s, r.rowCount ? '
|
|
87
|
+
flash(s, r.rowCount ? tx('fbk.saved') : tx('fbk.not_found'), r.rowCount ? 'ok' : 'error');
|
|
85
88
|
return back(reply, s, `${BASE}/apps/${req.params.id}/feedback`);
|
|
86
89
|
});
|
|
87
90
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import { owner } from '../db.ts';
|
|
3
|
-
import { esc, html, type Raw } from '../html.ts';
|
|
3
|
+
import { esc, html, raw, type Raw } from '../html.ts';
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
5
|
import { documentShell } from '../layout.ts';
|
|
6
6
|
import { senderAllowed, sendWith, type MailService } from '../mail/mail.ts';
|
|
7
7
|
import { clientIp, getSession, logActivity, saveState, takeFlash, type Session } from '../session.ts';
|
|
8
8
|
import { publicUrl } from '../sso.ts';
|
|
9
9
|
import { back, BASE, brandMark, builderHead, csrf, flash, THEME_COOKIE, validTheme, type Req } from './ui.ts';
|
|
10
|
+
import { cookieLanguage, translatorFor, type BuilderTranslate } from './i18n.ts';
|
|
10
11
|
|
|
11
12
|
// "Find my workspace" on the builder's sign-in page (migration 096; APEX: Find
|
|
12
13
|
// My Workspace): a developer who forgot which workspace to sign in to enters
|
|
@@ -30,8 +31,8 @@ export async function builderMail(): Promise<{ service: MailService; from: strin
|
|
|
30
31
|
return service && from && senderAllowed(service.senders, from) ? { service, from } : null;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/** The mail for one address: each account with it, and its workspaces. */
|
|
34
|
-
export async function workspacesMail(email: string): Promise<{ to: string; subject: string; text: string; html: string } | null> {
|
|
34
|
+
/** The mail for one address: each account with it, and its workspaces (in the language of the page it was asked from). */
|
|
35
|
+
export async function workspacesMail(email: string, tx: BuilderTranslate = translatorFor('en')): Promise<{ to: string; subject: string; text: string; html: string } | null> {
|
|
35
36
|
const accounts = (await owner.query<{ username: string; admin: boolean; workspaces: string[] }>(
|
|
36
37
|
`select d.username, d.is_admin as admin,
|
|
37
38
|
coalesce((select array_agg(w.name order by w.id = 1 desc, lower(w.name)) from meta.workspace w
|
|
@@ -39,28 +40,29 @@ export async function workspacesMail(email: string): Promise<{ to: string; subje
|
|
|
39
40
|
from meta.developer d where lower(d.email) = lower($1) order by d.username`, [email])).rows;
|
|
40
41
|
if (!accounts.length) return null;
|
|
41
42
|
const signIn = `${publicUrl()}${BASE}/login`;
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
43
|
+
const admin = ` (${tx('fw.mail_admin')})`;
|
|
44
|
+
const lines = accounts.map((a) => `${a.username}${a.admin ? admin : ''}\n${a.workspaces.length ? a.workspaces.map((w) => ` - ${w}`).join('\n') : ` (${tx('fw.mail_no_ws')})`}`);
|
|
45
|
+
const text = `${tx('fw.mail_intro')}\n\n${lines.join('\n\n')}\n\n${tx('fw.mail_sign_in', { url: signIn })}\n\n${tx('fw.mail_ignore')}`;
|
|
46
|
+
const body = `<p>${esc(tx('fw.mail_intro'))}</p>${accounts.map((a) => `<p><b>${esc(a.username)}</b>${a.admin ? esc(admin) : ''}</p><ul>${a.workspaces.length ? a.workspaces.map((w) => `<li>${esc(w)}</li>`).join('') : `<li>${esc(tx('fw.mail_no_ws'))}</li>`}</ul>`).join('')}<p>${tx.rich('fw.mail_sign_in', { url: raw(`<a href="${esc(signIn)}">${esc(signIn)}</a>`) })}</p><p>${esc(tx('fw.mail_ignore'))}</p>`;
|
|
47
|
+
return { to: email, subject: tx('fw.mail_subject'), text, html: body };
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
const page = (s: Session, theme: ReturnType<typeof validTheme>, body: Raw) =>
|
|
50
|
+
const page = (s: Session, theme: ReturnType<typeof validTheme>, body: Raw, tx: BuilderTranslate) =>
|
|
49
51
|
documentShell(
|
|
50
|
-
'
|
|
52
|
+
`${tx('signin.find_ws')} · ${tx('shell.builder')}`,
|
|
51
53
|
html`<main class="signin">
|
|
52
54
|
<section class="signin-hero" aria-label="pgkiln">
|
|
53
55
|
<span class="signin-logo" aria-hidden="true">${brandMark()}</span>
|
|
54
56
|
<h1 class="signin-title">pgkiln</h1>
|
|
55
|
-
<p class="signin-tagline"
|
|
57
|
+
<p class="signin-tagline">${tx('signin.tagline')}</p>
|
|
56
58
|
</section>
|
|
57
59
|
<section class="signin-panel"><div class="signin-form">${body}
|
|
58
|
-
<nav class="signin-actions" aria-label="
|
|
60
|
+
<nav class="signin-actions" aria-label="${tx('signin.quick')}"><a href="${BASE}/login">${icon('arrow-left')} ${tx('fw.back')}</a></nav></div></section>
|
|
59
61
|
</main>`,
|
|
60
62
|
'login-body ide-login signin-body',
|
|
61
63
|
{},
|
|
62
64
|
builderHead(),
|
|
63
|
-
{ theme: theme ?? 'dark' },
|
|
65
|
+
{ theme: theme ?? 'dark', lang: tx.lang },
|
|
64
66
|
);
|
|
65
67
|
|
|
66
68
|
export async function findWorkspaceRoutes(app: FastifyInstance) {
|
|
@@ -71,31 +73,33 @@ export async function findWorkspaceRoutes(app: FastifyInstance) {
|
|
|
71
73
|
await saveState(s);
|
|
72
74
|
const mail = await builderMail();
|
|
73
75
|
const theme = validTheme(req.cookies?.[THEME_COOKIE]);
|
|
76
|
+
const tx = translatorFor(cookieLanguage(req));
|
|
74
77
|
const body = !mail
|
|
75
|
-
? html`<h2
|
|
78
|
+
? html`<h2>${tx('signin.find_ws')}</h2><p class="muted u-mt0">${tx('fw.no_mail')}</p>`
|
|
76
79
|
: done
|
|
77
|
-
? html`<h2
|
|
78
|
-
: html`<h2
|
|
79
|
-
<p class="muted u-mt0"
|
|
80
|
+
? html`<h2>${tx('fw.check_mail')}</h2><div class="alert alert-success" role="status">${done}</div>`
|
|
81
|
+
: html`<h2>${tx('signin.find_ws')}</h2>
|
|
82
|
+
<p class="muted u-mt0">${tx('fw.intro')}</p>
|
|
80
83
|
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
81
84
|
<form method="post" action="${BASE}/find-workspace">${csrf(s)}
|
|
82
|
-
<div class="field signin-field"><label class="sr-only" for="f_email"
|
|
83
|
-
<div class="signin-input">${icon('at')}<input id="f_email" name="email" type="email" placeholder="
|
|
84
|
-
<button class="btn btn-hot signin-submit"
|
|
85
|
+
<div class="field signin-field"><label class="sr-only" for="f_email">${tx('dev.email')}</label>
|
|
86
|
+
<div class="signin-input">${icon('at')}<input id="f_email" name="email" type="email" placeholder="${tx('dev.email')}" required autocomplete="email" maxlength="254"></div></div>
|
|
87
|
+
<button class="btn btn-hot signin-submit">${tx('fw.send')}</button>
|
|
85
88
|
</form>`;
|
|
86
|
-
return reply.type('text/html').send(page(s, theme, body));
|
|
89
|
+
return reply.type('text/html').send(page(s, theme, body, tx));
|
|
87
90
|
});
|
|
88
91
|
|
|
89
92
|
app.post(`${BASE}/find-workspace`, async (req: Req, reply) => {
|
|
90
93
|
const s = await getSession(req, reply, null, BASE);
|
|
91
94
|
const to = `${BASE}/find-workspace`;
|
|
95
|
+
const tx = translatorFor(cookieLanguage(req));
|
|
92
96
|
if (req.body?.__csrf !== s.csrf_token) {
|
|
93
|
-
flash(s, '
|
|
97
|
+
flash(s, tx('signin.expired'), 'error');
|
|
94
98
|
return back(reply, s, to);
|
|
95
99
|
}
|
|
96
100
|
const email = String(req.body?.email ?? '').trim().slice(0, 254);
|
|
97
101
|
if (!EMAIL.test(email)) {
|
|
98
|
-
flash(s, '
|
|
102
|
+
flash(s, tx('fw.bad_email'), 'error');
|
|
99
103
|
return back(reply, s, to);
|
|
100
104
|
}
|
|
101
105
|
const ip = clientIp(req);
|
|
@@ -109,11 +113,11 @@ export async function findWorkspaceRoutes(app: FastifyInstance) {
|
|
|
109
113
|
if (mail && !limited)
|
|
110
114
|
// after the answer: the time it takes never tells whether an account has the address
|
|
111
115
|
setImmediate(() => {
|
|
112
|
-
workspacesMail(email)
|
|
116
|
+
workspacesMail(email, tx)
|
|
113
117
|
.then((m) => (m ? sendWith(mail.service, { from: mail.from, to: [m.to], cc: [], bcc: [], replyTo: null, subject: m.subject, text: m.text, html: m.html, attachments: [] }) : null))
|
|
114
118
|
.catch((e) => console.error('find my workspace: the mail was not sent:', (e as Error).message));
|
|
115
119
|
});
|
|
116
|
-
flash(s,
|
|
120
|
+
flash(s, tx('fw.sent', { email }));
|
|
117
121
|
return back(reply, s, to);
|
|
118
122
|
});
|
|
119
123
|
}
|
|
@@ -7,6 +7,7 @@ import { fdSourcesOf, type FdColumn, type FdSource } from '../runtime/form-desig
|
|
|
7
7
|
import type { Session } from '../session.ts';
|
|
8
8
|
import { findObject, objectList, type DbObject } from './reporter.ts';
|
|
9
9
|
import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
|
|
10
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
10
11
|
|
|
11
12
|
// Page designer → a Form Designer region → Settings (sprint 85): the tables
|
|
12
13
|
// and views the region offers (which columns forms may show and change, their
|
|
@@ -53,7 +54,7 @@ export async function mergeFormDesignerSettings(
|
|
|
53
54
|
else delete out.empty;
|
|
54
55
|
delete out.design_authz;
|
|
55
56
|
if (b.design_authz && a.authz.has(b.design_authz.toUpperCase())) out.design_authz = b.design_authz;
|
|
56
|
-
else if (b.design_authz) errors.push(
|
|
57
|
+
else if (b.design_authz) errors.push(tx('fdz.no_authz', { name: String(b.design_authz) }));
|
|
57
58
|
|
|
58
59
|
const lovOk = (v: string) => LOV_REF.test(v) && (!/^LOV:/i.test(v) || a.lovs.has(v.slice(4).toUpperCase()));
|
|
59
60
|
const sources: FdSource[] = [];
|
|
@@ -78,7 +79,7 @@ export async function mergeFormDesignerSettings(
|
|
|
78
79
|
}
|
|
79
80
|
columns.push({ name, ...(label ? { label } : {}), ...(lov ? { lov } : {}) });
|
|
80
81
|
}
|
|
81
|
-
if (!columns.length) errors.push(
|
|
82
|
+
if (!columns.length) errors.push(tx('fdz.no_columns', { id: s.id }));
|
|
82
83
|
const key = clip(b[`s${i}_keycol`], 63);
|
|
83
84
|
if (key && !names.has(key)) errors.push(`${s.id}: the key column ${key} is not in ${s.schema}.${s.table}.`);
|
|
84
85
|
const label = clip(b[`s${i}_label`], 80);
|
|
@@ -92,7 +93,7 @@ export async function mergeFormDesignerSettings(
|
|
|
92
93
|
|
|
93
94
|
if (b.new_object) {
|
|
94
95
|
const obj = /^\d{1,10}$/.test(b.new_object) ? await lookup.byOid(Number(b.new_object)) : null;
|
|
95
|
-
if (!obj || RESERVED_SCHEMAS.test(obj.schema)) errors.push('
|
|
96
|
+
if (!obj || RESERVED_SCHEMAS.test(obj.schema)) errors.push(tx('fdz.choose_a_table_or_view'));
|
|
96
97
|
else if (sources.length >= MAX_FD_SOURCES) errors.push(`A region offers at most ${MAX_FD_SOURCES} data sources.`);
|
|
97
98
|
else {
|
|
98
99
|
let id = clip(b.new_id, 40).toLowerCase();
|
|
@@ -101,7 +102,7 @@ export async function mergeFormDesignerSettings(
|
|
|
101
102
|
id = '';
|
|
102
103
|
}
|
|
103
104
|
if (id && sources.some((s) => s.id === id)) {
|
|
104
|
-
errors.push(
|
|
105
|
+
errors.push(tx('fdz.dup_source', { id }));
|
|
105
106
|
id = '';
|
|
106
107
|
}
|
|
107
108
|
if (!id) {
|
|
@@ -154,56 +155,56 @@ export async function formDesignerSettingsForm(pageId: number, appId: number, r:
|
|
|
154
155
|
const missing = privileges ? (['sel', 'ins', 'upd', 'del'] as const).filter((k) => !privileges[k] && (k === 'sel' || src[({ ins: 'insert', upd: 'update', del: 'delete' } as const)[k as 'ins' | 'upd' | 'del']])) : [];
|
|
155
156
|
const grant = { sel: 'SELECT', ins: 'INSERT', upd: 'UPDATE', del: 'DELETE' };
|
|
156
157
|
const check = (k: 'insert' | 'update' | 'delete', label: string, needsKey: boolean) =>
|
|
157
|
-
html`<label class="check"><input type="checkbox" name="s${i}_${k}" value="true"${src[k] ? raw(' checked') : ''}> ${label}${needsKey && !src.key ?
|
|
158
|
+
html`<label class="check"><input type="checkbox" name="s${i}_${k}" value="true"${src[k] ? raw(' checked') : ''}> ${label}${needsKey && !src.key ? ` ${tx('fdz.needs_key')}` : ''}</label>`;
|
|
158
159
|
return html`<fieldset class="prop-group"><legend>${src.label || src.id} <span class="muted">(${src.schema}.${src.table})</span></legend>
|
|
159
160
|
<input type="hidden" name="s${i}_key" value="${src.id}">
|
|
160
|
-
${!obj ? html`<div class="alert alert-error" role="alert"
|
|
161
|
-
${missing.length ? html`<div class="alert alert-error" role="alert"
|
|
161
|
+
${!obj ? html`<div class="alert alert-error" role="alert">${tx('fdz.no_object', { object: `${src.schema}.${src.table}` })}</div>` : ''}
|
|
162
|
+
${missing.length ? html`<div class="alert alert-error" role="alert">${tx('fdz.lacks', { privs: missing.map((k) => grant[k]).join(', '), object: `${src.schema}.${src.table}` })}</div>` : ''}
|
|
162
163
|
<div class="form-grid">
|
|
163
|
-
<div class="field"><label class="label" for="${id(`s${i}_id`)}"
|
|
164
|
-
<div class="field"><label class="label" for="${id(`s${i}_label`)}"
|
|
165
|
-
<div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}"
|
|
166
|
-
<div class="field"><label class="label" for="${id(`s${i}_keycol`)}"
|
|
167
|
-
<select id="${id(`s${i}_keycol`)}" name="s${i}_keycol"><option value=""
|
|
168
|
-
<div class="field"><span class="label"
|
|
169
|
-
${check('insert', '
|
|
164
|
+
<div class="field"><label class="label" for="${id(`s${i}_id`)}">${tx('fdz.static_id')}</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
|
|
165
|
+
<div class="field"><label class="label" for="${id(`s${i}_label`)}">${tx('fdz.label')}</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${heading(src.table)}"></div>
|
|
166
|
+
<div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">${tx('fdz.description_shown_to_designers')}</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}"></div>
|
|
167
|
+
<div class="field"><label class="label" for="${id(`s${i}_keycol`)}">${tx('fdz.key_column_identifies_a_row')}</label>
|
|
168
|
+
<select id="${id(`s${i}_keycol`)}" name="s${i}_keycol"><option value="">${tx('fdz.no_key')}</option>${(obj?.columns ?? []).map((c) => opt(c.name, c.name, src.key ?? ''))}</select></div>
|
|
169
|
+
<div class="field"><span class="label">${tx('fdz.forms_on_it_may')}</span>
|
|
170
|
+
${check('insert', tx('fdz.add_rows'), false)} ${check('update', tx('fdz.change_rows'), true)} ${check('delete', tx('fdz.delete_rows'), true)}</div>
|
|
170
171
|
</div>
|
|
171
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col"
|
|
172
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('fdz.offer')}</th><th scope="col">${tx('fdz.column')}</th><th scope="col">${tx('fdz.label')}</th><th scope="col">${tx('fdz.list_of_values')}</th></tr></thead><tbody>
|
|
172
173
|
${all.map((c, j) => {
|
|
173
174
|
const o = offered.get(c.name);
|
|
174
175
|
return html`<tr>
|
|
175
|
-
<td data-label="
|
|
176
|
-
<td data-label="
|
|
177
|
-
<td data-label="
|
|
178
|
-
<td data-label="
|
|
176
|
+
<td data-label="${tx('fdz.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('fdz.offer_col', { col: c.name })}"></td>
|
|
177
|
+
<td data-label="${tx('fdz.column')}"><code>${c.name}</code> <span class="muted">${c.type}</span>${c.missing ? html` <span class="tag tag-error">${tx('fdz.not_in_the_table')}</span>` : ''}</td>
|
|
178
|
+
<td data-label="${tx('fdz.label')}"><input name="s${i}_label_${j}" maxlength="80" value="${o?.label ?? ''}" placeholder="${heading(c.name)}" aria-label="${tx('fdz.label_of', { col: c.name })}"></td>
|
|
179
|
+
<td data-label="${tx('fdz.list_of_values')}"><input name="s${i}_lov_${j}" maxlength="2000" value="${o?.lov ?? ''}" placeholder="LOV:NAME" list="${id('lovs')}" aria-label="${tx('fdz.lov_of', { col: c.name })}"></td>
|
|
179
180
|
</tr>`;
|
|
180
181
|
})}
|
|
181
182
|
</tbody></table></div>
|
|
182
|
-
<div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true">
|
|
183
|
+
<div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> ${tx('fdz.remove_this_data_source_forms')}</label></div>
|
|
183
184
|
</fieldset>`;
|
|
184
185
|
}),
|
|
185
186
|
);
|
|
186
|
-
return html`<h3 class="u-mt15"
|
|
187
|
-
<p class="muted u-mt0"
|
|
187
|
+
return html`<h3 class="u-mt15">${tx('fdz.form_designer_settings')}</h3>
|
|
188
|
+
<p class="muted u-mt0">${tx('fdz.users_that_pass_the_designers')}</p>
|
|
188
189
|
<datalist id="${id('lovs')}">${lovs.map((n) => html`<option value="LOV:${n}">`)}</datalist>
|
|
189
190
|
<form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/form-designer" class="component-form">${csrf(s)}
|
|
190
|
-
<fieldset class="prop-group"><legend
|
|
191
|
-
<div class="field"><label class="label" for="${id('design_authz')}"
|
|
192
|
-
<select id="${id('design_authz')}" name="design_authz">${opt('', '
|
|
193
|
-
<div class="field"><label class="label" for="${id('page_size')}"
|
|
191
|
+
<fieldset class="prop-group"><legend>${tx('fdz.forms')}</legend><div class="form-grid">
|
|
192
|
+
<div class="field"><label class="label" for="${id('design_authz')}">${tx('fdz.who_may_design_forms')}</label>
|
|
193
|
+
<select id="${id('design_authz')}" name="design_authz">${opt('', tx('fdz.nobody_choose_an_authorization_scheme'), designAuthz)}${schemes.map((n) => opt(n, `Authorization: ${n}`, designAuthz))}</select></div>
|
|
194
|
+
<div class="field"><label class="label" for="${id('page_size')}">${tx('fdz.rows_per_page')}</label>
|
|
194
195
|
<input id="${id('page_size')}" name="page_size" type="number" min="5" max="200" value="${cfg.page_size ?? ''}" placeholder="25"></div>
|
|
195
|
-
<div class="field" data-wide><label class="label" for="${id('empty')}"
|
|
196
|
-
<input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="
|
|
196
|
+
<div class="field" data-wide><label class="label" for="${id('empty')}">${tx('fdz.text_when_there_are_no')}</label>
|
|
197
|
+
<input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="${tx('fdz.no_data_found')}"></div>
|
|
197
198
|
</div></fieldset>
|
|
198
199
|
${sourceSets}
|
|
199
|
-
<fieldset class="prop-group"><legend
|
|
200
|
-
<div class="field" data-wide><label class="label" for="${id('new_object')}"
|
|
200
|
+
<fieldset class="prop-group"><legend>${tx('fdz.add_a_data_source')}</legend><div class="form-grid">
|
|
201
|
+
<div class="field" data-wide><label class="label" for="${id('new_object')}">${tx('fdz.table_or_view')}</label>
|
|
201
202
|
<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>
|
|
202
|
-
<div class="field"><label class="label" for="${id('new_id')}"
|
|
203
|
-
<div class="field"><label class="label" for="${id('new_label')}"
|
|
203
|
+
<div class="field"><label class="label" for="${id('new_id')}">${tx('fdz.static_id')}</label><input id="${id('new_id')}" name="new_id" maxlength="40" pattern="[a-z][a-z0-9_]*" placeholder="${tx('fdz.from_the_name')}"></div>
|
|
204
|
+
<div class="field"><label class="label" for="${id('new_label')}">${tx('fdz.label')}</label><input id="${id('new_label')}" name="new_label" maxlength="80"></div>
|
|
204
205
|
</div>
|
|
205
|
-
<small class="help"
|
|
206
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
206
|
+
<small class="help">${tx('fdz.all_columns_are_offered_at')}</small></fieldset>
|
|
207
|
+
<div class="buttons"><button class="btn btn-hot">${tx('fdz.save_form_designer_settings')}</button></div>
|
|
207
208
|
</form>`;
|
|
208
209
|
}
|
|
209
210
|
|
|
@@ -223,7 +224,7 @@ export async function formDesignerBuilderRoutes(app: FastifyInstance) {
|
|
|
223
224
|
{ authz: new Set(schemes.map((x) => x.name.toUpperCase())), lovs: new Set(lovs.map((x) => x.name.toUpperCase())) });
|
|
224
225
|
await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
|
|
225
226
|
if (errors.length) flash(s, errors.join(' '), 'error');
|
|
226
|
-
else flash(s, '
|
|
227
|
+
else flash(s, tx('fdz.settings_saved'));
|
|
227
228
|
return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
|
|
228
229
|
});
|
|
229
230
|
}
|
package/src/builder/gallery.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { isAdmin } from './locks.ts';
|
|
|
12
12
|
import { back, BASE, csrf, developer, flash, send, shell, type Req } from './ui.ts';
|
|
13
13
|
import { currentWorkspace, inWorkspaceSql, placeApp } from './workspaces.ts';
|
|
14
14
|
import { workspacesChanged } from '../workspace-db.ts';
|
|
15
|
+
import { scopedTx as tx, type BuilderKey } from './i18n.ts';
|
|
15
16
|
|
|
16
17
|
// The App Gallery (sprint 61, sprint 120; APEX: App Gallery with Sample, Starter, Utility and
|
|
17
18
|
// Custom Apps).
|
|
@@ -69,7 +70,7 @@ const SAMPLE_FILE = /^(samples\/)?[a-z0-9-]+\.sql$/;
|
|
|
69
70
|
const galleryFile = (rel: string) => readFileSync(join(rel.includes('/') ? GALLERY : join(root, 'examples'), rel), 'utf8');
|
|
70
71
|
|
|
71
72
|
type Tab = 'samples' | 'starters' | 'utilities' | 'custom';
|
|
72
|
-
const TABS: readonly [Tab,
|
|
73
|
+
const TABS: readonly [Tab, BuilderKey][] = [['samples', 'gal.tab.samples'], ['starters', 'gal.tab.starters'], ['utilities', 'gal.tab.utilities'], ['custom', 'gal.tab.custom']];
|
|
73
74
|
|
|
74
75
|
export async function galleryRoutes(app: FastifyInstance) {
|
|
75
76
|
app.get(`${BASE}/gallery`, async (req: Req, reply) => {
|
|
@@ -100,50 +101,45 @@ export async function galleryRoutes(app: FastifyInstance) {
|
|
|
100
101
|
const card = (ic: string, title: string, by: string, text: string, action: unknown) =>
|
|
101
102
|
html`<article class="gal-card" data-filter-row><div class="gal-head"><span class="gal-icon">${icon(ic)}</span><div><h2>${title}</h2><span class="muted small">${by}</span></div></div>
|
|
102
103
|
<p>${text}</p><div class="gal-foot">${action}</div></article>`;
|
|
103
|
-
const tabs = html`<nav class="gal-tabs segmented" aria-label="
|
|
104
|
-
html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${l} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
|
|
104
|
+
const tabs = html`<nav class="gal-tabs segmented" aria-label="${tx('gal.kinds')}">${TABS.map(([t, l]) =>
|
|
105
|
+
html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${tx(l)} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
|
|
105
106
|
const blueprintCard = (kind: 'starters' | 'utilities') => (x: Starter) => card(x.icon, x.title, 'pgkiln', x.description,
|
|
106
|
-
html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm"
|
|
107
|
+
html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">${tx('gal.install')}</button></form>`);
|
|
107
108
|
const cards =
|
|
108
109
|
tab === 'samples'
|
|
109
110
|
? samples.filter(matches).map((x) => {
|
|
110
111
|
const it = installed.get(x.alias);
|
|
111
112
|
return card(x.icon, x.title, 'pgkiln', x.description,
|
|
112
113
|
it
|
|
113
|
-
? html`<span class="tag tag-ok"
|
|
114
|
-
? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}"
|
|
115
|
-
: html`<span class="muted small"
|
|
114
|
+
? html`<span class="tag tag-ok">${tx('gal.installed')}</span><a class="btn btn-sm" href="/a/${x.alias}" target="_blank" rel="noopener">${tx('common.run')}</a>${it.here
|
|
115
|
+
? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}">${tx('gal.edit')}</a>`
|
|
116
|
+
: html`<span class="muted small">${tx('gal.other_workspace')}</span>`}`
|
|
116
117
|
: admin
|
|
117
|
-
? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="
|
|
118
|
-
: html`<span class="muted small"
|
|
118
|
+
? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="${tx('gal.confirm_install', { title: x.title })}">${tx('gal.install')}</button></form>`
|
|
119
|
+
: html`<span class="muted small">${tx('gal.admin_installs')}</span>`);
|
|
119
120
|
})
|
|
120
121
|
: tab === 'starters' || tab === 'utilities'
|
|
121
122
|
? (tab === 'starters' ? starters : utilities).filter(matches).map(blueprintCard(tab))
|
|
122
|
-
: custom.filter(matches).map((x) => card('copy', x.name, '
|
|
123
|
-
html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}"
|
|
123
|
+
: custom.filter(matches).map((x) => card('copy', x.name, tx('gal.this_workspace'), x.description ?? tx('gal.boilerplate_desc'),
|
|
124
|
+
html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}">${tx('gal.create_from')}</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">${tx('gal.edit')}</a>`));
|
|
124
125
|
const empty = q && counts[tab]
|
|
125
|
-
? html`<p class="muted"
|
|
126
|
+
? html`<p class="muted">${tx.rich('gal.no_match', { q: html`<strong>${q}</strong>` })} <a href="${BASE}/gallery?tab=${tab}">${tx('gal.show_all')}</a></p>`
|
|
126
127
|
: tab === 'custom'
|
|
127
|
-
? html`<p class="muted"
|
|
128
|
-
: html`<p class="muted"
|
|
129
|
-
const intro = {
|
|
130
|
-
samples: 'Applications that show what pgkiln can do, each with its own schema, data and demo account: the showcase, the tutorial, one app per feature area and the UX Pattern Catalog. An administrator installs them on a development or demo instance.',
|
|
131
|
-
starters: 'Small, ready-to-use applications. Install shows what will be created (tables, pages, sample data) before anything happens; the application gets its own role and schema in this workspace.',
|
|
132
|
-
utilities: 'Applications a workspace uses rather than learns from: shared lookup lists, release notes, announcements. Like starter apps, Install shows what will be created first, and the application gets its own role and schema in this workspace.',
|
|
133
|
-
custom: 'The boilerplate applications of this workspace: start a new application from one.',
|
|
134
|
-
}[tab];
|
|
128
|
+
? html`<p class="muted">${tx.rich('gal.no_custom', { type: html`<em>${tx('gal.boilerplate')}</em>` })}</p>`
|
|
129
|
+
: html`<p class="muted">${tx('gal.empty')}</p>`;
|
|
130
|
+
const intro = tx(({ samples: 'gal.intro.samples', starters: 'gal.intro.starters', utilities: 'gal.intro.utilities', custom: 'gal.intro.custom' } as const)[tab]);
|
|
135
131
|
const main = html`<div class="gal">
|
|
136
132
|
<div class="gal-bar">
|
|
137
|
-
<form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q"
|
|
133
|
+
<form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q">${tx('gal.search')}</label>${icon('search')}
|
|
138
134
|
<input type="hidden" name="tab" value="${tab}">
|
|
139
|
-
<input id="gal-q" name="q" type="search" placeholder="
|
|
135
|
+
<input id="gal-q" name="q" type="search" placeholder="${tx('gal.search')}" autocomplete="off" value="${q}" data-filter-list=".gal [data-filter-row]"></form>
|
|
140
136
|
${tabs}
|
|
141
137
|
</div>
|
|
142
|
-
<h1 class="sr-only"
|
|
138
|
+
<h1 class="sr-only">${tx('rail.gallery')}</h1>
|
|
143
139
|
<p class="muted">${intro}</p>
|
|
144
140
|
${cards.length ? html`<div class="gal-grid">${cards}</div>` : empty}
|
|
145
141
|
</div>`;
|
|
146
|
-
return send(reply, s, shell(s, '
|
|
142
|
+
return send(reply, s, shell(s, tx('rail.gallery'), [[tx('rail.gallery')]], main, 'gallery'));
|
|
147
143
|
});
|
|
148
144
|
|
|
149
145
|
// a sample app: an administrator installs it (shipped SQL, run as the owner)
|
|
@@ -154,12 +150,12 @@ export async function galleryRoutes(app: FastifyInstance) {
|
|
|
154
150
|
const x = KEY.test(req.params.key) ? catalog().samples.find((c) => c.key === req.params.key) : undefined;
|
|
155
151
|
if (!x) return reply.code(404).send('Not found');
|
|
156
152
|
try {
|
|
157
|
-
if (await owner.one('select 1 as ok from meta.app where alias = $1', [x.alias])) throw new Error(
|
|
153
|
+
if (await owner.one('select 1 as ok from meta.app where alias = $1', [x.alias])) throw new Error(tx('gal.err_alias', { alias: x.alias }));
|
|
158
154
|
const ws = currentWorkspace(s);
|
|
159
155
|
// into the current workspace (an isolated one gets the application's role too)
|
|
160
156
|
const place = async (c: Client) => {
|
|
161
157
|
const made = (await c.query('select id, db_role from meta.app where alias = $1', [x.alias])).rows[0] as { id: number; db_role: string | null } | undefined;
|
|
162
|
-
if (!made) throw new Error(
|
|
158
|
+
if (!made) throw new Error(tx('gal.err_no_app', { alias: x.alias }));
|
|
163
159
|
if (ws > 1) {
|
|
164
160
|
if (made.db_role && (await c.query('select 1 from meta.workspace where id = $1 and isolated', [ws])).rowCount)
|
|
165
161
|
await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict do nothing', [ws, made.db_role]);
|
|
@@ -178,13 +174,13 @@ export async function galleryRoutes(app: FastifyInstance) {
|
|
|
178
174
|
await c.query(sql);
|
|
179
175
|
return place(c);
|
|
180
176
|
});
|
|
181
|
-
} else throw new Error('
|
|
177
|
+
} else throw new Error(tx('gal.err_nothing'));
|
|
182
178
|
await workspacesChanged();
|
|
183
179
|
await logActivity({ appId: made.id, username: s.username, event: 'gallery', ip: clientIp(req), detail: `installed sample app ${x.key}` });
|
|
184
|
-
flash(s,
|
|
180
|
+
flash(s, tx('gal.installed_flash', { title: x.title, alias: x.alias }));
|
|
185
181
|
return back(reply, s, `${BASE}/apps/${made.id}`);
|
|
186
182
|
} catch (e) {
|
|
187
|
-
flash(s,
|
|
183
|
+
flash(s, tx('gal.not_installed', { title: x.title, message: (e as Error).message }), 'error');
|
|
188
184
|
return back(reply, s, `${BASE}/gallery`);
|
|
189
185
|
}
|
|
190
186
|
});
|