pgkiln 0.84.0 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/155_mail_available.sql +23 -0
- package/docs/apex-feature-parity.md +2 -2
- package/docs/guide/03-builder.md +14 -5
- package/docs/guide/04-pages-and-regions.md +1 -1
- package/docs/guide/06-processing.md +7 -3
- package/docs/guide/07-dynamic-actions.md +3 -3
- package/docs/guide/09-reference.md +1 -0
- package/docs/guide/12-development.md +10 -1
- package/examples/gallery/README.md +23 -6
- package/examples/gallery/gallery.json +4 -0
- package/examples/gallery/samples/sample-approvals.sql +102 -11
- package/examples/gallery/samples/sample-calendar.sql +40 -5
- package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
- package/examples/gallery/samples/sample-grids.sql +35 -3
- package/examples/gallery/samples/sample-maps.sql +65 -7
- package/examples/gallery/samples/sample-processes.sql +466 -0
- package/examples/gallery/samples/sample-validations.sql +407 -0
- package/examples/gallery/samples/ux-patterns.sql +154 -14
- package/examples/hr/README.md +2 -0
- package/examples/hr/hr_80_notifications.sql +125 -0
- package/examples/hr/hr_81_vector_search_type.sql +33 -0
- package/package.json +1 -1
- package/public/app.css +8 -1
- package/public/app.js +28 -9
- package/src/app.ts +9 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3453 -0
- package/src/builder/i18n/en.ts +3453 -0
- package/src/builder/i18n/es.ts +3453 -0
- package/src/builder/i18n/fr.ts +3453 -0
- package/src/builder/i18n/it.ts +3453 -0
- package/src/builder/i18n/ja.ts +3453 -0
- package/src/builder/i18n/ko.ts +3453 -0
- package/src/builder/i18n/nl.ts +3453 -0
- package/src/builder/i18n/pt.ts +3453 -0
- package/src/builder/i18n/zh-cn.ts +3453 -0
- package/src/builder/i18n/zh-tw.ts +3453 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +27 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/runtime/engine.ts +6 -3
- package/src/runtime/lists.ts +2 -2
- package/src/runtime/push.ts +7 -1
- package/src/runtime/regions.ts +9 -4
package/src/builder/team.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from '../team.ts';
|
|
9
9
|
import { TEAM_LANGUAGES, TEAM_SAMPLES } from '../team-samples.ts';
|
|
10
10
|
import { ago } from './home.ts';
|
|
11
|
-
import { tr } from './i18n.ts';
|
|
11
|
+
import { scopedTx as tx, tr } from './i18n.ts';
|
|
12
12
|
import { back, BASE, csrf, designerUrl, developer, flash, region, send, shell, type Req } from './ui.ts';
|
|
13
13
|
import { appAllowed, appWorkspace, currentWorkspace, workspaceAdmin } from './workspaces.ts';
|
|
14
14
|
|
|
@@ -37,8 +37,8 @@ async function teamDev(req: Req, reply: FastifyReply): Promise<{ s: Session; ws:
|
|
|
37
37
|
if (!s) return null;
|
|
38
38
|
const ws = currentWorkspace(s);
|
|
39
39
|
if (ws < 0) {
|
|
40
|
-
await send(reply.code(403), s, shell(s, '
|
|
41
|
-
html`<h1
|
|
40
|
+
await send(reply.code(403), s, shell(s, tx('tm.team_development'), [[tx('tm.team_development')]],
|
|
41
|
+
html`<h1>${tx('tm.team_development')}</h1><div class="alert alert-info" role="status">${tx('tm.you_are_not_a_developer')}</div>`, 'team'));
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
44
|
return { s, ws };
|
|
@@ -68,16 +68,16 @@ const chip = (l: { name: string; color: string }) => html`<span class="team-labe
|
|
|
68
68
|
function teamHeader(active: Tab, counts?: { open: number; mine: number; mentions: number }) {
|
|
69
69
|
const tab = (key: Tab, href: string, ic: string, label: string) =>
|
|
70
70
|
html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
|
|
71
|
-
return html`<div class="team-head"><h1
|
|
72
|
-
${counts ? html`<p class="muted small team-counts">${counts.open
|
|
73
|
-
<a class="btn btn-hot btn-sm" href="${TEAM}/issues/new">${icon('plus')}
|
|
74
|
-
<nav class="ide-tabs team-tabs u-mb1" aria-label="
|
|
75
|
-
${tab('issues', TEAM, 'list', '
|
|
76
|
-
${tab('milestones', `${TEAM}/milestones`, 'flag', '
|
|
71
|
+
return html`<div class="team-head"><h1>${tx('tm.team_development')}</h1>
|
|
72
|
+
${counts ? html`<p class="muted small team-counts">${counts.open === 1 ? tx('tm.counts_open_1') : tx('tm.counts_open', { n: counts.open })} · <a href="${TEAM}?assignee=me">${tx('tm.counts_mine', { n: counts.mine })}</a>${counts.mentions ? html` · <a href="${TEAM}#mentions">${counts.mentions === 1 ? tx('tm.counts_mentions_1') : tx('tm.counts_mentions', { n: counts.mentions })}</a>` : ''}</p>` : ''}
|
|
73
|
+
<a class="btn btn-hot btn-sm" href="${TEAM}/issues/new">${icon('plus')} ${tx('tm.new_issue')}</a></div>
|
|
74
|
+
<nav class="ide-tabs team-tabs u-mb1" aria-label="${tx('tm.team_development')}">
|
|
75
|
+
${tab('issues', TEAM, 'list', tx('tm.tab.issues'))}${tab('board', `${TEAM}/board`, 'kanban', tx('tm.tab.board'))}${tab('labels', `${TEAM}/labels`, 'tag', tx('tm.tab.labels'))}
|
|
76
|
+
${tab('milestones', `${TEAM}/milestones`, 'flag', tx('tm.tab.milestones'))}${tab('templates', `${TEAM}/templates`, 'note', tx('tm.tab.templates'))}${tab('setup', `${TEAM}/setup`, 'settings', tx('tm.tab.setup'))}
|
|
77
77
|
</nav>`;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
const page = (s: Session, title: string, crumbs: [string, string?][], main: Raw) => shell(s, title, [['
|
|
80
|
+
const page = (s: Session, title: string, crumbs: [string, string?][], main: Raw) => shell(s, title, [[tx('tm.team_development'), TEAM], ...crumbs], main, 'team');
|
|
81
81
|
|
|
82
82
|
// ------------------------------------------------------------------ filters
|
|
83
83
|
|
|
@@ -141,16 +141,16 @@ function filterForm(f: Filters, lk: Lookups, action: string, hidden: Record<stri
|
|
|
141
141
|
const opt = (v: string, label: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${label}</option>`;
|
|
142
142
|
const sel = (name: string, label: string, cur: string, options: Raw[]) =>
|
|
143
143
|
html`<div class="field"><label class="label" for="tf_${name}">${label}</label><select id="tf_${name}" name="${name}">${options}</select></div>`;
|
|
144
|
-
return html`<form method="get" action="${action}" class="team-filters" role="search" aria-label="
|
|
144
|
+
return html`<form method="get" action="${action}" class="team-filters" role="search" aria-label="${tx('tm.filter_the_issues')}">
|
|
145
145
|
${Object.entries(hidden).map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}
|
|
146
146
|
${f.page ? html`<input type="hidden" name="page" value="${f.page}">` : ''}
|
|
147
|
-
<div class="field team-q"><label class="label" for="tf_q"
|
|
148
|
-
${sel('status', '
|
|
149
|
-
${sel('label', '
|
|
150
|
-
${sel('milestone', '
|
|
151
|
-
${sel('assignee', '
|
|
152
|
-
${sel('app', '
|
|
153
|
-
<div class="buttons"><button class="btn btn-sm">${icon('filter')}
|
|
147
|
+
<div class="field team-q"><label class="label" for="tf_q">${tx('tm.search')}</label><input id="tf_q" name="q" type="search" value="${f.q}" maxlength="100" placeholder="${tx('tm.title_text_or_number')}"></div>
|
|
148
|
+
${sel('status', tx('tm.f.status'), f.status, [opt('open', tx('tm.f.open'), f.status), opt('closed', tx('tm.closed'), f.status), opt('all', tx('tm.f.all'), f.status)])}
|
|
149
|
+
${sel('label', tx('tm.label'), f.label, [opt('', tx('tm.f.any'), f.label), ...lk.labels.map((l) => opt(String(l.id), l.group_name ? `${l.group_name}: ${l.name}` : l.name, f.label))])}
|
|
150
|
+
${sel('milestone', tx('tm.milestone'), f.milestone, [opt('', tx('tm.f.any'), f.milestone), opt('none', tx('tm.none'), f.milestone), ...lk.milestones.map((m) => opt(String(m.id), m.name, f.milestone))])}
|
|
151
|
+
${sel('assignee', tx('tm.f.assignee'), f.assignee, [opt('', tx('tm.f.anyone'), f.assignee), opt('me', tx('tm.f.me'), f.assignee), opt('none', tx('tm.nobody'), f.assignee), ...lk.devs.map((d) => opt(d, d, f.assignee))])}
|
|
152
|
+
${sel('app', tx('tm.application'), f.app, [opt('', tx('tm.f.any'), f.app), ...lk.apps.map((a) => opt(String(a.id), a.name, f.app))])}
|
|
153
|
+
<div class="buttons"><button class="btn btn-sm">${icon('filter')} ${tx('tm.show')}</button>${filterQuery(f) ? html`<a class="btn btn-sm" href="${action}${Object.keys(hidden).length ? `?${new URLSearchParams(hidden)}` : ''}">${tx('tm.clear')}</a>` : ''}</div>
|
|
154
154
|
</form>`;
|
|
155
155
|
}
|
|
156
156
|
|
|
@@ -173,30 +173,30 @@ interface IssueInput { title: string; body: string; labels: string[]; milestone:
|
|
|
173
173
|
async function readIssue(b: Record<string, unknown>, ws: number, lk: Lookups): Promise<{ v: IssueInput; errors: string[] }> {
|
|
174
174
|
const errors: string[] = [];
|
|
175
175
|
const title = text(b.title, 1000).replace(/\s+/g, ' ');
|
|
176
|
-
if (!title) errors.push('
|
|
177
|
-
if (title.length > 200) errors.push('
|
|
176
|
+
if (!title) errors.push(tx('tm.an_issue_needs_a_title'));
|
|
177
|
+
if (title.length > 200) errors.push(tx('tm.the_title_is_at_most'));
|
|
178
178
|
const body = String(b.body ?? '').replace(/\r\n?/g, '\n');
|
|
179
|
-
if (body.length > 20000) errors.push('
|
|
179
|
+
if (body.length > 20000) errors.push(tx('tm.the_text_is_at_most'));
|
|
180
180
|
const labelIds = new Set(lk.labels.map((l) => String(l.id)));
|
|
181
181
|
const labels = many(b.labels);
|
|
182
|
-
if (labels.some((l) => !labelIds.has(l))) errors.push('
|
|
182
|
+
if (labels.some((l) => !labelIds.has(l))) errors.push(tx('tm.unknown_label'));
|
|
183
183
|
const milestone = String(b.milestone ?? '');
|
|
184
|
-
if (milestone && !lk.milestones.some((m) => String(m.id) === milestone)) errors.push('
|
|
184
|
+
if (milestone && !lk.milestones.some((m) => String(m.id) === milestone)) errors.push(tx('tm.unknown_milestone'));
|
|
185
185
|
const assignees = many(b.assignees);
|
|
186
186
|
const devs = new Set(lk.devs);
|
|
187
|
-
if (assignees.some((a) => !devs.has(a))) errors.push('
|
|
187
|
+
if (assignees.some((a) => !devs.has(a))) errors.push(tx('tm.assignees_are_developers_of_this'));
|
|
188
188
|
const appRaw = String(b.app ?? '');
|
|
189
189
|
let app: number | null = null;
|
|
190
190
|
if (appRaw) {
|
|
191
|
-
if (!/^\d{1,9}$/.test(appRaw) || !lk.apps.some((a) => String(a.id) === appRaw) || (await appWorkspace(appRaw)) !== ws) errors.push('
|
|
191
|
+
if (!/^\d{1,9}$/.test(appRaw) || !lk.apps.some((a) => String(a.id) === appRaw) || (await appWorkspace(appRaw)) !== ws) errors.push(tx('tm.the_application_is_not_one'));
|
|
192
192
|
else app = Number(appRaw);
|
|
193
193
|
}
|
|
194
194
|
const pageRaw = String(b.page ?? '').trim();
|
|
195
195
|
let pageNo: number | null = null;
|
|
196
196
|
if (pageRaw) {
|
|
197
|
-
if (!/^\d{1,9}$/.test(pageRaw)) errors.push('
|
|
198
|
-
else if (!app) errors.push('
|
|
199
|
-
else if (!(await owner.one('select 1 as ok from meta.page where app_id = $1 and page_no = $2', [app, pageRaw]))) errors.push(
|
|
197
|
+
if (!/^\d{1,9}$/.test(pageRaw)) errors.push(tx('tm.the_page_is_a_page'));
|
|
198
|
+
else if (!app) errors.push(tx('tm.choose_the_application_of_the'));
|
|
199
|
+
else if (!(await owner.one('select 1 as ok from meta.page where app_id = $1 and page_no = $2', [app, pageRaw]))) errors.push(tx('tm.no_page', { page: pageRaw }));
|
|
200
200
|
else pageNo = Number(pageRaw);
|
|
201
201
|
}
|
|
202
202
|
return { v: { title: title.slice(0, 200), body: body.slice(0, 20000), labels: [...new Set(labels)], milestone: milestone || null, assignees: [...new Set(assignees)], app, page: pageNo }, errors };
|
|
@@ -205,25 +205,25 @@ async function readIssue(b: Record<string, unknown>, ws: number, lk: Lookups): P
|
|
|
205
205
|
function issueFields(v: Partial<IssueInput>, lk: Lookups, idp = 'f') {
|
|
206
206
|
const labelSet = new Set(v.labels ?? []);
|
|
207
207
|
const groups = new Map<string, Label[]>();
|
|
208
|
-
for (const l of lk.labels) groups.set(l.group_name ?? '
|
|
208
|
+
for (const l of lk.labels) groups.set(l.group_name ?? tx('tm.other_labels'), [...(groups.get(l.group_name ?? tx('tm.other_labels')) ?? []), l]);
|
|
209
209
|
const asg = new Set(v.assignees ?? []);
|
|
210
210
|
return html`
|
|
211
|
-
<div class="field"><label class="label" for="${idp}_title"
|
|
212
|
-
<div class="field"><label class="label" for="${idp}_body"
|
|
211
|
+
<div class="field"><label class="label" for="${idp}_title">${tx('tm.title')}</label><input id="${idp}_title" name="title" maxlength="200" required value="${v.title ?? ''}"></div>
|
|
212
|
+
<div class="field"><label class="label" for="${idp}_body">${tx('tm.description')}</label>
|
|
213
213
|
<textarea id="${idp}_body" name="body" rows="10" maxlength="20000">${v.body ?? ''}</textarea>
|
|
214
|
-
<small class="help"
|
|
214
|
+
<small class="help">${tx.code('tm.body_help')}</small></div>
|
|
215
215
|
<div class="form-grid">
|
|
216
|
-
<div class="field"><label class="label" for="${idp}_milestone"
|
|
216
|
+
<div class="field"><label class="label" for="${idp}_milestone">${tx('tm.milestone')}</label><select id="${idp}_milestone" name="milestone">
|
|
217
217
|
<option value="">None</option>${lk.milestones.map((m) => html`<option value="${m.id}"${String(m.id) === String(v.milestone ?? '') ? raw(' selected') : ''}>${m.name}${m.status === 'closed' ? ' (closed)' : ''}</option>`)}</select></div>
|
|
218
|
-
<div class="field"><label class="label" for="${idp}_app"
|
|
218
|
+
<div class="field"><label class="label" for="${idp}_app">${tx('tm.application')}</label><select id="${idp}_app" name="app">
|
|
219
219
|
<option value="">None</option>${lk.apps.map((a) => html`<option value="${a.id}"${a.id === v.app ? raw(' selected') : ''}>${a.name}</option>`)}</select></div>
|
|
220
|
-
<div class="field"><label class="label" for="${idp}_page"
|
|
221
|
-
<small class="help"
|
|
220
|
+
<div class="field"><label class="label" for="${idp}_page">${tx('tm.page_number')}</label><input id="${idp}_page" name="page" type="number" min="0" step="1" inputmode="numeric" value="${v.page ?? ''}">
|
|
221
|
+
<small class="help">${tx('tm.a_page_of_the_application')}</small></div>
|
|
222
222
|
</div>
|
|
223
|
-
${lk.labels.length ? html`<fieldset class="team-fieldset"><legend
|
|
223
|
+
${lk.labels.length ? html`<fieldset class="team-fieldset"><legend>${tx('tm.labels')}</legend>${[...groups].map(([g, ls]) => html`<div class="team-check-group"><span class="small muted">${g}</span>
|
|
224
224
|
${ls.map((l) => html`<label class="check"><input type="checkbox" name="labels" value="${l.id}"${labelSet.has(String(l.id)) ? raw(' checked') : ''}> ${chip(l)}</label>`)}</div>`)}</fieldset>`
|
|
225
|
-
: html`<p class="muted small"
|
|
226
|
-
<fieldset class="team-fieldset"><legend
|
|
225
|
+
: html`<p class="muted small">${tx.rich('tm.no_labels_or', { setup: html`<a href="${TEAM}/setup">${tx('tm.set_up_team_development')}</a>`, add: html`<a href="${TEAM}/labels">${tx('tm.add_labels')}</a>` })}</p>`}
|
|
226
|
+
<fieldset class="team-fieldset"><legend>${tx('tm.assignees')}</legend><div class="team-check-group">
|
|
227
227
|
${lk.devs.map((d) => html`<label class="check"><input type="checkbox" name="assignees" value="${d}"${asg.has(d) ? raw(' checked') : ''}> ${d}</label>`)}</div></fieldset>`;
|
|
228
228
|
}
|
|
229
229
|
|
|
@@ -247,10 +247,10 @@ async function setLinks(c: (sql: string, params?: unknown[]) => Promise<unknown>
|
|
|
247
247
|
const issueOf = async (ws: number, id: string) =>
|
|
248
248
|
isId(id) ? owner.one(`select i.*, m.name as milestone from meta.team_issue i left join meta.team_milestone m on m.id = i.milestone_id where i.id = $1 and i.workspace_id = $2`, [id, ws]) : undefined;
|
|
249
249
|
|
|
250
|
-
const EVENT_TEXT:
|
|
251
|
-
created: 'created
|
|
252
|
-
labels: '
|
|
253
|
-
};
|
|
250
|
+
const EVENT_TEXT = (action: string): string | undefined => ({
|
|
251
|
+
created: tx('tm.ev.created'), closed: tx('tm.ev.closed'), reopened: tx('tm.ev.reopened'), title: tx('tm.changed_the_title'), body: tx('tm.ev.body'),
|
|
252
|
+
labels: tx('tm.ev.labels'), assignees: tx('tm.ev.assignees'), milestone: tx('tm.ev.milestone'), link: tx('tm.ev.link'), commented: tx('tm.ev.commented'),
|
|
253
|
+
} as Record<string, string>)[action];
|
|
254
254
|
|
|
255
255
|
export async function teamRoutes(app: FastifyInstance) {
|
|
256
256
|
// ------------------------------------------------------------------ the issue list
|
|
@@ -267,35 +267,35 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
267
267
|
]);
|
|
268
268
|
const current = filterQuery(f);
|
|
269
269
|
const main = html`${teamHeader('issues', counts)}
|
|
270
|
-
${!counts.set_up ? html`<div class="alert alert-info" role="status">${icon('lightbulb')} <span
|
|
270
|
+
${!counts.set_up ? html`<div class="alert alert-info" role="status">${icon('lightbulb')} <span>${tx.rich('tm.new_here', { setup: html`<a href="${TEAM}/setup">${tx('tm.set_up_team_development_2')}</a>` })}</span></div>` : ''}
|
|
271
271
|
<div class="team-layout">
|
|
272
272
|
<div class="team-main">
|
|
273
273
|
${filterForm(f, lk, TEAM)}
|
|
274
|
-
${f.app && f.page ? html`<p class="small muted"
|
|
275
|
-
<p class="small muted" role="status">${rows.length
|
|
274
|
+
${f.app && f.page ? html`<p class="small muted">${tx('tm.issues_of_page', { page: f.page, app: lk.apps.find((a) => String(a.id) === f.app)?.name ?? tx('tm.the_application') })}</p>` : ''}
|
|
275
|
+
<p class="small muted" role="status">${rows.length === 1 ? tx('tm.n_issues_1') : rows.length === 500 ? tx('tm.n_issues_500', { n: rows.length }) : tx('tm.n_issues', { n: rows.length })}</p>
|
|
276
276
|
${rows.length ? html`<ul class="team-issues">${rows.map((i) => issueCard(i))}</ul>` : html`<p class="muted">No issues${current ? ' with these filters' : ' yet'}.</p>`}
|
|
277
277
|
</div>
|
|
278
278
|
<aside class="team-side">
|
|
279
|
-
<section id="mentions" aria-labelledby="team-mentions-h"><h2 id="team-mentions-h"
|
|
279
|
+
<section id="mentions" aria-labelledby="team-mentions-h"><h2 id="team-mentions-h">${tx('tm.mentions_of_you')}</h2>
|
|
280
280
|
${mentions.rows.length ? html`<ul class="team-mentions">${mentions.rows.map((m) => html`<li><a href="${TEAM}/issues/${m.issue_id}">${m.title}</a> <span class="small muted">${m.by_user ?? ''} · ${ago(m.created_at)}</span></li>`)}</ul>
|
|
281
|
-
<form method="post" action="${TEAM}/mentions/seen">${csrf(s)}<button class="btn btn-sm">${icon('check')}
|
|
282
|
-
: html`<p class="muted small"
|
|
283
|
-
<section aria-labelledby="team-views-h"><h2 id="team-views-h"
|
|
281
|
+
<form method="post" action="${TEAM}/mentions/seen">${csrf(s)}<button class="btn btn-sm">${icon('check')} ${tx('tm.mark_all_as_seen')}</button></form>`
|
|
282
|
+
: html`<p class="muted small">${tx('tm.nobody_mentioned_you_in_an')}</p>`}</section>
|
|
283
|
+
<section aria-labelledby="team-views-h"><h2 id="team-views-h">${tx('tm.saved_views')}</h2>
|
|
284
284
|
${views.rows.length ? html`<ul class="team-views">${views.rows.map((v) => html`<li><a href="${TEAM}${v.query ? `?${v.query}` : ''}"${v.query === current ? raw(' aria-current="true"') : ''}>${v.name}</a>
|
|
285
|
-
<form method="post" action="${TEAM}/views/${v.id}/delete" class="u-inline">${csrf(s)}<button class="link-button" title="
|
|
286
|
-
: html`<p class="muted small"
|
|
285
|
+
<form method="post" action="${TEAM}/views/${v.id}/delete" class="u-inline">${csrf(s)}<button class="link-button" title="${tx('tm.delete_view_confirm', { name: v.name })}" data-confirm="${tx('tm.delete_view_confirm', { name: v.name })}">${icon('trash')}<span class="sr-only">${tx('tm.delete_named', { name: v.name })}</span></button></form></li>`)}</ul>`
|
|
286
|
+
: html`<p class="muted small">${tx('tm.save_the_filters_you_use')}</p>`}
|
|
287
287
|
<form method="post" action="${TEAM}/views" class="team-save-view">${csrf(s)}<input type="hidden" name="query" value="${current}">
|
|
288
|
-
<label class="sr-only" for="tv_name"
|
|
289
|
-
<button class="btn btn-sm">${icon('bookmark')}
|
|
290
|
-
<section aria-labelledby="team-quick-h"><h2 id="team-quick-h"
|
|
291
|
-
<li><a href="${TEAM}?assignee=me"
|
|
292
|
-
<li><a href="${TEAM}?assignee=none"
|
|
293
|
-
<li><a href="${TEAM}?status=closed"
|
|
294
|
-
<li><a href="${TEAM}/board"
|
|
295
|
-
<li><a href="${TEAM}/board?by=milestone"
|
|
288
|
+
<label class="sr-only" for="tv_name">${tx('tm.name_of_the_view')}</label><input id="tv_name" name="name" maxlength="40" required placeholder="${tx('tm.name_of_this_view')}">
|
|
289
|
+
<button class="btn btn-sm">${icon('bookmark')} ${tx('tm.save_view')}</button></form></section>
|
|
290
|
+
<section aria-labelledby="team-quick-h"><h2 id="team-quick-h">${tx('tm.quick_filters')}</h2><ul class="ab-links ab-tasks">
|
|
291
|
+
<li><a href="${TEAM}?assignee=me">${tx('tm.assigned_to_me')}</a></li>
|
|
292
|
+
<li><a href="${TEAM}?assignee=none">${tx('tm.not_assigned')}</a></li>
|
|
293
|
+
<li><a href="${TEAM}?status=closed">${tx('tm.closed')}</a></li>
|
|
294
|
+
<li><a href="${TEAM}/board">${tx('tm.board_by_label')}</a></li>
|
|
295
|
+
<li><a href="${TEAM}/board?by=milestone">${tx('tm.board_by_milestone')}</a></li></ul></section>
|
|
296
296
|
</aside>
|
|
297
297
|
</div>`;
|
|
298
|
-
return send(reply, s, page(s, '
|
|
298
|
+
return send(reply, s, page(s, tx('tm.team_development'), [], main));
|
|
299
299
|
});
|
|
300
300
|
|
|
301
301
|
// ------------------------------------------------------------------ the board
|
|
@@ -314,37 +314,37 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
314
314
|
if (by === 'milestone') {
|
|
315
315
|
const ms = lk.milestones.filter((m) => m.status === 'open' || rows.some((r) => String(r.milestone_id) === String(m.id)));
|
|
316
316
|
cols = [...ms.map((m) => ({ key: String(m.id), title: m.name, items: rows.filter((r) => String(r.milestone_id) === String(m.id)) })),
|
|
317
|
-
{ key: '', title: '
|
|
317
|
+
{ key: '', title: tx('tm.no_milestone'), items: rows.filter((r) => r.milestone_id === null) }];
|
|
318
318
|
} else {
|
|
319
319
|
const ls = lk.labels.filter((l) => String(l.group_id ?? 0) === group);
|
|
320
320
|
// (ids: bigint columns come as strings, the json_agg ones as numbers)
|
|
321
321
|
const ids = new Set(ls.map((l) => String(l.id)));
|
|
322
322
|
cols = [...ls.map((l) => ({ key: String(l.id), title: l.name, chip: chip(l), items: rows.filter((r) => r.labels.some((x) => String(x.id) === String(l.id))) })),
|
|
323
|
-
{ key: '', title: '
|
|
323
|
+
{ key: '', title: tx('tm.none_of_these'), items: rows.filter((r) => !r.labels.some((x) => ids.has(String(x.id)))) }];
|
|
324
324
|
}
|
|
325
325
|
const back = `${TEAM}/board?${filterQuery(f, { by: by === 'milestone' ? 'milestone' : '', group: by === 'label' ? group : '' })}`;
|
|
326
326
|
const move = (i: IssueRow, from: string) => {
|
|
327
327
|
const others = cols.filter((c) => c.key !== from);
|
|
328
328
|
return html`<form method="post" action="${TEAM}/issues/${i.id}/move" class="team-move">${csrf(s)}
|
|
329
329
|
<input type="hidden" name="by" value="${by}"><input type="hidden" name="group" value="${group}"><input type="hidden" name="from" value="${from}"><input type="hidden" name="back" value="${back}">
|
|
330
|
-
<label class="sr-only" for="mv_${i.id}_${from}"
|
|
330
|
+
<label class="sr-only" for="mv_${i.id}_${from}">${tx('tm.move_to', { id: i.id })}</label>
|
|
331
331
|
<select id="mv_${i.id}_${from}" name="to">${others.map((c) => html`<option value="${c.key}">${c.title}</option>`)}</select>
|
|
332
|
-
<button class="btn btn-sm"
|
|
332
|
+
<button class="btn btn-sm">${tx('tm.move')}</button></form>`;
|
|
333
333
|
};
|
|
334
334
|
const groupNav = by === 'label' && groupIds.length > 1
|
|
335
|
-
? html`<nav class="seg team-groups" aria-label="
|
|
335
|
+
? html`<nav class="seg team-groups" aria-label="${tx('tm.label_group')}">${groupIds.map((g) => html`<a href="${TEAM}/board?${filterQuery(f, { group: g })}"${g === group ? raw(' aria-current="true"') : ''}>${g === '0' ? tx('tm.other_labels') : lk.groups.find((x) => String(x.id) === g)?.name}</a>`)}</nav>` : '';
|
|
336
336
|
const main = html`${teamHeader('board', counts)}
|
|
337
337
|
<div class="team-board-bar">
|
|
338
|
-
<nav class="seg" aria-label="
|
|
338
|
+
<nav class="seg" aria-label="${tx('tm.group_the_board_by')}"><a href="${TEAM}/board?${filterQuery(f)}"${by === 'label' ? raw(' aria-current="true"') : ''}>${icon('tag')} ${tx('tm.label')}</a><a href="${TEAM}/board?${filterQuery(f, { by: 'milestone' })}"${by === 'milestone' ? raw(' aria-current="true"') : ''}>${icon('flag')} ${tx('tm.milestone')}</a></nav>
|
|
339
339
|
${groupNav}
|
|
340
340
|
</div>
|
|
341
341
|
<details class="team-board-filters"><summary>${icon('filter')} Filters${filterQuery(f) ? ' (on)' : ''}</summary>${filterForm(f, lk, `${TEAM}/board`, { ...(by === 'milestone' ? { by } : {}), ...(by === 'label' ? { group } : {}) })}</details>
|
|
342
|
-
${by === 'label' && !lk.labels.length ? html`<p class="muted"
|
|
342
|
+
${by === 'label' && !lk.labels.length ? html`<p class="muted">${tx.rich('tm.no_labels', { setup: html`<a href="${TEAM}/setup">${tx('tm.set_up_team_development')}</a>` })}</p>` : ''}
|
|
343
343
|
<div class="team-board">${cols.map((c) => html`<section class="team-col" aria-label="${c.title}">
|
|
344
344
|
<h2 class="team-col-head">${c.chip ?? c.title} <span class="muted small">${c.items.length}</span></h2>
|
|
345
|
-
${c.items.length ? html`<ul class="team-issues">${c.items.map((i) => issueCard(i, move(i, c.key)))}</ul>` : html`<p class="muted small"
|
|
345
|
+
${c.items.length ? html`<ul class="team-issues">${c.items.map((i) => issueCard(i, move(i, c.key)))}</ul>` : html`<p class="muted small">${tx('tm.no_issues')}</p>`}
|
|
346
346
|
</section>`)}</div>`;
|
|
347
|
-
return send(reply, s, page(s, '
|
|
347
|
+
return send(reply, s, page(s, tx('tm.board'), [[tx('tm.board')]], main));
|
|
348
348
|
});
|
|
349
349
|
|
|
350
350
|
// move a card: by milestone sets the milestone; by label replaces the issue's labels of that group with the target label
|
|
@@ -398,12 +398,12 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
398
398
|
};
|
|
399
399
|
const main = html`${teamHeader('issues')}
|
|
400
400
|
${templates.length ? html`<form method="get" class="team-template-pick">${appQ ? html`<input type="hidden" name="app" value="${appQ.id}">` : ''}${v.page !== null && v.page !== undefined ? html`<input type="hidden" name="page" value="${v.page}">` : ''}
|
|
401
|
-
<div class="field"><label class="label" for="f_template"
|
|
402
|
-
<option value=""
|
|
403
|
-
<button class="btn btn-sm"
|
|
404
|
-
${region('
|
|
405
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
406
|
-
return send(reply, s, page(s, '
|
|
401
|
+
<div class="field"><label class="label" for="f_template">${tx('tm.start_from_a_template')}</label><select id="f_template" name="template" data-autosubmit>
|
|
402
|
+
<option value="">${tx('tm.blank_issue')}</option>${templates.map((x) => html`<option value="${x.id}"${x.id === tpl?.id ? raw(' selected') : ''}>${x.name}</option>`)}</select></div>
|
|
403
|
+
<button class="btn btn-sm">${tx('tm.use_template')}</button></form>` : ''}
|
|
404
|
+
${region(tx('tm.new_issue'), html`<form method="post" action="${TEAM}/issues">${csrf(s)}${issueFields(v, lk)}
|
|
405
|
+
<div class="buttons"><button class="btn btn-hot">${tx('tm.create_issue')}</button><a class="btn" href="${TEAM}">${tx('tm.cancel')}</a></div></form>`)}`;
|
|
406
|
+
return send(reply, s, page(s, tx('tm.new_issue'), [[tx('tm.new_issue')]], main));
|
|
407
407
|
});
|
|
408
408
|
|
|
409
409
|
app.post(`${TEAM}/issues`, async (req: Req, reply) => {
|
|
@@ -424,7 +424,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
424
424
|
await recordMentions(c, ws, id, null, v.body, s.username);
|
|
425
425
|
return id;
|
|
426
426
|
});
|
|
427
|
-
flash(s,
|
|
427
|
+
flash(s, tx('tm.created', { id }));
|
|
428
428
|
return back(reply, s, `${TEAM}/issues/${id}`);
|
|
429
429
|
});
|
|
430
430
|
|
|
@@ -458,7 +458,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
458
458
|
...comments.rows.map((c) => ({ at: new Date(c.created_at).getTime(), r: html`<li class="team-comment" id="comment-${c.id}">
|
|
459
459
|
<div class="team-comment-head"><b>${c.author}</b> <span class="muted small">${when(c.created_at)}</span></div>
|
|
460
460
|
<div class="rich-text team-md">${raw(issueHtml(c.body, lk.devs))}</div></li>` })),
|
|
461
|
-
...events.rows.map((e) => ({ at: new Date(e.at).getTime(), r: html`<li class="team-event small muted">${icon('history')}<span><b>${e.username ?? 'someone'}</b> ${EVENT_TEXT
|
|
461
|
+
...events.rows.map((e) => ({ at: new Date(e.at).getTime(), r: html`<li class="team-event small muted">${icon('history')}<span><b>${e.username ?? tx('tm.someone')}</b> ${EVENT_TEXT(e.action) ?? e.action}${e.detail ? html`: ${e.detail}` : ''} · ${when(e.at)}</span></li>` })),
|
|
462
462
|
].sort((a, b) => a.at - b.at);
|
|
463
463
|
const v: IssueInput = { title: i.title, body: i.body, labels: labels.rows.map((l) => String(l.id)), milestone: i.milestone_id ? String(i.milestone_id) : null,
|
|
464
464
|
assignees: assignees.rows.map((a) => a.username), app: appRow?.id ?? null, page: appRow ? i.page_no : null };
|
|
@@ -466,42 +466,42 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
466
466
|
<article class="team-issue-page">
|
|
467
467
|
<header class="team-issue-header">
|
|
468
468
|
<h2 class="team-issue-h">${i.title} <span class="muted">#${i.id}</span></h2>
|
|
469
|
-
<p><span class="team-status team-status-${i.status === 'closed' ? 'closed' : 'open'}">${i.status === 'closed' ? '
|
|
469
|
+
<p><span class="team-status team-status-${i.status === 'closed' ? 'closed' : 'open'}">${i.status === 'closed' ? tx('tm.status_closed') : tx('tm.status_open')}</span>
|
|
470
470
|
<span class="muted small">opened by ${i.created_by}, ${when(i.created_at)}${i.closed_at ? `; closed by ${i.closed_by ?? '?'}, ${when(i.closed_at)}` : ''}</span></p>
|
|
471
471
|
</header>
|
|
472
472
|
<div class="team-layout">
|
|
473
473
|
<div class="team-main">
|
|
474
|
-
<div class="rich-text team-md team-body">${i.body.trim() ? raw(issueHtml(i.body, lk.devs)) : html`<p class="muted"
|
|
475
|
-
<h3
|
|
474
|
+
<div class="rich-text team-md team-body">${i.body.trim() ? raw(issueHtml(i.body, lk.devs)) : html`<p class="muted">${tx('tm.no_description')}</p>`}</div>
|
|
475
|
+
<h3>${tx('tm.activity')}</h3>
|
|
476
476
|
<ul class="team-timeline">${timeline.map((x) => x.r)}</ul>
|
|
477
477
|
<form method="get" class="team-response-pick">${responses.rows.length ? html`
|
|
478
|
-
<div class="field"><label class="label" for="f_response"
|
|
478
|
+
<div class="field"><label class="label" for="f_response">${tx('tm.canned_response')}</label><select id="f_response" name="response" data-autosubmit>
|
|
479
479
|
<option value="">None</option>${responses.rows.map((r) => html`<option value="${r.id}"${r.id === response?.id ? raw(' selected') : ''}>${r.name}</option>`)}</select></div>
|
|
480
|
-
<button class="btn btn-sm"
|
|
480
|
+
<button class="btn btn-sm">${tx('tm.insert')}</button>` : ''}</form>
|
|
481
481
|
<form method="post" action="${TEAM}/issues/${i.id}/comments" id="add-comment">${csrf(s)}
|
|
482
|
-
<div class="field"><label class="label" for="f_comment"
|
|
482
|
+
<div class="field"><label class="label" for="f_comment">${tx('tm.add_a_comment')}</label>
|
|
483
483
|
<textarea id="f_comment" name="body" rows="4" maxlength="20000" required>${response?.body ?? ''}</textarea>
|
|
484
|
-
<small class="help"
|
|
485
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
486
|
-
<button class="btn" name="and" value="${i.status === 'closed' ? 'reopen' : 'close'}">${i.status === 'closed' ? '
|
|
484
|
+
<small class="help">${tx('tm.markdown_name_mentions_a_developer')}</small></div>
|
|
485
|
+
<div class="buttons"><button class="btn btn-hot">${tx('tm.comment')}</button>
|
|
486
|
+
<button class="btn" name="and" value="${i.status === 'closed' ? 'reopen' : 'close'}">${i.status === 'closed' ? tx('tm.comment_reopen') : tx('tm.comment_close')}</button></div></form>
|
|
487
487
|
</div>
|
|
488
488
|
<aside class="team-side">
|
|
489
|
-
<section><h2
|
|
490
|
-
<button class="btn btn-sm" name="status" value="${i.status === 'closed' ? 'open' : 'closed'}">${icon(i.status === 'closed' ? 'refresh' : 'check-circle')} ${i.status === 'closed' ? '
|
|
491
|
-
<section><h2
|
|
492
|
-
<section><h2
|
|
493
|
-
<section><h2
|
|
494
|
-
<section><h2
|
|
489
|
+
<section><h2>${tx('tm.status')}</h2><form method="post" action="${TEAM}/issues/${i.id}/status">${csrf(s)}
|
|
490
|
+
<button class="btn btn-sm" name="status" value="${i.status === 'closed' ? 'open' : 'closed'}">${icon(i.status === 'closed' ? 'refresh' : 'check-circle')} ${i.status === 'closed' ? tx('tm.reopen_issue') : tx('tm.close_issue')}</button></form></section>
|
|
491
|
+
<section><h2>${tx('tm.labels')}</h2>${labels.rows.length ? html`<div class="team-labels">${labels.rows.map(chip)}</div>` : html`<p class="muted small">${tx('tm.none')}</p>`}</section>
|
|
492
|
+
<section><h2>${tx('tm.milestone')}</h2><p>${i.milestone ? html`<a href="${TEAM}?milestone=${i.milestone_id}">${i.milestone}</a>` : html`<span class="muted small">${tx('tm.none')}</span>`}</p></section>
|
|
493
|
+
<section><h2>${tx('tm.assignees')}</h2><p>${assignees.rows.length ? assignees.rows.map((a) => a.username).join(', ') : html`<span class="muted small">${tx('tm.nobody')}</span>`}</p></section>
|
|
494
|
+
<section><h2>${tx('tm.application')}</h2><p>${appRow
|
|
495
495
|
? html`<a href="${BASE}/apps/${appRow.id}">${appRow.name}</a>${i.page_no !== null ? html` · <a href="${designerUrl(appRow.id, i.page_no)}">page ${i.page_no}</a>` : ''}`
|
|
496
|
-
: html`<span class="muted small"
|
|
497
|
-
${source ? html`<section><h2
|
|
498
|
-
${comment ? html`<section><h2
|
|
499
|
-
${i.created_by === s.username || admin ? html`<section><h2
|
|
496
|
+
: html`<span class="muted small">${tx('tm.none')}</span>`}</p></section>
|
|
497
|
+
${source ? html`<section><h2>${tx('tm.from_feedback')}</h2><p><a href="${BASE}/apps/${source.app_id}/feedback#feedback-${source.id}">${tx('tm.feedback_of', { user: source.username })}</a> <span class="small muted">(${source.name})</span></p></section>` : ''}
|
|
498
|
+
${comment ? html`<section><h2>${tx('tm.from_a_developer_comment')}</h2><p><a href="${comment.page_no ? designerUrl(comment.app_id, comment.page_no) : `${BASE}/apps/${comment.app_id}#app-lock`}">${tx('tm.comment_of', { author: comment.author })}</a> <span class="small muted">(${comment.name}${comment.page_no ? tx('tm.page_n', { n: comment.page_no }) : ''})</span></p></section>` : ''}
|
|
499
|
+
${i.created_by === s.username || admin ? html`<section><h2>${tx('tm.delete')}</h2><form method="post" action="${TEAM}/issues/${i.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('tm.delete_issue_confirm', { id: i.id })}">${icon('trash')} ${tx('tm.delete_issue')}</button></form></section>` : ''}
|
|
500
500
|
</aside>
|
|
501
501
|
</div>
|
|
502
|
-
<details class="team-edit" id="edit"><summary class="btn btn-sm">${icon('edit')}
|
|
502
|
+
<details class="team-edit" id="edit"><summary class="btn btn-sm">${icon('edit')} ${tx('tm.edit_the_issue')}</summary>
|
|
503
503
|
<form method="post" action="${TEAM}/issues/${i.id}">${csrf(s)}${issueFields(v, lk, 'e')}
|
|
504
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
504
|
+
<div class="buttons"><button class="btn btn-hot">${tx('tm.save')}</button></div></form></details>
|
|
505
505
|
</article>`;
|
|
506
506
|
return send(reply, s, page(s, `#${i.id} ${i.title}`, [[`#${i.id}`]], main));
|
|
507
507
|
});
|
|
@@ -533,7 +533,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
533
533
|
for (const [action, detail] of await setLinks((sql, p) => c.query(sql, p), i.id, v, lk, before)) await logIssue(c, i.id, s.username, action, detail);
|
|
534
534
|
await recordMentions(c, ws, i.id, null, v.body, s.username);
|
|
535
535
|
});
|
|
536
|
-
flash(s, '
|
|
536
|
+
flash(s, tx('tm.issue_saved'));
|
|
537
537
|
return back(reply, s, `${TEAM}/issues/${i.id}`);
|
|
538
538
|
});
|
|
539
539
|
|
|
@@ -551,7 +551,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
551
551
|
const status = req.body?.status;
|
|
552
552
|
if (status !== 'open' && status !== 'closed') return reply.code(400).send('Unknown status');
|
|
553
553
|
if (status !== i.status) await owner.tx((c) => setStatus(c, i.id, status, s.username));
|
|
554
|
-
flash(s, status === 'closed' ?
|
|
554
|
+
flash(s, status === 'closed' ? tx('tm.issue_closed', { id: i.id }) : tx('tm.reopened', { id: i.id }));
|
|
555
555
|
return back(reply, s, `${TEAM}/issues/${i.id}`);
|
|
556
556
|
});
|
|
557
557
|
|
|
@@ -563,7 +563,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
563
563
|
if (!i) return reply.code(404).send('Not found');
|
|
564
564
|
const body = String(req.body?.body ?? '').replace(/\r\n?/g, '\n');
|
|
565
565
|
if (!body.trim() || body.length > 20000) {
|
|
566
|
-
flash(s, '
|
|
566
|
+
flash(s, tx('tm.a_comment_has_1_to'), 'error');
|
|
567
567
|
return back(reply, s, `${TEAM}/issues/${i.id}#add-comment`);
|
|
568
568
|
}
|
|
569
569
|
const and = req.body?.and;
|
|
@@ -576,7 +576,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
576
576
|
if (and === 'reopen' && i.status === 'closed') await setStatus(c, i.id, 'open', s.username);
|
|
577
577
|
return cid;
|
|
578
578
|
});
|
|
579
|
-
flash(s, '
|
|
579
|
+
flash(s, tx('tm.comment_added'));
|
|
580
580
|
return back(reply, s, `${TEAM}/issues/${i.id}#comment-${cid}`);
|
|
581
581
|
});
|
|
582
582
|
|
|
@@ -589,7 +589,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
589
589
|
if (i.created_by !== s.username && !(await workspaceAdmin(s.username, ws))) return reply.code(403).send('Only its author or a workspace administrator deletes an issue.');
|
|
590
590
|
await owner.query('delete from meta.team_issue where id = $1', [i.id]);
|
|
591
591
|
await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `team: issue ${i.id} deleted` });
|
|
592
|
-
flash(s,
|
|
592
|
+
flash(s, tx('tm.deleted', { id: i.id }));
|
|
593
593
|
return back(reply, s, TEAM);
|
|
594
594
|
});
|
|
595
595
|
|
|
@@ -600,14 +600,14 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
600
600
|
const { s, ws } = t;
|
|
601
601
|
const name = text(req.body?.name, 100);
|
|
602
602
|
if (!NAME40.test(name)) {
|
|
603
|
-
flash(s, '
|
|
603
|
+
flash(s, tx('tm.a_view_has_a_name'), 'error');
|
|
604
604
|
return back(reply, s, TEAM);
|
|
605
605
|
}
|
|
606
606
|
// only the known filters, re-checked
|
|
607
607
|
const query = filterQuery(parseFilters(Object.fromEntries(new URLSearchParams(String(req.body?.query ?? '').slice(0, 1000)))));
|
|
608
608
|
await owner.query(`insert into meta.team_view (workspace_id, username, name, query) values ($1, $2, $3, $4)
|
|
609
609
|
on conflict (workspace_id, username, lower(name)) do update set query = excluded.query`, [ws, s.username, name, query]);
|
|
610
|
-
flash(s,
|
|
610
|
+
flash(s, tx('tm.view_saved', { name }));
|
|
611
611
|
return back(reply, s, `${TEAM}${query ? `?${query}` : ''}`);
|
|
612
612
|
});
|
|
613
613
|
|
|
@@ -618,7 +618,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
618
618
|
if (!isId(req.params.id)) return reply.code(404).send('Not found');
|
|
619
619
|
const r = await owner.query('delete from meta.team_view where id = $1 and workspace_id = $2 and username = $3', [req.params.id, ws, s.username]);
|
|
620
620
|
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
621
|
-
flash(s, '
|
|
621
|
+
flash(s, tx('tm.view_deleted'));
|
|
622
622
|
return back(reply, s, TEAM);
|
|
623
623
|
});
|
|
624
624
|
|
|
@@ -628,7 +628,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
628
628
|
const { s, ws } = t;
|
|
629
629
|
await owner.query(`update meta.team_mention m set seen_at = now() from meta.team_issue i
|
|
630
630
|
where i.id = m.issue_id and i.workspace_id = $1 and m.username = $2 and m.seen_at is null`, [ws, s.username]);
|
|
631
|
-
flash(s, '
|
|
631
|
+
flash(s, tx('tm.mentions_marked_as_seen'));
|
|
632
632
|
return back(reply, s, TEAM);
|
|
633
633
|
});
|
|
634
634
|
|
|
@@ -644,30 +644,30 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
644
644
|
const groupSel = (id: string, cur: number | null) => html`<select id="${id}" name="group"><option value="">No group</option>${lk.groups.map((g) => html`<option value="${g.id}"${g.id === cur ? raw(' selected') : ''}>${g.name}</option>`)}</select>`;
|
|
645
645
|
const labelForm = (l: Partial<Label> & { id?: number }, p: string) => html`<form method="post" action="${TEAM}/labels${l.id ? `/${l.id}` : ''}">${csrf(s)}
|
|
646
646
|
<div class="form-grid">
|
|
647
|
-
<div class="field"><label class="label" for="${p}_name"
|
|
648
|
-
<div class="field"><label class="label" for="${p}_color"
|
|
649
|
-
<div class="field"><label class="label" for="${p}_group"
|
|
650
|
-
<div class="field"><label class="label" for="${p}_desc"
|
|
651
|
-
</div><div class="buttons"><button class="btn btn-sm btn-hot">${l.id ? '
|
|
647
|
+
<div class="field"><label class="label" for="${p}_name">${tx('tm.name')}</label><input id="${p}_name" name="name" maxlength="40" required value="${l.name ?? ''}"></div>
|
|
648
|
+
<div class="field"><label class="label" for="${p}_color">${tx('tm.colour')}</label>${colorSel(`${p}_color`, l.color ?? 'gray')}</div>
|
|
649
|
+
<div class="field"><label class="label" for="${p}_group">${tx('tm.group')}</label>${groupSel(`${p}_group`, l.group_id ?? null)}</div>
|
|
650
|
+
<div class="field"><label class="label" for="${p}_desc">${tx('tm.description')}</label><input id="${p}_desc" name="description" maxlength="500" value="${l.description ?? ''}"></div>
|
|
651
|
+
</div><div class="buttons"><button class="btn btn-sm btn-hot">${l.id ? tx('tm.save') : tx('tm.add_label')}</button></div></form>`;
|
|
652
652
|
const byGroup = [...lk.groups.map((g) => ({ g, ls: lk.labels.filter((l) => String(l.group_id) === String(g.id)) })), { g: null, ls: lk.labels.filter((l) => l.group_id === null) }];
|
|
653
653
|
const main = html`${teamHeader('labels')}
|
|
654
|
-
${byGroup.filter((x) => x.g || x.ls.length).map(({ g, ls }) => region(g ? g.name : '
|
|
654
|
+
${byGroup.filter((x) => x.g || x.ls.length).map(({ g, ls }) => region(g ? g.name : tx('tm.labels_no_group'), html`
|
|
655
655
|
${g?.description ? html`<p class="muted small u-mt0">${g.description}</p>` : ''}
|
|
656
656
|
${ls.length ? html`<ul class="team-label-list">${ls.map((l) => html`<li>${chip(l)} <span class="small muted">${l.description ?? ''}</span>
|
|
657
657
|
<a class="small" href="${TEAM}?label=${l.id}">${n.get(Number(l.id)) ?? 0} open</a>
|
|
658
|
-
<details class="team-inline-edit"><summary class="link-button"
|
|
659
|
-
${admin ? html`<form method="post" action="${TEAM}/labels/${l.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="
|
|
660
|
-
: html`<p class="muted small"
|
|
661
|
-
${g && admin ? html`<form method="post" action="${TEAM}/groups/${g.id}/delete" class="u-mt1">${csrf(s)}<button class="btn btn-sm" data-confirm="
|
|
658
|
+
<details class="team-inline-edit"><summary class="link-button">${tx('tm.edit')}</summary>${labelForm(l, `l${l.id}`)}
|
|
659
|
+
${admin ? html`<form method="post" action="${TEAM}/labels/${l.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('tm.delete_label_confirm', { name: l.name })}">${icon('trash')} ${tx('tm.delete_label')}</button></form>` : ''}</details></li>`)}</ul>`
|
|
660
|
+
: html`<p class="muted small">${tx('tm.no_labels_in_this_group')}</p>`}
|
|
661
|
+
${g && admin ? html`<form method="post" action="${TEAM}/groups/${g.id}/delete" class="u-mt1">${csrf(s)}<button class="btn btn-sm" data-confirm="${tx('tm.delete_group_confirm', { name: g.name })}">${tx('tm.delete_group')}</button></form>` : ''}`))}
|
|
662
662
|
<div class="columns">
|
|
663
|
-
${region('
|
|
664
|
-
${region('
|
|
665
|
-
<div class="field"><label class="label" for="ng_name"
|
|
666
|
-
<div class="field"><label class="label" for="ng_desc"
|
|
667
|
-
<div class="buttons"><button class="btn btn-sm btn-hot"
|
|
668
|
-
<p class="small muted"
|
|
663
|
+
${region(tx('tm.add_a_label'), labelForm({}, 'nl'))}
|
|
664
|
+
${region(tx('tm.add_a_label_group'), html`<form method="post" action="${TEAM}/groups">${csrf(s)}
|
|
665
|
+
<div class="field"><label class="label" for="ng_name">${tx('tm.name')}</label><input id="ng_name" name="name" maxlength="40" required placeholder="${tx('tm.e_g_area')}"></div>
|
|
666
|
+
<div class="field"><label class="label" for="ng_desc">${tx('tm.description')}</label><input id="ng_desc" name="description" maxlength="500"></div>
|
|
667
|
+
<div class="buttons"><button class="btn btn-sm btn-hot">${tx('tm.add_group')}</button></div></form>
|
|
668
|
+
<p class="small muted">${tx('tm.the_board_groups_issues_by')}</p>`)}
|
|
669
669
|
</div>`;
|
|
670
|
-
return send(reply, s, page(s, '
|
|
670
|
+
return send(reply, s, page(s, tx('tm.labels'), [[tx('tm.labels')]], main));
|
|
671
671
|
});
|
|
672
672
|
|
|
673
673
|
const saveLabel = async (req: Req, reply: FastifyReply, id: string | null) => {
|
|
@@ -682,7 +682,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
682
682
|
if (!isLabelColor(color)) return reply.code(400).send('Unknown colour');
|
|
683
683
|
if (group && !(await owner.one('select 1 as ok from meta.team_label_group where id = $1 and workspace_id = $2', [isId(group) ? group : 0, ws]))) return reply.code(400).send('Unknown group');
|
|
684
684
|
if (!NAME40.test(name)) {
|
|
685
|
-
flash(s, '
|
|
685
|
+
flash(s, tx('tm.a_label_has_a_name'), 'error');
|
|
686
686
|
return back(reply, s, `${TEAM}/labels`);
|
|
687
687
|
}
|
|
688
688
|
try {
|
|
@@ -691,7 +691,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
691
691
|
const r = await owner.query('update meta.team_label set name = $3, color = $4, group_id = $5, description = $6 where id = $1 and workspace_id = $2', [id, ws, name, color, group || null, desc]);
|
|
692
692
|
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
693
693
|
} else await owner.query('insert into meta.team_label (workspace_id, name, color, group_id, description) values ($1, $2, $3, $4, $5)', [ws, name, color, group || null, desc]);
|
|
694
|
-
flash(s,
|
|
694
|
+
flash(s, tx('tm.label_saved', { name }));
|
|
695
695
|
} catch (e) {
|
|
696
696
|
flash(s, /team_label_name/.test((e as Error).message) ? `A label ${name} exists already.` : (e as Error).message, 'error');
|
|
697
697
|
}
|
|
@@ -706,13 +706,13 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
706
706
|
const { s, ws } = t;
|
|
707
707
|
const name = text(req.body?.name, 100);
|
|
708
708
|
if (!NAME40.test(name)) {
|
|
709
|
-
flash(s, '
|
|
709
|
+
flash(s, tx('tm.a_group_has_a_name'), 'error');
|
|
710
710
|
return back(reply, s, `${TEAM}/labels`);
|
|
711
711
|
}
|
|
712
712
|
const r = await owner.query(`insert into meta.team_label_group (workspace_id, name, description, seq)
|
|
713
713
|
values ($1, $2, $3, (select coalesce(max(seq), 0) + 10 from meta.team_label_group where workspace_id = $1)) on conflict (workspace_id, lower(name)) do nothing`,
|
|
714
714
|
[ws, name, text(req.body?.description, 500) || null]);
|
|
715
|
-
flash(s, r.rowCount ?
|
|
715
|
+
flash(s, r.rowCount ? tx('tm.group_added', { name }) : tx('tm.group_exists', { name }), r.rowCount ? 'ok' : 'error');
|
|
716
716
|
return back(reply, s, `${TEAM}/labels`);
|
|
717
717
|
});
|
|
718
718
|
|
|
@@ -725,7 +725,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
725
725
|
if (!(await workspaceAdmin(s.username, ws))) return reply.code(403).send(`Only the workspace's administrators delete ${what}s.`);
|
|
726
726
|
const r = await owner.query(`delete from meta.${table} where id = $1 and workspace_id = $2`, [req.params.id, ws]);
|
|
727
727
|
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
728
|
-
flash(s,
|
|
728
|
+
flash(s, tx(({ label: 'tm.label_deleted', 'label group': 'tm.group_deleted', milestone: 'tm.milestone_deleted', template: 'tm.template_deleted' } as const)[what as 'label'] ?? 'tm.label_deleted'));
|
|
729
729
|
return back(reply, s, to);
|
|
730
730
|
});
|
|
731
731
|
adminDelete('labels', 'team_label', 'label', `${TEAM}/labels`);
|
|
@@ -740,14 +740,14 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
740
740
|
const [lk, admin] = await Promise.all([lookups(ws), workspaceAdmin(s.username, ws)]);
|
|
741
741
|
const form = (m: Partial<Milestone>, p: string) => html`<form method="post" action="${TEAM}/milestones${m.id ? `/${m.id}` : ''}">${csrf(s)}
|
|
742
742
|
<div class="form-grid">
|
|
743
|
-
<div class="field"><label class="label" for="${p}_name"
|
|
744
|
-
<div class="field"><label class="label" for="${p}_due"
|
|
745
|
-
<div class="field"><label class="label" for="${p}_status"
|
|
743
|
+
<div class="field"><label class="label" for="${p}_name">${tx('tm.name')}</label><input id="${p}_name" name="name" maxlength="40" required value="${m.name ?? ''}"></div>
|
|
744
|
+
<div class="field"><label class="label" for="${p}_due">${tx('tm.due')}</label><input id="${p}_due" name="due_date" type="date" value="${m.due_date ?? ''}"></div>
|
|
745
|
+
<div class="field"><label class="label" for="${p}_status">${tx('tm.status')}</label><select id="${p}_status" name="status">
|
|
746
746
|
<option value="open"${m.status !== 'closed' ? raw(' selected') : ''}>Open</option><option value="closed"${m.status === 'closed' ? raw(' selected') : ''}>Closed</option></select></div>
|
|
747
|
-
<div class="field"><label class="label" for="${p}_desc"
|
|
748
|
-
</div><div class="buttons"><button class="btn btn-sm btn-hot">${m.id ? '
|
|
747
|
+
<div class="field"><label class="label" for="${p}_desc">${tx('tm.description')}</label><input id="${p}_desc" name="description" maxlength="500" value="${m.description ?? ''}"></div>
|
|
748
|
+
</div><div class="buttons"><button class="btn btn-sm btn-hot">${m.id ? tx('tm.save') : tx('tm.add_milestone')}</button></div></form>`;
|
|
749
749
|
const main = html`${teamHeader('milestones')}
|
|
750
|
-
${region('
|
|
750
|
+
${region(tx('tm.milestones'), lk.milestones.length ? html`<ul class="team-milestones">${lk.milestones.map((m) => {
|
|
751
751
|
const total = m.open + m.closed;
|
|
752
752
|
return html`<li class="team-milestone${m.status === 'closed' ? ' is-closed' : ''}">
|
|
753
753
|
<div class="team-milestone-head"><b>${icon('flag')}${m.name}</b>${m.status === 'closed' ? html` <span class="tag">closed</span>` : ''}
|
|
@@ -755,12 +755,12 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
755
755
|
${m.description ? html`<p class="small muted">${m.description}</p>` : ''}
|
|
756
756
|
<progress class="team-progress" max="${total || 1}" value="${m.closed}" aria-label="${m.closed} of ${total} issues closed"></progress>
|
|
757
757
|
<p class="small"><a href="${TEAM}?milestone=${m.id}">${m.open} open</a> · <a href="${TEAM}?milestone=${m.id}&status=closed">${m.closed} closed</a></p>
|
|
758
|
-
<details class="team-inline-edit"><summary class="link-button"
|
|
759
|
-
${admin ? html`<form method="post" action="${TEAM}/milestones/${m.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="
|
|
758
|
+
<details class="team-inline-edit"><summary class="link-button">${tx('tm.edit')}</summary>${form(m, `m${m.id}`)}
|
|
759
|
+
${admin ? html`<form method="post" action="${TEAM}/milestones/${m.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('tm.delete_milestone_confirm', { name: m.name })}">${icon('trash')} ${tx('tm.delete_milestone')}</button></form>` : ''}</details>
|
|
760
760
|
</li>`;
|
|
761
|
-
})}</ul>` : html`<p class="muted"
|
|
762
|
-
${region('
|
|
763
|
-
return send(reply, s, page(s, '
|
|
761
|
+
})}</ul>` : html`<p class="muted">${tx('tm.no_milestones_yet')}</p>`)}
|
|
762
|
+
${region(tx('tm.add_a_milestone'), form({}, 'nm'))}`;
|
|
763
|
+
return send(reply, s, page(s, tx('tm.milestones'), [[tx('tm.milestones')]], main));
|
|
764
764
|
});
|
|
765
765
|
|
|
766
766
|
const saveMilestone = async (req: Req, reply: FastifyReply, id: string | null) => {
|
|
@@ -771,7 +771,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
771
771
|
const name = text(b.name, 100);
|
|
772
772
|
const due = String(b.due_date ?? '');
|
|
773
773
|
if (!NAME40.test(name) || (due && (!/^\d{4}-\d{2}-\d{2}$/.test(due) || Number.isNaN(Date.parse(due))))) {
|
|
774
|
-
flash(s, '
|
|
774
|
+
flash(s, tx('tm.a_milestone_has_a_name'), 'error');
|
|
775
775
|
return back(reply, s, `${TEAM}/milestones`);
|
|
776
776
|
}
|
|
777
777
|
const vals = [name, text(b.description, 500) || null, due || null, b.status === 'closed' ? 'closed' : 'open'];
|
|
@@ -781,7 +781,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
781
781
|
const r = await owner.query('update meta.team_milestone set name = $3, description = $4, due_date = $5, status = $6 where id = $1 and workspace_id = $2', [id, ws, ...vals]);
|
|
782
782
|
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
783
783
|
} else await owner.query('insert into meta.team_milestone (workspace_id, name, description, due_date, status) values ($1, $2, $3, $4, $5)', [ws, ...vals]);
|
|
784
|
-
flash(s,
|
|
784
|
+
flash(s, tx('tm.milestone_saved', { name }));
|
|
785
785
|
} catch (e) {
|
|
786
786
|
flash(s, /team_milestone_name/.test((e as Error).message) ? `A milestone ${name} exists already.` : (e as Error).message, 'error');
|
|
787
787
|
}
|
|
@@ -798,26 +798,26 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
798
798
|
const rows = (await owner.query('select id, kind, name, title, body, labels from meta.team_template where workspace_id = $1 order by kind, seq, lower(name)', [ws])).rows;
|
|
799
799
|
const form = (x: { id?: number; kind?: string; name?: string; title?: string | null; body?: string; labels?: string[] }, p: string) => html`<form method="post" action="${TEAM}/templates${x.id ? `/${x.id}` : ''}">${csrf(s)}
|
|
800
800
|
<div class="form-grid">
|
|
801
|
-
<div class="field"><label class="label" for="${p}_name"
|
|
802
|
-
<div class="field"><label class="label" for="${p}_kind"
|
|
803
|
-
<option value="issue"${x.kind !== 'response' ? raw(' selected') : ''}
|
|
804
|
-
<div class="field"><label class="label" for="${p}_title"
|
|
805
|
-
<div class="field"><label class="label" for="${p}_labels"
|
|
801
|
+
<div class="field"><label class="label" for="${p}_name">${tx('tm.name')}</label><input id="${p}_name" name="name" maxlength="60" required value="${x.name ?? ''}"></div>
|
|
802
|
+
<div class="field"><label class="label" for="${p}_kind">${tx('tm.kind')}</label><select id="${p}_kind" name="kind">
|
|
803
|
+
<option value="issue"${x.kind !== 'response' ? raw(' selected') : ''}>${tx('tm.kind_issue')}</option><option value="response"${x.kind === 'response' ? raw(' selected') : ''}>${tx('tm.kind_response')}</option></select></div>
|
|
804
|
+
<div class="field"><label class="label" for="${p}_title">${tx('tm.issue_title')}</label><input id="${p}_title" name="title" maxlength="200" value="${x.title ?? ''}"><small class="help">${tx('tm.issue_templates_only')}</small></div>
|
|
805
|
+
<div class="field"><label class="label" for="${p}_labels">${tx('tm.labels')}</label><input id="${p}_labels" name="labels" maxlength="500" value="${(x.labels ?? []).join(', ')}"><small class="help">${tx('tm.issue_templates_label_names_separated')}</small></div>
|
|
806
806
|
</div>
|
|
807
|
-
<div class="field"><label class="label" for="${p}_body"
|
|
808
|
-
<div class="buttons"><button class="btn btn-sm btn-hot">${x.id ? '
|
|
807
|
+
<div class="field"><label class="label" for="${p}_body">${tx('tm.text')}</label><textarea id="${p}_body" name="body" rows="6" maxlength="20000">${x.body ?? ''}</textarea><small class="help">${tx('tm.markdown')}</small></div>
|
|
808
|
+
<div class="buttons"><button class="btn btn-sm btn-hot">${x.id ? tx('tm.save') : tx('tm.add_template')}</button></div></form>`;
|
|
809
809
|
const list = (kind: string) => {
|
|
810
810
|
const xs = rows.filter((r) => r.kind === kind);
|
|
811
811
|
return xs.length ? html`<ul class="team-template-list">${xs.map((x) => html`<li><b>${x.name}</b>
|
|
812
|
-
${kind === 'issue' ? html` <a class="small" href="${TEAM}/issues/new?template=${x.id}"
|
|
813
|
-
<details class="team-inline-edit"><summary class="link-button"
|
|
814
|
-
<form method="post" action="${TEAM}/templates/${x.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="
|
|
815
|
-
: html`<p class="muted small"
|
|
812
|
+
${kind === 'issue' ? html` <a class="small" href="${TEAM}/issues/new?template=${x.id}">${tx('tm.use')}</a>` : ''}
|
|
813
|
+
<details class="team-inline-edit"><summary class="link-button">${tx('tm.edit')}</summary>${form(x, `t${x.id}`)}
|
|
814
|
+
<form method="post" action="${TEAM}/templates/${x.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('tm.delete_template_confirm', { name: x.name })}">${icon('trash')} ${tx('tm.delete_template')}</button></form></details></li>`)}</ul>`
|
|
815
|
+
: html`<p class="muted small">${tx('tm.none_yet')}</p>`;
|
|
816
816
|
};
|
|
817
817
|
const main = html`${teamHeader('templates')}
|
|
818
|
-
<div class="columns">${region('
|
|
819
|
-
${region('
|
|
820
|
-
return send(reply, s, page(s, '
|
|
818
|
+
<div class="columns">${region(tx('tm.issue_templates'), list('issue'))}${region(tx('tm.canned_responses'), list('response'))}</div>
|
|
819
|
+
${region(tx('tm.add_a_template'), form({}, 'nt'))}`;
|
|
820
|
+
return send(reply, s, page(s, tx('tm.templates'), [[tx('tm.templates')]], main));
|
|
821
821
|
});
|
|
822
822
|
|
|
823
823
|
const saveTemplate = async (req: Req, reply: FastifyReply, id: string | null) => {
|
|
@@ -830,7 +830,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
830
830
|
const body = String(b.body ?? '').replace(/\r\n?/g, '\n');
|
|
831
831
|
const labels = String(b.labels ?? '').split(',').map((x) => x.trim()).filter((x) => NAME40.test(x)).slice(0, 20);
|
|
832
832
|
if (!/^\S(.{0,58}\S)?$/.test(name) || body.length > 20000) {
|
|
833
|
-
flash(s, '
|
|
833
|
+
flash(s, tx('tm.a_template_has_a_name'), 'error');
|
|
834
834
|
return back(reply, s, `${TEAM}/templates`);
|
|
835
835
|
}
|
|
836
836
|
const vals = [kind, name, kind === 'issue' ? text(b.title, 200) || null : null, body, kind === 'issue' ? labels : []];
|
|
@@ -840,7 +840,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
840
840
|
const r = await owner.query('update meta.team_template set kind = $3, name = $4, title = $5, body = $6, labels = $7 where id = $1 and workspace_id = $2', [id, ws, ...vals]);
|
|
841
841
|
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
842
842
|
} else await owner.query('insert into meta.team_template (workspace_id, kind, name, title, body, labels) values ($1, $2, $3, $4, $5, $6)', [ws, ...vals]);
|
|
843
|
-
flash(s,
|
|
843
|
+
flash(s, tx('tm.template_saved', { name }));
|
|
844
844
|
} catch (e) {
|
|
845
845
|
flash(s, /team_template_name/.test((e as Error).message) ? `A template ${name} exists already.` : (e as Error).message, 'error');
|
|
846
846
|
}
|
|
@@ -855,7 +855,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
855
855
|
if (!isId(req.params.id)) return reply.code(404).send('Not found');
|
|
856
856
|
const r = await owner.query('delete from meta.team_template where id = $1 and workspace_id = $2', [req.params.id, ws]);
|
|
857
857
|
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
858
|
-
flash(s, '
|
|
858
|
+
flash(s, tx('tm.template_deleted'));
|
|
859
859
|
return back(reply, s, `${TEAM}/templates`);
|
|
860
860
|
});
|
|
861
861
|
|
|
@@ -866,17 +866,17 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
866
866
|
const { s } = t;
|
|
867
867
|
const en = TEAM_SAMPLES.en;
|
|
868
868
|
const main = html`${teamHeader('setup')}
|
|
869
|
-
${region('
|
|
870
|
-
<p class="u-mt0"
|
|
869
|
+
${region(tx('tm.set_up_team_development_2'), html`
|
|
870
|
+
<p class="u-mt0">${tx('tm.setup_intro', { workspace: s.workspace?.name ?? tx('tm.this_workspace') })}</p>
|
|
871
871
|
<form method="post" action="${TEAM}/setup">${csrf(s)}
|
|
872
|
-
<div class="field"><label class="label" for="f_lang"
|
|
873
|
-
<fieldset class="team-fieldset"><legend
|
|
874
|
-
<label class="check"><input type="checkbox" name="labels" value="true" checked>
|
|
875
|
-
<label class="check"><input type="checkbox" name="templates" value="true" checked>
|
|
876
|
-
<label class="check"><input type="checkbox" name="milestones" value="true" checked>
|
|
872
|
+
<div class="field"><label class="label" for="f_lang">${tx('tm.language')}</label><select id="f_lang" name="lang">${TEAM_LANGUAGES.map((l) => html`<option value="${l}">${TEAM_SAMPLES[l].language}</option>`)}</select></div>
|
|
873
|
+
<fieldset class="team-fieldset"><legend>${tx('tm.install')}</legend>
|
|
874
|
+
<label class="check"><input type="checkbox" name="labels" value="true" checked> ${tx('tm.setup_labels', { list: en.groups.map((g) => `${g.name} (${g.labels.map((l) => l.name).join(', ')})`).join('; ') })}</label>
|
|
875
|
+
<label class="check"><input type="checkbox" name="templates" value="true" checked> ${tx('tm.setup_templates', { list: en.issueTemplates.map((x) => x.name).join(', '), n: en.responses.length })}</label>
|
|
876
|
+
<label class="check"><input type="checkbox" name="milestones" value="true" checked> ${tx('tm.setup_milestones', { list: en.milestones.map((m) => m.name).join(', ') })}</label>
|
|
877
877
|
</fieldset>
|
|
878
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
879
|
-
return send(reply, s, page(s, '
|
|
878
|
+
<div class="buttons"><button class="btn btn-hot">${tx('tm.set_up')}</button></div></form>`)}`;
|
|
879
|
+
return send(reply, s, page(s, tx('tm.set_up'), [[tx('tm.set_up')]], main));
|
|
880
880
|
});
|
|
881
881
|
|
|
882
882
|
app.post(`${TEAM}/setup`, async (req: Req, reply) => {
|
|
@@ -888,7 +888,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
888
888
|
const b = req.body ?? {};
|
|
889
889
|
const n = await owner.tx((c) => teamSetup(c, ws, lang, { labels: b.labels === 'true', templates: b.templates === 'true', milestones: b.milestones === 'true' }));
|
|
890
890
|
await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `team: set up (${lang})` });
|
|
891
|
-
flash(s,
|
|
891
|
+
flash(s, tx('tm.setup_done', { groups: n.groups, labels: n.labels, templates: n.templates, milestones: n.milestones }));
|
|
892
892
|
return back(reply, s, TEAM);
|
|
893
893
|
});
|
|
894
894
|
|
|
@@ -904,7 +904,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
904
904
|
const existing = await owner.one('select id from meta.team_issue where feedback_id = $1 and workspace_id = $2', [f.id, ws]);
|
|
905
905
|
if (existing) return back(reply, s, `${TEAM}/issues/${existing.id}`);
|
|
906
906
|
const kindLabel = f.kind === 'problem' ? 'bug' : f.kind === 'idea' ? 'feature' : null;
|
|
907
|
-
const title = `${f.kind === 'problem' ? '
|
|
907
|
+
const title = `${f.kind === 'problem' ? tx('tm.problem') : f.kind === 'idea' ? tx('tm.idea') : tx('tm.feedback')}: ${firstLine(f.body)}`;
|
|
908
908
|
const body = `Feedback of **${mdEscape(f.username)}** (${f.kind}${f.rating ? `, ${f.rating} of 5` : ''}) on ${f.page_no ? `page ${f.page_no} of ` : ''}${mdEscape(f.app_name)}:\n\n${quote(f.body)}`;
|
|
909
909
|
const id = await owner.tx(async (c) => {
|
|
910
910
|
const id = Number((await c.query(`insert into meta.team_issue (workspace_id, title, body, app_id, page_no, feedback_id, created_by) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
|
|
@@ -914,7 +914,7 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
914
914
|
if (f.status === 'open') await c.query(`update meta.feedback set status = 'in_progress' where id = $1`, [f.id]);
|
|
915
915
|
return id;
|
|
916
916
|
});
|
|
917
|
-
flash(s,
|
|
917
|
+
flash(s, tx('tm.created_feedback', { id }));
|
|
918
918
|
return back(reply, s, `${TEAM}/issues/${id}`);
|
|
919
919
|
});
|
|
920
920
|
|
|
@@ -935,13 +935,13 @@ export async function teamRoutes(app: FastifyInstance) {
|
|
|
935
935
|
await recordMentions(c, ws, id, null, c0.body, s.username);
|
|
936
936
|
return id;
|
|
937
937
|
});
|
|
938
|
-
flash(s,
|
|
938
|
+
flash(s, tx('tm.created_comment', { id }));
|
|
939
939
|
return back(reply, s, `${TEAM}/issues/${id}`);
|
|
940
940
|
});
|
|
941
941
|
}
|
|
942
942
|
|
|
943
943
|
const firstLine = (t: string) => {
|
|
944
|
-
const l = String(t).split('\n').map((x) => x.trim()).find(Boolean) ?? '
|
|
944
|
+
const l = String(t).split('\n').map((x) => x.trim()).find(Boolean) ?? tx('tm.untitled');
|
|
945
945
|
return l.length > 80 ? `${l.slice(0, 79)}…` : l;
|
|
946
946
|
};
|
|
947
947
|
const mdEscape = (t: string) => String(t).replace(/[\\`*_[\]<>#]/g, (c) => `\\${c}`);
|
|
@@ -954,9 +954,9 @@ export async function pageIssuesNote(appId: number, pageNo: number): Promise<Raw
|
|
|
954
954
|
const rows = await pageIssues(appId, pageNo);
|
|
955
955
|
if (!rows.length) return '';
|
|
956
956
|
const all = `${TEAM}?app=${appId}&page=${pageNo}`;
|
|
957
|
-
return html`<div class="team-page-issues small" role="note">${icon('kanban')}<span>${rows.length
|
|
958
|
-
${rows.slice(0, 3).map((r, i) => html`${i ? ', ' : ''}<a href="${TEAM}/issues/${r.id}">${r.title}</a>`)}${rows.length > 3 ? html`, <a href="${all}"
|
|
959
|
-
· <a href="${TEAM}/issues/new?app=${appId}&page=${pageNo}"
|
|
957
|
+
return html`<div class="team-page-issues small" role="note">${icon('kanban')}<span>${rows.length === 1 ? tx('tm.page_issues_1') : tx('tm.page_issues', { n: rows.length })}
|
|
958
|
+
${rows.slice(0, 3).map((r, i) => html`${i ? ', ' : ''}<a href="${TEAM}/issues/${r.id}">${r.title}</a>`)}${rows.length > 3 ? html`, <a href="${all}">${tx('tm.all_n', { n: rows.length })}</a>` : ''}
|
|
959
|
+
· <a href="${TEAM}/issues/new?app=${appId}&page=${pageNo}">${tx('tm.new_issue')}</a></span></div>`;
|
|
960
960
|
}
|
|
961
961
|
|
|
962
962
|
/** (App Home) open issues per page, and the Tasks entry. */
|
|
@@ -965,7 +965,7 @@ export async function appIssues(appId: number) {
|
|
|
965
965
|
return {
|
|
966
966
|
tag: (pageNo: number) => {
|
|
967
967
|
const n = c.perPage.get(pageNo);
|
|
968
|
-
return n ? html` <a class="tag tag-warning" href="${TEAM}?app=${appId}&page=${pageNo}" title="
|
|
968
|
+
return n ? html` <a class="tag tag-warning" href="${TEAM}?app=${appId}&page=${pageNo}" title="${tx('tm.open_issues_of_this_page')}">${n} issue${n === 1 ? '' : 's'}</a>` : '';
|
|
969
969
|
},
|
|
970
970
|
task: html`<li><a href="${TEAM}?app=${appId}">${icon('kanban')} Open issues (${c.total})</a></li>`,
|
|
971
971
|
};
|
|
@@ -989,5 +989,5 @@ export async function teamHomeBlock(s: Session) {
|
|
|
989
989
|
/** (Feedback, developer comments) a button that turns the entry into an issue, or the link to its issue. */
|
|
990
990
|
export function toIssueButton(s: Session, kind: 'feedback' | 'comment', id: number, issue: number | undefined) {
|
|
991
991
|
if (issue) return html`<a class="small team-issue-link" href="${TEAM}/issues/${issue}">${icon('kanban')} Issue #${issue}</a>`;
|
|
992
|
-
return html`<form method="post" action="${TEAM}/from-${kind}" class="u-inline">${csrf(s)}<input type="hidden" name="${kind}" value="${id}"><button class="link-button"
|
|
992
|
+
return html`<form method="post" action="${TEAM}/from-${kind}" class="u-inline">${csrf(s)}<input type="hidden" name="${kind}" value="${id}"><button class="link-button">${tx('tm.make_an_issue')}</button></form>`;
|
|
993
993
|
}
|