@chisquare-tech/origine-ngx-apps-design 0.0.0-stage → 0.2.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.
|
@@ -0,0 +1,2940 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, Injectable, input, output, signal, computed, viewChild, ChangeDetectionStrategy, Component, effect, untracked, HostListener } from '@angular/core';
|
|
3
|
+
import { ApiService } from '@thescaffold/ngx-core';
|
|
4
|
+
import { map, forkJoin, concat, last } from 'rxjs';
|
|
5
|
+
import * as i1 from '@angular/forms';
|
|
6
|
+
import { FormsModule } from '@angular/forms';
|
|
7
|
+
import { RouterLink, Router, ActivatedRoute } from '@angular/router';
|
|
8
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
9
|
+
import { marked } from 'marked';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the Design screens need from the app that mounts them (cloud/web provides it): where the person
|
|
13
|
+
* is, who is in the organization, and links inside the project. The library holds no routing or
|
|
14
|
+
* identity state of its own, so it renders the same in any host.
|
|
15
|
+
*/
|
|
16
|
+
class DesignHost {
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const D = 'v1/design';
|
|
20
|
+
const enc = encodeURIComponent;
|
|
21
|
+
const data = (r) => r?.data;
|
|
22
|
+
/** The error message an API refusal carries, or the fallback. */
|
|
23
|
+
function apiMessage(err, fallback = 'Something went wrong. Try again.') {
|
|
24
|
+
return err?.error?.message || err?.error?.title || err?.message || fallback;
|
|
25
|
+
}
|
|
26
|
+
/** True when the refusal is the version check (someone changed the issue first). */
|
|
27
|
+
function isConflict(err) {
|
|
28
|
+
return err?.status === 409 || err?.error?.code === 'conflict';
|
|
29
|
+
}
|
|
30
|
+
class DesignApi {
|
|
31
|
+
constructor() {
|
|
32
|
+
this.api = inject(ApiService);
|
|
33
|
+
}
|
|
34
|
+
statuses(project) {
|
|
35
|
+
return this.api.get(`${D}/project/${enc(project)}/status`).pipe(map((r) => data(r) ?? []));
|
|
36
|
+
}
|
|
37
|
+
board(project, q = {}) {
|
|
38
|
+
const params = {};
|
|
39
|
+
if (q.sprint)
|
|
40
|
+
params['sprint'] = q.sprint;
|
|
41
|
+
if (q.swimlane)
|
|
42
|
+
params['swimlane'] = q.swimlane;
|
|
43
|
+
if (q.filter)
|
|
44
|
+
params['filter'] = JSON.stringify(q.filter);
|
|
45
|
+
return this.api.get(`${D}/project/${enc(project)}/board`, params).pipe(map((r) => data(r)));
|
|
46
|
+
}
|
|
47
|
+
backlog(project, filter) {
|
|
48
|
+
return this.api.get(`${D}/project/${enc(project)}/backlog`, filter ? { filter: JSON.stringify(filter) } : undefined).pipe(map((r) => data(r) ?? []));
|
|
49
|
+
}
|
|
50
|
+
query(project, q) {
|
|
51
|
+
return this.api.post(`${D}/project/${enc(project)}/issue/query`, q).pipe(map((r) => data(r) ?? { items: [], total: 0 }));
|
|
52
|
+
}
|
|
53
|
+
/** Epics of the project, for pickers and the backlog's epic filter. */
|
|
54
|
+
epics(project) {
|
|
55
|
+
return this.api.get(`${D}/project/${enc(project)}/issue`, { type: 'epic', limit: '200' }).pipe(map((r) => data(r) ?? []));
|
|
56
|
+
}
|
|
57
|
+
create(project, body) {
|
|
58
|
+
return this.api.post(`${D}/project/${enc(project)}/issue`, body).pipe(map((r) => data(r)));
|
|
59
|
+
}
|
|
60
|
+
get(key) {
|
|
61
|
+
return this.api.get(`${D}/issue/${enc(key)}`).pipe(map((r) => data(r)));
|
|
62
|
+
}
|
|
63
|
+
update(key, change) {
|
|
64
|
+
return this.api.patch(`${D}/issue/${enc(key)}`, change).pipe(map((r) => data(r)));
|
|
65
|
+
}
|
|
66
|
+
remove(key) {
|
|
67
|
+
return this.api.delete(`${D}/issue/${enc(key)}`);
|
|
68
|
+
}
|
|
69
|
+
/** Into a status, and between two neighbours (keys) for the rank. */
|
|
70
|
+
move(key, statusId, after, before) {
|
|
71
|
+
return this.api.post(`${D}/issue/${enc(key)}/move`, { statusId, after: after ?? '', before: before ?? '' }).pipe(map((r) => data(r)));
|
|
72
|
+
}
|
|
73
|
+
history(key) {
|
|
74
|
+
return this.api.get(`${D}/issue/${enc(key)}/history`).pipe(map((r) => data(r) ?? []));
|
|
75
|
+
}
|
|
76
|
+
comments(key) {
|
|
77
|
+
return this.api.get(`${D}/issue/${enc(key)}/comment`).pipe(map((r) => data(r) ?? []));
|
|
78
|
+
}
|
|
79
|
+
comment(key, body) {
|
|
80
|
+
return this.api.post(`${D}/issue/${enc(key)}/comment`, { body }).pipe(map((r) => data(r)));
|
|
81
|
+
}
|
|
82
|
+
editComment(id, body) {
|
|
83
|
+
return this.api.patch(`${D}/comment/${enc(id)}`, { body }).pipe(map((r) => data(r)));
|
|
84
|
+
}
|
|
85
|
+
deleteComment(id) {
|
|
86
|
+
return this.api.delete(`${D}/comment/${enc(id)}`);
|
|
87
|
+
}
|
|
88
|
+
relate(key, kind, other) {
|
|
89
|
+
return this.api.post(`${D}/issue/${enc(key)}/relation`, { kind, issue: other });
|
|
90
|
+
}
|
|
91
|
+
unrelate(id) {
|
|
92
|
+
return this.api.delete(`${D}/relation/${enc(id)}`);
|
|
93
|
+
}
|
|
94
|
+
bulk(project, keys, change) {
|
|
95
|
+
return this.api.post(`${D}/project/${enc(project)}/issue/bulk`, { keys, change });
|
|
96
|
+
}
|
|
97
|
+
/** The export as text: CSV or JSON of what the filter matches. */
|
|
98
|
+
export(project, format, filter, sort) {
|
|
99
|
+
const params = { format };
|
|
100
|
+
if (filter)
|
|
101
|
+
params['filter'] = JSON.stringify(filter);
|
|
102
|
+
if (sort?.length)
|
|
103
|
+
params['sort'] = JSON.stringify(sort);
|
|
104
|
+
return this.api.get(`${D}/project/${enc(project)}/export`, params, { responseType: 'text' }).pipe(map((b) => String(b)));
|
|
105
|
+
}
|
|
106
|
+
sprints(project, completed = false) {
|
|
107
|
+
return this.api.get(`${D}/project/${enc(project)}/sprint`, completed ? { completed: 'true' } : undefined).pipe(map((r) => data(r) ?? []));
|
|
108
|
+
}
|
|
109
|
+
createSprint(project, body = {}) {
|
|
110
|
+
return this.api.post(`${D}/project/${enc(project)}/sprint`, body).pipe(map((r) => data(r)));
|
|
111
|
+
}
|
|
112
|
+
startSprint(project, id, body = {}) {
|
|
113
|
+
return this.api.post(`${D}/project/${enc(project)}/sprint/${enc(id)}/start`, body).pipe(map((r) => data(r)));
|
|
114
|
+
}
|
|
115
|
+
completeSprint(project, id, moveTo) {
|
|
116
|
+
return this.api.post(`${D}/project/${enc(project)}/sprint/${enc(id)}/complete`, { moveTo }).pipe(map((r) => data(r)));
|
|
117
|
+
}
|
|
118
|
+
views(project) {
|
|
119
|
+
return this.api.get(`${D}/project/${enc(project)}/view`).pipe(map((r) => data(r) ?? { mine: [], shared: [] }));
|
|
120
|
+
}
|
|
121
|
+
saveView(project, v) {
|
|
122
|
+
return this.api.post(`${D}/project/${enc(project)}/view`, v).pipe(map((r) => data(r)));
|
|
123
|
+
}
|
|
124
|
+
deleteView(project, id) {
|
|
125
|
+
return this.api.delete(`${D}/project/${enc(project)}/view/${enc(id)}`);
|
|
126
|
+
}
|
|
127
|
+
react(commentId, reaction, on) {
|
|
128
|
+
const url = `${D}/comment/${enc(commentId)}/reaction/${reaction}`;
|
|
129
|
+
return (on ? this.api.put(url, {}) : this.api.delete(url)).pipe(map((r) => data(r) ?? []));
|
|
130
|
+
}
|
|
131
|
+
attachments(key) {
|
|
132
|
+
return this.api.get(`${D}/issue/${enc(key)}/attachment`).pipe(map((r) => data(r) ?? []));
|
|
133
|
+
}
|
|
134
|
+
/** Uploads a file as the raw request body (at most 25 MB). */
|
|
135
|
+
attach(key, file) {
|
|
136
|
+
return this.api
|
|
137
|
+
.post(`${D}/issue/${enc(key)}/attachment?filename=${encodeURIComponent(file.name)}`, null, {
|
|
138
|
+
body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
139
|
+
})
|
|
140
|
+
.pipe(map((r) => data(r)));
|
|
141
|
+
}
|
|
142
|
+
/** A file's bytes (the content endpoint needs the session, so no plain link works). */
|
|
143
|
+
attachmentBlob(id) {
|
|
144
|
+
return this.api.get(`${D}/attachment/${enc(id)}/content`, null, { responseType: 'blob' }).pipe(map((b) => b));
|
|
145
|
+
}
|
|
146
|
+
deleteAttachment(id) {
|
|
147
|
+
return this.api.delete(`${D}/attachment/${enc(id)}`);
|
|
148
|
+
}
|
|
149
|
+
watchState(key) {
|
|
150
|
+
return this.api.get('v1/links/watch', { kind: 'issue', ref: key }).pipe(map((r) => data(r) ?? { watching: false, watchers: [] }));
|
|
151
|
+
}
|
|
152
|
+
watch(key, on) {
|
|
153
|
+
return on ? this.api.put('v1/links/watch', { kind: 'issue', ref: key }) : this.api.delete(`v1/links/watch?kind=issue&ref=${enc(key)}`);
|
|
154
|
+
}
|
|
155
|
+
development(key) {
|
|
156
|
+
return this.api.get(`v1/links/issue/${enc(key)}/development`).pipe(map((r) => data(r) ?? { branches: [], commits: [], pulls: [] }));
|
|
157
|
+
}
|
|
158
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: DesignApi, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
159
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: DesignApi, providedIn: 'root' }); }
|
|
160
|
+
}
|
|
161
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: DesignApi, decorators: [{
|
|
162
|
+
type: Injectable,
|
|
163
|
+
args: [{ providedIn: 'root' }]
|
|
164
|
+
}] });
|
|
165
|
+
|
|
166
|
+
// How Design draws things, as the prototype does (index/prototype: TYPE, STATUS, PRI), in the
|
|
167
|
+
// theme's tokens so dark mode follows.
|
|
168
|
+
const TYPES = ['story', 'task', 'bug', 'epic', 'subtask'];
|
|
169
|
+
const PRIORITIES = ['highest', 'high', 'medium', 'low', 'lowest'];
|
|
170
|
+
const TYPE_LABEL = { epic: 'Epic', story: 'Story', task: 'Task', bug: 'Bug', subtask: 'Sub-task' };
|
|
171
|
+
const TYPE_COLOUR = {
|
|
172
|
+
story: 'var(--o-ok)', task: 'var(--o-acc)', bug: 'var(--o-bad-solid)', epic: 'var(--o-vio)', subtask: 'var(--o-line-mid)',
|
|
173
|
+
};
|
|
174
|
+
function typeLabel(t) {
|
|
175
|
+
return TYPE_LABEL[t] ?? t;
|
|
176
|
+
}
|
|
177
|
+
function typeColour(t) {
|
|
178
|
+
return TYPE_COLOUR[t] ?? 'var(--o-line-mid)';
|
|
179
|
+
}
|
|
180
|
+
function priorityLabel(p) {
|
|
181
|
+
return p ? p[0].toUpperCase() + p.slice(1) : '';
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The chip class for a status: the prototype's To do grey, In progress blue, In review amber and
|
|
185
|
+
* Done green, by category, with review and blocked named apart.
|
|
186
|
+
*/
|
|
187
|
+
function statusChip(name, category) {
|
|
188
|
+
const n = (name || '').toLowerCase();
|
|
189
|
+
if (category === 'done')
|
|
190
|
+
return n.startsWith("won't") || n.startsWith('wont') ? 'o-chip' : 'o-chip o-grn';
|
|
191
|
+
if (category === 'in_progress') {
|
|
192
|
+
if (n.includes('review'))
|
|
193
|
+
return 'o-chip o-amb';
|
|
194
|
+
if (n.includes('block'))
|
|
195
|
+
return 'o-chip o-red';
|
|
196
|
+
return 'o-chip o-blu';
|
|
197
|
+
}
|
|
198
|
+
return 'o-chip';
|
|
199
|
+
}
|
|
200
|
+
/** Epic chips take the epic's colour when it has one, else a steady colour from its key. */
|
|
201
|
+
const EPIC_CHIPS = ['o-chip o-grn', 'o-chip o-vio', 'o-chip o-amb', 'o-chip o-blu', 'o-chip o-red'];
|
|
202
|
+
function epicChip(key) {
|
|
203
|
+
let h = 0;
|
|
204
|
+
for (const ch of key)
|
|
205
|
+
h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
|
206
|
+
return EPIC_CHIPS[h % EPIC_CHIPS.length];
|
|
207
|
+
}
|
|
208
|
+
function initials(name) {
|
|
209
|
+
const w = (name || '').split(/[\s@._-]+/).filter(Boolean);
|
|
210
|
+
return ((w[0]?.[0] ?? '?') + (w[1]?.[0] ?? '')).toUpperCase();
|
|
211
|
+
}
|
|
212
|
+
/** Names and initials by user id, from the host's people. */
|
|
213
|
+
class People {
|
|
214
|
+
constructor(people) {
|
|
215
|
+
this.byId = new Map(people.map((p) => [p.id, p]));
|
|
216
|
+
}
|
|
217
|
+
name(id) {
|
|
218
|
+
if (!id)
|
|
219
|
+
return 'Unassigned';
|
|
220
|
+
return this.byId.get(id)?.name || this.byId.get(id)?.email || 'Someone';
|
|
221
|
+
}
|
|
222
|
+
initials(id) {
|
|
223
|
+
return id ? initials(this.name(id)) : '–';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
227
|
+
/** "Nov 7" for a date (YYYY-MM-DD or ISO). */
|
|
228
|
+
function shortDate(d) {
|
|
229
|
+
if (!d)
|
|
230
|
+
return '';
|
|
231
|
+
const [y, m, day] = d.slice(0, 10).split('-').map(Number);
|
|
232
|
+
if (!y || !m || !day)
|
|
233
|
+
return '';
|
|
234
|
+
return `${MONTHS[m - 1]} ${day}`;
|
|
235
|
+
}
|
|
236
|
+
/** "Oct 27 – Nov 7". */
|
|
237
|
+
function dateRange(a, b) {
|
|
238
|
+
if (!a && !b)
|
|
239
|
+
return '';
|
|
240
|
+
return `${shortDate(a)} – ${shortDate(b)}`;
|
|
241
|
+
}
|
|
242
|
+
/** "just now", "5 min ago", "3 h ago", "Yesterday", "Oct 27". */
|
|
243
|
+
function ago(iso, now = Date.now()) {
|
|
244
|
+
if (!iso)
|
|
245
|
+
return '';
|
|
246
|
+
const t = Date.parse(iso);
|
|
247
|
+
if (Number.isNaN(t))
|
|
248
|
+
return '';
|
|
249
|
+
const s = Math.max(0, Math.round((now - t) / 1000));
|
|
250
|
+
if (s < 60)
|
|
251
|
+
return 'just now';
|
|
252
|
+
if (s < 3600)
|
|
253
|
+
return `${Math.floor(s / 60)} min ago`;
|
|
254
|
+
if (s < 86400)
|
|
255
|
+
return `${Math.floor(s / 3600)} h ago`;
|
|
256
|
+
if (s < 172800)
|
|
257
|
+
return 'Yesterday';
|
|
258
|
+
return shortDate(iso);
|
|
259
|
+
}
|
|
260
|
+
function points(p) {
|
|
261
|
+
return p === undefined || p === null ? '' : String(p);
|
|
262
|
+
}
|
|
263
|
+
/** True if the brief is in a Done-category status. */
|
|
264
|
+
function isDone(b) {
|
|
265
|
+
return b.category === 'done';
|
|
266
|
+
}
|
|
267
|
+
const FIELD_LABEL = {
|
|
268
|
+
title: 'the title', description: 'the description', status: 'the status', assignee: 'the assignee', priority: 'the priority',
|
|
269
|
+
labels: 'the labels', parent: 'the parent', points: 'the points', due: 'the due date', dueDate: 'the due date', sprint: 'the sprint', type: 'the type',
|
|
270
|
+
fields: 'the details', rank: 'the rank',
|
|
271
|
+
};
|
|
272
|
+
function shown(v, people, field) {
|
|
273
|
+
if (v === null || v === undefined || v === '')
|
|
274
|
+
return 'none';
|
|
275
|
+
if (field === 'assignee' || field === 'assigneeId')
|
|
276
|
+
return people.name(String(v));
|
|
277
|
+
if (Array.isArray(v))
|
|
278
|
+
return v.length ? v.join(', ') : 'none';
|
|
279
|
+
if (typeof v === 'object')
|
|
280
|
+
return v.name ?? v.key ?? v.title ?? JSON.stringify(v);
|
|
281
|
+
if (field === 'priority' || field === 'type')
|
|
282
|
+
return field === 'type' ? typeLabel(String(v)) : priorityLabel(String(v));
|
|
283
|
+
return String(v);
|
|
284
|
+
}
|
|
285
|
+
/** One history entry as a sentence: "Maya Kim changed the status from To do to In progress". */
|
|
286
|
+
function historyLine(h, people) {
|
|
287
|
+
const who = people.name(h.actorId);
|
|
288
|
+
switch (h.kind) {
|
|
289
|
+
case 'created':
|
|
290
|
+
return `${who} created the issue`;
|
|
291
|
+
case 'changed': {
|
|
292
|
+
const f = h.field ?? '';
|
|
293
|
+
if (f === 'description' || f === 'fields')
|
|
294
|
+
return `${who} edited ${FIELD_LABEL[f]}`;
|
|
295
|
+
return `${who} changed ${FIELD_LABEL[f] ?? f} from ${shown(h.old, people, f)} to ${shown(h.new, people, f)}`;
|
|
296
|
+
}
|
|
297
|
+
case 'ranked':
|
|
298
|
+
return `${who} reordered the issue`;
|
|
299
|
+
case 'commented':
|
|
300
|
+
return `${who} commented`;
|
|
301
|
+
case 'comment_deleted':
|
|
302
|
+
return `${who} deleted a comment`;
|
|
303
|
+
case 'related':
|
|
304
|
+
return `${who} linked ${shown(h.new, people)}`;
|
|
305
|
+
case 'unrelated':
|
|
306
|
+
return `${who} removed the link to ${shown(h.old, people)}`;
|
|
307
|
+
case 'closed_by':
|
|
308
|
+
return `${who} closed it by merging ${shown(h.new, people)}`;
|
|
309
|
+
case 'attached':
|
|
310
|
+
return `${who} attached ${typeof h.new === 'string' ? h.new : 'a file'}`;
|
|
311
|
+
case 'attachment_deleted':
|
|
312
|
+
return `${who} deleted the attachment ${typeof h.old === 'string' ? h.old : ''}`.trim();
|
|
313
|
+
default:
|
|
314
|
+
return `${who} ${h.kind.replace(/_/g, ' ')}`;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** A file download of text the API returned. */
|
|
318
|
+
function download(body, name, type) {
|
|
319
|
+
const blob = new Blob([body], { type });
|
|
320
|
+
const a = document.createElement('a');
|
|
321
|
+
a.href = URL.createObjectURL(blob);
|
|
322
|
+
a.download = name;
|
|
323
|
+
a.click();
|
|
324
|
+
URL.revokeObjectURL(a.href);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// "Create issue", as the prototype's dialog (modal.kind == 'issue'): a type, a title (the only
|
|
328
|
+
// required field), assignee, priority and sprint. Opened by the board's column "+", the backlog and
|
|
329
|
+
// the list, and by C anywhere on a Design screen.
|
|
330
|
+
class CreateIssueDialog {
|
|
331
|
+
constructor() {
|
|
332
|
+
this.host = inject(DesignHost);
|
|
333
|
+
this.api = inject(DesignApi);
|
|
334
|
+
/** The status to create it in (a board column), else the project's first. */
|
|
335
|
+
this.statusId = input('', ...(ngDevMode ? [{ debugName: "statusId" }] : /* istanbul ignore next */ []));
|
|
336
|
+
/** Whether the project has an active sprint (else new issues go to the backlog). */
|
|
337
|
+
this.hasActive = input(false, ...(ngDevMode ? [{ debugName: "hasActive" }] : /* istanbul ignore next */ []));
|
|
338
|
+
/** Where it goes by default: 'active' or '' (the backlog). */
|
|
339
|
+
this.defaultSprint = input('active', ...(ngDevMode ? [{ debugName: "defaultSprint" }] : /* istanbul ignore next */ []));
|
|
340
|
+
this.created = output();
|
|
341
|
+
this.cancel = output();
|
|
342
|
+
this.types = ['story', 'task', 'bug', 'epic'];
|
|
343
|
+
this.priorities = PRIORITIES;
|
|
344
|
+
this.typeLabel = typeLabel;
|
|
345
|
+
this.priorityLabel = priorityLabel;
|
|
346
|
+
this.type = signal('story', ...(ngDevMode ? [{ debugName: "type" }] : /* istanbul ignore next */ []));
|
|
347
|
+
this.title = '';
|
|
348
|
+
this.assignee = '';
|
|
349
|
+
this.priority = 'medium';
|
|
350
|
+
this.sprint = 'active';
|
|
351
|
+
this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
352
|
+
this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
353
|
+
this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
|
|
354
|
+
this.titleEl = viewChild('titleEl', ...(ngDevMode ? [{ debugName: "titleEl" }] : /* istanbul ignore next */ []));
|
|
355
|
+
}
|
|
356
|
+
ngOnInit() {
|
|
357
|
+
this.assignee = this.host.meId();
|
|
358
|
+
this.sprint = this.hasActive() ? this.defaultSprint() : '';
|
|
359
|
+
queueMicrotask(() => this.titleEl()?.nativeElement.focus());
|
|
360
|
+
}
|
|
361
|
+
submit() {
|
|
362
|
+
const p = this.host.project();
|
|
363
|
+
const title = this.title.trim();
|
|
364
|
+
if (!p || !title || this.busy())
|
|
365
|
+
return;
|
|
366
|
+
this.busy.set(true);
|
|
367
|
+
this.error.set('');
|
|
368
|
+
const sprint = this.type() === 'epic' ? '' : this.sprint;
|
|
369
|
+
this.api
|
|
370
|
+
.create(p.id, {
|
|
371
|
+
title, type: this.type(), priority: this.priority,
|
|
372
|
+
...(this.assignee ? { assigneeId: this.assignee } : {}),
|
|
373
|
+
...(this.statusId() ? { statusId: this.statusId() } : {}),
|
|
374
|
+
...(sprint ? { sprintId: sprint } : {}),
|
|
375
|
+
})
|
|
376
|
+
.subscribe({
|
|
377
|
+
next: (is) => { this.busy.set(false); this.created.emit(is); },
|
|
378
|
+
error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not create the issue.')); },
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: CreateIssueDialog, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
382
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: CreateIssueDialog, isStandalone: true, selector: "o-create-issue", inputs: { statusId: { classPropertyName: "statusId", publicName: "statusId", isSignal: true, isRequired: false, transformFunction: null }, hasActive: { classPropertyName: "hasActive", publicName: "hasActive", isSignal: true, isRequired: false, transformFunction: null }, defaultSprint: { classPropertyName: "defaultSprint", publicName: "defaultSprint", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { created: "created", cancel: "cancel" }, viewQueries: [{ propertyName: "titleEl", first: true, predicate: ["titleEl"], descendants: true, isSignal: true }], ngImport: i0, template: `
|
|
383
|
+
<div class="o-overlay o-mid" (click)="cancel.emit()">
|
|
384
|
+
<form class="o-dlg o-wide" role="dialog" aria-modal="true" aria-labelledby="ni-title" (click)="$event.stopPropagation()" (ngSubmit)="submit()" (keydown.escape)="cancel.emit()" novalidate>
|
|
385
|
+
<div class="o-dlg-h"><b id="ni-title">Create issue</b></div>
|
|
386
|
+
<div class="o-dlg-b">
|
|
387
|
+
<div class="o-seg" role="group" aria-label="Type" style="align-self:flex-start">
|
|
388
|
+
@for (t of types; track t) {
|
|
389
|
+
<button type="button" [class.o-on]="type() === t" [attr.aria-pressed]="type() === t" (click)="type.set(t)">{{ typeLabel(t) }}</button>
|
|
390
|
+
}
|
|
391
|
+
</div>
|
|
392
|
+
<div class="o-fld">
|
|
393
|
+
<label for="ni-t">Title <span class="o-faint">(the only required field)</span></label>
|
|
394
|
+
<input #titleEl id="ni-t" class="o-in" name="title" [(ngModel)]="title" placeholder="What needs to be done?" autocomplete="off" data-testid="new-issue-title" />
|
|
395
|
+
</div>
|
|
396
|
+
<div class="o-g3">
|
|
397
|
+
<div class="o-fld">
|
|
398
|
+
<label for="ni-who">Assignee</label>
|
|
399
|
+
<select id="ni-who" class="o-in" name="who" [(ngModel)]="assignee">
|
|
400
|
+
<option value="">Unassigned</option>
|
|
401
|
+
@for (p of host.people(); track p.id) {<option [value]="p.id">{{ p.name || p.email }}{{ p.id === host.meId() ? ' (me)' : '' }}</option>}
|
|
402
|
+
</select>
|
|
403
|
+
</div>
|
|
404
|
+
<div class="o-fld">
|
|
405
|
+
<label for="ni-pri">Priority</label>
|
|
406
|
+
<select id="ni-pri" class="o-in" name="pri" [(ngModel)]="priority">
|
|
407
|
+
@for (p of priorities; track p) {<option [value]="p">{{ priorityLabel(p) }}</option>}
|
|
408
|
+
</select>
|
|
409
|
+
</div>
|
|
410
|
+
<div class="o-fld">
|
|
411
|
+
<label for="ni-sp">Sprint</label>
|
|
412
|
+
<select id="ni-sp" class="o-in" name="sprint" [(ngModel)]="sprint">
|
|
413
|
+
<option value="active" [disabled]="!hasActive()">Active sprint</option>
|
|
414
|
+
<option value="">Backlog</option>
|
|
415
|
+
</select>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
419
|
+
<span class="o-hint">Tip: press C anywhere in Design to open this.</span>
|
|
420
|
+
</div>
|
|
421
|
+
<div class="o-dlg-f">
|
|
422
|
+
<button type="button" class="o-btn" (click)="cancel.emit()">Cancel</button>
|
|
423
|
+
<button type="submit" class="o-btn o-dark" [disabled]="busy() || !title.trim()" data-testid="new-issue-create">Create</button>
|
|
424
|
+
</div>
|
|
425
|
+
</form>
|
|
426
|
+
</div>
|
|
427
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
428
|
+
}
|
|
429
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: CreateIssueDialog, decorators: [{
|
|
430
|
+
type: Component,
|
|
431
|
+
args: [{
|
|
432
|
+
selector: 'o-create-issue',
|
|
433
|
+
imports: [FormsModule],
|
|
434
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
435
|
+
template: `
|
|
436
|
+
<div class="o-overlay o-mid" (click)="cancel.emit()">
|
|
437
|
+
<form class="o-dlg o-wide" role="dialog" aria-modal="true" aria-labelledby="ni-title" (click)="$event.stopPropagation()" (ngSubmit)="submit()" (keydown.escape)="cancel.emit()" novalidate>
|
|
438
|
+
<div class="o-dlg-h"><b id="ni-title">Create issue</b></div>
|
|
439
|
+
<div class="o-dlg-b">
|
|
440
|
+
<div class="o-seg" role="group" aria-label="Type" style="align-self:flex-start">
|
|
441
|
+
@for (t of types; track t) {
|
|
442
|
+
<button type="button" [class.o-on]="type() === t" [attr.aria-pressed]="type() === t" (click)="type.set(t)">{{ typeLabel(t) }}</button>
|
|
443
|
+
}
|
|
444
|
+
</div>
|
|
445
|
+
<div class="o-fld">
|
|
446
|
+
<label for="ni-t">Title <span class="o-faint">(the only required field)</span></label>
|
|
447
|
+
<input #titleEl id="ni-t" class="o-in" name="title" [(ngModel)]="title" placeholder="What needs to be done?" autocomplete="off" data-testid="new-issue-title" />
|
|
448
|
+
</div>
|
|
449
|
+
<div class="o-g3">
|
|
450
|
+
<div class="o-fld">
|
|
451
|
+
<label for="ni-who">Assignee</label>
|
|
452
|
+
<select id="ni-who" class="o-in" name="who" [(ngModel)]="assignee">
|
|
453
|
+
<option value="">Unassigned</option>
|
|
454
|
+
@for (p of host.people(); track p.id) {<option [value]="p.id">{{ p.name || p.email }}{{ p.id === host.meId() ? ' (me)' : '' }}</option>}
|
|
455
|
+
</select>
|
|
456
|
+
</div>
|
|
457
|
+
<div class="o-fld">
|
|
458
|
+
<label for="ni-pri">Priority</label>
|
|
459
|
+
<select id="ni-pri" class="o-in" name="pri" [(ngModel)]="priority">
|
|
460
|
+
@for (p of priorities; track p) {<option [value]="p">{{ priorityLabel(p) }}</option>}
|
|
461
|
+
</select>
|
|
462
|
+
</div>
|
|
463
|
+
<div class="o-fld">
|
|
464
|
+
<label for="ni-sp">Sprint</label>
|
|
465
|
+
<select id="ni-sp" class="o-in" name="sprint" [(ngModel)]="sprint">
|
|
466
|
+
<option value="active" [disabled]="!hasActive()">Active sprint</option>
|
|
467
|
+
<option value="">Backlog</option>
|
|
468
|
+
</select>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
472
|
+
<span class="o-hint">Tip: press C anywhere in Design to open this.</span>
|
|
473
|
+
</div>
|
|
474
|
+
<div class="o-dlg-f">
|
|
475
|
+
<button type="button" class="o-btn" (click)="cancel.emit()">Cancel</button>
|
|
476
|
+
<button type="submit" class="o-btn o-dark" [disabled]="busy() || !title.trim()" data-testid="new-issue-create">Create</button>
|
|
477
|
+
</div>
|
|
478
|
+
</form>
|
|
479
|
+
</div>
|
|
480
|
+
`,
|
|
481
|
+
}]
|
|
482
|
+
}], propDecorators: { statusId: [{ type: i0.Input, args: [{ isSignal: true, alias: "statusId", required: false }] }], hasActive: [{ type: i0.Input, args: [{ isSignal: true, alias: "hasActive", required: false }] }], defaultSprint: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultSprint", required: false }] }], created: [{ type: i0.Output, args: ["created"] }], cancel: [{ type: i0.Output, args: ["cancel"] }], titleEl: [{ type: i0.ViewChild, args: ['titleEl', { isSignal: true }] }] } });
|
|
483
|
+
|
|
484
|
+
// The board's side panel (prototype "design", aside): the selected card's status, people and
|
|
485
|
+
// numbers, what code names it (links' Development panel) and its history. The status select is the
|
|
486
|
+
// keyboard path for a move (NFR-12).
|
|
487
|
+
class IssuePanel {
|
|
488
|
+
constructor() {
|
|
489
|
+
this.host = inject(DesignHost);
|
|
490
|
+
this.api = inject(DesignApi);
|
|
491
|
+
this.key = input.required(...(ngDevMode ? [{ debugName: "key" }] : /* istanbul ignore next */ []));
|
|
492
|
+
this.statuses = input([], ...(ngDevMode ? [{ debugName: "statuses" }] : /* istanbul ignore next */ []));
|
|
493
|
+
this.sprintNames = input({}, ...(ngDevMode ? [{ debugName: "sprintNames" }] : /* istanbul ignore next */ []));
|
|
494
|
+
/** Bumped by the board when the card changed under the panel (a drag), to reload it. */
|
|
495
|
+
this.version = input(0, ...(ngDevMode ? [{ debugName: "version" }] : /* istanbul ignore next */ []));
|
|
496
|
+
this.changed = output();
|
|
497
|
+
this.closed = output();
|
|
498
|
+
this.issue = signal(null, ...(ngDevMode ? [{ debugName: "issue" }] : /* istanbul ignore next */ []));
|
|
499
|
+
this.history = signal([], ...(ngDevMode ? [{ debugName: "history" }] : /* istanbul ignore next */ []));
|
|
500
|
+
this.dev = signal(null, ...(ngDevMode ? [{ debugName: "dev" }] : /* istanbul ignore next */ []));
|
|
501
|
+
this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
502
|
+
this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
503
|
+
this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
|
|
504
|
+
this.canEdit = computed(() => (this.issue()?.capabilities ?? []).includes('issues.edit'), ...(ngDevMode ? [{ debugName: "canEdit" }] : /* istanbul ignore next */ []));
|
|
505
|
+
this.sprintName = computed(() => {
|
|
506
|
+
const id = this.issue()?.sprintId;
|
|
507
|
+
return id ? this.sprintNames()[id] ?? 'A sprint' : 'Backlog';
|
|
508
|
+
}, ...(ngDevMode ? [{ debugName: "sprintName" }] : /* istanbul ignore next */ []));
|
|
509
|
+
this.typeColour = typeColour;
|
|
510
|
+
this.typeLabel = typeLabel;
|
|
511
|
+
this.priorityLabel = priorityLabel;
|
|
512
|
+
this.ago = (iso) => ago(iso);
|
|
513
|
+
effect(() => {
|
|
514
|
+
const key = this.key();
|
|
515
|
+
this.version();
|
|
516
|
+
untracked(() => this.load(key));
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
line(h) {
|
|
520
|
+
return historyLine(h, this.people());
|
|
521
|
+
}
|
|
522
|
+
shas(d) {
|
|
523
|
+
return d.commits.slice(0, 3).map((c) => String(c.meta['sha'] ?? c.ref).slice(0, 7)).join(' · ');
|
|
524
|
+
}
|
|
525
|
+
prLabel(state) {
|
|
526
|
+
return state === 'merged' ? 'Merged' : state === 'closed' ? 'Closed' : 'Open';
|
|
527
|
+
}
|
|
528
|
+
prChip(state) {
|
|
529
|
+
return state === 'merged' ? 'o-chip o-vio' : state === 'closed' ? 'o-chip' : 'o-chip o-blu';
|
|
530
|
+
}
|
|
531
|
+
setStatus(statusId) {
|
|
532
|
+
const is = this.issue();
|
|
533
|
+
if (!is || statusId === is.statusId)
|
|
534
|
+
return;
|
|
535
|
+
this.busy.set(true);
|
|
536
|
+
this.error.set('');
|
|
537
|
+
this.api.move(is.key, statusId).subscribe({
|
|
538
|
+
next: (n) => { this.busy.set(false); this.issue.set(n); this.changed.emit(n); this.reloadHistory(n.key); },
|
|
539
|
+
error: (e) => { this.busy.set(false); this.error.set(isConflict(e) ? 'Someone changed this issue first. Showing the latest.' : apiMessage(e, 'Could not move the issue.')); this.load(is.key); },
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
load(key) {
|
|
543
|
+
this.issue.set(null);
|
|
544
|
+
this.error.set('');
|
|
545
|
+
this.dev.set(null);
|
|
546
|
+
forkJoin({ is: this.api.get(key), h: this.api.history(key) }).subscribe({
|
|
547
|
+
next: ({ is, h }) => { this.issue.set(is); this.history.set([...h].reverse().slice(0, 8)); },
|
|
548
|
+
error: (e) => this.error.set(apiMessage(e, `Could not open ${key}.`)),
|
|
549
|
+
});
|
|
550
|
+
this.api.development(key).subscribe({ next: (d) => this.dev.set(d), error: () => this.dev.set({ branches: [], commits: [], pulls: [] }) });
|
|
551
|
+
}
|
|
552
|
+
reloadHistory(key) {
|
|
553
|
+
this.api.history(key).subscribe({ next: (h) => this.history.set([...h].reverse().slice(0, 8)), error: () => undefined });
|
|
554
|
+
}
|
|
555
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: IssuePanel, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
556
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: IssuePanel, isStandalone: true, selector: "o-issue-panel", inputs: { key: { classPropertyName: "key", publicName: "key", isSignal: true, isRequired: true, transformFunction: null }, statuses: { classPropertyName: "statuses", publicName: "statuses", isSignal: true, isRequired: false, transformFunction: null }, sprintNames: { classPropertyName: "sprintNames", publicName: "sprintNames", isSignal: true, isRequired: false, transformFunction: null }, version: { classPropertyName: "version", publicName: "version", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { changed: "changed", closed: "closed" }, host: { attributes: { "role": "complementary" }, properties: { "attr.aria-label": "\"Issue \" + key()" } }, ngImport: i0, template: `
|
|
557
|
+
@if (issue(); as is) {
|
|
558
|
+
<div class="o-flex">
|
|
559
|
+
<span class="o-sq" [style.background]="typeColour(is.type)" aria-hidden="true"></span>
|
|
560
|
+
<span class="o-mono o-mut" style="font-size:12px">{{ is.key }} · {{ typeLabel(is.type) }}</span>
|
|
561
|
+
<a class="o-lnk o-r" [routerLink]="host.link('issues/' + is.key)">Open full page</a>
|
|
562
|
+
<button type="button" class="o-btn o-ghost o-xs" (click)="closed.emit()">Close</button>
|
|
563
|
+
</div>
|
|
564
|
+
<h2 style="margin:0;font-size:18px;font-weight:600;line-height:1.3;text-wrap:pretty">{{ is.title }}</h2>
|
|
565
|
+
<div class="o-kv" style="grid-template-columns:96px 1fr;row-gap:8px">
|
|
566
|
+
<label class="o-k" for="ip-status">Status</label>
|
|
567
|
+
<select id="ip-status" class="o-in o-sm" [value]="is.statusId" [disabled]="!canEdit() || busy()" (change)="setStatus($any($event.target).value)" data-testid="panel-status">
|
|
568
|
+
@for (s of statuses(); track s.id) {<option [value]="s.id" [selected]="s.id === is.statusId">{{ s.name }}</option>}
|
|
569
|
+
</select>
|
|
570
|
+
<span class="o-k">Assignee</span><span class="o-flex"><span class="o-av" aria-hidden="true">{{ people().initials(is.assigneeId) }}</span>{{ people().name(is.assigneeId) }}</span>
|
|
571
|
+
<span class="o-k">Priority</span><span>{{ priorityLabel(is.priority) }}</span>
|
|
572
|
+
<span class="o-k">Points</span><span class="o-mono">{{ is.points ?? '–' }}</span>
|
|
573
|
+
<span class="o-k">Sprint</span><span>{{ sprintName() }}</span>
|
|
574
|
+
</div>
|
|
575
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
576
|
+
|
|
577
|
+
@if (dev(); as d) {
|
|
578
|
+
@if (d.branches.length || d.pulls.length || d.commits.length) {
|
|
579
|
+
<div class="o-col" style="gap:10px">
|
|
580
|
+
<span class="o-lbl">Development</span>
|
|
581
|
+
<div class="o-card">
|
|
582
|
+
@for (b of d.branches; track b.ref) {<div class="o-row"><span class="o-faint" style="width:60px">Branch</span><span class="o-mono o-sm o-ell">{{ b.title || b.ref }}</span><span class="o-r o-faint o-xs">{{ b.repo }}</span></div>}
|
|
583
|
+
@for (p of d.pulls; track p.ref) {
|
|
584
|
+
<a class="o-row o-click" [routerLink]="host.link('code/' + p.repo + '/pull/' + (p.meta['number'] ?? p.ref))"><span class="o-faint" style="width:60px">PR</span><span class="o-ell">#{{ p.meta['number'] ?? p.ref }} {{ p.title }}</span><span class="o-r" [class]="prChip(p.meta['state'])">{{ prLabel(p.meta['state']) }}</span></a>
|
|
585
|
+
}
|
|
586
|
+
@if (d.commits.length) {<div class="o-row"><span class="o-faint" style="width:60px">Commits</span><span class="o-mono o-sm o-ell">{{ shas(d) }}</span></div>}
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
} @else {
|
|
590
|
+
<span class="o-faint o-lh">No linked branches, PRs or deployments yet. Put {{ is.key }} in a branch name or PR title to link it.</span>
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
<div class="o-col" style="margin-top:auto;border-top:1px solid var(--o-line-soft);padding-top:14px;gap:6px">
|
|
595
|
+
<span class="o-lbl">History</span>
|
|
596
|
+
@for (h of history(); track h.id) {<span class="o-mut o-sm">{{ line(h) }} · {{ ago(h.createdAt) }}</span>}
|
|
597
|
+
</div>
|
|
598
|
+
} @else if (error()) {
|
|
599
|
+
<div class="o-flex"><b>{{ key() }}</b><button type="button" class="o-btn o-ghost o-xs o-r" (click)="closed.emit()">Close</button></div>
|
|
600
|
+
<div class="o-note o-red" role="alert">{{ error() }}</div>
|
|
601
|
+
} @else {
|
|
602
|
+
<div class="o-empty">Loading {{ key() }}…</div>
|
|
603
|
+
}
|
|
604
|
+
`, isInline: true, styles: [":host{width:400px;flex:none;background:var(--o-surface);border-left:1px solid var(--o-line);padding:20px;display:flex;flex-direction:column;gap:18px;position:sticky;top:0;align-self:flex-start;max-height:calc(100vh - var(--o-topbar-h, 52px));overflow:auto}@media(max-width:1000px){:host{position:fixed;inset:auto 0 0;width:auto;max-height:70vh;border-left:none;border-top:1px solid var(--o-line);box-shadow:var(--o-shadow-dialog);z-index:var(--o-z-drawer, 60)}}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
605
|
+
}
|
|
606
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: IssuePanel, decorators: [{
|
|
607
|
+
type: Component,
|
|
608
|
+
args: [{ selector: 'o-issue-panel', imports: [RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, host: { role: 'complementary', '[attr.aria-label]': '"Issue " + key()' }, template: `
|
|
609
|
+
@if (issue(); as is) {
|
|
610
|
+
<div class="o-flex">
|
|
611
|
+
<span class="o-sq" [style.background]="typeColour(is.type)" aria-hidden="true"></span>
|
|
612
|
+
<span class="o-mono o-mut" style="font-size:12px">{{ is.key }} · {{ typeLabel(is.type) }}</span>
|
|
613
|
+
<a class="o-lnk o-r" [routerLink]="host.link('issues/' + is.key)">Open full page</a>
|
|
614
|
+
<button type="button" class="o-btn o-ghost o-xs" (click)="closed.emit()">Close</button>
|
|
615
|
+
</div>
|
|
616
|
+
<h2 style="margin:0;font-size:18px;font-weight:600;line-height:1.3;text-wrap:pretty">{{ is.title }}</h2>
|
|
617
|
+
<div class="o-kv" style="grid-template-columns:96px 1fr;row-gap:8px">
|
|
618
|
+
<label class="o-k" for="ip-status">Status</label>
|
|
619
|
+
<select id="ip-status" class="o-in o-sm" [value]="is.statusId" [disabled]="!canEdit() || busy()" (change)="setStatus($any($event.target).value)" data-testid="panel-status">
|
|
620
|
+
@for (s of statuses(); track s.id) {<option [value]="s.id" [selected]="s.id === is.statusId">{{ s.name }}</option>}
|
|
621
|
+
</select>
|
|
622
|
+
<span class="o-k">Assignee</span><span class="o-flex"><span class="o-av" aria-hidden="true">{{ people().initials(is.assigneeId) }}</span>{{ people().name(is.assigneeId) }}</span>
|
|
623
|
+
<span class="o-k">Priority</span><span>{{ priorityLabel(is.priority) }}</span>
|
|
624
|
+
<span class="o-k">Points</span><span class="o-mono">{{ is.points ?? '–' }}</span>
|
|
625
|
+
<span class="o-k">Sprint</span><span>{{ sprintName() }}</span>
|
|
626
|
+
</div>
|
|
627
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
628
|
+
|
|
629
|
+
@if (dev(); as d) {
|
|
630
|
+
@if (d.branches.length || d.pulls.length || d.commits.length) {
|
|
631
|
+
<div class="o-col" style="gap:10px">
|
|
632
|
+
<span class="o-lbl">Development</span>
|
|
633
|
+
<div class="o-card">
|
|
634
|
+
@for (b of d.branches; track b.ref) {<div class="o-row"><span class="o-faint" style="width:60px">Branch</span><span class="o-mono o-sm o-ell">{{ b.title || b.ref }}</span><span class="o-r o-faint o-xs">{{ b.repo }}</span></div>}
|
|
635
|
+
@for (p of d.pulls; track p.ref) {
|
|
636
|
+
<a class="o-row o-click" [routerLink]="host.link('code/' + p.repo + '/pull/' + (p.meta['number'] ?? p.ref))"><span class="o-faint" style="width:60px">PR</span><span class="o-ell">#{{ p.meta['number'] ?? p.ref }} {{ p.title }}</span><span class="o-r" [class]="prChip(p.meta['state'])">{{ prLabel(p.meta['state']) }}</span></a>
|
|
637
|
+
}
|
|
638
|
+
@if (d.commits.length) {<div class="o-row"><span class="o-faint" style="width:60px">Commits</span><span class="o-mono o-sm o-ell">{{ shas(d) }}</span></div>}
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
} @else {
|
|
642
|
+
<span class="o-faint o-lh">No linked branches, PRs or deployments yet. Put {{ is.key }} in a branch name or PR title to link it.</span>
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
<div class="o-col" style="margin-top:auto;border-top:1px solid var(--o-line-soft);padding-top:14px;gap:6px">
|
|
647
|
+
<span class="o-lbl">History</span>
|
|
648
|
+
@for (h of history(); track h.id) {<span class="o-mut o-sm">{{ line(h) }} · {{ ago(h.createdAt) }}</span>}
|
|
649
|
+
</div>
|
|
650
|
+
} @else if (error()) {
|
|
651
|
+
<div class="o-flex"><b>{{ key() }}</b><button type="button" class="o-btn o-ghost o-xs o-r" (click)="closed.emit()">Close</button></div>
|
|
652
|
+
<div class="o-note o-red" role="alert">{{ error() }}</div>
|
|
653
|
+
} @else {
|
|
654
|
+
<div class="o-empty">Loading {{ key() }}…</div>
|
|
655
|
+
}
|
|
656
|
+
`, styles: [":host{width:400px;flex:none;background:var(--o-surface);border-left:1px solid var(--o-line);padding:20px;display:flex;flex-direction:column;gap:18px;position:sticky;top:0;align-self:flex-start;max-height:calc(100vh - var(--o-topbar-h, 52px));overflow:auto}@media(max-width:1000px){:host{position:fixed;inset:auto 0 0;width:auto;max-height:70vh;border-left:none;border-top:1px solid var(--o-line);box-shadow:var(--o-shadow-dialog);z-index:var(--o-z-drawer, 60)}}\n"] }]
|
|
657
|
+
}], ctorParameters: () => [], propDecorators: { key: [{ type: i0.Input, args: [{ isSignal: true, alias: "key", required: true }] }], statuses: [{ type: i0.Input, args: [{ isSignal: true, alias: "statuses", required: false }] }], sprintNames: [{ type: i0.Input, args: [{ isSignal: true, alias: "sprintNames", required: false }] }], version: [{ type: i0.Input, args: [{ isSignal: true, alias: "version", required: false }] }], changed: [{ type: i0.Output, args: ["changed"] }], closed: [{ type: i0.Output, args: ["closed"] }] } });
|
|
658
|
+
|
|
659
|
+
// /{org}/{project}/board (prototype "design"): the active sprint's columns, one per status the board
|
|
660
|
+
// shows, with WIP limits, swimlanes and quick filters; drag a card to another column, or open it and
|
|
661
|
+
// change its status in the side panel.
|
|
662
|
+
class BoardPage {
|
|
663
|
+
constructor() {
|
|
664
|
+
this.host = inject(DesignHost);
|
|
665
|
+
this.api = inject(DesignApi);
|
|
666
|
+
this.board = signal(null, ...(ngDevMode ? [{ debugName: "board" }] : /* istanbul ignore next */ []));
|
|
667
|
+
this.statuses = signal([], ...(ngDevMode ? [{ debugName: "statuses" }] : /* istanbul ignore next */ []));
|
|
668
|
+
this.sprints = signal([], ...(ngDevMode ? [{ debugName: "sprints" }] : /* istanbul ignore next */ []));
|
|
669
|
+
this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
670
|
+
this.note = signal('', ...(ngDevMode ? [{ debugName: "note" }] : /* istanbul ignore next */ []));
|
|
671
|
+
this.lane = signal('', ...(ngDevMode ? [{ debugName: "lane" }] : /* istanbul ignore next */ []));
|
|
672
|
+
this.quick = signal(new Set(), ...(ngDevMode ? [{ debugName: "quick" }] : /* istanbul ignore next */ []));
|
|
673
|
+
this.selected = signal('', ...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
674
|
+
this.over = signal('', ...(ngDevMode ? [{ debugName: "over" }] : /* istanbul ignore next */ []));
|
|
675
|
+
this.createIn = signal(null, ...(ngDevMode ? [{ debugName: "createIn" }] : /* istanbul ignore next */ []));
|
|
676
|
+
this.completing = signal(false, ...(ngDevMode ? [{ debugName: "completing" }] : /* istanbul ignore next */ []));
|
|
677
|
+
this.moveTo = signal('next', ...(ngDevMode ? [{ debugName: "moveTo" }] : /* istanbul ignore next */ []));
|
|
678
|
+
this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
679
|
+
this.panelVersion = signal(0, ...(ngDevMode ? [{ debugName: "panelVersion" }] : /* istanbul ignore next */ []));
|
|
680
|
+
this.dragKey = '';
|
|
681
|
+
this.typeColour = typeColour;
|
|
682
|
+
this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
|
|
683
|
+
this.canEdit = computed(() => {
|
|
684
|
+
const p = this.host.project();
|
|
685
|
+
return !!p && !p.archivedAt && p.capabilities.includes('issues.edit');
|
|
686
|
+
}, ...(ngDevMode ? [{ debugName: "canEdit" }] : /* istanbul ignore next */ []));
|
|
687
|
+
this.sprintNames = computed(() => Object.fromEntries(this.sprints().map((s) => [s.id, s.name])), ...(ngDevMode ? [{ debugName: "sprintNames" }] : /* istanbul ignore next */ []));
|
|
688
|
+
this.eyebrow = computed(() => {
|
|
689
|
+
const s = this.board()?.sprint;
|
|
690
|
+
if (!s)
|
|
691
|
+
return `${this.host.project()?.key ?? ''} · no active sprint · every open issue`;
|
|
692
|
+
const d = dateRange(s.startsOn, s.endsOn).toUpperCase();
|
|
693
|
+
return `${s.name.toUpperCase()}${d ? ' · ' + d : ''}`;
|
|
694
|
+
}, ...(ngDevMode ? [{ debugName: "eyebrow" }] : /* istanbul ignore next */ []));
|
|
695
|
+
this.wipNote = computed(() => {
|
|
696
|
+
const cols = (this.board()?.columns ?? []).filter((c) => c.wipLimit > 0);
|
|
697
|
+
return cols.length ? ' ' + cols.map((c) => `${c.name} has a WIP limit of ${c.wipLimit}.`).join(' ') : '';
|
|
698
|
+
}, ...(ngDevMode ? [{ debugName: "wipNote" }] : /* istanbul ignore next */ []));
|
|
699
|
+
this.unfinished = computed(() => {
|
|
700
|
+
const s = this.board()?.sprint;
|
|
701
|
+
return s ? s.issues - s.done : 0;
|
|
702
|
+
}, ...(ngDevMode ? [{ debugName: "unfinished" }] : /* istanbul ignore next */ []));
|
|
703
|
+
this.completeSummary = computed(() => {
|
|
704
|
+
const s = this.board()?.sprint;
|
|
705
|
+
if (!s)
|
|
706
|
+
return '';
|
|
707
|
+
const n = this.unfinished();
|
|
708
|
+
return `${s.done} of ${s.issues} issues done (${s.donePoints} of ${s.points} points).${n ? ` ${n} not finished: where should they go?` : ''}`;
|
|
709
|
+
}, ...(ngDevMode ? [{ debugName: "completeSummary" }] : /* istanbul ignore next */ []));
|
|
710
|
+
this.nextLabel = computed(() => {
|
|
711
|
+
const next = this.sprints().find((s) => s.state === 'planned');
|
|
712
|
+
return next ? `${next.name}${next.startsOn ? ' · ' + dateRange(next.startsOn, next.endsOn) : ''}` : 'A new sprint of the same length, from tomorrow';
|
|
713
|
+
}, ...(ngDevMode ? [{ debugName: "nextLabel" }] : /* istanbul ignore next */ []));
|
|
714
|
+
effect(() => {
|
|
715
|
+
const p = this.host.project();
|
|
716
|
+
if (p)
|
|
717
|
+
untracked(() => this.loadAll());
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
/** C opens Create issue (prototype: "Tip: press C anywhere"). */
|
|
721
|
+
key(e) {
|
|
722
|
+
const t = e.target;
|
|
723
|
+
if (e.key !== 'c' || e.metaKey || e.ctrlKey || e.altKey || t?.closest('input,textarea,select,[contenteditable]'))
|
|
724
|
+
return;
|
|
725
|
+
if (this.canEdit() && this.createIn() === null) {
|
|
726
|
+
e.preventDefault();
|
|
727
|
+
this.createIn.set('');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
countText(c) {
|
|
731
|
+
return c.wipLimit > 0 ? `${c.count}/${c.wipLimit}` : String(c.count);
|
|
732
|
+
}
|
|
733
|
+
laneAv(key, name) {
|
|
734
|
+
const lane = this.board()?.swimlane;
|
|
735
|
+
if (lane === 'assignee')
|
|
736
|
+
return key && key !== 'none' ? this.people().initials(key) : '–';
|
|
737
|
+
return name ? name[0].toUpperCase() : '·';
|
|
738
|
+
}
|
|
739
|
+
flip(q) {
|
|
740
|
+
const s = new Set(this.quick());
|
|
741
|
+
s.has(q) ? s.delete(q) : s.add(q);
|
|
742
|
+
this.quick.set(s);
|
|
743
|
+
this.reload();
|
|
744
|
+
}
|
|
745
|
+
setLane(l) {
|
|
746
|
+
this.lane.set(l);
|
|
747
|
+
this.reload();
|
|
748
|
+
}
|
|
749
|
+
dragStart(e, c) {
|
|
750
|
+
if (!this.canEdit())
|
|
751
|
+
return;
|
|
752
|
+
this.dragKey = c.key;
|
|
753
|
+
e.dataTransfer?.setData('text/plain', c.key);
|
|
754
|
+
if (e.dataTransfer)
|
|
755
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
756
|
+
}
|
|
757
|
+
dragOver(e, col, lane) {
|
|
758
|
+
if (!this.canEdit())
|
|
759
|
+
return;
|
|
760
|
+
e.preventDefault();
|
|
761
|
+
const k = col.statusId + '|' + lane;
|
|
762
|
+
if (this.over() !== k)
|
|
763
|
+
this.over.set(k);
|
|
764
|
+
}
|
|
765
|
+
drop(e, col, cell) {
|
|
766
|
+
e.preventDefault();
|
|
767
|
+
this.over.set('');
|
|
768
|
+
const key = this.dragKey || e.dataTransfer?.getData('text/plain') || '';
|
|
769
|
+
this.dragKey = '';
|
|
770
|
+
if (!key)
|
|
771
|
+
return;
|
|
772
|
+
// To the end of the column: after its last card (other than itself).
|
|
773
|
+
const last = [...cell].reverse().find((c) => c.key !== key);
|
|
774
|
+
this.error.set('');
|
|
775
|
+
this.api.move(key, col.statusId, last?.key).subscribe({
|
|
776
|
+
next: () => { this.reload(); if (this.selected() === key)
|
|
777
|
+
this.panelVersion.update((v) => v + 1); },
|
|
778
|
+
error: (err) => { this.error.set(apiMessage(err, `Could not move ${key}.`)); this.reload(); },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
created(is) {
|
|
782
|
+
this.createIn.set(null);
|
|
783
|
+
this.note.set(`${is.key} created${is.sprintId ? ' in ' + (this.board()?.sprint?.name ?? 'the sprint') : ' in the backlog'}.`);
|
|
784
|
+
this.reload();
|
|
785
|
+
}
|
|
786
|
+
complete() {
|
|
787
|
+
const p = this.host.project();
|
|
788
|
+
const s = this.board()?.sprint;
|
|
789
|
+
if (!p || !s)
|
|
790
|
+
return;
|
|
791
|
+
this.busy.set(true);
|
|
792
|
+
this.api.completeSprint(p.id, s.id, this.moveTo()).subscribe({
|
|
793
|
+
next: () => { this.busy.set(false); this.completing.set(false); this.note.set(`${s.name} completed.`); this.loadAll(); },
|
|
794
|
+
error: (e) => { this.busy.set(false); this.completing.set(false); this.error.set(apiMessage(e, 'Could not complete the sprint.')); },
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
reload() {
|
|
798
|
+
const p = this.host.project();
|
|
799
|
+
if (!p)
|
|
800
|
+
return;
|
|
801
|
+
this.api.board(p.id, { swimlane: this.lane() || undefined, filter: this.filter() }).subscribe({
|
|
802
|
+
next: (b) => { this.board.set(b); this.error.set(''); if (!this.lane())
|
|
803
|
+
this.lane.set(b.swimlane); },
|
|
804
|
+
error: (e) => this.error.set(apiMessage(e, 'Could not load the board.')),
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
loadAll() {
|
|
808
|
+
const p = this.host.project();
|
|
809
|
+
if (!p)
|
|
810
|
+
return;
|
|
811
|
+
forkJoin({ st: this.api.statuses(p.id), sp: this.api.sprints(p.id) }).subscribe({
|
|
812
|
+
next: ({ st, sp }) => { this.statuses.set(st); this.sprints.set(sp); },
|
|
813
|
+
error: () => undefined,
|
|
814
|
+
});
|
|
815
|
+
this.reload();
|
|
816
|
+
}
|
|
817
|
+
filter() {
|
|
818
|
+
const q = this.quick();
|
|
819
|
+
const all = [];
|
|
820
|
+
if (q.has('mine'))
|
|
821
|
+
all.push({ field: 'assignee', op: 'in', values: ['me'] });
|
|
822
|
+
if (q.has('bugs'))
|
|
823
|
+
all.push({ field: 'type', op: 'in', values: ['bug'] });
|
|
824
|
+
if (q.has('recent'))
|
|
825
|
+
all.push({ field: 'updated', op: 'gte', values: ['-7d'] });
|
|
826
|
+
return all.length ? { all } : null;
|
|
827
|
+
}
|
|
828
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: BoardPage, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
829
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: BoardPage, isStandalone: true, selector: "o-design-board", host: { listeners: { "document:keydown": "key($event)" } }, ngImport: i0, template: `
|
|
830
|
+
<div style="display:flex;min-height:100%">
|
|
831
|
+
<div style="flex:1;min-width:0;padding:20px 20px 28px;display:flex;flex-direction:column;gap:14px">
|
|
832
|
+
<div class="o-ph">
|
|
833
|
+
<div class="o-col">
|
|
834
|
+
<span class="o-eyebrow">{{ eyebrow() }}</span>
|
|
835
|
+
<h1 class="o-t">{{ host.project()?.name }} board</h1>
|
|
836
|
+
@if (board()?.sprint?.goal) {<span class="o-mut">Goal: {{ board()!.sprint!.goal }}</span>}
|
|
837
|
+
</div>
|
|
838
|
+
<div class="o-acts">
|
|
839
|
+
<button type="button" class="o-btn o-sm" [class.o-on]="quick().has('mine')" [attr.aria-pressed]="quick().has('mine')" (click)="flip('mine')">Only my issues</button>
|
|
840
|
+
<button type="button" class="o-btn o-sm" [class.o-on]="quick().has('bugs')" [attr.aria-pressed]="quick().has('bugs')" (click)="flip('bugs')">Bugs</button>
|
|
841
|
+
<button type="button" class="o-btn o-sm" [class.o-on]="quick().has('recent')" [attr.aria-pressed]="quick().has('recent')" (click)="flip('recent')">Recently updated</button>
|
|
842
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Swimlanes" [ngModel]="lane()" (ngModelChange)="setLane($event)">
|
|
843
|
+
<option value="none">Swimlanes: none</option><option value="assignee">Swimlanes: assignee</option>
|
|
844
|
+
<option value="epic">Swimlanes: epic</option><option value="priority">Swimlanes: priority</option>
|
|
845
|
+
</select>
|
|
846
|
+
@if (board()?.sprint && canEdit()) {<button type="button" class="o-btn o-sm" (click)="completing.set(true)" data-testid="complete-sprint">Complete sprint</button>}
|
|
847
|
+
</div>
|
|
848
|
+
</div>
|
|
849
|
+
|
|
850
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
851
|
+
@if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
|
|
852
|
+
|
|
853
|
+
@if (board(); as b) {
|
|
854
|
+
@for (ln of b.lanes; track ln.key) {
|
|
855
|
+
@if (b.swimlane !== 'none') {
|
|
856
|
+
<div class="o-lane-h"><span class="o-av" aria-hidden="true">{{ laneAv(ln.key, ln.name) }}</span><span>{{ ln.name }}</span><span class="o-faint">{{ ln.count }} {{ ln.count === 1 ? 'issue' : 'issues' }}</span></div>
|
|
857
|
+
}
|
|
858
|
+
<div class="grid" role="list" [attr.aria-label]="b.swimlane === 'none' ? 'Board' : ln.name" [style.grid-template-columns]="'repeat(' + b.columns.length + ', minmax(200px, 1fr))'">
|
|
859
|
+
@for (col of b.columns; track col.statusId; let ci = $index) {
|
|
860
|
+
<section class="col" role="listitem" [attr.aria-label]="col.name + ', ' + countText(col)" [class.breach]="col.breach" [class.over]="over() === col.statusId + '|' + ln.key"
|
|
861
|
+
[style.min-height]="b.swimlane === 'none' ? '360px' : '90px'"
|
|
862
|
+
(dragover)="dragOver($event, col, ln.key)" (dragleave)="over.set('')" (drop)="drop($event, col, ln.cells[ci])" [attr.data-testid]="'col-' + col.name">
|
|
863
|
+
<div class="col-h">
|
|
864
|
+
<span class="nm">{{ col.name }}</span>
|
|
865
|
+
<span class="o-mono" style="font-size:12px">{{ countText(col) }}</span>
|
|
866
|
+
@if (canEdit()) {<button type="button" class="o-btn o-ghost o-xs o-r" (click)="createIn.set(col.statusId)" [attr.title]="'Create issue in ' + col.name" [attr.aria-label]="'Create issue in ' + col.name">+</button>}
|
|
867
|
+
</div>
|
|
868
|
+
@for (c of ln.cells[ci]; track c.key) {
|
|
869
|
+
<button type="button" class="card" [class.sel]="selected() === c.key" [attr.draggable]="canEdit()" (dragstart)="dragStart($event, c)" (dragend)="over.set('')" (click)="selected.set(c.key)" [attr.data-testid]="'card-' + c.key">
|
|
870
|
+
<span class="ti">{{ c.title }}</span>
|
|
871
|
+
<span style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
|
872
|
+
<span style="width:8px;height:8px;border-radius:2px" [style.background]="typeColour(c.type)" aria-hidden="true"></span>
|
|
873
|
+
<span class="o-mono" style="font-size:11px;color:var(--o-mut)">{{ c.key }}</span>
|
|
874
|
+
@if (c.epic && b.swimlane !== 'epic') {<span class="o-chip o-vio o-ell" style="max-width:120px">{{ c.epic.title }}</span>}
|
|
875
|
+
<span class="o-mono" style="margin-left:auto;font-size:11px;color:var(--o-dim)">{{ c.points ?? '' }}</span>
|
|
876
|
+
<span class="o-av" [attr.title]="people().name(c.assigneeId)">{{ people().initials(c.assigneeId) }}</span>
|
|
877
|
+
</span>
|
|
878
|
+
</button>
|
|
879
|
+
}
|
|
880
|
+
</section>
|
|
881
|
+
}
|
|
882
|
+
</div>
|
|
883
|
+
} @empty {
|
|
884
|
+
<div class="o-card"><div class="o-empty">No issues on the board yet. Create one with + in a column, or press C.</div></div>
|
|
885
|
+
}
|
|
886
|
+
<span class="o-faint o-sm">Drag cards between columns, or open a card and change its status from the keyboard.{{ wipNote() }}{{ b.truncated ? ' Showing the first 1,000 cards: narrow it with a quick filter.' : '' }}</span>
|
|
887
|
+
} @else if (!error()) {
|
|
888
|
+
<div class="o-card"><div class="o-empty">Loading the board…</div></div>
|
|
889
|
+
}
|
|
890
|
+
</div>
|
|
891
|
+
|
|
892
|
+
@if (selected(); as key) {
|
|
893
|
+
<o-issue-panel [key]="key" [version]="panelVersion()" [statuses]="statuses()" [sprintNames]="sprintNames()" (changed)="reload()" (closed)="selected.set('')" />
|
|
894
|
+
}
|
|
895
|
+
</div>
|
|
896
|
+
|
|
897
|
+
@if (createIn() !== null) {
|
|
898
|
+
<o-create-issue [statusId]="createIn()!" [hasActive]="!!board()?.sprint" (created)="created($event)" (cancel)="createIn.set(null)" />
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
@if (completing() && board()?.sprint) {
|
|
902
|
+
<div class="o-overlay o-mid" (click)="completing.set(false)">
|
|
903
|
+
<div class="o-dlg" role="dialog" aria-modal="true" aria-labelledby="cs-title" (click)="$event.stopPropagation()" (keydown.escape)="completing.set(false)">
|
|
904
|
+
<div class="o-dlg-h"><b id="cs-title">Complete {{ board()!.sprint!.name }}</b></div>
|
|
905
|
+
<div class="o-dlg-b">
|
|
906
|
+
<div class="o-note">{{ completeSummary() }}</div>
|
|
907
|
+
@if (unfinished()) {
|
|
908
|
+
<button type="button" class="o-opt" [class.o-on]="moveTo() === 'next'" (click)="moveTo.set('next')"><span class="o-rad" [class.o-on]="moveTo() === 'next'"></span><span class="o-col" style="gap:2px"><b>Move to the next sprint</b><span class="o-faint o-sm">{{ nextLabel() }}</span></span></button>
|
|
909
|
+
<button type="button" class="o-opt" [class.o-on]="moveTo() === 'backlog'" (click)="moveTo.set('backlog')"><span class="o-rad" [class.o-on]="moveTo() === 'backlog'"></span><span class="o-col" style="gap:2px"><b>Move to the backlog</b><span class="o-faint o-sm">Plan them again later</span></span></button>
|
|
910
|
+
}
|
|
911
|
+
<span class="o-hint">The sprint report freezes its scope when you complete the sprint.</span>
|
|
912
|
+
</div>
|
|
913
|
+
<div class="o-dlg-f">
|
|
914
|
+
<button type="button" class="o-btn" (click)="completing.set(false)">Cancel</button>
|
|
915
|
+
<button type="button" class="o-btn o-dark" [disabled]="busy()" (click)="complete()" data-testid="complete-sprint-confirm">Complete sprint</button>
|
|
916
|
+
</div>
|
|
917
|
+
</div>
|
|
918
|
+
</div>
|
|
919
|
+
}
|
|
920
|
+
`, isInline: true, styles: [".col{background:var(--o-sunken);border-radius:10px;padding:8px;display:flex;flex-direction:column;gap:8px}.col.over{outline:2px dashed var(--o-acc)}.col.breach{outline:2px solid var(--o-bad-solid)}.col-h{display:flex;align-items:center;gap:8px;padding:4px 6px;color:var(--o-mut)}.col.breach .col-h{color:var(--o-bad-tx)}.col-h .nm{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.card{background:var(--o-surface);border-radius:8px;padding:10px 11px;display:flex;flex-direction:column;gap:8px;cursor:grab;border:1px solid var(--o-line);box-shadow:0 1px #1113160a;text-align:left;font:inherit;color:inherit;width:100%}.card:hover{border-color:var(--o-line-mid)}.card.sel{border-color:var(--o-acc)}.card .ti{font-size:13px;line-height:1.35;text-wrap:pretty}.grid{display:grid;gap:12px;align-items:start;overflow-x:auto}.o-btn.o-on{background:var(--o-tx);border-color:var(--o-tx);color:var(--o-acc-fg)}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: CreateIssueDialog, selector: "o-create-issue", inputs: ["statusId", "hasActive", "defaultSprint"], outputs: ["created", "cancel"] }, { kind: "component", type: IssuePanel, selector: "o-issue-panel", inputs: ["key", "statuses", "sprintNames", "version"], outputs: ["changed", "closed"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
921
|
+
}
|
|
922
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: BoardPage, decorators: [{
|
|
923
|
+
type: Component,
|
|
924
|
+
args: [{ selector: 'o-design-board', imports: [FormsModule, CreateIssueDialog, IssuePanel], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
925
|
+
<div style="display:flex;min-height:100%">
|
|
926
|
+
<div style="flex:1;min-width:0;padding:20px 20px 28px;display:flex;flex-direction:column;gap:14px">
|
|
927
|
+
<div class="o-ph">
|
|
928
|
+
<div class="o-col">
|
|
929
|
+
<span class="o-eyebrow">{{ eyebrow() }}</span>
|
|
930
|
+
<h1 class="o-t">{{ host.project()?.name }} board</h1>
|
|
931
|
+
@if (board()?.sprint?.goal) {<span class="o-mut">Goal: {{ board()!.sprint!.goal }}</span>}
|
|
932
|
+
</div>
|
|
933
|
+
<div class="o-acts">
|
|
934
|
+
<button type="button" class="o-btn o-sm" [class.o-on]="quick().has('mine')" [attr.aria-pressed]="quick().has('mine')" (click)="flip('mine')">Only my issues</button>
|
|
935
|
+
<button type="button" class="o-btn o-sm" [class.o-on]="quick().has('bugs')" [attr.aria-pressed]="quick().has('bugs')" (click)="flip('bugs')">Bugs</button>
|
|
936
|
+
<button type="button" class="o-btn o-sm" [class.o-on]="quick().has('recent')" [attr.aria-pressed]="quick().has('recent')" (click)="flip('recent')">Recently updated</button>
|
|
937
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Swimlanes" [ngModel]="lane()" (ngModelChange)="setLane($event)">
|
|
938
|
+
<option value="none">Swimlanes: none</option><option value="assignee">Swimlanes: assignee</option>
|
|
939
|
+
<option value="epic">Swimlanes: epic</option><option value="priority">Swimlanes: priority</option>
|
|
940
|
+
</select>
|
|
941
|
+
@if (board()?.sprint && canEdit()) {<button type="button" class="o-btn o-sm" (click)="completing.set(true)" data-testid="complete-sprint">Complete sprint</button>}
|
|
942
|
+
</div>
|
|
943
|
+
</div>
|
|
944
|
+
|
|
945
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
946
|
+
@if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
|
|
947
|
+
|
|
948
|
+
@if (board(); as b) {
|
|
949
|
+
@for (ln of b.lanes; track ln.key) {
|
|
950
|
+
@if (b.swimlane !== 'none') {
|
|
951
|
+
<div class="o-lane-h"><span class="o-av" aria-hidden="true">{{ laneAv(ln.key, ln.name) }}</span><span>{{ ln.name }}</span><span class="o-faint">{{ ln.count }} {{ ln.count === 1 ? 'issue' : 'issues' }}</span></div>
|
|
952
|
+
}
|
|
953
|
+
<div class="grid" role="list" [attr.aria-label]="b.swimlane === 'none' ? 'Board' : ln.name" [style.grid-template-columns]="'repeat(' + b.columns.length + ', minmax(200px, 1fr))'">
|
|
954
|
+
@for (col of b.columns; track col.statusId; let ci = $index) {
|
|
955
|
+
<section class="col" role="listitem" [attr.aria-label]="col.name + ', ' + countText(col)" [class.breach]="col.breach" [class.over]="over() === col.statusId + '|' + ln.key"
|
|
956
|
+
[style.min-height]="b.swimlane === 'none' ? '360px' : '90px'"
|
|
957
|
+
(dragover)="dragOver($event, col, ln.key)" (dragleave)="over.set('')" (drop)="drop($event, col, ln.cells[ci])" [attr.data-testid]="'col-' + col.name">
|
|
958
|
+
<div class="col-h">
|
|
959
|
+
<span class="nm">{{ col.name }}</span>
|
|
960
|
+
<span class="o-mono" style="font-size:12px">{{ countText(col) }}</span>
|
|
961
|
+
@if (canEdit()) {<button type="button" class="o-btn o-ghost o-xs o-r" (click)="createIn.set(col.statusId)" [attr.title]="'Create issue in ' + col.name" [attr.aria-label]="'Create issue in ' + col.name">+</button>}
|
|
962
|
+
</div>
|
|
963
|
+
@for (c of ln.cells[ci]; track c.key) {
|
|
964
|
+
<button type="button" class="card" [class.sel]="selected() === c.key" [attr.draggable]="canEdit()" (dragstart)="dragStart($event, c)" (dragend)="over.set('')" (click)="selected.set(c.key)" [attr.data-testid]="'card-' + c.key">
|
|
965
|
+
<span class="ti">{{ c.title }}</span>
|
|
966
|
+
<span style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
|
967
|
+
<span style="width:8px;height:8px;border-radius:2px" [style.background]="typeColour(c.type)" aria-hidden="true"></span>
|
|
968
|
+
<span class="o-mono" style="font-size:11px;color:var(--o-mut)">{{ c.key }}</span>
|
|
969
|
+
@if (c.epic && b.swimlane !== 'epic') {<span class="o-chip o-vio o-ell" style="max-width:120px">{{ c.epic.title }}</span>}
|
|
970
|
+
<span class="o-mono" style="margin-left:auto;font-size:11px;color:var(--o-dim)">{{ c.points ?? '' }}</span>
|
|
971
|
+
<span class="o-av" [attr.title]="people().name(c.assigneeId)">{{ people().initials(c.assigneeId) }}</span>
|
|
972
|
+
</span>
|
|
973
|
+
</button>
|
|
974
|
+
}
|
|
975
|
+
</section>
|
|
976
|
+
}
|
|
977
|
+
</div>
|
|
978
|
+
} @empty {
|
|
979
|
+
<div class="o-card"><div class="o-empty">No issues on the board yet. Create one with + in a column, or press C.</div></div>
|
|
980
|
+
}
|
|
981
|
+
<span class="o-faint o-sm">Drag cards between columns, or open a card and change its status from the keyboard.{{ wipNote() }}{{ b.truncated ? ' Showing the first 1,000 cards: narrow it with a quick filter.' : '' }}</span>
|
|
982
|
+
} @else if (!error()) {
|
|
983
|
+
<div class="o-card"><div class="o-empty">Loading the board…</div></div>
|
|
984
|
+
}
|
|
985
|
+
</div>
|
|
986
|
+
|
|
987
|
+
@if (selected(); as key) {
|
|
988
|
+
<o-issue-panel [key]="key" [version]="panelVersion()" [statuses]="statuses()" [sprintNames]="sprintNames()" (changed)="reload()" (closed)="selected.set('')" />
|
|
989
|
+
}
|
|
990
|
+
</div>
|
|
991
|
+
|
|
992
|
+
@if (createIn() !== null) {
|
|
993
|
+
<o-create-issue [statusId]="createIn()!" [hasActive]="!!board()?.sprint" (created)="created($event)" (cancel)="createIn.set(null)" />
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
@if (completing() && board()?.sprint) {
|
|
997
|
+
<div class="o-overlay o-mid" (click)="completing.set(false)">
|
|
998
|
+
<div class="o-dlg" role="dialog" aria-modal="true" aria-labelledby="cs-title" (click)="$event.stopPropagation()" (keydown.escape)="completing.set(false)">
|
|
999
|
+
<div class="o-dlg-h"><b id="cs-title">Complete {{ board()!.sprint!.name }}</b></div>
|
|
1000
|
+
<div class="o-dlg-b">
|
|
1001
|
+
<div class="o-note">{{ completeSummary() }}</div>
|
|
1002
|
+
@if (unfinished()) {
|
|
1003
|
+
<button type="button" class="o-opt" [class.o-on]="moveTo() === 'next'" (click)="moveTo.set('next')"><span class="o-rad" [class.o-on]="moveTo() === 'next'"></span><span class="o-col" style="gap:2px"><b>Move to the next sprint</b><span class="o-faint o-sm">{{ nextLabel() }}</span></span></button>
|
|
1004
|
+
<button type="button" class="o-opt" [class.o-on]="moveTo() === 'backlog'" (click)="moveTo.set('backlog')"><span class="o-rad" [class.o-on]="moveTo() === 'backlog'"></span><span class="o-col" style="gap:2px"><b>Move to the backlog</b><span class="o-faint o-sm">Plan them again later</span></span></button>
|
|
1005
|
+
}
|
|
1006
|
+
<span class="o-hint">The sprint report freezes its scope when you complete the sprint.</span>
|
|
1007
|
+
</div>
|
|
1008
|
+
<div class="o-dlg-f">
|
|
1009
|
+
<button type="button" class="o-btn" (click)="completing.set(false)">Cancel</button>
|
|
1010
|
+
<button type="button" class="o-btn o-dark" [disabled]="busy()" (click)="complete()" data-testid="complete-sprint-confirm">Complete sprint</button>
|
|
1011
|
+
</div>
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
}
|
|
1015
|
+
`, styles: [".col{background:var(--o-sunken);border-radius:10px;padding:8px;display:flex;flex-direction:column;gap:8px}.col.over{outline:2px dashed var(--o-acc)}.col.breach{outline:2px solid var(--o-bad-solid)}.col-h{display:flex;align-items:center;gap:8px;padding:4px 6px;color:var(--o-mut)}.col.breach .col-h{color:var(--o-bad-tx)}.col-h .nm{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.card{background:var(--o-surface);border-radius:8px;padding:10px 11px;display:flex;flex-direction:column;gap:8px;cursor:grab;border:1px solid var(--o-line);box-shadow:0 1px #1113160a;text-align:left;font:inherit;color:inherit;width:100%}.card:hover{border-color:var(--o-line-mid)}.card.sel{border-color:var(--o-acc)}.card .ti{font-size:13px;line-height:1.35;text-wrap:pretty}.grid{display:grid;gap:12px;align-items:start;overflow-x:auto}.o-btn.o-on{background:var(--o-tx);border-color:var(--o-tx);color:var(--o-acc-fg)}\n"] }]
|
|
1016
|
+
}], ctorParameters: () => [], propDecorators: { key: [{
|
|
1017
|
+
type: HostListener,
|
|
1018
|
+
args: ['document:keydown', ['$event']]
|
|
1019
|
+
}] } });
|
|
1020
|
+
|
|
1021
|
+
// /{org}/{project}/backlog (prototype "design_backlog"): the active sprint, planned sprints and the
|
|
1022
|
+
// backlog, each in rank order. Drag a row to reorder it or to plan it into a sprint; select rows for
|
|
1023
|
+
// the bulk bar (the keyboard path for the same changes).
|
|
1024
|
+
class BacklogPage {
|
|
1025
|
+
clearPicked() {
|
|
1026
|
+
this.picked.set(new Set());
|
|
1027
|
+
}
|
|
1028
|
+
constructor() {
|
|
1029
|
+
this.host = inject(DesignHost);
|
|
1030
|
+
this.api = inject(DesignApi);
|
|
1031
|
+
this.router = inject(Router);
|
|
1032
|
+
this.sections = signal([], ...(ngDevMode ? [{ debugName: "sections" }] : /* istanbul ignore next */ []));
|
|
1033
|
+
this.statuses = signal([], ...(ngDevMode ? [{ debugName: "statuses" }] : /* istanbul ignore next */ []));
|
|
1034
|
+
this.epics = signal([], ...(ngDevMode ? [{ debugName: "epics" }] : /* istanbul ignore next */ []));
|
|
1035
|
+
this.text = signal('', ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
|
|
1036
|
+
this.epic = signal('', ...(ngDevMode ? [{ debugName: "epic" }] : /* istanbul ignore next */ []));
|
|
1037
|
+
this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1038
|
+
this.note = signal('', ...(ngDevMode ? [{ debugName: "note" }] : /* istanbul ignore next */ []));
|
|
1039
|
+
this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1040
|
+
this.picked = signal(new Set(), ...(ngDevMode ? [{ debugName: "picked" }] : /* istanbul ignore next */ []));
|
|
1041
|
+
this.overSec = signal('', ...(ngDevMode ? [{ debugName: "overSec" }] : /* istanbul ignore next */ []));
|
|
1042
|
+
this.overRow = signal('', ...(ngDevMode ? [{ debugName: "overRow" }] : /* istanbul ignore next */ []));
|
|
1043
|
+
this.newTitle = signal('', ...(ngDevMode ? [{ debugName: "newTitle" }] : /* istanbul ignore next */ []));
|
|
1044
|
+
this.labelling = signal(false, ...(ngDevMode ? [{ debugName: "labelling" }] : /* istanbul ignore next */ []));
|
|
1045
|
+
this.label = signal('', ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
1046
|
+
this.creating = signal(false, ...(ngDevMode ? [{ debugName: "creating" }] : /* istanbul ignore next */ []));
|
|
1047
|
+
this.drag = null;
|
|
1048
|
+
this.typeColour = typeColour;
|
|
1049
|
+
this.statusChip = statusChip;
|
|
1050
|
+
this.epicChip = epicChip;
|
|
1051
|
+
this.priorityLabel = priorityLabel;
|
|
1052
|
+
this.priorities = PRIORITIES;
|
|
1053
|
+
this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
|
|
1054
|
+
this.canEdit = computed(() => {
|
|
1055
|
+
const p = this.host.project();
|
|
1056
|
+
return !!p && !p.archivedAt && p.capabilities.includes('issues.edit');
|
|
1057
|
+
}, ...(ngDevMode ? [{ debugName: "canEdit" }] : /* istanbul ignore next */ []));
|
|
1058
|
+
this.hasActive = computed(() => this.sections().some((s) => s.sprint?.state === 'active'), ...(ngDevMode ? [{ debugName: "hasActive" }] : /* istanbul ignore next */ []));
|
|
1059
|
+
this.firstPlanned = computed(() => this.sections().find((s) => s.sprint?.state === 'planned')?.sprint ?? null, ...(ngDevMode ? [{ debugName: "firstPlanned" }] : /* istanbul ignore next */ []));
|
|
1060
|
+
this.openSprints = computed(() => this.sections().filter((s) => !!s.sprint).map((s) => s.sprint), ...(ngDevMode ? [{ debugName: "openSprints" }] : /* istanbul ignore next */ []));
|
|
1061
|
+
effect(() => {
|
|
1062
|
+
const p = this.host.project();
|
|
1063
|
+
if (p)
|
|
1064
|
+
untracked(() => this.loadAll());
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
key(e) {
|
|
1068
|
+
const t = e.target;
|
|
1069
|
+
if (e.key !== 'c' || e.metaKey || e.ctrlKey || e.altKey || t?.closest('input,textarea,select,[contenteditable]'))
|
|
1070
|
+
return;
|
|
1071
|
+
if (this.canEdit() && !this.creating()) {
|
|
1072
|
+
e.preventDefault();
|
|
1073
|
+
this.creating.set(true);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
secId(s) {
|
|
1077
|
+
return s.sprint?.id ?? 'backlog';
|
|
1078
|
+
}
|
|
1079
|
+
secName(s) {
|
|
1080
|
+
return s.sprint ? s.sprint.name + (s.sprint.state === 'active' ? ' · active' : '') : 'Backlog';
|
|
1081
|
+
}
|
|
1082
|
+
secMeta(s) {
|
|
1083
|
+
const n = `${s.items.length} ${s.items.length === 1 ? 'issue' : 'issues'}`;
|
|
1084
|
+
const d = s.sprint ? dateRange(s.sprint.startsOn, s.sprint.endsOn) : '';
|
|
1085
|
+
return d ? `${d} · ${n}` : n;
|
|
1086
|
+
}
|
|
1087
|
+
toggle(key) {
|
|
1088
|
+
const s = new Set(this.picked());
|
|
1089
|
+
s.has(key) ? s.delete(key) : s.add(key);
|
|
1090
|
+
this.picked.set(s);
|
|
1091
|
+
}
|
|
1092
|
+
dragStart(e, i) {
|
|
1093
|
+
if (!this.canEdit())
|
|
1094
|
+
return;
|
|
1095
|
+
const sec = this.sections().find((s) => s.items.some((x) => x.key === i.key));
|
|
1096
|
+
this.drag = { ...i, section: sec ? this.secId(sec) : 'backlog' };
|
|
1097
|
+
e.dataTransfer?.setData('text/plain', i.key);
|
|
1098
|
+
if (e.dataTransfer)
|
|
1099
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1100
|
+
}
|
|
1101
|
+
overSection(e, s) {
|
|
1102
|
+
if (!this.drag || s.sprint?.state === 'completed')
|
|
1103
|
+
return;
|
|
1104
|
+
e.preventDefault();
|
|
1105
|
+
if (this.overSec() !== this.secId(s))
|
|
1106
|
+
this.overSec.set(this.secId(s));
|
|
1107
|
+
}
|
|
1108
|
+
overItem(e, i) {
|
|
1109
|
+
if (!this.drag || this.drag.key === i.key)
|
|
1110
|
+
return;
|
|
1111
|
+
e.preventDefault();
|
|
1112
|
+
if (this.overRow() !== i.key)
|
|
1113
|
+
this.overRow.set(i.key);
|
|
1114
|
+
}
|
|
1115
|
+
/** Dropped on a section's empty space: plan it there, at the end. */
|
|
1116
|
+
dropOnSection(e, s) {
|
|
1117
|
+
e.preventDefault();
|
|
1118
|
+
const d = this.drag;
|
|
1119
|
+
this.clearDrag();
|
|
1120
|
+
if (!d)
|
|
1121
|
+
return;
|
|
1122
|
+
const steps = [];
|
|
1123
|
+
if (d.section !== this.secId(s))
|
|
1124
|
+
steps.push(this.api.update(d.key, { sprintId: s.sprint?.id ?? '' }));
|
|
1125
|
+
const lastRow = [...s.items].reverse().find((x) => x.key !== d.key);
|
|
1126
|
+
if (lastRow)
|
|
1127
|
+
steps.push(this.api.move(d.key, d.statusId, lastRow.key));
|
|
1128
|
+
this.run(steps, `Could not move ${d.key}.`);
|
|
1129
|
+
}
|
|
1130
|
+
/** Dropped on a row: just above it, planned into its section. */
|
|
1131
|
+
dropOnRow(e, s, ix) {
|
|
1132
|
+
e.preventDefault();
|
|
1133
|
+
e.stopPropagation();
|
|
1134
|
+
const d = this.drag;
|
|
1135
|
+
this.clearDrag();
|
|
1136
|
+
const target = s.items[ix];
|
|
1137
|
+
if (!d || !target || target.key === d.key)
|
|
1138
|
+
return;
|
|
1139
|
+
const before = target.key;
|
|
1140
|
+
const after = s.items.slice(0, ix).reverse().find((x) => x.key !== d.key)?.key;
|
|
1141
|
+
const steps = [];
|
|
1142
|
+
if (d.section !== this.secId(s))
|
|
1143
|
+
steps.push(this.api.update(d.key, { sprintId: s.sprint?.id ?? '' }));
|
|
1144
|
+
steps.push(this.api.move(d.key, d.statusId, after, before));
|
|
1145
|
+
this.run(steps, `Could not move ${d.key}.`);
|
|
1146
|
+
}
|
|
1147
|
+
bulkPick(e, field) {
|
|
1148
|
+
const el = e.target;
|
|
1149
|
+
const v = el.value;
|
|
1150
|
+
el.value = '';
|
|
1151
|
+
if (!v)
|
|
1152
|
+
return;
|
|
1153
|
+
const change = { [field]: field === 'sprintId' && v === 'backlog' ? '' : v };
|
|
1154
|
+
this.bulk(change);
|
|
1155
|
+
}
|
|
1156
|
+
addLabel() {
|
|
1157
|
+
const l = this.label().trim();
|
|
1158
|
+
if (!l)
|
|
1159
|
+
return;
|
|
1160
|
+
this.label.set('');
|
|
1161
|
+
this.labelling.set(false);
|
|
1162
|
+
this.bulk({ addLabels: [l] });
|
|
1163
|
+
}
|
|
1164
|
+
quickCreate() {
|
|
1165
|
+
const p = this.host.project();
|
|
1166
|
+
const title = this.newTitle().trim();
|
|
1167
|
+
if (!p || !title || this.busy())
|
|
1168
|
+
return;
|
|
1169
|
+
this.busy.set(true);
|
|
1170
|
+
this.api.create(p.id, { title, type: 'task' }).subscribe({
|
|
1171
|
+
next: (is) => { this.busy.set(false); this.newTitle.set(''); this.note.set(`${is.key} created in the backlog.`); this.load(); },
|
|
1172
|
+
error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not create the issue.')); },
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
created(is) {
|
|
1176
|
+
this.creating.set(false);
|
|
1177
|
+
this.note.set(`${is.key} created.`);
|
|
1178
|
+
this.load();
|
|
1179
|
+
}
|
|
1180
|
+
createSprint() {
|
|
1181
|
+
const p = this.host.project();
|
|
1182
|
+
if (!p)
|
|
1183
|
+
return;
|
|
1184
|
+
this.busy.set(true);
|
|
1185
|
+
this.api.createSprint(p.id).subscribe({
|
|
1186
|
+
next: (s) => { this.busy.set(false); this.note.set(`${s.name} created. Drag issues into it, then start it.`); this.load(); },
|
|
1187
|
+
error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not create a sprint.')); },
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
start(sec) {
|
|
1191
|
+
const p = this.host.project();
|
|
1192
|
+
if (!p || !sec.sprint)
|
|
1193
|
+
return;
|
|
1194
|
+
this.busy.set(true);
|
|
1195
|
+
this.api.startSprint(p.id, sec.sprint.id).subscribe({
|
|
1196
|
+
next: (s) => { this.busy.set(false); this.note.set(`${s.name} started.`); void this.router.navigate(this.host.link('board')); },
|
|
1197
|
+
error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not start the sprint.')); },
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
load() {
|
|
1201
|
+
const p = this.host.project();
|
|
1202
|
+
if (!p)
|
|
1203
|
+
return;
|
|
1204
|
+
this.api.backlog(p.id, this.filter()).subscribe({
|
|
1205
|
+
next: (s) => { this.sections.set(s); this.error.set(''); },
|
|
1206
|
+
error: (e) => this.error.set(apiMessage(e, 'Could not load the backlog.')),
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
bulk(change) {
|
|
1210
|
+
const p = this.host.project();
|
|
1211
|
+
const keys = [...this.picked()];
|
|
1212
|
+
if (!p || !keys.length)
|
|
1213
|
+
return;
|
|
1214
|
+
this.busy.set(true);
|
|
1215
|
+
this.api.bulk(p.id, keys, change).subscribe({
|
|
1216
|
+
next: () => { this.busy.set(false); this.note.set(`${keys.length} ${keys.length === 1 ? 'issue' : 'issues'} changed.`); this.picked.set(new Set()); this.load(); },
|
|
1217
|
+
error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not change the issues.')); },
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
run(steps, fallback) {
|
|
1221
|
+
if (!steps.length)
|
|
1222
|
+
return;
|
|
1223
|
+
this.error.set('');
|
|
1224
|
+
concat(...steps).pipe(last()).subscribe({
|
|
1225
|
+
next: () => this.load(),
|
|
1226
|
+
error: (e) => { this.error.set(apiMessage(e, fallback)); this.load(); },
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
clearDrag() {
|
|
1230
|
+
this.drag = null;
|
|
1231
|
+
this.overRow.set('');
|
|
1232
|
+
this.overSec.set('');
|
|
1233
|
+
}
|
|
1234
|
+
loadAll() {
|
|
1235
|
+
const p = this.host.project();
|
|
1236
|
+
if (!p)
|
|
1237
|
+
return;
|
|
1238
|
+
forkJoin({ st: this.api.statuses(p.id), ep: this.api.epics(p.id) }).subscribe({
|
|
1239
|
+
next: ({ st, ep }) => { this.statuses.set(st); this.epics.set(ep); },
|
|
1240
|
+
error: () => undefined,
|
|
1241
|
+
});
|
|
1242
|
+
this.load();
|
|
1243
|
+
}
|
|
1244
|
+
filter() {
|
|
1245
|
+
const all = [];
|
|
1246
|
+
const t = this.text().trim();
|
|
1247
|
+
if (t)
|
|
1248
|
+
all.push({ field: 'text', op: 'contains', values: [t] });
|
|
1249
|
+
if (this.epic())
|
|
1250
|
+
all.push({ field: 'epic', op: 'in', values: [this.epic()] });
|
|
1251
|
+
return all.length ? { all } : null;
|
|
1252
|
+
}
|
|
1253
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: BacklogPage, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1254
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: BacklogPage, isStandalone: true, selector: "o-design-backlog", host: { listeners: { "document:keydown": "key($event)" } }, ngImport: i0, template: `
|
|
1255
|
+
<div class="o-page o-wide">
|
|
1256
|
+
<div class="o-ph">
|
|
1257
|
+
<div class="o-col"><span class="o-eyebrow">{{ host.project()?.key }} · ranked by priority</span><h1 class="o-t">Backlog</h1></div>
|
|
1258
|
+
<div class="o-acts">
|
|
1259
|
+
<input class="o-in o-sm" style="width:200px" placeholder="Filter by text" aria-label="Filter by text" [ngModel]="text()" (ngModelChange)="text.set($event)" (keydown.enter)="load()" (blur)="load()" />
|
|
1260
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Epic" [ngModel]="epic()" (ngModelChange)="epic.set($event); load()">
|
|
1261
|
+
<option value="">All epics</option>
|
|
1262
|
+
@for (e of epics(); track e.key) {<option [value]="e.key">{{ e.title }}</option>}
|
|
1263
|
+
</select>
|
|
1264
|
+
@if (canEdit()) {<button type="button" class="o-btn o-sm" (click)="createSprint()" [disabled]="busy()" data-testid="create-sprint">Create sprint</button>}
|
|
1265
|
+
</div>
|
|
1266
|
+
</div>
|
|
1267
|
+
|
|
1268
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
1269
|
+
@if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
|
|
1270
|
+
|
|
1271
|
+
@for (sec of sections(); track secId(sec)) {
|
|
1272
|
+
<section class="o-card" [class.drop-on]="overSec() === secId(sec)" [attr.aria-label]="secName(sec)" [attr.data-testid]="'section-' + secName(sec)"
|
|
1273
|
+
(dragover)="overSection($event, sec)" (dragleave)="overSec.set('')" (drop)="dropOnSection($event, sec)">
|
|
1274
|
+
<div class="o-ch">
|
|
1275
|
+
<b>{{ secName(sec) }}</b>
|
|
1276
|
+
<span class="o-faint o-sm">{{ secMeta(sec) }}</span>
|
|
1277
|
+
<span class="o-r o-flex">
|
|
1278
|
+
<span class="o-sm">{{ sec.points }} {{ sec.points === 1 ? 'point' : 'points' }}</span>
|
|
1279
|
+
@if (canEdit() && sec.sprint?.state === 'active') {<a class="o-btn o-sm" [routerLink]="host.link('board')">Complete sprint</a>}
|
|
1280
|
+
@if (canEdit() && sec.sprint?.state === 'planned' && !hasActive() && firstPlanned()?.id === sec.sprint?.id) {
|
|
1281
|
+
<button type="button" class="o-btn o-sm" (click)="start(sec)" [disabled]="busy()" data-testid="start-sprint">Start sprint</button>
|
|
1282
|
+
}
|
|
1283
|
+
</span>
|
|
1284
|
+
</div>
|
|
1285
|
+
@if (sec.sprint?.goal) {<div class="o-row o-faint o-sm" style="background:var(--o-well)">Goal: {{ sec.sprint!.goal }}</div>}
|
|
1286
|
+
@for (i of sec.items; track i.key; let ix = $index) {
|
|
1287
|
+
<div class="o-row" [class.row-sel]="picked().has(i.key)" [class.row-over]="overRow() === i.key" [attr.draggable]="canEdit()"
|
|
1288
|
+
(dragstart)="dragStart($event, i)" (dragend)="overRow.set(''); overSec.set('')" (dragover)="overItem($event, i)" (drop)="dropOnRow($event, sec, ix)" [attr.data-testid]="'row-' + i.key">
|
|
1289
|
+
@if (canEdit()) {<span class="grip" aria-hidden="true">⋮⋮</span>}
|
|
1290
|
+
<button type="button" class="o-cbx" [class.o-on]="picked().has(i.key)" role="checkbox" [attr.aria-checked]="picked().has(i.key)" (click)="toggle(i.key)" [attr.aria-label]="'Select ' + i.key"></button>
|
|
1291
|
+
<span class="o-sq" [style.background]="typeColour(i.type)" aria-hidden="true"></span>
|
|
1292
|
+
<span class="o-mono o-sm o-mut" style="width:64px">{{ i.key }}</span>
|
|
1293
|
+
<a class="title o-ell" [routerLink]="host.link('issues/' + i.key)">{{ i.title }}</a>
|
|
1294
|
+
@if (i.epic) {<span [class]="epicChip(i.epic.key)">{{ i.epic.title }}</span>}
|
|
1295
|
+
<span [class]="statusChip(i.status, i.category)">{{ i.status }}</span>
|
|
1296
|
+
<span class="o-faint o-sm o-hide-m" style="width:60px">{{ priorityLabel(i.priority) }}</span>
|
|
1297
|
+
<span class="o-mono o-sm o-faint" style="width:22px;text-align:right">{{ i.points ?? '' }}</span>
|
|
1298
|
+
<span class="o-av" [attr.title]="people().name(i.assigneeId)">{{ people().initials(i.assigneeId) }}</span>
|
|
1299
|
+
</div>
|
|
1300
|
+
}
|
|
1301
|
+
@if (!sec.items.length && sec.sprint) {<div class="o-empty">Drag issues here to plan the sprint.</div>}
|
|
1302
|
+
@if (!sec.items.length && !sec.sprint) {<div class="o-empty">The backlog is empty.</div>}
|
|
1303
|
+
@if (!sec.sprint && canEdit()) {
|
|
1304
|
+
<div class="o-row"><span class="o-faint" aria-hidden="true">+</span>
|
|
1305
|
+
<input class="o-in o-sm" style="border:none;box-shadow:none" placeholder="What needs to be done? Press Enter to create" aria-label="New issue in the backlog"
|
|
1306
|
+
[ngModel]="newTitle()" (ngModelChange)="newTitle.set($event)" (keydown.enter)="quickCreate()" data-testid="backlog-new" />
|
|
1307
|
+
</div>
|
|
1308
|
+
}
|
|
1309
|
+
</section>
|
|
1310
|
+
} @empty {
|
|
1311
|
+
@if (!error()) {<div class="o-card"><div class="o-empty">Loading the backlog…</div></div>}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
@if (picked().size) {
|
|
1315
|
+
<div class="bulk" role="toolbar" aria-label="Bulk edit" data-testid="bulk-bar">
|
|
1316
|
+
<b style="padding:0 6px">{{ picked().size }} selected</b>
|
|
1317
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Set status" (change)="bulkPick($event, 'statusId')"><option value="">Status…</option>@for (s of statuses(); track s.id) {<option [value]="s.id">{{ s.name }}</option>}</select>
|
|
1318
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Set assignee" (change)="bulkPick($event, 'assigneeId')"><option value="">Assignee…</option>@for (p of host.people(); track p.id) {<option [value]="p.id">{{ p.name || p.email }}</option>}</select>
|
|
1319
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Move to sprint" (change)="bulkPick($event, 'sprintId')" data-testid="bulk-sprint">
|
|
1320
|
+
<option value="">Sprint…</option>
|
|
1321
|
+
@for (s of openSprints(); track s.id) {<option [value]="s.id">{{ s.name }}</option>}
|
|
1322
|
+
<option value="backlog">Backlog</option>
|
|
1323
|
+
</select>
|
|
1324
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Set priority" (change)="bulkPick($event, 'priority')"><option value="">Priority…</option>@for (p of priorities; track p) {<option [value]="p">{{ priorityLabel(p) }}</option>}</select>
|
|
1325
|
+
@if (labelling()) {
|
|
1326
|
+
<input class="o-in o-sm" style="width:140px" placeholder="Label, then Enter" aria-label="Add a label" [ngModel]="label()" (ngModelChange)="label.set($event)" (keydown.enter)="addLabel()" (keydown.escape)="labelling.set(false)" />
|
|
1327
|
+
} @else {
|
|
1328
|
+
<button type="button" class="o-btn o-sm" (click)="labelling.set(true)">+ Label</button>
|
|
1329
|
+
}
|
|
1330
|
+
<button type="button" class="o-btn o-sm o-ghost" (click)="clearPicked()">Clear</button>
|
|
1331
|
+
</div>
|
|
1332
|
+
}
|
|
1333
|
+
</div>
|
|
1334
|
+
|
|
1335
|
+
@if (creating()) {
|
|
1336
|
+
<o-create-issue [hasActive]="hasActive()" defaultSprint="" (created)="created($event)" (cancel)="creating.set(false)" />
|
|
1337
|
+
}
|
|
1338
|
+
`, isInline: true, styles: [".row-sel{background:var(--o-acc-tint)}.grip{cursor:grab;letter-spacing:-2px;color:var(--o-dim);-webkit-user-select:none;user-select:none}.drop-on{outline:2px dashed var(--o-acc)}.row-over{box-shadow:inset 0 2px 0 var(--o-acc)}.bulk{position:sticky;bottom:16px;align-self:center;background:var(--o-tx);color:var(--o-acc-fg);border-radius:10px;padding:8px 10px;display:flex;gap:8px;align-items:center;box-shadow:0 12px 30px #00000040;flex-wrap:wrap;z-index:5}.bulk .o-btn.o-ghost{color:var(--o-acc-fg)}.title{flex:1;min-width:0;color:var(--o-tx)}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: CreateIssueDialog, selector: "o-create-issue", inputs: ["statusId", "hasActive", "defaultSprint"], outputs: ["created", "cancel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1339
|
+
}
|
|
1340
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: BacklogPage, decorators: [{
|
|
1341
|
+
type: Component,
|
|
1342
|
+
args: [{ selector: 'o-design-backlog', imports: [FormsModule, RouterLink, CreateIssueDialog], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
1343
|
+
<div class="o-page o-wide">
|
|
1344
|
+
<div class="o-ph">
|
|
1345
|
+
<div class="o-col"><span class="o-eyebrow">{{ host.project()?.key }} · ranked by priority</span><h1 class="o-t">Backlog</h1></div>
|
|
1346
|
+
<div class="o-acts">
|
|
1347
|
+
<input class="o-in o-sm" style="width:200px" placeholder="Filter by text" aria-label="Filter by text" [ngModel]="text()" (ngModelChange)="text.set($event)" (keydown.enter)="load()" (blur)="load()" />
|
|
1348
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Epic" [ngModel]="epic()" (ngModelChange)="epic.set($event); load()">
|
|
1349
|
+
<option value="">All epics</option>
|
|
1350
|
+
@for (e of epics(); track e.key) {<option [value]="e.key">{{ e.title }}</option>}
|
|
1351
|
+
</select>
|
|
1352
|
+
@if (canEdit()) {<button type="button" class="o-btn o-sm" (click)="createSprint()" [disabled]="busy()" data-testid="create-sprint">Create sprint</button>}
|
|
1353
|
+
</div>
|
|
1354
|
+
</div>
|
|
1355
|
+
|
|
1356
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
1357
|
+
@if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
|
|
1358
|
+
|
|
1359
|
+
@for (sec of sections(); track secId(sec)) {
|
|
1360
|
+
<section class="o-card" [class.drop-on]="overSec() === secId(sec)" [attr.aria-label]="secName(sec)" [attr.data-testid]="'section-' + secName(sec)"
|
|
1361
|
+
(dragover)="overSection($event, sec)" (dragleave)="overSec.set('')" (drop)="dropOnSection($event, sec)">
|
|
1362
|
+
<div class="o-ch">
|
|
1363
|
+
<b>{{ secName(sec) }}</b>
|
|
1364
|
+
<span class="o-faint o-sm">{{ secMeta(sec) }}</span>
|
|
1365
|
+
<span class="o-r o-flex">
|
|
1366
|
+
<span class="o-sm">{{ sec.points }} {{ sec.points === 1 ? 'point' : 'points' }}</span>
|
|
1367
|
+
@if (canEdit() && sec.sprint?.state === 'active') {<a class="o-btn o-sm" [routerLink]="host.link('board')">Complete sprint</a>}
|
|
1368
|
+
@if (canEdit() && sec.sprint?.state === 'planned' && !hasActive() && firstPlanned()?.id === sec.sprint?.id) {
|
|
1369
|
+
<button type="button" class="o-btn o-sm" (click)="start(sec)" [disabled]="busy()" data-testid="start-sprint">Start sprint</button>
|
|
1370
|
+
}
|
|
1371
|
+
</span>
|
|
1372
|
+
</div>
|
|
1373
|
+
@if (sec.sprint?.goal) {<div class="o-row o-faint o-sm" style="background:var(--o-well)">Goal: {{ sec.sprint!.goal }}</div>}
|
|
1374
|
+
@for (i of sec.items; track i.key; let ix = $index) {
|
|
1375
|
+
<div class="o-row" [class.row-sel]="picked().has(i.key)" [class.row-over]="overRow() === i.key" [attr.draggable]="canEdit()"
|
|
1376
|
+
(dragstart)="dragStart($event, i)" (dragend)="overRow.set(''); overSec.set('')" (dragover)="overItem($event, i)" (drop)="dropOnRow($event, sec, ix)" [attr.data-testid]="'row-' + i.key">
|
|
1377
|
+
@if (canEdit()) {<span class="grip" aria-hidden="true">⋮⋮</span>}
|
|
1378
|
+
<button type="button" class="o-cbx" [class.o-on]="picked().has(i.key)" role="checkbox" [attr.aria-checked]="picked().has(i.key)" (click)="toggle(i.key)" [attr.aria-label]="'Select ' + i.key"></button>
|
|
1379
|
+
<span class="o-sq" [style.background]="typeColour(i.type)" aria-hidden="true"></span>
|
|
1380
|
+
<span class="o-mono o-sm o-mut" style="width:64px">{{ i.key }}</span>
|
|
1381
|
+
<a class="title o-ell" [routerLink]="host.link('issues/' + i.key)">{{ i.title }}</a>
|
|
1382
|
+
@if (i.epic) {<span [class]="epicChip(i.epic.key)">{{ i.epic.title }}</span>}
|
|
1383
|
+
<span [class]="statusChip(i.status, i.category)">{{ i.status }}</span>
|
|
1384
|
+
<span class="o-faint o-sm o-hide-m" style="width:60px">{{ priorityLabel(i.priority) }}</span>
|
|
1385
|
+
<span class="o-mono o-sm o-faint" style="width:22px;text-align:right">{{ i.points ?? '' }}</span>
|
|
1386
|
+
<span class="o-av" [attr.title]="people().name(i.assigneeId)">{{ people().initials(i.assigneeId) }}</span>
|
|
1387
|
+
</div>
|
|
1388
|
+
}
|
|
1389
|
+
@if (!sec.items.length && sec.sprint) {<div class="o-empty">Drag issues here to plan the sprint.</div>}
|
|
1390
|
+
@if (!sec.items.length && !sec.sprint) {<div class="o-empty">The backlog is empty.</div>}
|
|
1391
|
+
@if (!sec.sprint && canEdit()) {
|
|
1392
|
+
<div class="o-row"><span class="o-faint" aria-hidden="true">+</span>
|
|
1393
|
+
<input class="o-in o-sm" style="border:none;box-shadow:none" placeholder="What needs to be done? Press Enter to create" aria-label="New issue in the backlog"
|
|
1394
|
+
[ngModel]="newTitle()" (ngModelChange)="newTitle.set($event)" (keydown.enter)="quickCreate()" data-testid="backlog-new" />
|
|
1395
|
+
</div>
|
|
1396
|
+
}
|
|
1397
|
+
</section>
|
|
1398
|
+
} @empty {
|
|
1399
|
+
@if (!error()) {<div class="o-card"><div class="o-empty">Loading the backlog…</div></div>}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
@if (picked().size) {
|
|
1403
|
+
<div class="bulk" role="toolbar" aria-label="Bulk edit" data-testid="bulk-bar">
|
|
1404
|
+
<b style="padding:0 6px">{{ picked().size }} selected</b>
|
|
1405
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Set status" (change)="bulkPick($event, 'statusId')"><option value="">Status…</option>@for (s of statuses(); track s.id) {<option [value]="s.id">{{ s.name }}</option>}</select>
|
|
1406
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Set assignee" (change)="bulkPick($event, 'assigneeId')"><option value="">Assignee…</option>@for (p of host.people(); track p.id) {<option [value]="p.id">{{ p.name || p.email }}</option>}</select>
|
|
1407
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Move to sprint" (change)="bulkPick($event, 'sprintId')" data-testid="bulk-sprint">
|
|
1408
|
+
<option value="">Sprint…</option>
|
|
1409
|
+
@for (s of openSprints(); track s.id) {<option [value]="s.id">{{ s.name }}</option>}
|
|
1410
|
+
<option value="backlog">Backlog</option>
|
|
1411
|
+
</select>
|
|
1412
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Set priority" (change)="bulkPick($event, 'priority')"><option value="">Priority…</option>@for (p of priorities; track p) {<option [value]="p">{{ priorityLabel(p) }}</option>}</select>
|
|
1413
|
+
@if (labelling()) {
|
|
1414
|
+
<input class="o-in o-sm" style="width:140px" placeholder="Label, then Enter" aria-label="Add a label" [ngModel]="label()" (ngModelChange)="label.set($event)" (keydown.enter)="addLabel()" (keydown.escape)="labelling.set(false)" />
|
|
1415
|
+
} @else {
|
|
1416
|
+
<button type="button" class="o-btn o-sm" (click)="labelling.set(true)">+ Label</button>
|
|
1417
|
+
}
|
|
1418
|
+
<button type="button" class="o-btn o-sm o-ghost" (click)="clearPicked()">Clear</button>
|
|
1419
|
+
</div>
|
|
1420
|
+
}
|
|
1421
|
+
</div>
|
|
1422
|
+
|
|
1423
|
+
@if (creating()) {
|
|
1424
|
+
<o-create-issue [hasActive]="hasActive()" defaultSprint="" (created)="created($event)" (cancel)="creating.set(false)" />
|
|
1425
|
+
}
|
|
1426
|
+
`, styles: [".row-sel{background:var(--o-acc-tint)}.grip{cursor:grab;letter-spacing:-2px;color:var(--o-dim);-webkit-user-select:none;user-select:none}.drop-on{outline:2px dashed var(--o-acc)}.row-over{box-shadow:inset 0 2px 0 var(--o-acc)}.bulk{position:sticky;bottom:16px;align-self:center;background:var(--o-tx);color:var(--o-acc-fg);border-radius:10px;padding:8px 10px;display:flex;gap:8px;align-items:center;box-shadow:0 12px 30px #00000040;flex-wrap:wrap;z-index:5}.bulk .o-btn.o-ghost{color:var(--o-acc-fg)}.title{flex:1;min-width:0;color:var(--o-tx)}\n"] }]
|
|
1427
|
+
}], ctorParameters: () => [], propDecorators: { key: [{
|
|
1428
|
+
type: HostListener,
|
|
1429
|
+
args: ['document:keydown', ['$event']]
|
|
1430
|
+
}] } });
|
|
1431
|
+
|
|
1432
|
+
const COLS = [
|
|
1433
|
+
{ id: 'key', label: 'Key', sort: 'key' }, { id: 'title', label: 'Title', sort: 'title' }, { id: 'type', label: 'Type', sort: 'type' },
|
|
1434
|
+
{ id: 'status', label: 'Status', sort: 'status' }, { id: 'assignee', label: 'Assignee', sort: 'assignee' }, { id: 'priority', label: 'Priority', sort: 'priority' },
|
|
1435
|
+
{ id: 'points', label: 'Points', sort: 'points' }, { id: 'sprint', label: 'Sprint', sort: 'sprint' }, { id: 'updated', label: 'Updated', sort: 'updated' },
|
|
1436
|
+
];
|
|
1437
|
+
const DEFAULT_COLS = COLS.map((c) => c.id);
|
|
1438
|
+
/** The views every project has, before any are saved (prototype VIEWS: all, mine, bugs). */
|
|
1439
|
+
const BUILT_IN = [
|
|
1440
|
+
{ id: 'all', name: 'All issues', filter: null },
|
|
1441
|
+
{ id: 'mine', name: 'My open issues', filter: { all: [{ field: 'assignee', op: 'in', values: ['me'] }, { field: 'category', op: 'not_in', values: ['done'] }] } },
|
|
1442
|
+
{ id: 'bugs', name: 'Bugs', filter: { field: 'type', op: 'in', values: ['bug'] } },
|
|
1443
|
+
];
|
|
1444
|
+
const PAGE = 100;
|
|
1445
|
+
// /{org}/{project}/issues (prototype "design_list"): every issue as a table, a filter builder over the
|
|
1446
|
+
// typed filter, sortable columns, saved views (private, or shared by those who edit issues) and an
|
|
1447
|
+
// export of what the filter matches.
|
|
1448
|
+
class IssueListPage {
|
|
1449
|
+
constructor() {
|
|
1450
|
+
this.host = inject(DesignHost);
|
|
1451
|
+
this.api = inject(DesignApi);
|
|
1452
|
+
this.router = inject(Router);
|
|
1453
|
+
this.route = inject(ActivatedRoute);
|
|
1454
|
+
this.allCols = COLS;
|
|
1455
|
+
this.builtIn = BUILT_IN;
|
|
1456
|
+
this.types = TYPES;
|
|
1457
|
+
this.priorities = PRIORITIES;
|
|
1458
|
+
this.typeLabel = typeLabel;
|
|
1459
|
+
this.typeColour = typeColour;
|
|
1460
|
+
this.priorityLabel = priorityLabel;
|
|
1461
|
+
this.statusChip = statusChip;
|
|
1462
|
+
this.ago = (s) => ago(s);
|
|
1463
|
+
this.statuses = signal([], ...(ngDevMode ? [{ debugName: "statuses" }] : /* istanbul ignore next */ []));
|
|
1464
|
+
this.sprints = signal([], ...(ngDevMode ? [{ debugName: "sprints" }] : /* istanbul ignore next */ []));
|
|
1465
|
+
this.views = signal({ mine: [], shared: [] }, ...(ngDevMode ? [{ debugName: "views" }] : /* istanbul ignore next */ []));
|
|
1466
|
+
this.counts = signal({}, ...(ngDevMode ? [{ debugName: "counts" }] : /* istanbul ignore next */ []));
|
|
1467
|
+
this.view = signal('all', ...(ngDevMode ? [{ debugName: "view" }] : /* istanbul ignore next */ []));
|
|
1468
|
+
this.rows = signal([], ...(ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []));
|
|
1469
|
+
this.total = signal(0, ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
|
|
1470
|
+
this.sort = signal([], ...(ngDevMode ? [{ debugName: "sort" }] : /* istanbul ignore next */ []));
|
|
1471
|
+
this.cols = signal(DEFAULT_COLS, ...(ngDevMode ? [{ debugName: "cols" }] : /* istanbul ignore next */ []));
|
|
1472
|
+
this.loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
1473
|
+
this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1474
|
+
this.note = signal('', ...(ngDevMode ? [{ debugName: "note" }] : /* istanbul ignore next */ []));
|
|
1475
|
+
this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1476
|
+
this.colsOpen = signal(false, ...(ngDevMode ? [{ debugName: "colsOpen" }] : /* istanbul ignore next */ []));
|
|
1477
|
+
this.saving = signal(false, ...(ngDevMode ? [{ debugName: "saving" }] : /* istanbul ignore next */ []));
|
|
1478
|
+
this.creating = signal(false, ...(ngDevMode ? [{ debugName: "creating" }] : /* istanbul ignore next */ []));
|
|
1479
|
+
this.fType = '';
|
|
1480
|
+
this.fStatus = '';
|
|
1481
|
+
this.fWho = '';
|
|
1482
|
+
this.fPri = '';
|
|
1483
|
+
this.fSprint = '';
|
|
1484
|
+
this.fText = '';
|
|
1485
|
+
this.lastText = '';
|
|
1486
|
+
this.viewName = '';
|
|
1487
|
+
this.viewShared = false;
|
|
1488
|
+
this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
|
|
1489
|
+
this.canEdit = computed(() => {
|
|
1490
|
+
const p = this.host.project();
|
|
1491
|
+
return !!p && !p.archivedAt && p.capabilities.includes('issues.edit');
|
|
1492
|
+
}, ...(ngDevMode ? [{ debugName: "canEdit" }] : /* istanbul ignore next */ []));
|
|
1493
|
+
this.hasActive = computed(() => this.sprints().some((s) => s.state === 'active'), ...(ngDevMode ? [{ debugName: "hasActive" }] : /* istanbul ignore next */ []));
|
|
1494
|
+
this.savedView = computed(() => [...this.views().mine, ...this.views().shared].find((v) => v.id === this.view()) ?? null, ...(ngDevMode ? [{ debugName: "savedView" }] : /* istanbul ignore next */ []));
|
|
1495
|
+
this.title = computed(() => BUILT_IN.find((v) => v.id === this.view())?.name ?? this.savedView()?.name ?? 'All issues', ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
|
|
1496
|
+
this.shownCols = computed(() => COLS.filter((c) => this.cols().includes(c.id)), ...(ngDevMode ? [{ debugName: "shownCols" }] : /* istanbul ignore next */ []));
|
|
1497
|
+
this.sprintNames = computed(() => new Map(this.sprints().map((s) => [s.id, s.name])), ...(ngDevMode ? [{ debugName: "sprintNames" }] : /* istanbul ignore next */ []));
|
|
1498
|
+
effect(() => {
|
|
1499
|
+
const p = this.host.project();
|
|
1500
|
+
if (p)
|
|
1501
|
+
untracked(() => this.loadAll());
|
|
1502
|
+
});
|
|
1503
|
+
// ?new=1: C pressed elsewhere in the project (the shell sends it here).
|
|
1504
|
+
this.route.queryParamMap.subscribe((q) => {
|
|
1505
|
+
if (q.get('new')) {
|
|
1506
|
+
this.creating.set(true);
|
|
1507
|
+
void this.router.navigate([], { relativeTo: this.route, queryParams: { new: null }, queryParamsHandling: 'merge', replaceUrl: true });
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
key(e) {
|
|
1512
|
+
const t = e.target;
|
|
1513
|
+
if (e.key !== 'c' || e.metaKey || e.ctrlKey || e.altKey || t?.closest('input,textarea,select,[contenteditable]'))
|
|
1514
|
+
return;
|
|
1515
|
+
if (this.canEdit() && !this.creating()) {
|
|
1516
|
+
e.preventDefault();
|
|
1517
|
+
this.creating.set(true);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
sprintLabel(id) {
|
|
1521
|
+
return id ? this.sprintNames().get(id) ?? 'Sprint' : 'Backlog';
|
|
1522
|
+
}
|
|
1523
|
+
pick(id) {
|
|
1524
|
+
this.view.set(id);
|
|
1525
|
+
const sv = this.savedView();
|
|
1526
|
+
this.cols.set(sv?.columns?.length ? sv.columns : DEFAULT_COLS);
|
|
1527
|
+
this.sort.set(sv?.sort ?? []);
|
|
1528
|
+
this.fType = this.fStatus = this.fWho = this.fPri = this.fSprint = this.fText = '';
|
|
1529
|
+
this.lastText = '';
|
|
1530
|
+
this.reset();
|
|
1531
|
+
}
|
|
1532
|
+
flipCol(id) {
|
|
1533
|
+
const c = this.cols();
|
|
1534
|
+
this.cols.set(c.includes(id) ? c.filter((x) => x !== id) : DEFAULT_COLS.filter((x) => x === id || c.includes(x)));
|
|
1535
|
+
}
|
|
1536
|
+
sortBy(c) {
|
|
1537
|
+
const cur = this.sort()[0];
|
|
1538
|
+
this.sort.set([{ field: c.sort, desc: cur?.field === c.sort ? !cur.desc : false }]);
|
|
1539
|
+
this.reset();
|
|
1540
|
+
}
|
|
1541
|
+
arrow(c) {
|
|
1542
|
+
const s = this.sort()[0];
|
|
1543
|
+
return s?.field === c.sort ? (s.desc ? '↓' : '↑') : '';
|
|
1544
|
+
}
|
|
1545
|
+
ariaSort(c) {
|
|
1546
|
+
const s = this.sort()[0];
|
|
1547
|
+
return s?.field === c.sort ? (s.desc ? 'descending' : 'ascending') : 'none';
|
|
1548
|
+
}
|
|
1549
|
+
textChanged() {
|
|
1550
|
+
if (this.fText !== this.lastText)
|
|
1551
|
+
this.reset();
|
|
1552
|
+
}
|
|
1553
|
+
open(i) {
|
|
1554
|
+
void this.router.navigate(this.host.link('issues/' + i.key));
|
|
1555
|
+
}
|
|
1556
|
+
created(is) {
|
|
1557
|
+
this.creating.set(false);
|
|
1558
|
+
this.note.set(`${is.key} created.`);
|
|
1559
|
+
this.reset();
|
|
1560
|
+
}
|
|
1561
|
+
reset() {
|
|
1562
|
+
this.lastText = this.fText;
|
|
1563
|
+
this.rows.set([]);
|
|
1564
|
+
this.fetch(0);
|
|
1565
|
+
}
|
|
1566
|
+
more() {
|
|
1567
|
+
this.fetch(this.rows().length);
|
|
1568
|
+
}
|
|
1569
|
+
exportAs(format) {
|
|
1570
|
+
const p = this.host.project();
|
|
1571
|
+
if (!p)
|
|
1572
|
+
return;
|
|
1573
|
+
this.api.export(p.id, format, this.filter(), this.sort()).subscribe({
|
|
1574
|
+
next: (body) => download(body, `${p.key.toLowerCase()}-issues.${format}`, format === 'csv' ? 'text/csv' : 'application/json'),
|
|
1575
|
+
error: (e) => this.error.set(apiMessage(e, 'Could not export the issues.')),
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
saveView() {
|
|
1579
|
+
const p = this.host.project();
|
|
1580
|
+
const name = this.viewName.trim();
|
|
1581
|
+
if (!p || !name)
|
|
1582
|
+
return;
|
|
1583
|
+
this.busy.set(true);
|
|
1584
|
+
this.api.saveView(p.id, { name, shared: this.viewShared && this.canEdit(), filter: this.filter(), columns: this.cols(), sort: this.sort() }).subscribe({
|
|
1585
|
+
next: (v) => {
|
|
1586
|
+
this.busy.set(false);
|
|
1587
|
+
this.saving.set(false);
|
|
1588
|
+
this.viewName = '';
|
|
1589
|
+
this.viewShared = false;
|
|
1590
|
+
this.note.set(`Saved “${v.name}”.`);
|
|
1591
|
+
this.loadViews(v.id);
|
|
1592
|
+
},
|
|
1593
|
+
error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not save the view.')); },
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
deleteView(v) {
|
|
1597
|
+
const p = this.host.project();
|
|
1598
|
+
if (!p)
|
|
1599
|
+
return;
|
|
1600
|
+
this.api.deleteView(p.id, v.id).subscribe({
|
|
1601
|
+
next: () => { this.note.set(`Deleted “${v.name}”.`); this.loadViews('all'); },
|
|
1602
|
+
error: (e) => this.error.set(apiMessage(e, 'Could not delete the view.')),
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
fetch(offset) {
|
|
1606
|
+
const p = this.host.project();
|
|
1607
|
+
if (!p)
|
|
1608
|
+
return;
|
|
1609
|
+
this.loading.set(true);
|
|
1610
|
+
this.error.set('');
|
|
1611
|
+
this.api.query(p.id, { filter: this.filter(), sort: this.sort(), limit: PAGE, offset }).subscribe({
|
|
1612
|
+
next: (r) => { this.loading.set(false); this.rows.set(offset ? [...this.rows(), ...r.items] : r.items); this.total.set(r.total); },
|
|
1613
|
+
error: (e) => { this.loading.set(false); this.rows.set([]); this.total.set(0); this.error.set(apiMessage(e, 'Could not load the issues.')); },
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
/** The view's filter and the builder's conditions, together. */
|
|
1617
|
+
filter() {
|
|
1618
|
+
const all = [];
|
|
1619
|
+
const base = BUILT_IN.find((v) => v.id === this.view())?.filter ?? this.savedView()?.filter ?? null;
|
|
1620
|
+
if (base)
|
|
1621
|
+
all.push(base);
|
|
1622
|
+
if (this.fType)
|
|
1623
|
+
all.push({ field: 'type', op: 'in', values: [this.fType] });
|
|
1624
|
+
if (this.fStatus)
|
|
1625
|
+
all.push({ field: 'status', op: 'in', values: [this.fStatus] });
|
|
1626
|
+
if (this.fWho)
|
|
1627
|
+
all.push({ field: 'assignee', op: 'in', values: [this.fWho] });
|
|
1628
|
+
if (this.fPri)
|
|
1629
|
+
all.push({ field: 'priority', op: 'in', values: [this.fPri] });
|
|
1630
|
+
if (this.fSprint)
|
|
1631
|
+
all.push({ field: 'sprint', op: 'in', values: [this.fSprint] });
|
|
1632
|
+
if (this.fText.trim())
|
|
1633
|
+
all.push({ field: 'text', op: 'contains', values: [this.fText.trim()] });
|
|
1634
|
+
if (!all.length)
|
|
1635
|
+
return null;
|
|
1636
|
+
return all.length === 1 ? all[0] : { all };
|
|
1637
|
+
}
|
|
1638
|
+
loadViews(select) {
|
|
1639
|
+
const p = this.host.project();
|
|
1640
|
+
if (!p)
|
|
1641
|
+
return;
|
|
1642
|
+
this.api.views(p.id).subscribe({
|
|
1643
|
+
next: (v) => { this.views.set(v); if (select)
|
|
1644
|
+
this.pick(select); },
|
|
1645
|
+
error: () => undefined,
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
loadAll() {
|
|
1649
|
+
const p = this.host.project();
|
|
1650
|
+
if (!p)
|
|
1651
|
+
return;
|
|
1652
|
+
forkJoin({ st: this.api.statuses(p.id), sp: this.api.sprints(p.id) }).subscribe({
|
|
1653
|
+
next: ({ st, sp }) => { this.statuses.set(st); this.sprints.set(sp); },
|
|
1654
|
+
error: () => undefined,
|
|
1655
|
+
});
|
|
1656
|
+
this.loadViews();
|
|
1657
|
+
forkJoin(Object.fromEntries(BUILT_IN.map((v) => [v.id, this.api.query(p.id, { filter: v.filter, limit: 1 })]))).subscribe({
|
|
1658
|
+
next: (r) => this.counts.set(Object.fromEntries(Object.entries(r).map(([k, x]) => [k, x.total]))),
|
|
1659
|
+
error: () => undefined,
|
|
1660
|
+
});
|
|
1661
|
+
this.reset();
|
|
1662
|
+
}
|
|
1663
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: IssueListPage, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1664
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: IssueListPage, isStandalone: true, selector: "o-design-list", host: { listeners: { "document:keydown": "key($event)" } }, ngImport: i0, template: `
|
|
1665
|
+
<div class="o-page o-wide">
|
|
1666
|
+
<div class="o-ph">
|
|
1667
|
+
<div class="o-col"><span class="o-eyebrow">{{ host.project()?.key }} · Issues</span><h1 class="o-t">{{ title() }}</h1></div>
|
|
1668
|
+
<div class="o-acts">
|
|
1669
|
+
<button type="button" class="o-btn o-sm" (click)="exportAs('csv')" data-testid="export-csv">Export CSV</button>
|
|
1670
|
+
<button type="button" class="o-btn o-sm" (click)="exportAs('json')">Export JSON</button>
|
|
1671
|
+
<span class="menu-anchor">
|
|
1672
|
+
<button type="button" class="o-btn o-sm" (click)="colsOpen.set(!colsOpen())" [attr.aria-expanded]="colsOpen()" aria-haspopup="true">Columns</button>
|
|
1673
|
+
@if (colsOpen()) {
|
|
1674
|
+
<div class="o-menu cols" role="group" aria-label="Columns">
|
|
1675
|
+
@for (c of allCols; track c.id) {
|
|
1676
|
+
<label><input type="checkbox" [checked]="cols().includes(c.id)" [disabled]="c.id === 'key' || c.id === 'title'" (change)="flipCol(c.id)" />{{ c.label }}</label>
|
|
1677
|
+
}
|
|
1678
|
+
</div>
|
|
1679
|
+
}
|
|
1680
|
+
</span>
|
|
1681
|
+
@if (canEdit()) {<button type="button" class="o-btn o-sm" (click)="creating.set(true)">+ Issue</button>}
|
|
1682
|
+
<button type="button" class="o-btn o-sm o-dark" (click)="saving.set(!saving())" data-testid="save-view">Save view</button>
|
|
1683
|
+
</div>
|
|
1684
|
+
</div>
|
|
1685
|
+
|
|
1686
|
+
@if (saving()) {
|
|
1687
|
+
<form class="o-card o-cb o-flex o-wrap" style="gap:10px" (ngSubmit)="saveView()">
|
|
1688
|
+
<label class="o-sr-only" for="sv-name">View name</label>
|
|
1689
|
+
<input id="sv-name" class="o-in o-sm" style="width:240px" name="name" placeholder="Name this view" [(ngModel)]="viewName" data-testid="view-name" />
|
|
1690
|
+
<label class="o-flex o-sm" [class.o-faint]="!canEdit()"><input type="checkbox" name="shared" [(ngModel)]="viewShared" [disabled]="!canEdit()" />Share with the project</label>
|
|
1691
|
+
<span class="o-faint o-sm">Saves these filters, columns and sort.</span>
|
|
1692
|
+
<span class="o-r o-flex"><button type="button" class="o-btn o-sm" (click)="saving.set(false)">Cancel</button><button type="submit" class="o-btn o-sm o-dark" [disabled]="!viewName.trim() || busy()">Save</button></span>
|
|
1693
|
+
</form>
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
<div class="o-split o-lft">
|
|
1697
|
+
<nav class="o-setnav" aria-label="Views">
|
|
1698
|
+
<span class="o-lbl" style="padding-top:0">My views</span>
|
|
1699
|
+
@for (v of builtIn; track v.id) {
|
|
1700
|
+
<button type="button" [class.o-on]="view() === v.id" [attr.aria-current]="view() === v.id ? 'true' : null" (click)="pick(v.id)">{{ v.name }}<span class="o-r o-faint o-xs">{{ counts()[v.id] ?? '' }}</span></button>
|
|
1701
|
+
}
|
|
1702
|
+
@for (v of views().mine; track v.id) {
|
|
1703
|
+
<button type="button" [class.o-on]="view() === v.id" [attr.aria-current]="view() === v.id ? 'true' : null" (click)="pick(v.id)">{{ v.name }}<span class="o-r o-faint o-xs">{{ v.count }}</span></button>
|
|
1704
|
+
}
|
|
1705
|
+
<span class="o-lbl">Shared</span>
|
|
1706
|
+
@for (v of views().shared; track v.id) {
|
|
1707
|
+
<button type="button" [class.o-on]="view() === v.id" [attr.aria-current]="view() === v.id ? 'true' : null" (click)="pick(v.id)">{{ v.name }}<span class="o-r o-faint o-xs">{{ v.count }}</span></button>
|
|
1708
|
+
} @empty {
|
|
1709
|
+
<span class="o-faint o-sm" style="padding:4px 10px">No shared views yet.</span>
|
|
1710
|
+
}
|
|
1711
|
+
@if (savedView(); as sv) {
|
|
1712
|
+
@if (sv.mine) {<button type="button" class="o-faint o-sm" style="margin-top:10px" (click)="deleteView(sv)">Delete “{{ sv.name }}”</button>}
|
|
1713
|
+
}
|
|
1714
|
+
</nav>
|
|
1715
|
+
|
|
1716
|
+
<div class="o-col" style="gap:12px;min-width:0">
|
|
1717
|
+
<div class="o-flex o-wrap">
|
|
1718
|
+
<div class="o-seg" role="group" aria-label="Filter mode"><button type="button" class="o-on" aria-pressed="true">Filters</button><button type="button" disabled title="The query syntax comes at GA">Query</button></div>
|
|
1719
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Type" [(ngModel)]="fType" (ngModelChange)="reset()"><option value="">Type: any</option>@for (t of types; track t) {<option [value]="t">{{ typeLabel(t) }}</option>}</select>
|
|
1720
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Status" [(ngModel)]="fStatus" (ngModelChange)="reset()"><option value="">Status: any</option>@for (s of statuses(); track s.id) {<option [value]="s.id">{{ s.name }}</option>}</select>
|
|
1721
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Assignee" [(ngModel)]="fWho" (ngModelChange)="reset()">
|
|
1722
|
+
<option value="">Assignee: any</option><option value="me">Me</option><option value="none">Unassigned</option>
|
|
1723
|
+
@for (p of host.people(); track p.id) {<option [value]="p.id">{{ p.name || p.email }}</option>}
|
|
1724
|
+
</select>
|
|
1725
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Priority" [(ngModel)]="fPri" (ngModelChange)="reset()"><option value="">Priority: any</option>@for (p of priorities; track p) {<option [value]="p">{{ priorityLabel(p) }}</option>}</select>
|
|
1726
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Sprint" [(ngModel)]="fSprint" (ngModelChange)="reset()">
|
|
1727
|
+
<option value="">Sprint: any</option>@for (s of sprints(); track s.id) {<option [value]="s.id">{{ s.name }}</option>}<option value="backlog">Backlog</option>
|
|
1728
|
+
</select>
|
|
1729
|
+
<input class="o-in o-sm" style="width:160px" placeholder="Text" aria-label="Text" [(ngModel)]="fText" (keydown.enter)="reset()" (blur)="textChanged()" />
|
|
1730
|
+
<span class="o-r o-faint o-sm" aria-live="polite">{{ total() }} {{ total() === 1 ? 'issue' : 'issues' }}</span>
|
|
1731
|
+
</div>
|
|
1732
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
1733
|
+
@if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
|
|
1734
|
+
<div class="o-card" style="overflow-x:auto">
|
|
1735
|
+
<div class="o-tbl" role="table" [attr.aria-label]="title()" style="min-width:820px">
|
|
1736
|
+
<div class="o-thead" role="rowgroup"><div class="o-tr" role="row">
|
|
1737
|
+
@for (c of shownCols(); track c.id) {
|
|
1738
|
+
<div class="o-th o-sort" role="columnheader" tabindex="0" [attr.aria-sort]="ariaSort(c)" (click)="sortBy(c)" (keydown.enter)="sortBy(c)">{{ c.label }}<span class="o-faint"> {{ arrow(c) }}</span></div>
|
|
1739
|
+
}
|
|
1740
|
+
</div></div>
|
|
1741
|
+
<div class="o-tbody" role="rowgroup">
|
|
1742
|
+
@for (i of rows(); track i.key) {
|
|
1743
|
+
<div class="o-tr o-click" role="row" tabindex="0" (click)="open(i)" (keydown.enter)="open(i)" [attr.data-testid]="'list-' + i.key">
|
|
1744
|
+
@for (c of shownCols(); track c.id) {
|
|
1745
|
+
@switch (c.id) {
|
|
1746
|
+
@case ('key') {<div class="o-td o-mono o-sm o-mut" role="cell">{{ i.key }}</div>}
|
|
1747
|
+
@case ('title') {<div class="o-td" role="cell"><span class="o-flex"><span class="o-sq" [style.background]="typeColour(i.type)" aria-hidden="true"></span><span class="o-ell">{{ i.title }}</span></span></div>}
|
|
1748
|
+
@case ('type') {<div class="o-td o-mut" role="cell">{{ typeLabel(i.type) }}</div>}
|
|
1749
|
+
@case ('status') {<div class="o-td" role="cell"><span [class]="statusChip(i.status, i.category)">{{ i.status }}</span></div>}
|
|
1750
|
+
@case ('assignee') {<div class="o-td" role="cell"><span class="o-flex"><span class="o-av" aria-hidden="true">{{ people().initials(i.assigneeId) }}</span><span class="o-ell">{{ people().name(i.assigneeId) }}</span></span></div>}
|
|
1751
|
+
@case ('priority') {<div class="o-td o-mut" role="cell">{{ priorityLabel(i.priority) }}</div>}
|
|
1752
|
+
@case ('points') {<div class="o-td o-num o-mono" role="cell">{{ i.points ?? '' }}</div>}
|
|
1753
|
+
@case ('sprint') {<div class="o-td o-mut" role="cell">{{ sprintLabel(i.sprintId) }}</div>}
|
|
1754
|
+
@case ('updated') {<div class="o-td o-faint o-sm" role="cell">{{ ago(i.updatedAt) }}</div>}
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
</div>
|
|
1758
|
+
} @empty {
|
|
1759
|
+
<div class="o-empty">{{ loading() ? 'Loading issues…' : 'No issues match.' }}</div>
|
|
1760
|
+
}
|
|
1761
|
+
</div>
|
|
1762
|
+
</div>
|
|
1763
|
+
</div>
|
|
1764
|
+
@if (rows().length < total()) {<button type="button" class="o-btn o-sm" style="align-self:center" (click)="more()" [disabled]="loading()">Show more</button>}
|
|
1765
|
+
</div>
|
|
1766
|
+
</div>
|
|
1767
|
+
</div>
|
|
1768
|
+
|
|
1769
|
+
@if (creating()) {
|
|
1770
|
+
<o-create-issue [hasActive]="hasActive()" (created)="created($event)" (cancel)="creating.set(false)" />
|
|
1771
|
+
}
|
|
1772
|
+
`, isInline: true, styles: [".o-th.o-sort{cursor:pointer;-webkit-user-select:none;user-select:none}.menu-anchor{position:relative}.cols{right:0;top:34px;min-width:180px;padding:6px}.cols label{display:flex;gap:8px;align-items:center;padding:5px 6px;font-size:13px;cursor:pointer}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: CreateIssueDialog, selector: "o-create-issue", inputs: ["statusId", "hasActive", "defaultSprint"], outputs: ["created", "cancel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1773
|
+
}
|
|
1774
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: IssueListPage, decorators: [{
|
|
1775
|
+
type: Component,
|
|
1776
|
+
args: [{ selector: 'o-design-list', imports: [FormsModule, CreateIssueDialog], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
1777
|
+
<div class="o-page o-wide">
|
|
1778
|
+
<div class="o-ph">
|
|
1779
|
+
<div class="o-col"><span class="o-eyebrow">{{ host.project()?.key }} · Issues</span><h1 class="o-t">{{ title() }}</h1></div>
|
|
1780
|
+
<div class="o-acts">
|
|
1781
|
+
<button type="button" class="o-btn o-sm" (click)="exportAs('csv')" data-testid="export-csv">Export CSV</button>
|
|
1782
|
+
<button type="button" class="o-btn o-sm" (click)="exportAs('json')">Export JSON</button>
|
|
1783
|
+
<span class="menu-anchor">
|
|
1784
|
+
<button type="button" class="o-btn o-sm" (click)="colsOpen.set(!colsOpen())" [attr.aria-expanded]="colsOpen()" aria-haspopup="true">Columns</button>
|
|
1785
|
+
@if (colsOpen()) {
|
|
1786
|
+
<div class="o-menu cols" role="group" aria-label="Columns">
|
|
1787
|
+
@for (c of allCols; track c.id) {
|
|
1788
|
+
<label><input type="checkbox" [checked]="cols().includes(c.id)" [disabled]="c.id === 'key' || c.id === 'title'" (change)="flipCol(c.id)" />{{ c.label }}</label>
|
|
1789
|
+
}
|
|
1790
|
+
</div>
|
|
1791
|
+
}
|
|
1792
|
+
</span>
|
|
1793
|
+
@if (canEdit()) {<button type="button" class="o-btn o-sm" (click)="creating.set(true)">+ Issue</button>}
|
|
1794
|
+
<button type="button" class="o-btn o-sm o-dark" (click)="saving.set(!saving())" data-testid="save-view">Save view</button>
|
|
1795
|
+
</div>
|
|
1796
|
+
</div>
|
|
1797
|
+
|
|
1798
|
+
@if (saving()) {
|
|
1799
|
+
<form class="o-card o-cb o-flex o-wrap" style="gap:10px" (ngSubmit)="saveView()">
|
|
1800
|
+
<label class="o-sr-only" for="sv-name">View name</label>
|
|
1801
|
+
<input id="sv-name" class="o-in o-sm" style="width:240px" name="name" placeholder="Name this view" [(ngModel)]="viewName" data-testid="view-name" />
|
|
1802
|
+
<label class="o-flex o-sm" [class.o-faint]="!canEdit()"><input type="checkbox" name="shared" [(ngModel)]="viewShared" [disabled]="!canEdit()" />Share with the project</label>
|
|
1803
|
+
<span class="o-faint o-sm">Saves these filters, columns and sort.</span>
|
|
1804
|
+
<span class="o-r o-flex"><button type="button" class="o-btn o-sm" (click)="saving.set(false)">Cancel</button><button type="submit" class="o-btn o-sm o-dark" [disabled]="!viewName.trim() || busy()">Save</button></span>
|
|
1805
|
+
</form>
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
<div class="o-split o-lft">
|
|
1809
|
+
<nav class="o-setnav" aria-label="Views">
|
|
1810
|
+
<span class="o-lbl" style="padding-top:0">My views</span>
|
|
1811
|
+
@for (v of builtIn; track v.id) {
|
|
1812
|
+
<button type="button" [class.o-on]="view() === v.id" [attr.aria-current]="view() === v.id ? 'true' : null" (click)="pick(v.id)">{{ v.name }}<span class="o-r o-faint o-xs">{{ counts()[v.id] ?? '' }}</span></button>
|
|
1813
|
+
}
|
|
1814
|
+
@for (v of views().mine; track v.id) {
|
|
1815
|
+
<button type="button" [class.o-on]="view() === v.id" [attr.aria-current]="view() === v.id ? 'true' : null" (click)="pick(v.id)">{{ v.name }}<span class="o-r o-faint o-xs">{{ v.count }}</span></button>
|
|
1816
|
+
}
|
|
1817
|
+
<span class="o-lbl">Shared</span>
|
|
1818
|
+
@for (v of views().shared; track v.id) {
|
|
1819
|
+
<button type="button" [class.o-on]="view() === v.id" [attr.aria-current]="view() === v.id ? 'true' : null" (click)="pick(v.id)">{{ v.name }}<span class="o-r o-faint o-xs">{{ v.count }}</span></button>
|
|
1820
|
+
} @empty {
|
|
1821
|
+
<span class="o-faint o-sm" style="padding:4px 10px">No shared views yet.</span>
|
|
1822
|
+
}
|
|
1823
|
+
@if (savedView(); as sv) {
|
|
1824
|
+
@if (sv.mine) {<button type="button" class="o-faint o-sm" style="margin-top:10px" (click)="deleteView(sv)">Delete “{{ sv.name }}”</button>}
|
|
1825
|
+
}
|
|
1826
|
+
</nav>
|
|
1827
|
+
|
|
1828
|
+
<div class="o-col" style="gap:12px;min-width:0">
|
|
1829
|
+
<div class="o-flex o-wrap">
|
|
1830
|
+
<div class="o-seg" role="group" aria-label="Filter mode"><button type="button" class="o-on" aria-pressed="true">Filters</button><button type="button" disabled title="The query syntax comes at GA">Query</button></div>
|
|
1831
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Type" [(ngModel)]="fType" (ngModelChange)="reset()"><option value="">Type: any</option>@for (t of types; track t) {<option [value]="t">{{ typeLabel(t) }}</option>}</select>
|
|
1832
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Status" [(ngModel)]="fStatus" (ngModelChange)="reset()"><option value="">Status: any</option>@for (s of statuses(); track s.id) {<option [value]="s.id">{{ s.name }}</option>}</select>
|
|
1833
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Assignee" [(ngModel)]="fWho" (ngModelChange)="reset()">
|
|
1834
|
+
<option value="">Assignee: any</option><option value="me">Me</option><option value="none">Unassigned</option>
|
|
1835
|
+
@for (p of host.people(); track p.id) {<option [value]="p.id">{{ p.name || p.email }}</option>}
|
|
1836
|
+
</select>
|
|
1837
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Priority" [(ngModel)]="fPri" (ngModelChange)="reset()"><option value="">Priority: any</option>@for (p of priorities; track p) {<option [value]="p">{{ priorityLabel(p) }}</option>}</select>
|
|
1838
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Sprint" [(ngModel)]="fSprint" (ngModelChange)="reset()">
|
|
1839
|
+
<option value="">Sprint: any</option>@for (s of sprints(); track s.id) {<option [value]="s.id">{{ s.name }}</option>}<option value="backlog">Backlog</option>
|
|
1840
|
+
</select>
|
|
1841
|
+
<input class="o-in o-sm" style="width:160px" placeholder="Text" aria-label="Text" [(ngModel)]="fText" (keydown.enter)="reset()" (blur)="textChanged()" />
|
|
1842
|
+
<span class="o-r o-faint o-sm" aria-live="polite">{{ total() }} {{ total() === 1 ? 'issue' : 'issues' }}</span>
|
|
1843
|
+
</div>
|
|
1844
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
1845
|
+
@if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
|
|
1846
|
+
<div class="o-card" style="overflow-x:auto">
|
|
1847
|
+
<div class="o-tbl" role="table" [attr.aria-label]="title()" style="min-width:820px">
|
|
1848
|
+
<div class="o-thead" role="rowgroup"><div class="o-tr" role="row">
|
|
1849
|
+
@for (c of shownCols(); track c.id) {
|
|
1850
|
+
<div class="o-th o-sort" role="columnheader" tabindex="0" [attr.aria-sort]="ariaSort(c)" (click)="sortBy(c)" (keydown.enter)="sortBy(c)">{{ c.label }}<span class="o-faint"> {{ arrow(c) }}</span></div>
|
|
1851
|
+
}
|
|
1852
|
+
</div></div>
|
|
1853
|
+
<div class="o-tbody" role="rowgroup">
|
|
1854
|
+
@for (i of rows(); track i.key) {
|
|
1855
|
+
<div class="o-tr o-click" role="row" tabindex="0" (click)="open(i)" (keydown.enter)="open(i)" [attr.data-testid]="'list-' + i.key">
|
|
1856
|
+
@for (c of shownCols(); track c.id) {
|
|
1857
|
+
@switch (c.id) {
|
|
1858
|
+
@case ('key') {<div class="o-td o-mono o-sm o-mut" role="cell">{{ i.key }}</div>}
|
|
1859
|
+
@case ('title') {<div class="o-td" role="cell"><span class="o-flex"><span class="o-sq" [style.background]="typeColour(i.type)" aria-hidden="true"></span><span class="o-ell">{{ i.title }}</span></span></div>}
|
|
1860
|
+
@case ('type') {<div class="o-td o-mut" role="cell">{{ typeLabel(i.type) }}</div>}
|
|
1861
|
+
@case ('status') {<div class="o-td" role="cell"><span [class]="statusChip(i.status, i.category)">{{ i.status }}</span></div>}
|
|
1862
|
+
@case ('assignee') {<div class="o-td" role="cell"><span class="o-flex"><span class="o-av" aria-hidden="true">{{ people().initials(i.assigneeId) }}</span><span class="o-ell">{{ people().name(i.assigneeId) }}</span></span></div>}
|
|
1863
|
+
@case ('priority') {<div class="o-td o-mut" role="cell">{{ priorityLabel(i.priority) }}</div>}
|
|
1864
|
+
@case ('points') {<div class="o-td o-num o-mono" role="cell">{{ i.points ?? '' }}</div>}
|
|
1865
|
+
@case ('sprint') {<div class="o-td o-mut" role="cell">{{ sprintLabel(i.sprintId) }}</div>}
|
|
1866
|
+
@case ('updated') {<div class="o-td o-faint o-sm" role="cell">{{ ago(i.updatedAt) }}</div>}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
</div>
|
|
1870
|
+
} @empty {
|
|
1871
|
+
<div class="o-empty">{{ loading() ? 'Loading issues…' : 'No issues match.' }}</div>
|
|
1872
|
+
}
|
|
1873
|
+
</div>
|
|
1874
|
+
</div>
|
|
1875
|
+
</div>
|
|
1876
|
+
@if (rows().length < total()) {<button type="button" class="o-btn o-sm" style="align-self:center" (click)="more()" [disabled]="loading()">Show more</button>}
|
|
1877
|
+
</div>
|
|
1878
|
+
</div>
|
|
1879
|
+
</div>
|
|
1880
|
+
|
|
1881
|
+
@if (creating()) {
|
|
1882
|
+
<o-create-issue [hasActive]="hasActive()" (created)="created($event)" (cancel)="creating.set(false)" />
|
|
1883
|
+
}
|
|
1884
|
+
`, styles: [".o-th.o-sort{cursor:pointer;-webkit-user-select:none;user-select:none}.menu-anchor{position:relative}.cols{right:0;top:34px;min-width:180px;padding:6px}.cols label{display:flex;gap:8px;align-items:center;padding:5px 6px;font-size:13px;cursor:pointer}\n"] }]
|
|
1885
|
+
}], ctorParameters: () => [], propDecorators: { key: [{
|
|
1886
|
+
type: HostListener,
|
|
1887
|
+
args: ['document:keydown', ['$event']]
|
|
1888
|
+
}] } });
|
|
1889
|
+
|
|
1890
|
+
const REL_LABEL = {
|
|
1891
|
+
blocks: 'blocks', blocked_by: 'is blocked by', relates: 'relates to', duplicates: 'duplicates', duplicated_by: 'is duplicated by',
|
|
1892
|
+
};
|
|
1893
|
+
const SEVERITIES = ['critical', 'major', 'minor', 'trivial'];
|
|
1894
|
+
const REACTIONS = [
|
|
1895
|
+
{ name: '+1', emoji: '👍', label: 'thumbs up' }, { name: '-1', emoji: '👎', label: 'thumbs down' }, { name: 'laugh', emoji: '😄', label: 'laugh' },
|
|
1896
|
+
{ name: 'hooray', emoji: '🎉', label: 'hooray' }, { name: 'confused', emoji: '😕', label: 'confused' }, { name: 'heart', emoji: '❤️', label: 'heart' },
|
|
1897
|
+
{ name: 'rocket', emoji: '🚀', label: 'rocket' }, { name: 'eyes', emoji: '👀', label: 'eyes' },
|
|
1898
|
+
];
|
|
1899
|
+
const MAX_FILE = 25 * 1024 * 1024;
|
|
1900
|
+
const MENTION = /\[@([^\]\n]{1,120})\]\(mention:([A-Za-z0-9_-]{1,64})\)/g;
|
|
1901
|
+
const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
1902
|
+
const size = (n) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
1903
|
+
// /{org}/{project}/issues/PAY-142 (prototype "design_issue"): the issue's description (Markdown),
|
|
1904
|
+
// sub-tasks, what code names it, linked issues, comments and history; its fields on the right, each
|
|
1905
|
+
// editable in place by those who edit issues. Every change names the version it read, so a change
|
|
1906
|
+
// someone made first is never overwritten.
|
|
1907
|
+
class IssuePage {
|
|
1908
|
+
constructor() {
|
|
1909
|
+
this.host = inject(DesignHost);
|
|
1910
|
+
this.api = inject(DesignApi);
|
|
1911
|
+
this.route = inject(ActivatedRoute);
|
|
1912
|
+
this.router = inject(Router);
|
|
1913
|
+
this.key = signal('', ...(ngDevMode ? [{ debugName: "key" }] : /* istanbul ignore next */ []));
|
|
1914
|
+
this.issue = signal(null, ...(ngDevMode ? [{ debugName: "issue" }] : /* istanbul ignore next */ []));
|
|
1915
|
+
this.statuses = signal([], ...(ngDevMode ? [{ debugName: "statuses" }] : /* istanbul ignore next */ []));
|
|
1916
|
+
this.sprints = signal([], ...(ngDevMode ? [{ debugName: "sprints" }] : /* istanbul ignore next */ []));
|
|
1917
|
+
this.epics = signal([], ...(ngDevMode ? [{ debugName: "epics" }] : /* istanbul ignore next */ []));
|
|
1918
|
+
this.comments = signal([], ...(ngDevMode ? [{ debugName: "comments" }] : /* istanbul ignore next */ []));
|
|
1919
|
+
this.history = signal([], ...(ngDevMode ? [{ debugName: "history" }] : /* istanbul ignore next */ []));
|
|
1920
|
+
this.dev = signal(null, ...(ngDevMode ? [{ debugName: "dev" }] : /* istanbul ignore next */ []));
|
|
1921
|
+
this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1922
|
+
this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1923
|
+
this.tab = signal('comments', ...(ngDevMode ? [{ debugName: "tab" }] : /* istanbul ignore next */ []));
|
|
1924
|
+
this.draft = signal('', ...(ngDevMode ? [{ debugName: "draft" }] : /* istanbul ignore next */ []));
|
|
1925
|
+
this.childTitle = signal('', ...(ngDevMode ? [{ debugName: "childTitle" }] : /* istanbul ignore next */ []));
|
|
1926
|
+
this.labelDraft = signal('', ...(ngDevMode ? [{ debugName: "labelDraft" }] : /* istanbul ignore next */ []));
|
|
1927
|
+
this.editingTitle = signal(false, ...(ngDevMode ? [{ debugName: "editingTitle" }] : /* istanbul ignore next */ []));
|
|
1928
|
+
this.editingDesc = signal(false, ...(ngDevMode ? [{ debugName: "editingDesc" }] : /* istanbul ignore next */ []));
|
|
1929
|
+
this.linking = signal(false, ...(ngDevMode ? [{ debugName: "linking" }] : /* istanbul ignore next */ []));
|
|
1930
|
+
this.copied = signal(false, ...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
|
|
1931
|
+
this.confirmDelete = signal(false, ...(ngDevMode ? [{ debugName: "confirmDelete" }] : /* istanbul ignore next */ []));
|
|
1932
|
+
this.deleteKey = signal('', ...(ngDevMode ? [{ debugName: "deleteKey" }] : /* istanbul ignore next */ []));
|
|
1933
|
+
this.attachments = signal([], ...(ngDevMode ? [{ debugName: "attachments" }] : /* istanbul ignore next */ []));
|
|
1934
|
+
this.thumbs = signal({}, ...(ngDevMode ? [{ debugName: "thumbs" }] : /* istanbul ignore next */ []));
|
|
1935
|
+
this.uploading = signal(false, ...(ngDevMode ? [{ debugName: "uploading" }] : /* istanbul ignore next */ []));
|
|
1936
|
+
this.watching = signal(false, ...(ngDevMode ? [{ debugName: "watching" }] : /* istanbul ignore next */ []));
|
|
1937
|
+
this.watchers = signal([], ...(ngDevMode ? [{ debugName: "watchers" }] : /* istanbul ignore next */ []));
|
|
1938
|
+
this.picker = signal(null, ...(ngDevMode ? [{ debugName: "picker" }] : /* istanbul ignore next */ []));
|
|
1939
|
+
this.mentionFor = signal(null, ...(ngDevMode ? [{ debugName: "mentionFor" }] : /* istanbul ignore next */ []));
|
|
1940
|
+
this.mentionQuery = signal('', ...(ngDevMode ? [{ debugName: "mentionQuery" }] : /* istanbul ignore next */ []));
|
|
1941
|
+
this.mentionIndex = signal(0, ...(ngDevMode ? [{ debugName: "mentionIndex" }] : /* istanbul ignore next */ []));
|
|
1942
|
+
this.mentionStart = 0;
|
|
1943
|
+
this.reactionList = REACTIONS;
|
|
1944
|
+
this.size = size;
|
|
1945
|
+
this.titleDraft = '';
|
|
1946
|
+
this.descDraft = '';
|
|
1947
|
+
this.linkKind = 'relates';
|
|
1948
|
+
this.linkKey = '';
|
|
1949
|
+
this.relKinds = Object.keys(REL_LABEL);
|
|
1950
|
+
this.priorities = PRIORITIES;
|
|
1951
|
+
this.severities = SEVERITIES;
|
|
1952
|
+
this.typeColour = typeColour;
|
|
1953
|
+
this.typeLabel = typeLabel;
|
|
1954
|
+
this.priorityLabel = priorityLabel;
|
|
1955
|
+
this.statusChip = statusChip;
|
|
1956
|
+
this.epicChip = epicChip;
|
|
1957
|
+
this.shortDate = shortDate;
|
|
1958
|
+
this.ago = (s) => ago(s);
|
|
1959
|
+
this.cap = (s) => s[0].toUpperCase() + s.slice(1);
|
|
1960
|
+
this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
|
|
1961
|
+
this.caps = computed(() => {
|
|
1962
|
+
const p = this.host.project();
|
|
1963
|
+
return p?.archivedAt ? [] : this.issue()?.capabilities ?? [];
|
|
1964
|
+
}, ...(ngDevMode ? [{ debugName: "caps" }] : /* istanbul ignore next */ []));
|
|
1965
|
+
this.canEdit = computed(() => this.caps().includes('issues.edit'), ...(ngDevMode ? [{ debugName: "canEdit" }] : /* istanbul ignore next */ []));
|
|
1966
|
+
this.canComment = computed(() => this.caps().includes('issues.comment'), ...(ngDevMode ? [{ debugName: "canComment" }] : /* istanbul ignore next */ []));
|
|
1967
|
+
this.canManage = computed(() => this.caps().includes('resources.manage'), ...(ngDevMode ? [{ debugName: "canManage" }] : /* istanbul ignore next */ []));
|
|
1968
|
+
this.openSprints = computed(() => this.sprints().filter((s) => s.state !== 'completed'), ...(ngDevMode ? [{ debugName: "openSprints" }] : /* istanbul ignore next */ []));
|
|
1969
|
+
this.completedSprint = computed(() => {
|
|
1970
|
+
const id = this.issue()?.sprintId;
|
|
1971
|
+
return id ? this.sprints().find((s) => s.id === id && s.state === 'completed') ?? null : null;
|
|
1972
|
+
}, ...(ngDevMode ? [{ debugName: "completedSprint" }] : /* istanbul ignore next */ []));
|
|
1973
|
+
this.html = computed(() => this.md(this.issue()?.description ?? ''), ...(ngDevMode ? [{ debugName: "html" }] : /* istanbul ignore next */ []));
|
|
1974
|
+
this.watcherNames = computed(() => {
|
|
1975
|
+
const me = this.host.meId();
|
|
1976
|
+
const ids = this.watchers();
|
|
1977
|
+
if (!ids.length)
|
|
1978
|
+
return 'nobody yet';
|
|
1979
|
+
const names = ids.map((id) => (id === me ? 'you' : this.people().name(id)));
|
|
1980
|
+
names.sort((a, b) => (a === 'you' ? -1 : b === 'you' ? 1 : 0));
|
|
1981
|
+
return names.join(', ');
|
|
1982
|
+
}, ...(ngDevMode ? [{ debugName: "watcherNames" }] : /* istanbul ignore next */ []));
|
|
1983
|
+
this.suggestions = computed(() => {
|
|
1984
|
+
const q = this.mentionQuery().toLowerCase();
|
|
1985
|
+
const me = this.host.meId();
|
|
1986
|
+
return this.host.people()
|
|
1987
|
+
.filter((p) => p.id !== me && (!q || (p.name ?? '').toLowerCase().split(/\s+/).some((w) => w.startsWith(q)) || (p.name ?? '').toLowerCase().startsWith(q) || (p.email ?? '').toLowerCase().startsWith(q)))
|
|
1988
|
+
.slice(0, 6);
|
|
1989
|
+
}, ...(ngDevMode ? [{ debugName: "suggestions" }] : /* istanbul ignore next */ []));
|
|
1990
|
+
this.route.paramMap.subscribe((m) => this.key.set((m.get('key') ?? '').toUpperCase()));
|
|
1991
|
+
effect(() => {
|
|
1992
|
+
const key = this.key();
|
|
1993
|
+
const p = this.host.project();
|
|
1994
|
+
if (key && p)
|
|
1995
|
+
untracked(() => this.loadAll(key, p.id));
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
/** Markdown to HTML; a mention shows the person's current name, not the label typed with it. */
|
|
1999
|
+
md(src) {
|
|
2000
|
+
const people = this.people();
|
|
2001
|
+
const withMentions = (src || '').replace(MENTION, (_m, label, id) => {
|
|
2002
|
+
const name = people.name(id);
|
|
2003
|
+
return `<span class="mention">@${esc(name && name !== id ? name : label)}</span>`;
|
|
2004
|
+
});
|
|
2005
|
+
return marked.parse(withMentions, { async: false, gfm: true, breaks: true });
|
|
2006
|
+
}
|
|
2007
|
+
// ---- mentions ----
|
|
2008
|
+
typed(el, which) {
|
|
2009
|
+
const before = el.value.slice(0, el.selectionStart ?? el.value.length);
|
|
2010
|
+
const m = /(^|\s)@([\p{L}\w.-]{0,30})$/u.exec(before);
|
|
2011
|
+
if (!m) {
|
|
2012
|
+
this.mentionFor.set(null);
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
this.mentionStart = before.length - m[2].length - 1;
|
|
2016
|
+
this.mentionQuery.set(m[2]);
|
|
2017
|
+
this.mentionIndex.set(0);
|
|
2018
|
+
this.mentionFor.set(which);
|
|
2019
|
+
}
|
|
2020
|
+
mentionKey(ev, el) {
|
|
2021
|
+
if (!this.mentionFor() || !this.suggestions().length)
|
|
2022
|
+
return;
|
|
2023
|
+
const n = this.suggestions().length;
|
|
2024
|
+
if (ev.key === 'ArrowDown') {
|
|
2025
|
+
ev.preventDefault();
|
|
2026
|
+
this.mentionIndex.set((this.mentionIndex() + 1) % n);
|
|
2027
|
+
}
|
|
2028
|
+
else if (ev.key === 'ArrowUp') {
|
|
2029
|
+
ev.preventDefault();
|
|
2030
|
+
this.mentionIndex.set((this.mentionIndex() + n - 1) % n);
|
|
2031
|
+
}
|
|
2032
|
+
else if (ev.key === 'Enter' || ev.key === 'Tab') {
|
|
2033
|
+
ev.preventDefault();
|
|
2034
|
+
this.pick(this.suggestions()[this.mentionIndex()], el);
|
|
2035
|
+
}
|
|
2036
|
+
else if (ev.key === 'Escape') {
|
|
2037
|
+
ev.preventDefault();
|
|
2038
|
+
this.mentionFor.set(null);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
closeMentionSoon() {
|
|
2042
|
+
setTimeout(() => this.mentionFor.set(null), 150);
|
|
2043
|
+
}
|
|
2044
|
+
pick(p, el) {
|
|
2045
|
+
const caret = el.selectionStart ?? el.value.length;
|
|
2046
|
+
const token = `[@${(p.name || p.email || 'someone').replace(/[\[\]]/g, '')}](mention:${p.id}) `;
|
|
2047
|
+
const next = el.value.slice(0, this.mentionStart) + token + el.value.slice(caret);
|
|
2048
|
+
if (this.mentionFor() === 'desc')
|
|
2049
|
+
this.descDraft = next;
|
|
2050
|
+
else
|
|
2051
|
+
this.draft.set(next);
|
|
2052
|
+
el.value = next;
|
|
2053
|
+
const at = this.mentionStart + token.length;
|
|
2054
|
+
this.mentionFor.set(null);
|
|
2055
|
+
setTimeout(() => { el.focus(); el.setSelectionRange(at, at); });
|
|
2056
|
+
}
|
|
2057
|
+
// ---- reactions ----
|
|
2058
|
+
emoji(r) { return REACTIONS.find((x) => x.name === r)?.emoji ?? r; }
|
|
2059
|
+
emojiLabel(r) { return REACTIONS.find((x) => x.name === r)?.label ?? r; }
|
|
2060
|
+
reactors(ids) {
|
|
2061
|
+
const me = this.host.meId();
|
|
2062
|
+
return ids.map((id) => (id === me ? 'You' : this.people().name(id))).join(', ');
|
|
2063
|
+
}
|
|
2064
|
+
react(c, r, on) {
|
|
2065
|
+
this.picker.set(null);
|
|
2066
|
+
this.api.react(c.id, r, on).subscribe({
|
|
2067
|
+
next: (rs) => this.comments.set(this.comments().map((x) => (x.id === c.id ? { ...x, reactions: rs } : x))),
|
|
2068
|
+
error: (e) => this.fail(e, 'Could not react.'),
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
// ---- watching ----
|
|
2072
|
+
toggleWatch() {
|
|
2073
|
+
const is = this.issue();
|
|
2074
|
+
if (!is)
|
|
2075
|
+
return;
|
|
2076
|
+
const on = !this.watching();
|
|
2077
|
+
this.api.watch(is.key, on).subscribe({ next: () => this.loadWatch(is.key), error: (e) => this.fail(e, 'Could not change watching.') });
|
|
2078
|
+
}
|
|
2079
|
+
loadWatch(key) {
|
|
2080
|
+
this.api.watchState(key).subscribe({ next: (w) => { this.watching.set(w.watching); this.watchers.set(w.watchers); }, error: () => undefined });
|
|
2081
|
+
}
|
|
2082
|
+
// ---- attachments ----
|
|
2083
|
+
ext(name) {
|
|
2084
|
+
const i = name.lastIndexOf('.');
|
|
2085
|
+
return i > 0 && name.length - i <= 5 ? name.slice(i + 1) : 'file';
|
|
2086
|
+
}
|
|
2087
|
+
upload(input) {
|
|
2088
|
+
const is = this.issue();
|
|
2089
|
+
const files = Array.from(input.files ?? []);
|
|
2090
|
+
input.value = '';
|
|
2091
|
+
if (!is || !files.length)
|
|
2092
|
+
return;
|
|
2093
|
+
const big = files.find((f) => f.size > MAX_FILE);
|
|
2094
|
+
if (big) {
|
|
2095
|
+
this.error.set(`${big.name} is larger than 25 MB.`);
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
this.uploading.set(true);
|
|
2099
|
+
this.error.set('');
|
|
2100
|
+
forkJoin(files.map((f) => this.api.attach(is.key, f))).subscribe({
|
|
2101
|
+
next: () => { this.uploading.set(false); this.loadAttachments(is.key); this.reloadSide(is.key); },
|
|
2102
|
+
error: (e) => { this.uploading.set(false); this.loadAttachments(is.key); this.fail(e, 'Could not attach the file.'); },
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
download(a) {
|
|
2106
|
+
this.api.attachmentBlob(a.id).subscribe({
|
|
2107
|
+
next: (b) => {
|
|
2108
|
+
const url = URL.createObjectURL(b);
|
|
2109
|
+
const link = document.createElement('a');
|
|
2110
|
+
link.href = url;
|
|
2111
|
+
link.download = a.filename;
|
|
2112
|
+
link.click();
|
|
2113
|
+
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
2114
|
+
},
|
|
2115
|
+
error: (e) => this.fail(e, 'Could not download the file.'),
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
removeAttachment(a) {
|
|
2119
|
+
const is = this.issue();
|
|
2120
|
+
if (!is)
|
|
2121
|
+
return;
|
|
2122
|
+
this.api.deleteAttachment(a.id).subscribe({
|
|
2123
|
+
next: () => { this.loadAttachments(is.key); this.reloadSide(is.key); },
|
|
2124
|
+
error: (e) => this.fail(e, 'Could not delete the file.'),
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
loadAttachments(key) {
|
|
2128
|
+
this.api.attachments(key).subscribe({
|
|
2129
|
+
next: (list) => {
|
|
2130
|
+
this.attachments.set(list);
|
|
2131
|
+
const have = this.thumbs();
|
|
2132
|
+
for (const a of list) {
|
|
2133
|
+
if (a.inline && !have[a.id] && a.size <= 5 * 1024 * 1024) {
|
|
2134
|
+
this.api.attachmentBlob(a.id).subscribe({ next: (b) => this.thumbs.set({ ...this.thumbs(), [a.id]: URL.createObjectURL(b) }), error: () => undefined });
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
},
|
|
2138
|
+
error: () => undefined,
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
line(h) {
|
|
2142
|
+
return historyLine(h, this.people());
|
|
2143
|
+
}
|
|
2144
|
+
relLabel(k) {
|
|
2145
|
+
return REL_LABEL[k] ?? k;
|
|
2146
|
+
}
|
|
2147
|
+
shas(d) {
|
|
2148
|
+
return d.commits.slice(0, 3).map((c) => String(c.meta['sha'] ?? c.ref).slice(0, 7)).join(' · ');
|
|
2149
|
+
}
|
|
2150
|
+
prLabel(state, relation) {
|
|
2151
|
+
const s = state === 'merged' ? 'Merged' : state === 'closed' ? 'Closed' : 'Open';
|
|
2152
|
+
return relation === 'closes' && state !== 'merged' ? `${s} · closes` : s;
|
|
2153
|
+
}
|
|
2154
|
+
prChip(state) {
|
|
2155
|
+
return state === 'merged' ? 'o-chip o-vio' : state === 'closed' ? 'o-chip' : 'o-chip o-blu';
|
|
2156
|
+
}
|
|
2157
|
+
copyLink() {
|
|
2158
|
+
void navigator.clipboard?.writeText(location.href).then(() => { this.copied.set(true); setTimeout(() => this.copied.set(false), 1500); });
|
|
2159
|
+
}
|
|
2160
|
+
editTitle() {
|
|
2161
|
+
this.titleDraft = this.issue()?.title ?? '';
|
|
2162
|
+
this.editingTitle.set(true);
|
|
2163
|
+
}
|
|
2164
|
+
saveTitle() {
|
|
2165
|
+
const t = this.titleDraft.trim();
|
|
2166
|
+
if (!t)
|
|
2167
|
+
return;
|
|
2168
|
+
this.change({ title: t }, () => this.editingTitle.set(false));
|
|
2169
|
+
}
|
|
2170
|
+
editDesc() {
|
|
2171
|
+
this.descDraft = this.issue()?.description ?? '';
|
|
2172
|
+
this.editingDesc.set(true);
|
|
2173
|
+
}
|
|
2174
|
+
saveDesc() {
|
|
2175
|
+
this.change({ description: this.descDraft }, () => this.editingDesc.set(false));
|
|
2176
|
+
}
|
|
2177
|
+
setPoints(v) {
|
|
2178
|
+
if (v === '')
|
|
2179
|
+
this.change({ clearPoints: true });
|
|
2180
|
+
else
|
|
2181
|
+
this.change({ points: Number(v) });
|
|
2182
|
+
}
|
|
2183
|
+
addLabel() {
|
|
2184
|
+
const is = this.issue();
|
|
2185
|
+
const l = this.labelDraft().trim();
|
|
2186
|
+
if (!is || !l)
|
|
2187
|
+
return;
|
|
2188
|
+
this.labelDraft.set('');
|
|
2189
|
+
if (!is.labels.includes(l))
|
|
2190
|
+
this.change({ labels: [...is.labels, l] });
|
|
2191
|
+
}
|
|
2192
|
+
removeLabel(l) {
|
|
2193
|
+
const is = this.issue();
|
|
2194
|
+
if (is)
|
|
2195
|
+
this.change({ labels: is.labels.filter((x) => x !== l) });
|
|
2196
|
+
}
|
|
2197
|
+
setStatus(statusId) {
|
|
2198
|
+
const is = this.issue();
|
|
2199
|
+
if (!is || statusId === is.statusId)
|
|
2200
|
+
return;
|
|
2201
|
+
this.busy.set(true);
|
|
2202
|
+
this.api.move(is.key, statusId).subscribe({
|
|
2203
|
+
next: (n) => { this.busy.set(false); this.issue.set(n); this.reloadSide(n.key); },
|
|
2204
|
+
error: (e) => this.fail(e, 'Could not change the status.'),
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
/** One field change, against the version on screen. */
|
|
2208
|
+
change(c, after) {
|
|
2209
|
+
const is = this.issue();
|
|
2210
|
+
if (!is)
|
|
2211
|
+
return;
|
|
2212
|
+
this.busy.set(true);
|
|
2213
|
+
this.error.set('');
|
|
2214
|
+
this.api.update(is.key, { ...c, version: is.version }).subscribe({
|
|
2215
|
+
next: (n) => { this.busy.set(false); this.issue.set(n); after?.(); this.reloadSide(n.key); },
|
|
2216
|
+
error: (e) => this.fail(e, 'Could not save the change.'),
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
addChild() {
|
|
2220
|
+
const is = this.issue();
|
|
2221
|
+
const p = this.host.project();
|
|
2222
|
+
const title = this.childTitle().trim();
|
|
2223
|
+
if (!is || !p || !title || this.busy())
|
|
2224
|
+
return;
|
|
2225
|
+
this.busy.set(true);
|
|
2226
|
+
this.api.create(p.id, { title, type: is.type === 'epic' ? 'story' : 'subtask', parent: is.key }).subscribe({
|
|
2227
|
+
next: () => { this.busy.set(false); this.childTitle.set(''); this.reload(is.key); },
|
|
2228
|
+
error: (e) => this.fail(e, 'Could not add it.'),
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
addLink() {
|
|
2232
|
+
const is = this.issue();
|
|
2233
|
+
const other = this.linkKey.trim().toUpperCase();
|
|
2234
|
+
if (!is || !other)
|
|
2235
|
+
return;
|
|
2236
|
+
this.busy.set(true);
|
|
2237
|
+
this.api.relate(is.key, this.linkKind, other).subscribe({
|
|
2238
|
+
next: () => { this.busy.set(false); this.linkKey = ''; this.linking.set(false); this.reload(is.key); },
|
|
2239
|
+
error: (e) => this.fail(e, `Could not link ${other}.`),
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
unlink(r) {
|
|
2243
|
+
const is = this.issue();
|
|
2244
|
+
if (!is)
|
|
2245
|
+
return;
|
|
2246
|
+
this.api.unrelate(r.id).subscribe({ next: () => this.reload(is.key), error: (e) => this.fail(e, 'Could not remove the link.') });
|
|
2247
|
+
}
|
|
2248
|
+
addComment() {
|
|
2249
|
+
const is = this.issue();
|
|
2250
|
+
const body = this.draft().trim();
|
|
2251
|
+
if (!is || !body)
|
|
2252
|
+
return;
|
|
2253
|
+
this.busy.set(true);
|
|
2254
|
+
this.api.comment(is.key, body).subscribe({
|
|
2255
|
+
next: (c) => { this.busy.set(false); this.draft.set(''); this.comments.set([...this.comments(), { ...c, reactions: [] }]); this.reloadSide(is.key); setTimeout(() => this.loadWatch(is.key), 800); },
|
|
2256
|
+
error: (e) => this.fail(e, 'Could not add the comment.'),
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
removeComment(c) {
|
|
2260
|
+
const is = this.issue();
|
|
2261
|
+
if (!is)
|
|
2262
|
+
return;
|
|
2263
|
+
this.api.deleteComment(c.id).subscribe({
|
|
2264
|
+
next: () => { this.comments.set(this.comments().filter((x) => x.id !== c.id)); this.reloadSide(is.key); },
|
|
2265
|
+
error: (e) => this.fail(e, 'Could not delete the comment.'),
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
remove() {
|
|
2269
|
+
const is = this.issue();
|
|
2270
|
+
if (!is)
|
|
2271
|
+
return;
|
|
2272
|
+
this.busy.set(true);
|
|
2273
|
+
this.api.remove(is.key).subscribe({
|
|
2274
|
+
next: () => { this.busy.set(false); void this.router.navigate(this.host.link('issues')); },
|
|
2275
|
+
error: (e) => this.fail(e, 'Could not delete the issue.'),
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
fail(e, fallback) {
|
|
2279
|
+
this.busy.set(false);
|
|
2280
|
+
const is = this.issue();
|
|
2281
|
+
if (isConflict(e) && is) {
|
|
2282
|
+
this.error.set(`Someone changed ${is.key} while you were looking at it. This is the latest; make your change again.`);
|
|
2283
|
+
this.reload(is.key);
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
this.error.set(apiMessage(e, fallback));
|
|
2287
|
+
}
|
|
2288
|
+
reload(key) {
|
|
2289
|
+
this.api.get(key).subscribe({ next: (n) => this.issue.set(n), error: () => undefined });
|
|
2290
|
+
this.reloadSide(key);
|
|
2291
|
+
}
|
|
2292
|
+
reloadSide(key) {
|
|
2293
|
+
this.api.history(key).subscribe({ next: (h) => this.history.set([...h].reverse()), error: () => undefined });
|
|
2294
|
+
}
|
|
2295
|
+
loadAll(key, project) {
|
|
2296
|
+
this.issue.set(null);
|
|
2297
|
+
this.error.set('');
|
|
2298
|
+
this.dev.set(null);
|
|
2299
|
+
this.editingTitle.set(false);
|
|
2300
|
+
this.editingDesc.set(false);
|
|
2301
|
+
this.confirmDelete.set(false);
|
|
2302
|
+
this.attachments.set([]);
|
|
2303
|
+
this.watchers.set([]);
|
|
2304
|
+
this.watching.set(false);
|
|
2305
|
+
this.loadAttachments(key);
|
|
2306
|
+
this.loadWatch(key);
|
|
2307
|
+
forkJoin({ is: this.api.get(key), c: this.api.comments(key), h: this.api.history(key) }).subscribe({
|
|
2308
|
+
next: ({ is, c, h }) => { this.issue.set(is); this.comments.set(c); this.history.set([...h].reverse()); },
|
|
2309
|
+
error: (e) => this.error.set(e?.status === 404 ? `There is no issue ${key} you can see in this project.` : apiMessage(e, `Could not open ${key}.`)),
|
|
2310
|
+
});
|
|
2311
|
+
forkJoin({ st: this.api.statuses(project), sp: this.api.sprints(project, true), ep: this.api.epics(project) }).subscribe({
|
|
2312
|
+
next: ({ st, sp, ep }) => { this.statuses.set(st); this.sprints.set(sp); this.epics.set(ep); },
|
|
2313
|
+
error: () => undefined,
|
|
2314
|
+
});
|
|
2315
|
+
this.api.development(key).subscribe({ next: (d) => this.dev.set(d), error: () => this.dev.set({ branches: [], commits: [], pulls: [] }) });
|
|
2316
|
+
}
|
|
2317
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: IssuePage, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2318
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: IssuePage, isStandalone: true, selector: "o-design-issue", ngImport: i0, template: `
|
|
2319
|
+
@if (issue(); as is) {
|
|
2320
|
+
<div class="o-page o-wide">
|
|
2321
|
+
<div class="o-flex o-sm o-wrap">
|
|
2322
|
+
<nav class="o-flex o-mut o-wrap" aria-label="Issue path">
|
|
2323
|
+
<a [routerLink]="host.link('backlog')">{{ is.projectKey }}</a><span aria-hidden="true">/</span>
|
|
2324
|
+
@if (is.parent; as par) {
|
|
2325
|
+
<a [class]="par.type === 'epic' ? epicChip(par.key) : 'o-chip'" [routerLink]="host.link('issues/' + par.key)">{{ par.key }} · {{ par.title }}</a><span aria-hidden="true">/</span>
|
|
2326
|
+
}
|
|
2327
|
+
<span class="o-mono" aria-current="page">{{ is.key }}</span>
|
|
2328
|
+
</nav>
|
|
2329
|
+
<span class="o-r o-flex">
|
|
2330
|
+
<button type="button" class="o-btn o-sm" [attr.aria-pressed]="watching()" (click)="toggleWatch()" data-testid="watch">{{ watching() ? 'Watching' : 'Watch' }}</button>
|
|
2331
|
+
<button type="button" class="o-btn o-sm" (click)="copyLink()">{{ copied() ? 'Copied' : 'Copy link' }}</button>
|
|
2332
|
+
<a class="o-btn o-sm" [routerLink]="host.link('board')">Board</a>
|
|
2333
|
+
</span>
|
|
2334
|
+
</div>
|
|
2335
|
+
|
|
2336
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
2337
|
+
|
|
2338
|
+
<div class="o-split o-w340">
|
|
2339
|
+
<div class="o-col" style="gap:18px;min-width:0">
|
|
2340
|
+
<div class="o-col" style="gap:6px">
|
|
2341
|
+
<div class="o-flex"><span class="o-sq" [style.background]="typeColour(is.type)" aria-hidden="true"></span><span class="o-mono o-sm o-mut">{{ is.key }} · {{ typeLabel(is.type) }}</span></div>
|
|
2342
|
+
@if (editingTitle()) {
|
|
2343
|
+
<form class="o-flex" (ngSubmit)="saveTitle()">
|
|
2344
|
+
<label class="o-sr-only" for="is-title">Title</label>
|
|
2345
|
+
<input id="is-title" class="o-in" style="font-size:18px;height:40px" name="title" [(ngModel)]="titleDraft" (keydown.escape)="editingTitle.set(false)" />
|
|
2346
|
+
<button type="submit" class="o-btn o-sm o-dark" [disabled]="!titleDraft.trim()">Save</button>
|
|
2347
|
+
<button type="button" class="o-btn o-sm" (click)="editingTitle.set(false)">Cancel</button>
|
|
2348
|
+
</form>
|
|
2349
|
+
} @else {
|
|
2350
|
+
<h1 class="o-t ti">@if (canEdit()) {<button type="button" class="ti-btn" (click)="editTitle()" title="Edit the title">{{ is.title }}</button>} @else {{{ is.title }}}</h1>
|
|
2351
|
+
}
|
|
2352
|
+
</div>
|
|
2353
|
+
|
|
2354
|
+
@if (editingDesc()) {
|
|
2355
|
+
<div class="o-card mn">
|
|
2356
|
+
<label class="o-sr-only" for="is-desc">Description</label>
|
|
2357
|
+
<textarea #descEl id="is-desc" class="o-in" style="border:none;box-shadow:none" [(ngModel)]="descDraft" placeholder="Write with Markdown: checklists, code blocks, images and @mentions." data-testid="desc-input"
|
|
2358
|
+
(input)="typed(descEl, 'desc')" (keydown)="mentionKey($event, descEl)" (blur)="closeMentionSoon()" role="combobox" [attr.aria-expanded]="mentionFor() === 'desc'" aria-autocomplete="list"></textarea>
|
|
2359
|
+
@if (mentionFor() === 'desc' && suggestions().length) {<ng-container *ngTemplateOutlet="mentionList; context: { el: descEl }" />}
|
|
2360
|
+
<div class="o-cf"><span>Markdown</span><button type="button" class="o-btn o-sm o-r" (click)="editingDesc.set(false)">Cancel</button><button type="button" class="o-btn o-sm o-dark" (click)="saveDesc()" [disabled]="busy()" data-testid="desc-save">Save</button></div>
|
|
2361
|
+
</div>
|
|
2362
|
+
} @else {
|
|
2363
|
+
<div class="o-col" style="gap:6px">
|
|
2364
|
+
@if (is.description) {
|
|
2365
|
+
<div class="o-md md" style="font-size:14px" [innerHTML]="html()"></div>
|
|
2366
|
+
} @else {
|
|
2367
|
+
<div class="o-md" style="font-size:14px"><p class="o-faint" style="margin:0">No description yet. Write one with Markdown: checklists, code blocks, images and @mentions.</p></div>
|
|
2368
|
+
}
|
|
2369
|
+
@if (canEdit()) {<button type="button" class="o-lnk" style="align-self:flex-start" (click)="editDesc()" data-testid="desc-edit">{{ is.description ? 'Edit description' : 'Add a description' }}</button>}
|
|
2370
|
+
</div>
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
@if (is.type === 'story' && is.fields['acceptanceCriteria']) {
|
|
2374
|
+
<div class="o-col" style="gap:6px"><h4 style="margin:0;font-size:13px">Acceptance criteria</h4><div class="o-md md" [innerHTML]="md(is.fields['acceptanceCriteria'])"></div></div>
|
|
2375
|
+
}
|
|
2376
|
+
@if (is.type === 'bug' && (is.fields['steps'] || is.fields['environment'])) {
|
|
2377
|
+
<div class="o-col" style="gap:6px">
|
|
2378
|
+
@if (is.fields['environment']) {<div class="o-kv" style="grid-template-columns:140px 1fr;font-size:13px"><span class="o-k">Environment</span><span>{{ is.fields['environment'] }}</span>@if (is.fields['affectedVersion']) {<span class="o-k">Affected version</span><span class="o-mono o-sm">{{ is.fields['affectedVersion'] }}</span>}</div>}
|
|
2379
|
+
@if (is.fields['steps']) {<h4 style="margin:0;font-size:13px">Steps to reproduce</h4><div class="o-md md" [innerHTML]="md(is.fields['steps'])"></div>}
|
|
2380
|
+
</div>
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
@if (is.type !== 'subtask') {
|
|
2384
|
+
<div class="o-card">
|
|
2385
|
+
<div class="o-ch">
|
|
2386
|
+
<b>{{ is.type === 'epic' ? 'Issues in this epic' : 'Sub-tasks' }}</b>
|
|
2387
|
+
@if (is.progress; as pr) {<span class="o-faint o-sm">{{ pr.done }} of {{ pr.total }} done</span><div class="o-bar o-grn o-r" style="max-width:120px" role="progressbar" [attr.aria-valuenow]="pr.done" [attr.aria-valuemax]="pr.total" aria-label="Done"><i [style.width.%]="pr.total ? (100 * pr.done) / pr.total : 0"></i></div>}
|
|
2388
|
+
</div>
|
|
2389
|
+
@for (c of is.children; track c.key) {
|
|
2390
|
+
<a class="o-row o-click" [routerLink]="host.link('issues/' + c.key)">
|
|
2391
|
+
<span class="o-cbx" [class.o-on]="c.category === 'done'" aria-hidden="true"></span>
|
|
2392
|
+
<span class="o-mono o-sm o-mut">{{ c.key }}</span><span class="o-ell">{{ c.title }}</span>
|
|
2393
|
+
<span class="o-r" [class]="statusChip(c.status, c.category)">{{ c.status }}</span>
|
|
2394
|
+
</a>
|
|
2395
|
+
} @empty {
|
|
2396
|
+
<div class="o-empty o-sm">{{ is.type === 'epic' ? 'No issues in this epic yet.' : 'No sub-tasks.' }}</div>
|
|
2397
|
+
}
|
|
2398
|
+
@if (canEdit()) {
|
|
2399
|
+
<div class="o-row"><span class="o-faint" aria-hidden="true">+</span>
|
|
2400
|
+
<input class="o-in o-sm" style="border:none;box-shadow:none" [placeholder]="is.type === 'epic' ? 'Add a story to this epic, then Enter' : 'Add a sub-task, then Enter'" [attr.aria-label]="is.type === 'epic' ? 'New issue in this epic' : 'New sub-task'"
|
|
2401
|
+
[ngModel]="childTitle()" (ngModelChange)="childTitle.set($event)" (keydown.enter)="addChild()" data-testid="child-new" />
|
|
2402
|
+
</div>
|
|
2403
|
+
}
|
|
2404
|
+
</div>
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
<div class="o-card">
|
|
2408
|
+
<div class="o-ch"><b>Development</b><span class="o-r o-faint o-sm">linked by issue key</span></div>
|
|
2409
|
+
@if (dev(); as d) {
|
|
2410
|
+
@for (b of d.branches; track b.ref) {<div class="o-row"><span class="o-faint" style="width:70px">Branch</span><span class="o-mono o-sm o-ell">{{ b.title || b.ref }}</span><span class="o-r o-faint o-sm">{{ b.repo }}</span></div>}
|
|
2411
|
+
@for (p of d.pulls; track p.ref) {
|
|
2412
|
+
<a class="o-row o-click" [routerLink]="host.link('code/' + p.repo + '/pull/' + (p.meta['number'] ?? ''))"><span class="o-faint" style="width:70px">PR</span><span class="o-ell">#{{ p.meta['number'] }} {{ p.title }}</span><span class="o-r" [class]="prChip(p.meta['state'])">{{ prLabel(p.meta['state'], p.relation) }}</span></a>
|
|
2413
|
+
}
|
|
2414
|
+
@if (d.commits.length) {
|
|
2415
|
+
<a class="o-row o-click" [routerLink]="host.link('code/' + d.commits[0].repo + '/commits')"><span class="o-faint" style="width:70px">Commits</span><span class="o-mono o-sm o-ell">{{ shas(d) }}</span></a>
|
|
2416
|
+
}
|
|
2417
|
+
@if (!d.branches.length && !d.pulls.length && !d.commits.length) {
|
|
2418
|
+
<div class="o-empty o-sm">Nothing yet. Put {{ is.key }} in a branch name, a commit message or a pull request title to link it; “Closes {{ is.key }}” closes it when the pull request merges.</div>
|
|
2419
|
+
}
|
|
2420
|
+
} @else {
|
|
2421
|
+
<div class="o-empty o-sm">Loading…</div>
|
|
2422
|
+
}
|
|
2423
|
+
</div>
|
|
2424
|
+
|
|
2425
|
+
<div class="o-card">
|
|
2426
|
+
<div class="o-ch"><b>Linked issues</b>@if (canEdit()) {<button type="button" class="o-lnk o-r" (click)="linking.set(!linking())">+ Link</button>}</div>
|
|
2427
|
+
@if (linking()) {
|
|
2428
|
+
<form class="o-row o-wrap" style="gap:8px" (ngSubmit)="addLink()">
|
|
2429
|
+
<select class="o-in o-sm" style="width:auto" name="kind" aria-label="Relation" [(ngModel)]="linkKind">
|
|
2430
|
+
@for (k of relKinds; track k) {<option [value]="k">{{ relLabel(k) }}</option>}
|
|
2431
|
+
</select>
|
|
2432
|
+
<input class="o-in o-sm o-mono" style="width:140px" name="other" placeholder="PAY-150" aria-label="Issue key" [(ngModel)]="linkKey" data-testid="link-key" />
|
|
2433
|
+
<button type="submit" class="o-btn o-sm o-dark" [disabled]="!linkKey.trim()">Link</button>
|
|
2434
|
+
<button type="button" class="o-btn o-sm" (click)="linking.set(false)">Cancel</button>
|
|
2435
|
+
</form>
|
|
2436
|
+
}
|
|
2437
|
+
@for (r of is.relations; track r.id) {
|
|
2438
|
+
<div class="o-row">
|
|
2439
|
+
<span class="o-faint o-sm" style="width:110px">{{ relLabel(r.kind) }}</span>
|
|
2440
|
+
<span class="o-sq" [style.background]="typeColour(r.issue.type)" aria-hidden="true"></span>
|
|
2441
|
+
<a class="o-flex o-ell" style="flex:1;min-width:0;color:var(--o-tx)" [routerLink]="host.link('issues/' + r.issue.key)"><span class="o-mono o-sm o-mut">{{ r.issue.key }}</span><span class="o-ell">{{ r.issue.title }}</span></a>
|
|
2442
|
+
<span class="o-r" [class]="statusChip(r.issue.status, r.issue.category)">{{ r.issue.status }}</span>
|
|
2443
|
+
@if (canEdit()) {<button type="button" class="o-btn o-ghost o-xs" (click)="unlink(r)" [attr.aria-label]="'Remove the link to ' + r.issue.key">×</button>}
|
|
2444
|
+
</div>
|
|
2445
|
+
} @empty {
|
|
2446
|
+
<div class="o-empty">No linked issues.</div>
|
|
2447
|
+
}
|
|
2448
|
+
</div>
|
|
2449
|
+
|
|
2450
|
+
@if (attachments().length) {
|
|
2451
|
+
<div class="o-card" data-testid="attachments">
|
|
2452
|
+
<div class="o-ch"><b>Attachments</b><span class="o-faint o-sm">{{ attachments().length }}</span>
|
|
2453
|
+
@if (canEdit()) {<label class="o-lnk o-r" for="att-in-2">+ Attach</label>}</div>
|
|
2454
|
+
@for (a of attachments(); track a.id) {
|
|
2455
|
+
<div class="o-row" [attr.data-testid]="'attachment-' + a.filename">
|
|
2456
|
+
@if (a.inline && thumbs()[a.id]) {<img class="att-img" [src]="thumbs()[a.id]" [alt]="a.filename" />} @else {<span class="att-ic" aria-hidden="true">{{ ext(a.filename) }}</span>}
|
|
2457
|
+
<div class="o-col" style="gap:2px;min-width:0;flex:1">
|
|
2458
|
+
<button type="button" class="o-lnk o-ell" style="text-align:left" (click)="download(a)">{{ a.filename }}</button>
|
|
2459
|
+
<span class="o-faint o-xs">{{ size(a.size) }} · {{ people().name(a.uploadedBy) }} · {{ ago(a.createdAt) }}</span>
|
|
2460
|
+
</div>
|
|
2461
|
+
@if ((a.uploadedBy === host.meId() && canEdit()) || canManage()) {<button type="button" class="o-btn o-ghost o-xs" (click)="removeAttachment(a)" [attr.aria-label]="'Delete ' + a.filename">×</button>}
|
|
2462
|
+
</div>
|
|
2463
|
+
}
|
|
2464
|
+
@if (canEdit()) {<input id="att-in-2" class="file-in" type="file" multiple (change)="upload($any($event.target))" />}
|
|
2465
|
+
</div>
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
<div class="o-col" style="gap:12px">
|
|
2469
|
+
<div class="o-tabs" role="tablist">
|
|
2470
|
+
<button type="button" role="tab" class="o-tab" [class.o-on]="tab() === 'comments'" [attr.aria-selected]="tab() === 'comments'" (click)="tab.set('comments')">Comments<span class="o-n">{{ comments().length }}</span></button>
|
|
2471
|
+
<button type="button" role="tab" class="o-tab" [class.o-on]="tab() === 'history'" [attr.aria-selected]="tab() === 'history'" (click)="tab.set('history')">History</button>
|
|
2472
|
+
</div>
|
|
2473
|
+
@if (tab() === 'comments') {
|
|
2474
|
+
@for (c of comments(); track c.id) {
|
|
2475
|
+
<div class="o-card" [attr.data-testid]="'comment-' + c.id">
|
|
2476
|
+
<div class="o-ch" style="min-height:38px;background:var(--o-well)">
|
|
2477
|
+
<span class="o-av" aria-hidden="true">{{ people().initials(c.authorId) }}</span><b class="o-b5">{{ people().name(c.authorId) }}</b>
|
|
2478
|
+
<span class="o-faint o-sm">{{ ago(c.createdAt) }}{{ c.edited ? ' · edited' : '' }}</span>
|
|
2479
|
+
@if (c.authorId === host.meId() || canManage()) {<button type="button" class="o-lnk o-r" style="color:var(--o-mut)" (click)="removeComment(c)">Delete</button>}
|
|
2480
|
+
</div>
|
|
2481
|
+
<div class="o-cb o-lh" style="font-size:14px">
|
|
2482
|
+
<div class="o-md md" [innerHTML]="md(c.body)"></div>
|
|
2483
|
+
<div class="rx">
|
|
2484
|
+
@for (r of c.reactions ?? []; track r.reaction) {
|
|
2485
|
+
<button type="button" class="o-chip o-out" [class.mine]="r.mine" [attr.aria-pressed]="r.mine" [disabled]="!canComment()" (click)="react(c, r.reaction, !r.mine)"
|
|
2486
|
+
[attr.aria-label]="emojiLabel(r.reaction) + ': ' + r.count + (r.mine ? ', including you' : '')" [title]="reactors(r.people)">{{ emoji(r.reaction) }} {{ r.count }}</button>
|
|
2487
|
+
}
|
|
2488
|
+
@if (canComment()) {
|
|
2489
|
+
@if (picker() === c.id) {
|
|
2490
|
+
<span class="rx-pal" role="group" aria-label="Add a reaction">
|
|
2491
|
+
@for (x of reactionList; track x.name) {<button type="button" [attr.aria-label]="x.label" (click)="react(c, x.name, true)">{{ x.emoji }}</button>}
|
|
2492
|
+
</span>
|
|
2493
|
+
} @else {
|
|
2494
|
+
<button type="button" class="o-chip o-out" style="color:var(--o-mut)" (click)="picker.set(c.id)" aria-label="Add a reaction" data-testid="react">☺︎ +</button>
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
</div>
|
|
2498
|
+
</div>
|
|
2499
|
+
</div>
|
|
2500
|
+
}
|
|
2501
|
+
@if (canComment()) {
|
|
2502
|
+
<div class="o-card mn">
|
|
2503
|
+
<label class="o-sr-only" for="is-cmt">Add a comment</label>
|
|
2504
|
+
<textarea #cmtEl id="is-cmt" class="o-in" style="border:none;box-shadow:none;min-height:70px;font-family:var(--o-font);font-size:13px" placeholder="Add a comment. Markdown and @mentions work." [ngModel]="draft()" (ngModelChange)="draft.set($event)" data-testid="comment-input"
|
|
2505
|
+
(input)="typed(cmtEl, 'comment')" (keydown)="mentionKey($event, cmtEl)" (blur)="closeMentionSoon()" role="combobox" [attr.aria-expanded]="mentionFor() === 'comment'" aria-autocomplete="list"></textarea>
|
|
2506
|
+
@if (mentionFor() === 'comment' && suggestions().length) {<ng-container *ngTemplateOutlet="mentionList; context: { el: cmtEl }" />}
|
|
2507
|
+
<div class="o-cf">
|
|
2508
|
+
@if (canEdit()) {<label for="att-in" class="o-lnk" style="color:var(--o-mut)">{{ uploading() ? 'Uploading…' : 'Attach files up to 25 MB' }}</label><input id="att-in" class="file-in" type="file" multiple (change)="upload($any($event.target))" data-testid="attach-input" />} @else {<span>Markdown</span>}
|
|
2509
|
+
<button type="button" class="o-btn o-sm o-dark o-r" (click)="addComment()" [disabled]="!draft().trim() || busy()" data-testid="comment-add">Comment</button>
|
|
2510
|
+
</div>
|
|
2511
|
+
</div>
|
|
2512
|
+
}
|
|
2513
|
+
} @else {
|
|
2514
|
+
<div class="o-card">
|
|
2515
|
+
@for (h of history(); track h.id) {<div class="o-row o-sm"><span class="o-dot o-gry" aria-hidden="true"></span><span class="o-mut">{{ line(h) }}</span><span class="o-r o-faint">{{ ago(h.createdAt) }}</span></div>}
|
|
2516
|
+
</div>
|
|
2517
|
+
}
|
|
2518
|
+
</div>
|
|
2519
|
+
</div>
|
|
2520
|
+
|
|
2521
|
+
<div class="o-col" style="gap:14px">
|
|
2522
|
+
<div class="o-card o-cb">
|
|
2523
|
+
<div class="o-kv" style="grid-template-columns:90px 1fr;row-gap:8px">
|
|
2524
|
+
<label class="o-k" for="f-status">Status</label>
|
|
2525
|
+
<select id="f-status" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy()" (change)="setStatus($any($event.target).value)" data-testid="issue-status">
|
|
2526
|
+
@for (s of statuses(); track s.id) {<option [value]="s.id" [selected]="s.id === is.statusId">{{ s.name }}</option>}
|
|
2527
|
+
</select>
|
|
2528
|
+
<label class="o-k" for="f-who">Assignee</label>
|
|
2529
|
+
<select id="f-who" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy()" (change)="change({ assigneeId: $any($event.target).value })" data-testid="issue-assignee">
|
|
2530
|
+
<option value="" [selected]="!is.assigneeId">Unassigned</option>
|
|
2531
|
+
@for (p of host.people(); track p.id) {<option [value]="p.id" [selected]="p.id === is.assigneeId">{{ p.name || p.email }}</option>}
|
|
2532
|
+
</select>
|
|
2533
|
+
<span class="o-k">Reporter</span><span class="o-flex"><span class="o-av" aria-hidden="true">{{ people().initials(is.reporterId) }}</span>{{ people().name(is.reporterId) }}</span>
|
|
2534
|
+
<label class="o-k" for="f-pri">Priority</label>
|
|
2535
|
+
<select id="f-pri" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy()" (change)="change({ priority: $any($event.target).value })">
|
|
2536
|
+
@for (p of priorities; track p) {<option [value]="p" [selected]="p === is.priority">{{ priorityLabel(p) }}</option>}
|
|
2537
|
+
</select>
|
|
2538
|
+
<span class="o-k">Labels</span>
|
|
2539
|
+
<span class="o-flex o-wrap" style="gap:4px">
|
|
2540
|
+
@for (l of is.labels; track l) {<span class="o-chip o-out">{{ l }}@if (canEdit()) {<button type="button" class="o-lnk" style="font-size:11px;color:var(--o-dim)" (click)="removeLabel(l)" [attr.aria-label]="'Remove label ' + l">×</button>}</span>}
|
|
2541
|
+
@if (canEdit()) {<input class="o-in o-sm" style="width:90px;height:22px" placeholder="+ label" aria-label="Add a label" [ngModel]="labelDraft()" (ngModelChange)="labelDraft.set($event)" (keydown.enter)="addLabel()" />}
|
|
2542
|
+
@if (!is.labels.length && !canEdit()) {<span class="o-faint">None</span>}
|
|
2543
|
+
</span>
|
|
2544
|
+
@if (is.type !== 'epic') {
|
|
2545
|
+
<label class="o-k" for="f-parent">{{ is.type === 'subtask' ? 'Parent' : 'Epic' }}</label>
|
|
2546
|
+
@if (is.type === 'subtask') {
|
|
2547
|
+
<span>@if (is.parent) {<a [routerLink]="host.link('issues/' + is.parent.key)">{{ is.parent.key }} · {{ is.parent.title }}</a>}</span>
|
|
2548
|
+
} @else {
|
|
2549
|
+
<select id="f-parent" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy()" (change)="change({ parent: $any($event.target).value })">
|
|
2550
|
+
<option value="" [selected]="!is.parent">None</option>
|
|
2551
|
+
@for (e of epics(); track e.key) {<option [value]="e.key" [selected]="e.key === is.parent?.key">{{ e.key }} · {{ e.title }}</option>}
|
|
2552
|
+
</select>
|
|
2553
|
+
}
|
|
2554
|
+
<label class="o-k" for="f-sprint">Sprint</label>
|
|
2555
|
+
<select id="f-sprint" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy() || is.type === 'subtask'" (change)="change({ sprintId: $any($event.target).value })">
|
|
2556
|
+
<option value="" [selected]="!is.sprintId">Backlog</option>
|
|
2557
|
+
@for (s of openSprints(); track s.id) {<option [value]="s.id" [selected]="s.id === is.sprintId">{{ s.name }}{{ s.state === 'active' ? ' (active)' : '' }}</option>}
|
|
2558
|
+
@if (completedSprint(); as cs) {<option [value]="cs.id" selected disabled>{{ cs.name }} (completed)</option>}
|
|
2559
|
+
</select>
|
|
2560
|
+
<label class="o-k" for="f-pts">Points</label>
|
|
2561
|
+
<input id="f-pts" class="o-in o-sm o-mono kv-in" type="number" min="0" step="0.5" [disabled]="!canEdit() || busy()" [value]="is.points ?? ''" (change)="setPoints($any($event.target).value)" />
|
|
2562
|
+
}
|
|
2563
|
+
<label class="o-k" for="f-due">{{ is.type === 'epic' ? 'Target' : 'Due' }}</label>
|
|
2564
|
+
@if (is.type === 'epic') {
|
|
2565
|
+
<input id="f-due" class="o-in o-sm kv-in" type="date" [disabled]="!canEdit() || busy()" [value]="is.fields['targetDate'] ?? ''" (change)="change({ fields: { targetDate: $any($event.target).value } })" />
|
|
2566
|
+
} @else {
|
|
2567
|
+
<input id="f-due" class="o-in o-sm kv-in" type="date" [disabled]="!canEdit() || busy()" [value]="is.dueDate ?? ''" (change)="change({ dueDate: $any($event.target).value })" />
|
|
2568
|
+
}
|
|
2569
|
+
@if (is.type === 'bug') {
|
|
2570
|
+
<label class="o-k" for="f-sev">Severity</label>
|
|
2571
|
+
<select id="f-sev" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy()" (change)="change({ fields: { severity: $any($event.target).value } })">
|
|
2572
|
+
<option value="" [selected]="!is.fields['severity']">Not set</option>
|
|
2573
|
+
@for (s of severities; track s) {<option [value]="s" [selected]="s === is.fields['severity']">{{ cap(s) }}</option>}
|
|
2574
|
+
</select>
|
|
2575
|
+
}
|
|
2576
|
+
@if (is.type === 'epic') {
|
|
2577
|
+
<label class="o-k" for="f-start">Start</label>
|
|
2578
|
+
<input id="f-start" class="o-in o-sm kv-in" type="date" [disabled]="!canEdit() || busy()" [value]="is.fields['startDate'] ?? ''" (change)="change({ fields: { startDate: $any($event.target).value } })" />
|
|
2579
|
+
}
|
|
2580
|
+
</div>
|
|
2581
|
+
</div>
|
|
2582
|
+
<div class="o-card">
|
|
2583
|
+
<div class="o-ch"><b>Deployments</b></div>
|
|
2584
|
+
<div class="o-empty o-sm">Releases that include {{ is.key }} show here once Deploy is set up.</div>
|
|
2585
|
+
</div>
|
|
2586
|
+
<div class="o-card o-cb o-col o-sm" style="gap:4px">
|
|
2587
|
+
<span class="o-faint">Created {{ shortDate(is.createdAt) }} by {{ people().name(is.reporterId) }}</span>
|
|
2588
|
+
<span class="o-faint">Updated {{ ago(is.updatedAt) }}</span>
|
|
2589
|
+
@if (is.resolvedAt) {<span class="o-faint">Resolved {{ shortDate(is.resolvedAt) }} · {{ is.resolution }}</span>}
|
|
2590
|
+
<span class="o-faint" data-testid="watchers">Watching: {{ watcherNames() }}</span>
|
|
2591
|
+
</div>
|
|
2592
|
+
@if (canManage()) {
|
|
2593
|
+
@if (confirmDelete()) {
|
|
2594
|
+
<div class="o-card o-cb o-col" style="gap:8px">
|
|
2595
|
+
<span class="o-mut o-sm">Delete {{ is.key }}? Its number is never reused. Type <b class="o-mono">{{ is.key }}</b> to confirm.</span>
|
|
2596
|
+
<input class="o-in o-sm o-mono" aria-label="Type the key to confirm" [ngModel]="deleteKey()" (ngModelChange)="deleteKey.set($event)" />
|
|
2597
|
+
<div class="o-flex"><button type="button" class="o-btn o-sm" (click)="confirmDelete.set(false)">Cancel</button><button type="button" class="o-btn o-sm o-red o-r" [disabled]="deleteKey().trim().toUpperCase() !== is.key || busy()" (click)="remove()">Delete issue</button></div>
|
|
2598
|
+
</div>
|
|
2599
|
+
} @else {
|
|
2600
|
+
<button type="button" class="o-lnk" style="align-self:flex-start;color:var(--o-bad-tx)" (click)="confirmDelete.set(true)">Delete issue</button>
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
</div>
|
|
2604
|
+
</div>
|
|
2605
|
+
</div>
|
|
2606
|
+
} @else if (error()) {
|
|
2607
|
+
<div class="o-page o-narrow"><div class="o-card o-empty"><p>{{ error() }}</p><a class="o-btn" style="margin-top:14px" [routerLink]="host.link('issues')">All issues</a></div></div>
|
|
2608
|
+
} @else {
|
|
2609
|
+
<div class="o-page"><div class="o-card"><div class="o-empty">Loading {{ key() }}…</div></div></div>
|
|
2610
|
+
}
|
|
2611
|
+
<ng-template #mentionList let-el="el">
|
|
2612
|
+
<ul class="mn-list" role="listbox" aria-label="People to mention">
|
|
2613
|
+
@for (p of suggestions(); track p.id; let i = $index) {
|
|
2614
|
+
<li role="option" [class.on]="i === mentionIndex()" [attr.aria-selected]="i === mentionIndex()" (mousedown)="$event.preventDefault(); pick(p, el)">
|
|
2615
|
+
<span class="o-av" aria-hidden="true">{{ people().initials(p.id) }}</span><span class="o-ell">{{ p.name || p.email }}</span>@if (p.email && p.name) {<span class="o-faint o-xs o-ell">{{ p.email }}</span>}
|
|
2616
|
+
</li>
|
|
2617
|
+
}
|
|
2618
|
+
</ul>
|
|
2619
|
+
</ng-template>
|
|
2620
|
+
`, isInline: true, styles: [".ti{font-size:24px;margin:0;line-height:1.25;text-wrap:pretty}.ti-btn{border:none;background:none;padding:0;text-align:left;font:inherit;color:inherit;cursor:text}.md :where(p,ul,ol,pre){margin:0}.md pre{background:var(--o-well);padding:10px 12px;border-radius:8px;overflow:auto;font-family:var(--o-mono);font-size:12px}.md code{font-family:var(--o-mono);font-size:12px;color:var(--o-tx);background:var(--o-sunken);padding:0 4px;border-radius:4px}.md pre code{background:none;padding:0}.md img{max-width:100%}.kv-in{width:100%}textarea.o-in{min-height:140px;font-family:var(--o-mono);font-size:12px;line-height:1.6;padding:10px 12px;height:auto}.md .mention{color:var(--o-acc-tx, var(--o-acc));background:var(--o-acc-bg, var(--o-sunken));border-radius:4px;padding:0 3px;font-weight:500}.rx{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center}.rx .o-chip{cursor:pointer;font-size:12px}.rx .o-chip.mine{border-color:var(--o-acc);background:var(--o-acc-bg, var(--o-sunken))}.rx-pal{display:flex;gap:2px;padding:4px;border:1px solid var(--o-line);border-radius:8px;background:var(--o-surface);box-shadow:var(--o-shadow-sm, none)}.rx-pal button{border:none;background:none;font-size:16px;padding:2px 4px;border-radius:6px;cursor:pointer}.rx-pal button:hover,.rx-pal button:focus-visible{background:var(--o-sunken)}.mn{position:relative}.mn-list{position:absolute;left:8px;top:calc(100% - 4px);z-index:20;min-width:220px;max-width:320px;border:1px solid var(--o-line);border-radius:8px;background:var(--o-surface);box-shadow:var(--o-shadow, 0 6px 20px rgba(0,0,0,.12));padding:4px;list-style:none;margin:0}.mn-list li{display:flex;gap:8px;align-items:center;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:13px}.mn-list li.on,.mn-list li:hover{background:var(--o-sunken)}.att-img{width:40px;height:40px;border-radius:6px;object-fit:cover;background:var(--o-well);flex:none;overflow:hidden;font-size:0}.att-ic{width:40px;height:40px;border-radius:6px;background:var(--o-well);display:grid;place-items:center;font-size:10px;color:var(--o-mut);font-family:var(--o-mono);flex:none;text-transform:uppercase}.file-in{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2621
|
+
}
|
|
2622
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: IssuePage, decorators: [{
|
|
2623
|
+
type: Component,
|
|
2624
|
+
args: [{ selector: 'o-design-issue', imports: [FormsModule, RouterLink, NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
2625
|
+
@if (issue(); as is) {
|
|
2626
|
+
<div class="o-page o-wide">
|
|
2627
|
+
<div class="o-flex o-sm o-wrap">
|
|
2628
|
+
<nav class="o-flex o-mut o-wrap" aria-label="Issue path">
|
|
2629
|
+
<a [routerLink]="host.link('backlog')">{{ is.projectKey }}</a><span aria-hidden="true">/</span>
|
|
2630
|
+
@if (is.parent; as par) {
|
|
2631
|
+
<a [class]="par.type === 'epic' ? epicChip(par.key) : 'o-chip'" [routerLink]="host.link('issues/' + par.key)">{{ par.key }} · {{ par.title }}</a><span aria-hidden="true">/</span>
|
|
2632
|
+
}
|
|
2633
|
+
<span class="o-mono" aria-current="page">{{ is.key }}</span>
|
|
2634
|
+
</nav>
|
|
2635
|
+
<span class="o-r o-flex">
|
|
2636
|
+
<button type="button" class="o-btn o-sm" [attr.aria-pressed]="watching()" (click)="toggleWatch()" data-testid="watch">{{ watching() ? 'Watching' : 'Watch' }}</button>
|
|
2637
|
+
<button type="button" class="o-btn o-sm" (click)="copyLink()">{{ copied() ? 'Copied' : 'Copy link' }}</button>
|
|
2638
|
+
<a class="o-btn o-sm" [routerLink]="host.link('board')">Board</a>
|
|
2639
|
+
</span>
|
|
2640
|
+
</div>
|
|
2641
|
+
|
|
2642
|
+
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
2643
|
+
|
|
2644
|
+
<div class="o-split o-w340">
|
|
2645
|
+
<div class="o-col" style="gap:18px;min-width:0">
|
|
2646
|
+
<div class="o-col" style="gap:6px">
|
|
2647
|
+
<div class="o-flex"><span class="o-sq" [style.background]="typeColour(is.type)" aria-hidden="true"></span><span class="o-mono o-sm o-mut">{{ is.key }} · {{ typeLabel(is.type) }}</span></div>
|
|
2648
|
+
@if (editingTitle()) {
|
|
2649
|
+
<form class="o-flex" (ngSubmit)="saveTitle()">
|
|
2650
|
+
<label class="o-sr-only" for="is-title">Title</label>
|
|
2651
|
+
<input id="is-title" class="o-in" style="font-size:18px;height:40px" name="title" [(ngModel)]="titleDraft" (keydown.escape)="editingTitle.set(false)" />
|
|
2652
|
+
<button type="submit" class="o-btn o-sm o-dark" [disabled]="!titleDraft.trim()">Save</button>
|
|
2653
|
+
<button type="button" class="o-btn o-sm" (click)="editingTitle.set(false)">Cancel</button>
|
|
2654
|
+
</form>
|
|
2655
|
+
} @else {
|
|
2656
|
+
<h1 class="o-t ti">@if (canEdit()) {<button type="button" class="ti-btn" (click)="editTitle()" title="Edit the title">{{ is.title }}</button>} @else {{{ is.title }}}</h1>
|
|
2657
|
+
}
|
|
2658
|
+
</div>
|
|
2659
|
+
|
|
2660
|
+
@if (editingDesc()) {
|
|
2661
|
+
<div class="o-card mn">
|
|
2662
|
+
<label class="o-sr-only" for="is-desc">Description</label>
|
|
2663
|
+
<textarea #descEl id="is-desc" class="o-in" style="border:none;box-shadow:none" [(ngModel)]="descDraft" placeholder="Write with Markdown: checklists, code blocks, images and @mentions." data-testid="desc-input"
|
|
2664
|
+
(input)="typed(descEl, 'desc')" (keydown)="mentionKey($event, descEl)" (blur)="closeMentionSoon()" role="combobox" [attr.aria-expanded]="mentionFor() === 'desc'" aria-autocomplete="list"></textarea>
|
|
2665
|
+
@if (mentionFor() === 'desc' && suggestions().length) {<ng-container *ngTemplateOutlet="mentionList; context: { el: descEl }" />}
|
|
2666
|
+
<div class="o-cf"><span>Markdown</span><button type="button" class="o-btn o-sm o-r" (click)="editingDesc.set(false)">Cancel</button><button type="button" class="o-btn o-sm o-dark" (click)="saveDesc()" [disabled]="busy()" data-testid="desc-save">Save</button></div>
|
|
2667
|
+
</div>
|
|
2668
|
+
} @else {
|
|
2669
|
+
<div class="o-col" style="gap:6px">
|
|
2670
|
+
@if (is.description) {
|
|
2671
|
+
<div class="o-md md" style="font-size:14px" [innerHTML]="html()"></div>
|
|
2672
|
+
} @else {
|
|
2673
|
+
<div class="o-md" style="font-size:14px"><p class="o-faint" style="margin:0">No description yet. Write one with Markdown: checklists, code blocks, images and @mentions.</p></div>
|
|
2674
|
+
}
|
|
2675
|
+
@if (canEdit()) {<button type="button" class="o-lnk" style="align-self:flex-start" (click)="editDesc()" data-testid="desc-edit">{{ is.description ? 'Edit description' : 'Add a description' }}</button>}
|
|
2676
|
+
</div>
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
@if (is.type === 'story' && is.fields['acceptanceCriteria']) {
|
|
2680
|
+
<div class="o-col" style="gap:6px"><h4 style="margin:0;font-size:13px">Acceptance criteria</h4><div class="o-md md" [innerHTML]="md(is.fields['acceptanceCriteria'])"></div></div>
|
|
2681
|
+
}
|
|
2682
|
+
@if (is.type === 'bug' && (is.fields['steps'] || is.fields['environment'])) {
|
|
2683
|
+
<div class="o-col" style="gap:6px">
|
|
2684
|
+
@if (is.fields['environment']) {<div class="o-kv" style="grid-template-columns:140px 1fr;font-size:13px"><span class="o-k">Environment</span><span>{{ is.fields['environment'] }}</span>@if (is.fields['affectedVersion']) {<span class="o-k">Affected version</span><span class="o-mono o-sm">{{ is.fields['affectedVersion'] }}</span>}</div>}
|
|
2685
|
+
@if (is.fields['steps']) {<h4 style="margin:0;font-size:13px">Steps to reproduce</h4><div class="o-md md" [innerHTML]="md(is.fields['steps'])"></div>}
|
|
2686
|
+
</div>
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
@if (is.type !== 'subtask') {
|
|
2690
|
+
<div class="o-card">
|
|
2691
|
+
<div class="o-ch">
|
|
2692
|
+
<b>{{ is.type === 'epic' ? 'Issues in this epic' : 'Sub-tasks' }}</b>
|
|
2693
|
+
@if (is.progress; as pr) {<span class="o-faint o-sm">{{ pr.done }} of {{ pr.total }} done</span><div class="o-bar o-grn o-r" style="max-width:120px" role="progressbar" [attr.aria-valuenow]="pr.done" [attr.aria-valuemax]="pr.total" aria-label="Done"><i [style.width.%]="pr.total ? (100 * pr.done) / pr.total : 0"></i></div>}
|
|
2694
|
+
</div>
|
|
2695
|
+
@for (c of is.children; track c.key) {
|
|
2696
|
+
<a class="o-row o-click" [routerLink]="host.link('issues/' + c.key)">
|
|
2697
|
+
<span class="o-cbx" [class.o-on]="c.category === 'done'" aria-hidden="true"></span>
|
|
2698
|
+
<span class="o-mono o-sm o-mut">{{ c.key }}</span><span class="o-ell">{{ c.title }}</span>
|
|
2699
|
+
<span class="o-r" [class]="statusChip(c.status, c.category)">{{ c.status }}</span>
|
|
2700
|
+
</a>
|
|
2701
|
+
} @empty {
|
|
2702
|
+
<div class="o-empty o-sm">{{ is.type === 'epic' ? 'No issues in this epic yet.' : 'No sub-tasks.' }}</div>
|
|
2703
|
+
}
|
|
2704
|
+
@if (canEdit()) {
|
|
2705
|
+
<div class="o-row"><span class="o-faint" aria-hidden="true">+</span>
|
|
2706
|
+
<input class="o-in o-sm" style="border:none;box-shadow:none" [placeholder]="is.type === 'epic' ? 'Add a story to this epic, then Enter' : 'Add a sub-task, then Enter'" [attr.aria-label]="is.type === 'epic' ? 'New issue in this epic' : 'New sub-task'"
|
|
2707
|
+
[ngModel]="childTitle()" (ngModelChange)="childTitle.set($event)" (keydown.enter)="addChild()" data-testid="child-new" />
|
|
2708
|
+
</div>
|
|
2709
|
+
}
|
|
2710
|
+
</div>
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
<div class="o-card">
|
|
2714
|
+
<div class="o-ch"><b>Development</b><span class="o-r o-faint o-sm">linked by issue key</span></div>
|
|
2715
|
+
@if (dev(); as d) {
|
|
2716
|
+
@for (b of d.branches; track b.ref) {<div class="o-row"><span class="o-faint" style="width:70px">Branch</span><span class="o-mono o-sm o-ell">{{ b.title || b.ref }}</span><span class="o-r o-faint o-sm">{{ b.repo }}</span></div>}
|
|
2717
|
+
@for (p of d.pulls; track p.ref) {
|
|
2718
|
+
<a class="o-row o-click" [routerLink]="host.link('code/' + p.repo + '/pull/' + (p.meta['number'] ?? ''))"><span class="o-faint" style="width:70px">PR</span><span class="o-ell">#{{ p.meta['number'] }} {{ p.title }}</span><span class="o-r" [class]="prChip(p.meta['state'])">{{ prLabel(p.meta['state'], p.relation) }}</span></a>
|
|
2719
|
+
}
|
|
2720
|
+
@if (d.commits.length) {
|
|
2721
|
+
<a class="o-row o-click" [routerLink]="host.link('code/' + d.commits[0].repo + '/commits')"><span class="o-faint" style="width:70px">Commits</span><span class="o-mono o-sm o-ell">{{ shas(d) }}</span></a>
|
|
2722
|
+
}
|
|
2723
|
+
@if (!d.branches.length && !d.pulls.length && !d.commits.length) {
|
|
2724
|
+
<div class="o-empty o-sm">Nothing yet. Put {{ is.key }} in a branch name, a commit message or a pull request title to link it; “Closes {{ is.key }}” closes it when the pull request merges.</div>
|
|
2725
|
+
}
|
|
2726
|
+
} @else {
|
|
2727
|
+
<div class="o-empty o-sm">Loading…</div>
|
|
2728
|
+
}
|
|
2729
|
+
</div>
|
|
2730
|
+
|
|
2731
|
+
<div class="o-card">
|
|
2732
|
+
<div class="o-ch"><b>Linked issues</b>@if (canEdit()) {<button type="button" class="o-lnk o-r" (click)="linking.set(!linking())">+ Link</button>}</div>
|
|
2733
|
+
@if (linking()) {
|
|
2734
|
+
<form class="o-row o-wrap" style="gap:8px" (ngSubmit)="addLink()">
|
|
2735
|
+
<select class="o-in o-sm" style="width:auto" name="kind" aria-label="Relation" [(ngModel)]="linkKind">
|
|
2736
|
+
@for (k of relKinds; track k) {<option [value]="k">{{ relLabel(k) }}</option>}
|
|
2737
|
+
</select>
|
|
2738
|
+
<input class="o-in o-sm o-mono" style="width:140px" name="other" placeholder="PAY-150" aria-label="Issue key" [(ngModel)]="linkKey" data-testid="link-key" />
|
|
2739
|
+
<button type="submit" class="o-btn o-sm o-dark" [disabled]="!linkKey.trim()">Link</button>
|
|
2740
|
+
<button type="button" class="o-btn o-sm" (click)="linking.set(false)">Cancel</button>
|
|
2741
|
+
</form>
|
|
2742
|
+
}
|
|
2743
|
+
@for (r of is.relations; track r.id) {
|
|
2744
|
+
<div class="o-row">
|
|
2745
|
+
<span class="o-faint o-sm" style="width:110px">{{ relLabel(r.kind) }}</span>
|
|
2746
|
+
<span class="o-sq" [style.background]="typeColour(r.issue.type)" aria-hidden="true"></span>
|
|
2747
|
+
<a class="o-flex o-ell" style="flex:1;min-width:0;color:var(--o-tx)" [routerLink]="host.link('issues/' + r.issue.key)"><span class="o-mono o-sm o-mut">{{ r.issue.key }}</span><span class="o-ell">{{ r.issue.title }}</span></a>
|
|
2748
|
+
<span class="o-r" [class]="statusChip(r.issue.status, r.issue.category)">{{ r.issue.status }}</span>
|
|
2749
|
+
@if (canEdit()) {<button type="button" class="o-btn o-ghost o-xs" (click)="unlink(r)" [attr.aria-label]="'Remove the link to ' + r.issue.key">×</button>}
|
|
2750
|
+
</div>
|
|
2751
|
+
} @empty {
|
|
2752
|
+
<div class="o-empty">No linked issues.</div>
|
|
2753
|
+
}
|
|
2754
|
+
</div>
|
|
2755
|
+
|
|
2756
|
+
@if (attachments().length) {
|
|
2757
|
+
<div class="o-card" data-testid="attachments">
|
|
2758
|
+
<div class="o-ch"><b>Attachments</b><span class="o-faint o-sm">{{ attachments().length }}</span>
|
|
2759
|
+
@if (canEdit()) {<label class="o-lnk o-r" for="att-in-2">+ Attach</label>}</div>
|
|
2760
|
+
@for (a of attachments(); track a.id) {
|
|
2761
|
+
<div class="o-row" [attr.data-testid]="'attachment-' + a.filename">
|
|
2762
|
+
@if (a.inline && thumbs()[a.id]) {<img class="att-img" [src]="thumbs()[a.id]" [alt]="a.filename" />} @else {<span class="att-ic" aria-hidden="true">{{ ext(a.filename) }}</span>}
|
|
2763
|
+
<div class="o-col" style="gap:2px;min-width:0;flex:1">
|
|
2764
|
+
<button type="button" class="o-lnk o-ell" style="text-align:left" (click)="download(a)">{{ a.filename }}</button>
|
|
2765
|
+
<span class="o-faint o-xs">{{ size(a.size) }} · {{ people().name(a.uploadedBy) }} · {{ ago(a.createdAt) }}</span>
|
|
2766
|
+
</div>
|
|
2767
|
+
@if ((a.uploadedBy === host.meId() && canEdit()) || canManage()) {<button type="button" class="o-btn o-ghost o-xs" (click)="removeAttachment(a)" [attr.aria-label]="'Delete ' + a.filename">×</button>}
|
|
2768
|
+
</div>
|
|
2769
|
+
}
|
|
2770
|
+
@if (canEdit()) {<input id="att-in-2" class="file-in" type="file" multiple (change)="upload($any($event.target))" />}
|
|
2771
|
+
</div>
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
<div class="o-col" style="gap:12px">
|
|
2775
|
+
<div class="o-tabs" role="tablist">
|
|
2776
|
+
<button type="button" role="tab" class="o-tab" [class.o-on]="tab() === 'comments'" [attr.aria-selected]="tab() === 'comments'" (click)="tab.set('comments')">Comments<span class="o-n">{{ comments().length }}</span></button>
|
|
2777
|
+
<button type="button" role="tab" class="o-tab" [class.o-on]="tab() === 'history'" [attr.aria-selected]="tab() === 'history'" (click)="tab.set('history')">History</button>
|
|
2778
|
+
</div>
|
|
2779
|
+
@if (tab() === 'comments') {
|
|
2780
|
+
@for (c of comments(); track c.id) {
|
|
2781
|
+
<div class="o-card" [attr.data-testid]="'comment-' + c.id">
|
|
2782
|
+
<div class="o-ch" style="min-height:38px;background:var(--o-well)">
|
|
2783
|
+
<span class="o-av" aria-hidden="true">{{ people().initials(c.authorId) }}</span><b class="o-b5">{{ people().name(c.authorId) }}</b>
|
|
2784
|
+
<span class="o-faint o-sm">{{ ago(c.createdAt) }}{{ c.edited ? ' · edited' : '' }}</span>
|
|
2785
|
+
@if (c.authorId === host.meId() || canManage()) {<button type="button" class="o-lnk o-r" style="color:var(--o-mut)" (click)="removeComment(c)">Delete</button>}
|
|
2786
|
+
</div>
|
|
2787
|
+
<div class="o-cb o-lh" style="font-size:14px">
|
|
2788
|
+
<div class="o-md md" [innerHTML]="md(c.body)"></div>
|
|
2789
|
+
<div class="rx">
|
|
2790
|
+
@for (r of c.reactions ?? []; track r.reaction) {
|
|
2791
|
+
<button type="button" class="o-chip o-out" [class.mine]="r.mine" [attr.aria-pressed]="r.mine" [disabled]="!canComment()" (click)="react(c, r.reaction, !r.mine)"
|
|
2792
|
+
[attr.aria-label]="emojiLabel(r.reaction) + ': ' + r.count + (r.mine ? ', including you' : '')" [title]="reactors(r.people)">{{ emoji(r.reaction) }} {{ r.count }}</button>
|
|
2793
|
+
}
|
|
2794
|
+
@if (canComment()) {
|
|
2795
|
+
@if (picker() === c.id) {
|
|
2796
|
+
<span class="rx-pal" role="group" aria-label="Add a reaction">
|
|
2797
|
+
@for (x of reactionList; track x.name) {<button type="button" [attr.aria-label]="x.label" (click)="react(c, x.name, true)">{{ x.emoji }}</button>}
|
|
2798
|
+
</span>
|
|
2799
|
+
} @else {
|
|
2800
|
+
<button type="button" class="o-chip o-out" style="color:var(--o-mut)" (click)="picker.set(c.id)" aria-label="Add a reaction" data-testid="react">☺︎ +</button>
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
</div>
|
|
2804
|
+
</div>
|
|
2805
|
+
</div>
|
|
2806
|
+
}
|
|
2807
|
+
@if (canComment()) {
|
|
2808
|
+
<div class="o-card mn">
|
|
2809
|
+
<label class="o-sr-only" for="is-cmt">Add a comment</label>
|
|
2810
|
+
<textarea #cmtEl id="is-cmt" class="o-in" style="border:none;box-shadow:none;min-height:70px;font-family:var(--o-font);font-size:13px" placeholder="Add a comment. Markdown and @mentions work." [ngModel]="draft()" (ngModelChange)="draft.set($event)" data-testid="comment-input"
|
|
2811
|
+
(input)="typed(cmtEl, 'comment')" (keydown)="mentionKey($event, cmtEl)" (blur)="closeMentionSoon()" role="combobox" [attr.aria-expanded]="mentionFor() === 'comment'" aria-autocomplete="list"></textarea>
|
|
2812
|
+
@if (mentionFor() === 'comment' && suggestions().length) {<ng-container *ngTemplateOutlet="mentionList; context: { el: cmtEl }" />}
|
|
2813
|
+
<div class="o-cf">
|
|
2814
|
+
@if (canEdit()) {<label for="att-in" class="o-lnk" style="color:var(--o-mut)">{{ uploading() ? 'Uploading…' : 'Attach files up to 25 MB' }}</label><input id="att-in" class="file-in" type="file" multiple (change)="upload($any($event.target))" data-testid="attach-input" />} @else {<span>Markdown</span>}
|
|
2815
|
+
<button type="button" class="o-btn o-sm o-dark o-r" (click)="addComment()" [disabled]="!draft().trim() || busy()" data-testid="comment-add">Comment</button>
|
|
2816
|
+
</div>
|
|
2817
|
+
</div>
|
|
2818
|
+
}
|
|
2819
|
+
} @else {
|
|
2820
|
+
<div class="o-card">
|
|
2821
|
+
@for (h of history(); track h.id) {<div class="o-row o-sm"><span class="o-dot o-gry" aria-hidden="true"></span><span class="o-mut">{{ line(h) }}</span><span class="o-r o-faint">{{ ago(h.createdAt) }}</span></div>}
|
|
2822
|
+
</div>
|
|
2823
|
+
}
|
|
2824
|
+
</div>
|
|
2825
|
+
</div>
|
|
2826
|
+
|
|
2827
|
+
<div class="o-col" style="gap:14px">
|
|
2828
|
+
<div class="o-card o-cb">
|
|
2829
|
+
<div class="o-kv" style="grid-template-columns:90px 1fr;row-gap:8px">
|
|
2830
|
+
<label class="o-k" for="f-status">Status</label>
|
|
2831
|
+
<select id="f-status" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy()" (change)="setStatus($any($event.target).value)" data-testid="issue-status">
|
|
2832
|
+
@for (s of statuses(); track s.id) {<option [value]="s.id" [selected]="s.id === is.statusId">{{ s.name }}</option>}
|
|
2833
|
+
</select>
|
|
2834
|
+
<label class="o-k" for="f-who">Assignee</label>
|
|
2835
|
+
<select id="f-who" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy()" (change)="change({ assigneeId: $any($event.target).value })" data-testid="issue-assignee">
|
|
2836
|
+
<option value="" [selected]="!is.assigneeId">Unassigned</option>
|
|
2837
|
+
@for (p of host.people(); track p.id) {<option [value]="p.id" [selected]="p.id === is.assigneeId">{{ p.name || p.email }}</option>}
|
|
2838
|
+
</select>
|
|
2839
|
+
<span class="o-k">Reporter</span><span class="o-flex"><span class="o-av" aria-hidden="true">{{ people().initials(is.reporterId) }}</span>{{ people().name(is.reporterId) }}</span>
|
|
2840
|
+
<label class="o-k" for="f-pri">Priority</label>
|
|
2841
|
+
<select id="f-pri" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy()" (change)="change({ priority: $any($event.target).value })">
|
|
2842
|
+
@for (p of priorities; track p) {<option [value]="p" [selected]="p === is.priority">{{ priorityLabel(p) }}</option>}
|
|
2843
|
+
</select>
|
|
2844
|
+
<span class="o-k">Labels</span>
|
|
2845
|
+
<span class="o-flex o-wrap" style="gap:4px">
|
|
2846
|
+
@for (l of is.labels; track l) {<span class="o-chip o-out">{{ l }}@if (canEdit()) {<button type="button" class="o-lnk" style="font-size:11px;color:var(--o-dim)" (click)="removeLabel(l)" [attr.aria-label]="'Remove label ' + l">×</button>}</span>}
|
|
2847
|
+
@if (canEdit()) {<input class="o-in o-sm" style="width:90px;height:22px" placeholder="+ label" aria-label="Add a label" [ngModel]="labelDraft()" (ngModelChange)="labelDraft.set($event)" (keydown.enter)="addLabel()" />}
|
|
2848
|
+
@if (!is.labels.length && !canEdit()) {<span class="o-faint">None</span>}
|
|
2849
|
+
</span>
|
|
2850
|
+
@if (is.type !== 'epic') {
|
|
2851
|
+
<label class="o-k" for="f-parent">{{ is.type === 'subtask' ? 'Parent' : 'Epic' }}</label>
|
|
2852
|
+
@if (is.type === 'subtask') {
|
|
2853
|
+
<span>@if (is.parent) {<a [routerLink]="host.link('issues/' + is.parent.key)">{{ is.parent.key }} · {{ is.parent.title }}</a>}</span>
|
|
2854
|
+
} @else {
|
|
2855
|
+
<select id="f-parent" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy()" (change)="change({ parent: $any($event.target).value })">
|
|
2856
|
+
<option value="" [selected]="!is.parent">None</option>
|
|
2857
|
+
@for (e of epics(); track e.key) {<option [value]="e.key" [selected]="e.key === is.parent?.key">{{ e.key }} · {{ e.title }}</option>}
|
|
2858
|
+
</select>
|
|
2859
|
+
}
|
|
2860
|
+
<label class="o-k" for="f-sprint">Sprint</label>
|
|
2861
|
+
<select id="f-sprint" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy() || is.type === 'subtask'" (change)="change({ sprintId: $any($event.target).value })">
|
|
2862
|
+
<option value="" [selected]="!is.sprintId">Backlog</option>
|
|
2863
|
+
@for (s of openSprints(); track s.id) {<option [value]="s.id" [selected]="s.id === is.sprintId">{{ s.name }}{{ s.state === 'active' ? ' (active)' : '' }}</option>}
|
|
2864
|
+
@if (completedSprint(); as cs) {<option [value]="cs.id" selected disabled>{{ cs.name }} (completed)</option>}
|
|
2865
|
+
</select>
|
|
2866
|
+
<label class="o-k" for="f-pts">Points</label>
|
|
2867
|
+
<input id="f-pts" class="o-in o-sm o-mono kv-in" type="number" min="0" step="0.5" [disabled]="!canEdit() || busy()" [value]="is.points ?? ''" (change)="setPoints($any($event.target).value)" />
|
|
2868
|
+
}
|
|
2869
|
+
<label class="o-k" for="f-due">{{ is.type === 'epic' ? 'Target' : 'Due' }}</label>
|
|
2870
|
+
@if (is.type === 'epic') {
|
|
2871
|
+
<input id="f-due" class="o-in o-sm kv-in" type="date" [disabled]="!canEdit() || busy()" [value]="is.fields['targetDate'] ?? ''" (change)="change({ fields: { targetDate: $any($event.target).value } })" />
|
|
2872
|
+
} @else {
|
|
2873
|
+
<input id="f-due" class="o-in o-sm kv-in" type="date" [disabled]="!canEdit() || busy()" [value]="is.dueDate ?? ''" (change)="change({ dueDate: $any($event.target).value })" />
|
|
2874
|
+
}
|
|
2875
|
+
@if (is.type === 'bug') {
|
|
2876
|
+
<label class="o-k" for="f-sev">Severity</label>
|
|
2877
|
+
<select id="f-sev" class="o-in o-sm kv-in" [disabled]="!canEdit() || busy()" (change)="change({ fields: { severity: $any($event.target).value } })">
|
|
2878
|
+
<option value="" [selected]="!is.fields['severity']">Not set</option>
|
|
2879
|
+
@for (s of severities; track s) {<option [value]="s" [selected]="s === is.fields['severity']">{{ cap(s) }}</option>}
|
|
2880
|
+
</select>
|
|
2881
|
+
}
|
|
2882
|
+
@if (is.type === 'epic') {
|
|
2883
|
+
<label class="o-k" for="f-start">Start</label>
|
|
2884
|
+
<input id="f-start" class="o-in o-sm kv-in" type="date" [disabled]="!canEdit() || busy()" [value]="is.fields['startDate'] ?? ''" (change)="change({ fields: { startDate: $any($event.target).value } })" />
|
|
2885
|
+
}
|
|
2886
|
+
</div>
|
|
2887
|
+
</div>
|
|
2888
|
+
<div class="o-card">
|
|
2889
|
+
<div class="o-ch"><b>Deployments</b></div>
|
|
2890
|
+
<div class="o-empty o-sm">Releases that include {{ is.key }} show here once Deploy is set up.</div>
|
|
2891
|
+
</div>
|
|
2892
|
+
<div class="o-card o-cb o-col o-sm" style="gap:4px">
|
|
2893
|
+
<span class="o-faint">Created {{ shortDate(is.createdAt) }} by {{ people().name(is.reporterId) }}</span>
|
|
2894
|
+
<span class="o-faint">Updated {{ ago(is.updatedAt) }}</span>
|
|
2895
|
+
@if (is.resolvedAt) {<span class="o-faint">Resolved {{ shortDate(is.resolvedAt) }} · {{ is.resolution }}</span>}
|
|
2896
|
+
<span class="o-faint" data-testid="watchers">Watching: {{ watcherNames() }}</span>
|
|
2897
|
+
</div>
|
|
2898
|
+
@if (canManage()) {
|
|
2899
|
+
@if (confirmDelete()) {
|
|
2900
|
+
<div class="o-card o-cb o-col" style="gap:8px">
|
|
2901
|
+
<span class="o-mut o-sm">Delete {{ is.key }}? Its number is never reused. Type <b class="o-mono">{{ is.key }}</b> to confirm.</span>
|
|
2902
|
+
<input class="o-in o-sm o-mono" aria-label="Type the key to confirm" [ngModel]="deleteKey()" (ngModelChange)="deleteKey.set($event)" />
|
|
2903
|
+
<div class="o-flex"><button type="button" class="o-btn o-sm" (click)="confirmDelete.set(false)">Cancel</button><button type="button" class="o-btn o-sm o-red o-r" [disabled]="deleteKey().trim().toUpperCase() !== is.key || busy()" (click)="remove()">Delete issue</button></div>
|
|
2904
|
+
</div>
|
|
2905
|
+
} @else {
|
|
2906
|
+
<button type="button" class="o-lnk" style="align-self:flex-start;color:var(--o-bad-tx)" (click)="confirmDelete.set(true)">Delete issue</button>
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
</div>
|
|
2910
|
+
</div>
|
|
2911
|
+
</div>
|
|
2912
|
+
} @else if (error()) {
|
|
2913
|
+
<div class="o-page o-narrow"><div class="o-card o-empty"><p>{{ error() }}</p><a class="o-btn" style="margin-top:14px" [routerLink]="host.link('issues')">All issues</a></div></div>
|
|
2914
|
+
} @else {
|
|
2915
|
+
<div class="o-page"><div class="o-card"><div class="o-empty">Loading {{ key() }}…</div></div></div>
|
|
2916
|
+
}
|
|
2917
|
+
<ng-template #mentionList let-el="el">
|
|
2918
|
+
<ul class="mn-list" role="listbox" aria-label="People to mention">
|
|
2919
|
+
@for (p of suggestions(); track p.id; let i = $index) {
|
|
2920
|
+
<li role="option" [class.on]="i === mentionIndex()" [attr.aria-selected]="i === mentionIndex()" (mousedown)="$event.preventDefault(); pick(p, el)">
|
|
2921
|
+
<span class="o-av" aria-hidden="true">{{ people().initials(p.id) }}</span><span class="o-ell">{{ p.name || p.email }}</span>@if (p.email && p.name) {<span class="o-faint o-xs o-ell">{{ p.email }}</span>}
|
|
2922
|
+
</li>
|
|
2923
|
+
}
|
|
2924
|
+
</ul>
|
|
2925
|
+
</ng-template>
|
|
2926
|
+
`, styles: [".ti{font-size:24px;margin:0;line-height:1.25;text-wrap:pretty}.ti-btn{border:none;background:none;padding:0;text-align:left;font:inherit;color:inherit;cursor:text}.md :where(p,ul,ol,pre){margin:0}.md pre{background:var(--o-well);padding:10px 12px;border-radius:8px;overflow:auto;font-family:var(--o-mono);font-size:12px}.md code{font-family:var(--o-mono);font-size:12px;color:var(--o-tx);background:var(--o-sunken);padding:0 4px;border-radius:4px}.md pre code{background:none;padding:0}.md img{max-width:100%}.kv-in{width:100%}textarea.o-in{min-height:140px;font-family:var(--o-mono);font-size:12px;line-height:1.6;padding:10px 12px;height:auto}.md .mention{color:var(--o-acc-tx, var(--o-acc));background:var(--o-acc-bg, var(--o-sunken));border-radius:4px;padding:0 3px;font-weight:500}.rx{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center}.rx .o-chip{cursor:pointer;font-size:12px}.rx .o-chip.mine{border-color:var(--o-acc);background:var(--o-acc-bg, var(--o-sunken))}.rx-pal{display:flex;gap:2px;padding:4px;border:1px solid var(--o-line);border-radius:8px;background:var(--o-surface);box-shadow:var(--o-shadow-sm, none)}.rx-pal button{border:none;background:none;font-size:16px;padding:2px 4px;border-radius:6px;cursor:pointer}.rx-pal button:hover,.rx-pal button:focus-visible{background:var(--o-sunken)}.mn{position:relative}.mn-list{position:absolute;left:8px;top:calc(100% - 4px);z-index:20;min-width:220px;max-width:320px;border:1px solid var(--o-line);border-radius:8px;background:var(--o-surface);box-shadow:var(--o-shadow, 0 6px 20px rgba(0,0,0,.12));padding:4px;list-style:none;margin:0}.mn-list li{display:flex;gap:8px;align-items:center;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:13px}.mn-list li.on,.mn-list li:hover{background:var(--o-sunken)}.att-img{width:40px;height:40px;border-radius:6px;object-fit:cover;background:var(--o-well);flex:none;overflow:hidden;font-size:0}.att-ic{width:40px;height:40px;border-radius:6px;background:var(--o-well);display:grid;place-items:center;font-size:10px;color:var(--o-mut);font-family:var(--o-mono);flex:none;text-transform:uppercase}.file-in{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}\n"] }]
|
|
2927
|
+
}], ctorParameters: () => [] });
|
|
2928
|
+
|
|
2929
|
+
/*
|
|
2930
|
+
* @chisquare-tech/origine-ngx-apps-design: the Design module's screens (TRD §6.10). The host app
|
|
2931
|
+
* provides DesignHost and mounts the pages at /{org}/{project}/board, /backlog, /issues and
|
|
2932
|
+
* /issues/:key.
|
|
2933
|
+
*/
|
|
2934
|
+
|
|
2935
|
+
/**
|
|
2936
|
+
* Generated bundle index. Do not edit.
|
|
2937
|
+
*/
|
|
2938
|
+
|
|
2939
|
+
export { BacklogPage, BoardPage, CreateIssueDialog, DesignApi, DesignHost, IssueListPage, IssuePage, IssuePanel, PRIORITIES, People, TYPES, ago, apiMessage, dateRange, download, epicChip, historyLine, initials, isConflict, isDone, points, priorityLabel, shortDate, statusChip, typeColour, typeLabel };
|
|
2940
|
+
//# sourceMappingURL=chisquare-tech-origine-ngx-apps-design.mjs.map
|