hyper-pm-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/server.mjs +4512 -0
- package/env.example +8 -0
- package/package.json +44 -0
- package/public/app.js +2326 -0
- package/public/audit-event-summary.js +182 -0
- package/public/index.html +1261 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,2326 @@
|
|
|
1
|
+
/* global window, document, fetch, localStorage, marked, DOMPurify, HyperPmAuditSummary */
|
|
2
|
+
|
|
3
|
+
/** @type {boolean} */
|
|
4
|
+
let markdownLibConfigured = false;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Enables GFM-style line breaks for issue-style markdown (once per page load).
|
|
8
|
+
*/
|
|
9
|
+
function configureMarkdownLibOnce() {
|
|
10
|
+
if (markdownLibConfigured) return;
|
|
11
|
+
if (typeof marked !== "undefined" && typeof marked.use === "function") {
|
|
12
|
+
marked.use({
|
|
13
|
+
gfm: true,
|
|
14
|
+
breaks: true,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
markdownLibConfigured = true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @returns {boolean} True when CDN markdown + sanitizer globals are available.
|
|
22
|
+
*/
|
|
23
|
+
function markdownLibsLoaded() {
|
|
24
|
+
return (
|
|
25
|
+
typeof marked !== "undefined" &&
|
|
26
|
+
typeof marked.parse === "function" &&
|
|
27
|
+
typeof DOMPurify !== "undefined" &&
|
|
28
|
+
typeof DOMPurify.sanitize === "function"
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sanitizes HTML produced from user-authored markdown before `innerHTML` use.
|
|
34
|
+
* @param {string} dirtyHtml
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function sanitizeUserMarkdownHtml(dirtyHtml) {
|
|
38
|
+
if (
|
|
39
|
+
typeof DOMPurify === "undefined" ||
|
|
40
|
+
typeof DOMPurify.sanitize !== "function"
|
|
41
|
+
) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
return DOMPurify.sanitize(dirtyHtml, { USE_PROFILES: { html: true } });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Renders inline markdown (titles, short labels). Falls back to escaped plain text.
|
|
49
|
+
* @param {unknown} text
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function markdownInlineHtml(text) {
|
|
53
|
+
const s = String(text ?? "");
|
|
54
|
+
configureMarkdownLibOnce();
|
|
55
|
+
if (!markdownLibsLoaded()) {
|
|
56
|
+
return escapeHtml(s);
|
|
57
|
+
}
|
|
58
|
+
const raw =
|
|
59
|
+
typeof marked.parseInline === "function"
|
|
60
|
+
? marked.parseInline(s, { async: false })
|
|
61
|
+
: marked.parse(s, { async: false });
|
|
62
|
+
return sanitizeUserMarkdownHtml(/** @type {string} */ (raw));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Flatten markdown to plain text for the window/toolbar title (no HTML).
|
|
67
|
+
* @param {unknown} text
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function markdownToPlainTextForUiChrome(text) {
|
|
71
|
+
const s = String(text ?? "");
|
|
72
|
+
if (!trimU(s)) return "";
|
|
73
|
+
if (!markdownLibsLoaded()) return s.replace(/\s+/g, " ").trim();
|
|
74
|
+
const wrap = document.createElement("div");
|
|
75
|
+
wrap.innerHTML = markdownInlineHtml(s);
|
|
76
|
+
const out = wrap.textContent?.replace(/\s+/g, " ").trim();
|
|
77
|
+
return out && out.length > 0 ? out : s;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const TOKEN_KEY = "hyperPmWebBearer";
|
|
81
|
+
|
|
82
|
+
const STATUSES = ["backlog", "todo", "in_progress", "done", "cancelled"];
|
|
83
|
+
|
|
84
|
+
/** @type {{ view: string; epicId?: string; storyId?: string; ticketId?: string; storyFilterEpic?: string; ticketFilterStory?: string; epicDetailForm?: boolean; storyDetailForm?: boolean; ticketDetailForm?: boolean }} */
|
|
85
|
+
const state = {
|
|
86
|
+
view: "dashboard",
|
|
87
|
+
storyFilterEpic: "",
|
|
88
|
+
ticketFilterStory: "",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string} s
|
|
93
|
+
*/
|
|
94
|
+
function escapeHtml(s) {
|
|
95
|
+
return String(s)
|
|
96
|
+
.replace(/&/g, "&")
|
|
97
|
+
.replace(/</g, "<")
|
|
98
|
+
.replace(/>/g, ">")
|
|
99
|
+
.replace(/"/g, """);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string} status
|
|
104
|
+
*/
|
|
105
|
+
function badgeHtml(status) {
|
|
106
|
+
const s = String(status);
|
|
107
|
+
const label = s.replace(/_/g, " ");
|
|
108
|
+
return `<span class="status-pill" data-status="${escapeHtml(s)}">${escapeHtml(label)}</span>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} id
|
|
113
|
+
*/
|
|
114
|
+
function idChip(id) {
|
|
115
|
+
return `<span class="id-chip">${escapeHtml(id)}</span>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Renders markdown as safe HTML for read-only multiline display (epic/story/ticket bodies).
|
|
120
|
+
* @param {unknown} text
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
function readBodyHtml(text) {
|
|
124
|
+
const s = String(text ?? "");
|
|
125
|
+
if (!trimU(s)) {
|
|
126
|
+
return '<p class="muted read-empty">No description.</p>';
|
|
127
|
+
}
|
|
128
|
+
configureMarkdownLibOnce();
|
|
129
|
+
if (!markdownLibsLoaded()) {
|
|
130
|
+
return `<div class="read-body">${escapeHtml(s).replace(/\n/g, "<br />")}</div>`;
|
|
131
|
+
}
|
|
132
|
+
const raw = marked.parse(s, { async: false });
|
|
133
|
+
const clean = sanitizeUserMarkdownHtml(/** @type {string} */ (raw));
|
|
134
|
+
return `<div class="read-body md-body">${clean}</div>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One labeled block in a read-only detail stack (`innerHtml` is trusted HTML from this file only).
|
|
139
|
+
* @param {string} label
|
|
140
|
+
* @param {string} innerHtml
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function readRowHtml(label, innerHtml) {
|
|
144
|
+
return `<div class="read-row"><div class="read-label">${escapeHtml(label)}</div><div class="read-value">${innerHtml}</div></div>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} v
|
|
149
|
+
* @returns {string | undefined}
|
|
150
|
+
*/
|
|
151
|
+
function trimU(v) {
|
|
152
|
+
const t = String(v).trim();
|
|
153
|
+
return t.length > 0 ? t : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @typedef {{
|
|
158
|
+
* kind: 'dashboard'|'epics'|'epicNew'|'epicEdit'|'stories'|'storyNew'|'storyEdit'|'tickets'|'ticketNew'|'ticketEdit'|'tools'|'advanced';
|
|
159
|
+
* epicId?: string; storyId?: string; ticketId?: string;
|
|
160
|
+
* epicForm?: boolean; storyForm?: boolean; ticketForm?: boolean;
|
|
161
|
+
* storyFilterEpic?: string; ticketFilterStory?: string;
|
|
162
|
+
* }} AppRoute
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parses `location.hash` into a structured route.
|
|
167
|
+
* @returns {AppRoute}
|
|
168
|
+
*/
|
|
169
|
+
function parseHash() {
|
|
170
|
+
try {
|
|
171
|
+
let raw = (window.location.hash || "").replace(/^#/, "").trim();
|
|
172
|
+
if (!raw || raw === "/") return { kind: "dashboard" };
|
|
173
|
+
const qIndex = raw.indexOf("?");
|
|
174
|
+
const pathPart = qIndex === -1 ? raw : raw.slice(0, qIndex);
|
|
175
|
+
const qs = qIndex === -1 ? "" : raw.slice(qIndex + 1);
|
|
176
|
+
const params = new URLSearchParams(qs);
|
|
177
|
+
const path = pathPart.startsWith("/") ? pathPart : `/${pathPart}`;
|
|
178
|
+
const parts = path.split("/").filter(Boolean);
|
|
179
|
+
|
|
180
|
+
if (parts[0] === "dashboard" && parts.length === 1) {
|
|
181
|
+
return { kind: "dashboard" };
|
|
182
|
+
}
|
|
183
|
+
if (parts[0] === "epics" && parts.length === 1) return { kind: "epics" };
|
|
184
|
+
if (parts[0] === "epic" && parts[1] === "new") return { kind: "epicNew" };
|
|
185
|
+
if (parts[0] === "epic" && parts.length >= 2 && parts[1] !== "new") {
|
|
186
|
+
const id = decodeURIComponent(parts[1]);
|
|
187
|
+
if (!trimU(id)) return { kind: "dashboard" };
|
|
188
|
+
const epicForm = parts[2] === "edit";
|
|
189
|
+
return { kind: "epicEdit", epicId: id, epicForm };
|
|
190
|
+
}
|
|
191
|
+
if (parts[0] === "stories" && parts.length === 1) {
|
|
192
|
+
const epic = params.get("epic") || "";
|
|
193
|
+
return { kind: "stories", storyFilterEpic: epic };
|
|
194
|
+
}
|
|
195
|
+
if (parts[0] === "story" && parts[1] === "new") return { kind: "storyNew" };
|
|
196
|
+
if (parts[0] === "story" && parts.length >= 2 && parts[1] !== "new") {
|
|
197
|
+
const id = decodeURIComponent(parts[1]);
|
|
198
|
+
if (!trimU(id)) return { kind: "dashboard" };
|
|
199
|
+
const storyForm = parts[2] === "edit";
|
|
200
|
+
return { kind: "storyEdit", storyId: id, storyForm };
|
|
201
|
+
}
|
|
202
|
+
if (parts[0] === "tickets" && parts.length === 1) {
|
|
203
|
+
const st = params.get("story") || "";
|
|
204
|
+
return { kind: "tickets", ticketFilterStory: st };
|
|
205
|
+
}
|
|
206
|
+
if (parts[0] === "ticket" && parts[1] === "new")
|
|
207
|
+
return { kind: "ticketNew" };
|
|
208
|
+
if (parts[0] === "ticket" && parts.length >= 2 && parts[1] !== "new") {
|
|
209
|
+
const id = decodeURIComponent(parts[1]);
|
|
210
|
+
if (!trimU(id)) return { kind: "dashboard" };
|
|
211
|
+
const ticketForm = parts[2] === "edit";
|
|
212
|
+
return { kind: "ticketEdit", ticketId: id, ticketForm };
|
|
213
|
+
}
|
|
214
|
+
if (parts[0] === "tools" && parts.length === 1) return { kind: "tools" };
|
|
215
|
+
if (parts[0] === "advanced" && parts.length === 1) {
|
|
216
|
+
return { kind: "advanced" };
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
/* fall through */
|
|
220
|
+
}
|
|
221
|
+
return { kind: "dashboard" };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {AppRoute} r
|
|
226
|
+
* @returns {string} path and optional query, without leading #
|
|
227
|
+
*/
|
|
228
|
+
function routeToHashPath(r) {
|
|
229
|
+
switch (r.kind) {
|
|
230
|
+
case "dashboard":
|
|
231
|
+
return "/";
|
|
232
|
+
case "epics":
|
|
233
|
+
return "/epics";
|
|
234
|
+
case "epicNew":
|
|
235
|
+
return "/epic/new";
|
|
236
|
+
case "epicEdit": {
|
|
237
|
+
const id = encodeURIComponent(r.epicId || "");
|
|
238
|
+
return r.epicForm ? `/epic/${id}/edit` : `/epic/${id}`;
|
|
239
|
+
}
|
|
240
|
+
case "stories": {
|
|
241
|
+
const fe = trimU(r.storyFilterEpic);
|
|
242
|
+
return fe ? `/stories?epic=${encodeURIComponent(fe)}` : "/stories";
|
|
243
|
+
}
|
|
244
|
+
case "storyNew":
|
|
245
|
+
return "/story/new";
|
|
246
|
+
case "storyEdit": {
|
|
247
|
+
const id = encodeURIComponent(r.storyId || "");
|
|
248
|
+
return r.storyForm ? `/story/${id}/edit` : `/story/${id}`;
|
|
249
|
+
}
|
|
250
|
+
case "tickets": {
|
|
251
|
+
const fs = trimU(r.ticketFilterStory);
|
|
252
|
+
return fs ? `/tickets?story=${encodeURIComponent(fs)}` : "/tickets";
|
|
253
|
+
}
|
|
254
|
+
case "ticketNew":
|
|
255
|
+
return "/ticket/new";
|
|
256
|
+
case "ticketEdit": {
|
|
257
|
+
const id = encodeURIComponent(r.ticketId || "");
|
|
258
|
+
return r.ticketForm ? `/ticket/${id}/edit` : `/ticket/${id}`;
|
|
259
|
+
}
|
|
260
|
+
case "tools":
|
|
261
|
+
return "/tools";
|
|
262
|
+
case "advanced":
|
|
263
|
+
return "/advanced";
|
|
264
|
+
default:
|
|
265
|
+
return "/";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Applies route fields to in-memory `state` (single source with URL after navigation).
|
|
271
|
+
* @param {AppRoute} r
|
|
272
|
+
*/
|
|
273
|
+
function syncStateFromRoute(r) {
|
|
274
|
+
switch (r.kind) {
|
|
275
|
+
case "dashboard":
|
|
276
|
+
state.view = "dashboard";
|
|
277
|
+
delete state.epicId;
|
|
278
|
+
delete state.storyId;
|
|
279
|
+
delete state.ticketId;
|
|
280
|
+
state.storyFilterEpic = "";
|
|
281
|
+
state.ticketFilterStory = "";
|
|
282
|
+
delete state.epicDetailForm;
|
|
283
|
+
delete state.storyDetailForm;
|
|
284
|
+
delete state.ticketDetailForm;
|
|
285
|
+
break;
|
|
286
|
+
case "epics":
|
|
287
|
+
state.view = "epics";
|
|
288
|
+
delete state.epicId;
|
|
289
|
+
delete state.epicDetailForm;
|
|
290
|
+
state.storyFilterEpic = "";
|
|
291
|
+
state.ticketFilterStory = "";
|
|
292
|
+
delete state.storyId;
|
|
293
|
+
delete state.ticketId;
|
|
294
|
+
delete state.storyDetailForm;
|
|
295
|
+
delete state.ticketDetailForm;
|
|
296
|
+
break;
|
|
297
|
+
case "epicNew":
|
|
298
|
+
state.view = "epicNew";
|
|
299
|
+
delete state.epicId;
|
|
300
|
+
delete state.epicDetailForm;
|
|
301
|
+
state.storyFilterEpic = "";
|
|
302
|
+
state.ticketFilterStory = "";
|
|
303
|
+
delete state.storyId;
|
|
304
|
+
delete state.ticketId;
|
|
305
|
+
delete state.storyDetailForm;
|
|
306
|
+
delete state.ticketDetailForm;
|
|
307
|
+
break;
|
|
308
|
+
case "epicEdit":
|
|
309
|
+
state.view = "epicEdit";
|
|
310
|
+
state.epicId = r.epicId;
|
|
311
|
+
state.epicDetailForm = Boolean(r.epicForm);
|
|
312
|
+
state.storyFilterEpic = "";
|
|
313
|
+
state.ticketFilterStory = "";
|
|
314
|
+
break;
|
|
315
|
+
case "stories":
|
|
316
|
+
state.view = "stories";
|
|
317
|
+
state.storyFilterEpic = r.storyFilterEpic || "";
|
|
318
|
+
state.ticketFilterStory = "";
|
|
319
|
+
delete state.storyId;
|
|
320
|
+
delete state.storyDetailForm;
|
|
321
|
+
break;
|
|
322
|
+
case "storyNew":
|
|
323
|
+
state.view = "storyNew";
|
|
324
|
+
delete state.storyId;
|
|
325
|
+
delete state.storyDetailForm;
|
|
326
|
+
state.ticketFilterStory = "";
|
|
327
|
+
break;
|
|
328
|
+
case "storyEdit":
|
|
329
|
+
state.view = "storyEdit";
|
|
330
|
+
state.storyId = r.storyId;
|
|
331
|
+
state.storyDetailForm = Boolean(r.storyForm);
|
|
332
|
+
state.storyFilterEpic = "";
|
|
333
|
+
state.ticketFilterStory = "";
|
|
334
|
+
break;
|
|
335
|
+
case "tickets":
|
|
336
|
+
state.view = "tickets";
|
|
337
|
+
state.ticketFilterStory = r.ticketFilterStory || "";
|
|
338
|
+
state.storyFilterEpic = "";
|
|
339
|
+
delete state.ticketId;
|
|
340
|
+
delete state.ticketDetailForm;
|
|
341
|
+
break;
|
|
342
|
+
case "ticketNew":
|
|
343
|
+
state.view = "ticketNew";
|
|
344
|
+
delete state.ticketId;
|
|
345
|
+
delete state.ticketDetailForm;
|
|
346
|
+
state.storyFilterEpic = "";
|
|
347
|
+
break;
|
|
348
|
+
case "ticketEdit":
|
|
349
|
+
state.view = "ticketEdit";
|
|
350
|
+
state.ticketId = r.ticketId;
|
|
351
|
+
state.ticketDetailForm = Boolean(r.ticketForm);
|
|
352
|
+
state.storyFilterEpic = "";
|
|
353
|
+
state.ticketFilterStory = "";
|
|
354
|
+
break;
|
|
355
|
+
case "tools":
|
|
356
|
+
state.view = "tools";
|
|
357
|
+
delete state.epicId;
|
|
358
|
+
delete state.storyId;
|
|
359
|
+
delete state.ticketId;
|
|
360
|
+
delete state.epicDetailForm;
|
|
361
|
+
delete state.storyDetailForm;
|
|
362
|
+
delete state.ticketDetailForm;
|
|
363
|
+
break;
|
|
364
|
+
case "advanced":
|
|
365
|
+
state.view = "advanced";
|
|
366
|
+
delete state.epicId;
|
|
367
|
+
delete state.storyId;
|
|
368
|
+
delete state.ticketId;
|
|
369
|
+
delete state.epicDetailForm;
|
|
370
|
+
delete state.storyDetailForm;
|
|
371
|
+
delete state.ticketDetailForm;
|
|
372
|
+
break;
|
|
373
|
+
default:
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @param {AppRoute} r
|
|
380
|
+
* @returns {void|Promise<void>}
|
|
381
|
+
*/
|
|
382
|
+
function renderForRoute(r) {
|
|
383
|
+
switch (r.kind) {
|
|
384
|
+
case "dashboard":
|
|
385
|
+
return renderDashboard();
|
|
386
|
+
case "epics":
|
|
387
|
+
return renderEpicsList();
|
|
388
|
+
case "epicNew":
|
|
389
|
+
return renderEpicNew();
|
|
390
|
+
case "epicEdit":
|
|
391
|
+
return renderEpicEdit();
|
|
392
|
+
case "stories":
|
|
393
|
+
return renderStoriesList();
|
|
394
|
+
case "storyNew":
|
|
395
|
+
return renderStoryNew();
|
|
396
|
+
case "storyEdit":
|
|
397
|
+
return renderStoryEdit();
|
|
398
|
+
case "tickets":
|
|
399
|
+
return renderTicketsList();
|
|
400
|
+
case "ticketNew":
|
|
401
|
+
return renderTicketNew();
|
|
402
|
+
case "ticketEdit":
|
|
403
|
+
return renderTicketEdit();
|
|
404
|
+
case "tools":
|
|
405
|
+
renderTools();
|
|
406
|
+
return undefined;
|
|
407
|
+
case "advanced":
|
|
408
|
+
renderAdvanced();
|
|
409
|
+
return undefined;
|
|
410
|
+
default:
|
|
411
|
+
return renderDashboard();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Updates the URL and renders. Use `replace: true` for filter-only or post-save URL fixes.
|
|
417
|
+
* @param {AppRoute} r
|
|
418
|
+
* @param {{ replace?: boolean }} [opts]
|
|
419
|
+
*/
|
|
420
|
+
function pushAppRoute(r, opts) {
|
|
421
|
+
const next = `#${routeToHashPath(r)}`;
|
|
422
|
+
const useReplace = Boolean(opts?.replace);
|
|
423
|
+
if (window.location.hash !== next) {
|
|
424
|
+
history[useReplace ? "replaceState" : "pushState"](null, "", next);
|
|
425
|
+
}
|
|
426
|
+
syncStateFromRoute(r);
|
|
427
|
+
const out = renderForRoute(r);
|
|
428
|
+
setNavCurrent();
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @param {AppRoute} r
|
|
434
|
+
*/
|
|
435
|
+
function replaceAppRoute(r) {
|
|
436
|
+
pushAppRoute(r, { replace: true });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function wireHistoryNavigation() {
|
|
440
|
+
window.addEventListener("popstate", () => {
|
|
441
|
+
const r = parseHash();
|
|
442
|
+
syncStateFromRoute(r);
|
|
443
|
+
void renderForRoute(r);
|
|
444
|
+
setNavCurrent();
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Opens an epic in read mode (detail view).
|
|
450
|
+
* @param {string} epicId
|
|
451
|
+
*/
|
|
452
|
+
function navigateToEpic(epicId) {
|
|
453
|
+
void pushAppRoute({ kind: "epicEdit", epicId, epicForm: false });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Opens a story in read mode (detail view).
|
|
458
|
+
* @param {string} storyId
|
|
459
|
+
*/
|
|
460
|
+
function navigateToStory(storyId) {
|
|
461
|
+
void pushAppRoute({ kind: "storyEdit", storyId, storyForm: false });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Opens a ticket in read mode (detail view).
|
|
466
|
+
* @param {string} ticketId
|
|
467
|
+
*/
|
|
468
|
+
function navigateToTicket(ticketId) {
|
|
469
|
+
void pushAppRoute({ kind: "ticketEdit", ticketId, ticketForm: false });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Goes to the stories list filtered to one epic.
|
|
474
|
+
* @param {string} epicId
|
|
475
|
+
*/
|
|
476
|
+
function navigateToStoriesForEpic(epicId) {
|
|
477
|
+
void pushAppRoute({ kind: "stories", storyFilterEpic: epicId });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Goes to the tickets list filtered to one story.
|
|
482
|
+
* @param {string} storyId
|
|
483
|
+
*/
|
|
484
|
+
function navigateToTicketsForStory(storyId) {
|
|
485
|
+
void pushAppRoute({ kind: "tickets", ticketFilterStory: storyId });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* @param {Record<string, unknown>} data
|
|
490
|
+
*/
|
|
491
|
+
async function runApi(data) {
|
|
492
|
+
const headers = { "Content-Type": "application/json" };
|
|
493
|
+
const token = localStorage.getItem(TOKEN_KEY);
|
|
494
|
+
if (token) {
|
|
495
|
+
headers.Authorization = `Bearer ${token}`;
|
|
496
|
+
}
|
|
497
|
+
const res = await fetch("/api/run", {
|
|
498
|
+
method: "POST",
|
|
499
|
+
headers,
|
|
500
|
+
body: JSON.stringify(data),
|
|
501
|
+
});
|
|
502
|
+
const text = await res.text();
|
|
503
|
+
let parsed;
|
|
504
|
+
try {
|
|
505
|
+
parsed = JSON.parse(text);
|
|
506
|
+
} catch {
|
|
507
|
+
parsed = { raw: text };
|
|
508
|
+
}
|
|
509
|
+
return { ok: res.ok, status: res.status, body: parsed };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* @param {string[]} argv
|
|
514
|
+
* @param {Record<string, unknown>} [extra]
|
|
515
|
+
* @returns {Promise<unknown>}
|
|
516
|
+
*/
|
|
517
|
+
async function runCli(argv, extra = {}) {
|
|
518
|
+
const r = await runApi({ argv, ...extra });
|
|
519
|
+
const b = r.body;
|
|
520
|
+
if (!r.ok) {
|
|
521
|
+
const msg =
|
|
522
|
+
typeof b.error === "string" ? b.error : `Request failed (${r.status})`;
|
|
523
|
+
throw new Error(msg);
|
|
524
|
+
}
|
|
525
|
+
if (b.exitCode !== 0) {
|
|
526
|
+
const errText = (b.stderr && String(b.stderr).trim()) || "";
|
|
527
|
+
throw new Error(errText || `Command failed (exit ${b.exitCode})`);
|
|
528
|
+
}
|
|
529
|
+
return b.json;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Formats an ISO audit timestamp for the timeline UI.
|
|
534
|
+
* @param {unknown} iso
|
|
535
|
+
* @returns {string}
|
|
536
|
+
*/
|
|
537
|
+
function formatAuditInstant(iso) {
|
|
538
|
+
const s = String(iso ?? "");
|
|
539
|
+
const d = new Date(s);
|
|
540
|
+
if (Number.isNaN(d.getTime())) return s;
|
|
541
|
+
return d.toLocaleString(undefined, {
|
|
542
|
+
dateStyle: "medium",
|
|
543
|
+
timeStyle: "short",
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Removes the audit history modal if present.
|
|
549
|
+
*/
|
|
550
|
+
function closeHistoryModal() {
|
|
551
|
+
document.getElementById("hpw-history-modal")?.remove();
|
|
552
|
+
document.documentElement.classList.remove("history-modal-open");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Opens a modal and loads the audit trail for a work item id via `hyper-pm audit`.
|
|
557
|
+
* @param {string} entityId
|
|
558
|
+
* @param {string} entityKindLabel
|
|
559
|
+
* @returns {Promise<void>}
|
|
560
|
+
*/
|
|
561
|
+
async function openHistoryModal(entityId, entityKindLabel) {
|
|
562
|
+
closeHistoryModal();
|
|
563
|
+
document.documentElement.classList.add("history-modal-open");
|
|
564
|
+
const backdrop = document.createElement("div");
|
|
565
|
+
backdrop.className = "history-modal-backdrop";
|
|
566
|
+
backdrop.id = "hpw-history-modal";
|
|
567
|
+
backdrop.setAttribute("role", "dialog");
|
|
568
|
+
backdrop.setAttribute("aria-modal", "true");
|
|
569
|
+
backdrop.setAttribute("aria-labelledby", "hpw-history-modal-title");
|
|
570
|
+
backdrop.innerHTML = `
|
|
571
|
+
<div class="history-modal" id="hpw-history-modal-panel">
|
|
572
|
+
<header>
|
|
573
|
+
<h2 id="hpw-history-modal-title">History · ${escapeHtml(entityKindLabel)} · ${escapeHtml(entityId)}</h2>
|
|
574
|
+
<button type="button" class="ghost" id="hpw-history-modal-close">Close</button>
|
|
575
|
+
</header>
|
|
576
|
+
<div class="history-modal-body" id="hpw-history-modal-body">
|
|
577
|
+
<p class="muted">Loading audit trail…</p>
|
|
578
|
+
</div>
|
|
579
|
+
</div>`;
|
|
580
|
+
document.body.appendChild(backdrop);
|
|
581
|
+
/** @param {KeyboardEvent} e */
|
|
582
|
+
const onKey = (e) => {
|
|
583
|
+
if (e.key === "Escape") {
|
|
584
|
+
window.removeEventListener("keydown", onKey);
|
|
585
|
+
closeHistoryModal();
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
window.addEventListener("keydown", onKey);
|
|
589
|
+
backdrop.addEventListener("click", () => {
|
|
590
|
+
window.removeEventListener("keydown", onKey);
|
|
591
|
+
closeHistoryModal();
|
|
592
|
+
});
|
|
593
|
+
document
|
|
594
|
+
.getElementById("hpw-history-modal-close")
|
|
595
|
+
?.addEventListener("click", () => {
|
|
596
|
+
window.removeEventListener("keydown", onKey);
|
|
597
|
+
closeHistoryModal();
|
|
598
|
+
});
|
|
599
|
+
document
|
|
600
|
+
.getElementById("hpw-history-modal-panel")
|
|
601
|
+
?.addEventListener("click", (e) => {
|
|
602
|
+
e.stopPropagation();
|
|
603
|
+
});
|
|
604
|
+
const bodyEl = document.getElementById("hpw-history-modal-body");
|
|
605
|
+
try {
|
|
606
|
+
const json = await runCli([
|
|
607
|
+
"audit",
|
|
608
|
+
"--entity-id",
|
|
609
|
+
entityId,
|
|
610
|
+
"--limit",
|
|
611
|
+
"500",
|
|
612
|
+
]);
|
|
613
|
+
const raw = /** @type {{ events?: unknown[] }} */ (json);
|
|
614
|
+
const events = Array.isArray(raw.events) ? raw.events : [];
|
|
615
|
+
const summarize =
|
|
616
|
+
typeof HyperPmAuditSummary !== "undefined" &&
|
|
617
|
+
typeof HyperPmAuditSummary.summarizeAuditEventForWeb === "function"
|
|
618
|
+
? HyperPmAuditSummary.summarizeAuditEventForWeb
|
|
619
|
+
: (evt) => {
|
|
620
|
+
const t =
|
|
621
|
+
evt &&
|
|
622
|
+
typeof evt === "object" &&
|
|
623
|
+
typeof (/** @type {{ type?: string }} */ (evt).type) === "string"
|
|
624
|
+
? String(/** @type {{ type?: string }} */ (evt).type)
|
|
625
|
+
: "Event";
|
|
626
|
+
return { title: t, detailLines: [] };
|
|
627
|
+
};
|
|
628
|
+
const ordered = [...events].reverse();
|
|
629
|
+
if (ordered.length === 0) {
|
|
630
|
+
if (bodyEl) {
|
|
631
|
+
bodyEl.innerHTML =
|
|
632
|
+
'<p class="muted">No audit events recorded for this item yet.</p>';
|
|
633
|
+
}
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const items = ordered
|
|
637
|
+
.map((evt) => {
|
|
638
|
+
const row = /** @type {Record<string, unknown>} */ (evt);
|
|
639
|
+
const ts = formatAuditInstant(row.ts);
|
|
640
|
+
const actor = String(row.actor ?? "");
|
|
641
|
+
const { title, detailLines } = summarize(evt);
|
|
642
|
+
const details =
|
|
643
|
+
detailLines.length === 0
|
|
644
|
+
? ""
|
|
645
|
+
: detailLines
|
|
646
|
+
.map(
|
|
647
|
+
(line) =>
|
|
648
|
+
`<p class="audit-detail">${escapeHtml(String(line))}</p>`,
|
|
649
|
+
)
|
|
650
|
+
.join("");
|
|
651
|
+
return `<li>
|
|
652
|
+
<div class="audit-ts">${escapeHtml(ts)}</div>
|
|
653
|
+
<div class="audit-actor">${escapeHtml(actor)}</div>
|
|
654
|
+
<div class="audit-title">${escapeHtml(title)}</div>
|
|
655
|
+
${details}
|
|
656
|
+
</li>`;
|
|
657
|
+
})
|
|
658
|
+
.join("");
|
|
659
|
+
if (bodyEl) {
|
|
660
|
+
bodyEl.innerHTML = `<ol class="audit-timeline" aria-label="Audit events">${items}</ol>`;
|
|
661
|
+
}
|
|
662
|
+
} catch (e) {
|
|
663
|
+
if (bodyEl) {
|
|
664
|
+
bodyEl.innerHTML = `<p class="muted">${escapeHtml(String(e))}</p>`;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* @param {unknown} json
|
|
671
|
+
* @returns {unknown[]}
|
|
672
|
+
*/
|
|
673
|
+
function listFromJson(json) {
|
|
674
|
+
if (
|
|
675
|
+
json &&
|
|
676
|
+
typeof json === "object" &&
|
|
677
|
+
Array.isArray(/** @type {{items?: unknown[]}} */ (json).items)
|
|
678
|
+
) {
|
|
679
|
+
return /** @type {{items: unknown[]}} */ (json).items;
|
|
680
|
+
}
|
|
681
|
+
return [];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Parses `repo commit-authors` JSON rows for the assignee picker.
|
|
686
|
+
* @param {unknown} json
|
|
687
|
+
* @returns {{ name: string; email: string; loginGuess?: string }[]}
|
|
688
|
+
*/
|
|
689
|
+
function commitAuthorsFromJson(json) {
|
|
690
|
+
const items = listFromJson(json);
|
|
691
|
+
/** @type {{ name: string; email: string; loginGuess?: string }[]} */
|
|
692
|
+
const out = [];
|
|
693
|
+
for (const it of items) {
|
|
694
|
+
const r = /** @type {Record<string, unknown>} */ (it);
|
|
695
|
+
const email = trimU(String(r.email ?? ""));
|
|
696
|
+
if (!email) continue;
|
|
697
|
+
const name = String(r.name ?? "");
|
|
698
|
+
/** @type {{ name: string; email: string; loginGuess?: string }} */
|
|
699
|
+
const row = { name, email };
|
|
700
|
+
if (typeof r.loginGuess === "string") {
|
|
701
|
+
const g = trimU(r.loginGuess);
|
|
702
|
+
if (g) row.loginGuess = g.toLowerCase();
|
|
703
|
+
}
|
|
704
|
+
out.push(row);
|
|
705
|
+
}
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Wires commit-author select and “suggest login” for ticket assignee fields.
|
|
711
|
+
* @param {string} prefix `edit` or `new` (element id prefix).
|
|
712
|
+
* @param {{ name: string; email: string; loginGuess?: string }[]} authors
|
|
713
|
+
*/
|
|
714
|
+
function wireTicketAssigneeControls(prefix, authors) {
|
|
715
|
+
const sel = document.getElementById(`${prefix}TicketCommitAuthor`);
|
|
716
|
+
const loginEl = document.getElementById(`${prefix}TicketAssigneeLogin`);
|
|
717
|
+
const nameEl = document.getElementById(`${prefix}TicketAssigneeName`);
|
|
718
|
+
const emailEl = document.getElementById(`${prefix}TicketAssigneeEmail`);
|
|
719
|
+
const btn = document.getElementById(
|
|
720
|
+
`btn${prefix === "edit" ? "Edit" : "New"}TicketSuggestAssignee`,
|
|
721
|
+
);
|
|
722
|
+
sel?.addEventListener("change", () => {
|
|
723
|
+
if (!sel || !loginEl) return;
|
|
724
|
+
const v = /** @type {HTMLSelectElement} */ (sel).value;
|
|
725
|
+
if (!v) return;
|
|
726
|
+
const idx = Number(v);
|
|
727
|
+
if (!Number.isFinite(idx) || !authors[idx]) return;
|
|
728
|
+
const a = authors[idx];
|
|
729
|
+
if (nameEl) nameEl.value = a.name;
|
|
730
|
+
if (emailEl) emailEl.value = a.email;
|
|
731
|
+
if (a.loginGuess) loginEl.value = a.loginGuess;
|
|
732
|
+
/** @type {HTMLSelectElement} */ (sel).value = "";
|
|
733
|
+
});
|
|
734
|
+
btn?.addEventListener("click", async () => {
|
|
735
|
+
const email = trimU(emailEl?.value ?? "");
|
|
736
|
+
if (!email) {
|
|
737
|
+
toast("Enter an email to suggest a GitHub login.", true);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const name = nameEl?.value ?? "";
|
|
741
|
+
try {
|
|
742
|
+
/** @type {string[]} */
|
|
743
|
+
const argv = ["repo", "suggest-assignee-login", "--email", email];
|
|
744
|
+
if (trimU(name)) argv.push("--name", name.trim());
|
|
745
|
+
const j = /** @type {{ loginGuess?: string | null }} */ (
|
|
746
|
+
await runCli(argv)
|
|
747
|
+
);
|
|
748
|
+
const g =
|
|
749
|
+
j && typeof j.loginGuess === "string" ? trimU(j.loginGuess) : undefined;
|
|
750
|
+
if (!g) {
|
|
751
|
+
toast("No login guess; enter the GitHub username manually.", true);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (loginEl) loginEl.value = g.toLowerCase();
|
|
755
|
+
} catch (e) {
|
|
756
|
+
toast(String(e), true);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* @param {string} message
|
|
763
|
+
* @param {boolean} isError
|
|
764
|
+
*/
|
|
765
|
+
function toast(message, isError) {
|
|
766
|
+
const el = document.getElementById("toast");
|
|
767
|
+
if (!el) return;
|
|
768
|
+
el.textContent = message;
|
|
769
|
+
el.className = `show ${isError ? "error" : "ok"}`;
|
|
770
|
+
window.clearTimeout(toast._t);
|
|
771
|
+
toast._t = window.setTimeout(() => {
|
|
772
|
+
el.className = "";
|
|
773
|
+
el.textContent = "";
|
|
774
|
+
}, 4500);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* @param {unknown[]} items
|
|
779
|
+
* @param {(row: Record<string, unknown>) => string} rowHtml
|
|
780
|
+
*/
|
|
781
|
+
function tableHtml(items, rowHtml) {
|
|
782
|
+
if (items.length === 0) {
|
|
783
|
+
return '<div class="empty-state">Nothing here yet. Create your first item to get started.</div>';
|
|
784
|
+
}
|
|
785
|
+
const rows = items
|
|
786
|
+
.map((row) => rowHtml(/** @type {Record<string, unknown>} */ (row)))
|
|
787
|
+
.join("");
|
|
788
|
+
return `<div class="table-wrap"><table class="data-table"><thead><tr><th>Title</th><th>Status</th><th>Id</th><th></th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* @param {string} name
|
|
793
|
+
* @param {string} [value]
|
|
794
|
+
*/
|
|
795
|
+
function statusOptionsHtml(name, value) {
|
|
796
|
+
const v = value || "backlog";
|
|
797
|
+
return `<select name="${escapeHtml(name)}" id="${escapeHtml(name)}">${STATUSES.map(
|
|
798
|
+
(s) =>
|
|
799
|
+
`<option value="${s}"${s === v ? " selected" : ""}>${escapeHtml(
|
|
800
|
+
s.replace(/_/g, " "),
|
|
801
|
+
)}</option>`,
|
|
802
|
+
).join("")}</select>`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function navSectionKey() {
|
|
806
|
+
if (state.view.startsWith("epic")) return "epics";
|
|
807
|
+
if (state.view.startsWith("story")) return "stories";
|
|
808
|
+
if (state.view.startsWith("ticket")) return "tickets";
|
|
809
|
+
return state.view;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function setNavCurrent() {
|
|
813
|
+
const key = navSectionKey();
|
|
814
|
+
document.querySelectorAll(".nav-btn[data-nav]").forEach((btn) => {
|
|
815
|
+
const v = /** @type {HTMLButtonElement} */ (btn).dataset.nav;
|
|
816
|
+
btn.setAttribute("aria-current", v === key ? "true" : "false");
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function setPageTitle(title) {
|
|
821
|
+
const el = document.getElementById("pageTitle");
|
|
822
|
+
if (el) el.textContent = title;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* @param {Record<string, unknown>} row
|
|
827
|
+
*/
|
|
828
|
+
function epicRowHtml(row) {
|
|
829
|
+
const id = String(row.id);
|
|
830
|
+
return `<tr>
|
|
831
|
+
<td class="cell-title"><button type="button" class="link-title btn-open-epic" data-epic-id="${escapeHtml(id)}"><span class="md-inline">${markdownInlineHtml(row.title)}</span></button></td>
|
|
832
|
+
<td>${badgeHtml(String(row.status))}</td>
|
|
833
|
+
<td>${idChip(id)}</td>
|
|
834
|
+
<td><button type="button" class="ghost btn-open-epic" data-epic-id="${escapeHtml(id)}">Open</button></td>
|
|
835
|
+
</tr>`;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* @param {Record<string, unknown>} row
|
|
840
|
+
* @param {Record<string, string>} epicTitles epic id → title
|
|
841
|
+
*/
|
|
842
|
+
function storyRowHtml(row, epicTitles) {
|
|
843
|
+
const id = String(row.id);
|
|
844
|
+
const eid = String(row.epicId);
|
|
845
|
+
const epicTitle = epicTitles[eid];
|
|
846
|
+
const epicCell = epicTitle
|
|
847
|
+
? `<button type="button" class="link-title btn-nav-epic" data-epic-id="${escapeHtml(eid)}"><span class="md-inline">${markdownInlineHtml(epicTitle)}</span></button>`
|
|
848
|
+
: idChip(eid);
|
|
849
|
+
return `<tr>
|
|
850
|
+
<td class="cell-title"><button type="button" class="link-title btn-open-story" data-story-id="${escapeHtml(id)}"><span class="md-inline">${markdownInlineHtml(row.title)}</span></button></td>
|
|
851
|
+
<td>${badgeHtml(String(row.status))}</td>
|
|
852
|
+
<td>${epicCell}</td>
|
|
853
|
+
<td>${idChip(id)}</td>
|
|
854
|
+
<td><button type="button" class="ghost btn-open-story" data-story-id="${escapeHtml(id)}">Open</button></td>
|
|
855
|
+
</tr>`;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* @param {Record<string, unknown>} row
|
|
860
|
+
* @param {Record<string, string>} storyTitles story id → title
|
|
861
|
+
*/
|
|
862
|
+
function ticketRowHtml(row, storyTitles) {
|
|
863
|
+
const id = String(row.id);
|
|
864
|
+
const sid =
|
|
865
|
+
row.storyId === null || row.storyId === undefined
|
|
866
|
+
? ""
|
|
867
|
+
: String(row.storyId);
|
|
868
|
+
const stitle = sid ? storyTitles[sid] : "";
|
|
869
|
+
const sidCell =
|
|
870
|
+
sid === ""
|
|
871
|
+
? '<span class="muted">—</span>'
|
|
872
|
+
: stitle
|
|
873
|
+
? `<button type="button" class="link-title btn-nav-story" data-story-id="${escapeHtml(sid)}"><span class="md-inline">${markdownInlineHtml(stitle)}</span></button>`
|
|
874
|
+
: idChip(sid);
|
|
875
|
+
return `<tr>
|
|
876
|
+
<td class="cell-title"><button type="button" class="link-title btn-open-ticket" data-ticket-id="${escapeHtml(id)}"><span class="md-inline">${markdownInlineHtml(row.title)}</span></button></td>
|
|
877
|
+
<td>${badgeHtml(String(row.status))}</td>
|
|
878
|
+
<td>${sidCell}</td>
|
|
879
|
+
<td>${idChip(id)}</td>
|
|
880
|
+
<td><button type="button" class="ghost btn-open-ticket" data-ticket-id="${escapeHtml(id)}">Open</button></td>
|
|
881
|
+
</tr>`;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function loadHealth() {
|
|
885
|
+
const el = document.getElementById("healthLine");
|
|
886
|
+
try {
|
|
887
|
+
const r = await fetch("/api/health");
|
|
888
|
+
const j = await r.json();
|
|
889
|
+
if (el) {
|
|
890
|
+
el.textContent = `Repo: ${j.repoPath ?? "—"} · Worktrees: ${j.tempDirParent ?? "—"}`;
|
|
891
|
+
}
|
|
892
|
+
} catch (e) {
|
|
893
|
+
if (el) el.textContent = `Could not load health: ${String(e)}`;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function renderDashboard() {
|
|
898
|
+
setPageTitle("Overview");
|
|
899
|
+
const main = document.getElementById("main");
|
|
900
|
+
if (!main) return;
|
|
901
|
+
main.innerHTML = '<p class="muted">Loading…</p>';
|
|
902
|
+
try {
|
|
903
|
+
const [ej, sj, tj] = await Promise.all([
|
|
904
|
+
runCli(["epic", "read"]),
|
|
905
|
+
runCli(["story", "read"]),
|
|
906
|
+
runCli(["ticket", "read"]),
|
|
907
|
+
]);
|
|
908
|
+
const epics = listFromJson(ej);
|
|
909
|
+
const stories = listFromJson(sj);
|
|
910
|
+
const tickets = listFromJson(tj);
|
|
911
|
+
main.innerHTML = `
|
|
912
|
+
<div class="panel">
|
|
913
|
+
<div class="panel-head">
|
|
914
|
+
<h2>Overview</h2>
|
|
915
|
+
</div>
|
|
916
|
+
<p class="muted lead">Work item counts for this repository. Use <strong>Refresh</strong> in the header after edits outside this tab.</p>
|
|
917
|
+
<div class="stat-grid">
|
|
918
|
+
<div class="stat-card"><div class="label">Epics</div><strong>${epics.length}</strong></div>
|
|
919
|
+
<div class="stat-card"><div class="label">Stories</div><strong>${stories.length}</strong></div>
|
|
920
|
+
<div class="stat-card"><div class="label">Tickets</div><strong>${tickets.length}</strong></div>
|
|
921
|
+
</div>
|
|
922
|
+
<div class="nav-related row" style="margin-top:1.25rem">
|
|
923
|
+
<button type="button" class="primary" id="dashOpenEpics">Browse epics</button>
|
|
924
|
+
<button type="button" class="ghost" id="dashOpenStories">Browse stories</button>
|
|
925
|
+
<button type="button" class="ghost" id="dashOpenTickets">Browse tickets</button>
|
|
926
|
+
</div>
|
|
927
|
+
</div>`;
|
|
928
|
+
document.getElementById("dashOpenEpics")?.addEventListener("click", () => {
|
|
929
|
+
void pushAppRoute({ kind: "epics" });
|
|
930
|
+
});
|
|
931
|
+
document
|
|
932
|
+
.getElementById("dashOpenStories")
|
|
933
|
+
?.addEventListener("click", () => {
|
|
934
|
+
void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
|
|
935
|
+
});
|
|
936
|
+
document
|
|
937
|
+
.getElementById("dashOpenTickets")
|
|
938
|
+
?.addEventListener("click", () => {
|
|
939
|
+
void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
|
|
940
|
+
});
|
|
941
|
+
} catch (e) {
|
|
942
|
+
main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p></div>`;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function renderEpicsList() {
|
|
947
|
+
setPageTitle("Epics");
|
|
948
|
+
state.view = "epics";
|
|
949
|
+
delete state.epicId;
|
|
950
|
+
delete state.epicDetailForm;
|
|
951
|
+
setNavCurrent();
|
|
952
|
+
const main = document.getElementById("main");
|
|
953
|
+
if (!main) return;
|
|
954
|
+
main.innerHTML = '<p class="muted">Loading epics…</p>';
|
|
955
|
+
try {
|
|
956
|
+
const json = await runCli(["epic", "read"]);
|
|
957
|
+
const items = listFromJson(json);
|
|
958
|
+
main.innerHTML = `
|
|
959
|
+
<div class="panel">
|
|
960
|
+
<div class="panel-head">
|
|
961
|
+
<h2>Epics</h2>
|
|
962
|
+
<button type="button" class="primary" id="btnNewEpic">New epic</button>
|
|
963
|
+
</div>
|
|
964
|
+
${tableHtml(items, epicRowHtml)}
|
|
965
|
+
</div>`;
|
|
966
|
+
document.getElementById("btnNewEpic")?.addEventListener("click", () => {
|
|
967
|
+
void pushAppRoute({ kind: "epicNew" });
|
|
968
|
+
});
|
|
969
|
+
main.querySelectorAll(".btn-open-epic").forEach((btn) => {
|
|
970
|
+
btn.addEventListener("click", () => {
|
|
971
|
+
const eid = /** @type {HTMLButtonElement} */ (btn).dataset.epicId;
|
|
972
|
+
if (eid) navigateToEpic(eid);
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
} catch (e) {
|
|
976
|
+
main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p></div>`;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function renderEpicNew() {
|
|
981
|
+
setPageTitle("New epic");
|
|
982
|
+
state.view = "epicNew";
|
|
983
|
+
setNavCurrent();
|
|
984
|
+
const main = document.getElementById("main");
|
|
985
|
+
if (!main) return;
|
|
986
|
+
main.innerHTML = `
|
|
987
|
+
<div class="panel">
|
|
988
|
+
<div class="back-link">
|
|
989
|
+
<button type="button" class="ghost" id="btnBackEpics">← Back to epics</button>
|
|
990
|
+
</div>
|
|
991
|
+
<div class="panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
|
|
992
|
+
<h2>Create epic</h2>
|
|
993
|
+
</div>
|
|
994
|
+
<label for="newEpicTitle">Title</label>
|
|
995
|
+
<input type="text" id="newEpicTitle" required />
|
|
996
|
+
<label for="newEpicBody">Description</label>
|
|
997
|
+
<textarea id="newEpicBody" rows="4"></textarea>
|
|
998
|
+
<label for="newEpicStatus">Status</label>
|
|
999
|
+
${statusOptionsHtml("newEpicStatus", "backlog")}
|
|
1000
|
+
<div class="row">
|
|
1001
|
+
<button type="button" class="primary" id="btnCreateEpic">Create</button>
|
|
1002
|
+
</div>
|
|
1003
|
+
</div>`;
|
|
1004
|
+
document.getElementById("btnBackEpics")?.addEventListener("click", () => {
|
|
1005
|
+
void pushAppRoute({ kind: "epics" });
|
|
1006
|
+
});
|
|
1007
|
+
document
|
|
1008
|
+
.getElementById("btnCreateEpic")
|
|
1009
|
+
?.addEventListener("click", async () => {
|
|
1010
|
+
const title = trimU(document.getElementById("newEpicTitle")?.value ?? "");
|
|
1011
|
+
if (!title) {
|
|
1012
|
+
toast("Title is required", true);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const body = document.getElementById("newEpicBody")?.value ?? "";
|
|
1016
|
+
const status =
|
|
1017
|
+
document.getElementById("newEpicStatus")?.value ?? "backlog";
|
|
1018
|
+
const argv = [
|
|
1019
|
+
"epic",
|
|
1020
|
+
"create",
|
|
1021
|
+
"--title",
|
|
1022
|
+
title,
|
|
1023
|
+
"--body",
|
|
1024
|
+
body,
|
|
1025
|
+
"--status",
|
|
1026
|
+
status,
|
|
1027
|
+
];
|
|
1028
|
+
try {
|
|
1029
|
+
await runCli(argv);
|
|
1030
|
+
toast("Epic created", false);
|
|
1031
|
+
void pushAppRoute({ kind: "epics" });
|
|
1032
|
+
} catch (e) {
|
|
1033
|
+
toast(String(e), true);
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async function renderEpicEdit() {
|
|
1039
|
+
const id = state.epicId;
|
|
1040
|
+
if (!id) {
|
|
1041
|
+
void pushAppRoute({ kind: "epics" });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
state.view = "epicEdit";
|
|
1045
|
+
setNavCurrent();
|
|
1046
|
+
const main = document.getElementById("main");
|
|
1047
|
+
if (!main) return;
|
|
1048
|
+
main.innerHTML = '<p class="muted">Loading…</p>';
|
|
1049
|
+
try {
|
|
1050
|
+
const row = /** @type {Record<string, unknown>} */ (
|
|
1051
|
+
await runCli(["epic", "read", "--id", id])
|
|
1052
|
+
);
|
|
1053
|
+
const showForm = Boolean(state.epicDetailForm);
|
|
1054
|
+
setPageTitle(
|
|
1055
|
+
showForm ? "Edit epic" : markdownToPlainTextForUiChrome(row.title),
|
|
1056
|
+
);
|
|
1057
|
+
const readBlock = `
|
|
1058
|
+
<div class="issue-detail-layout">
|
|
1059
|
+
<div class="issue-main">
|
|
1060
|
+
<div class="panel-head issue-panel-head">
|
|
1061
|
+
<div>
|
|
1062
|
+
<p class="muted issue-kicker" style="margin:0 0 0.25rem">Epic</p>
|
|
1063
|
+
<h2 class="issue-title"><span class="md-inline">${markdownInlineHtml(row.title)}</span></h2>
|
|
1064
|
+
</div>
|
|
1065
|
+
<div class="panel-head-actions">
|
|
1066
|
+
<button type="button" class="btn-subtle" id="btnEpicHistory">History</button>
|
|
1067
|
+
<button type="button" class="btn-subtle" id="btnEpicSeeStories">See stories</button>
|
|
1068
|
+
<button type="button" class="btn-subtle" id="btnEpicEnterEdit">Edit</button>
|
|
1069
|
+
</div>
|
|
1070
|
+
</div>
|
|
1071
|
+
<div class="issue-body">
|
|
1072
|
+
${readBodyHtml(row.body)}
|
|
1073
|
+
</div>
|
|
1074
|
+
</div>
|
|
1075
|
+
<aside class="issue-sidebar" aria-label="Epic metadata">
|
|
1076
|
+
<div class="read-stack issue-meta-stack">
|
|
1077
|
+
${readRowHtml("Epic ID", idChip(id))}
|
|
1078
|
+
${readRowHtml("Status", badgeHtml(String(row.status)))}
|
|
1079
|
+
</div>
|
|
1080
|
+
</aside>
|
|
1081
|
+
</div>`;
|
|
1082
|
+
const formBlock = `
|
|
1083
|
+
<div class="issue-detail-layout">
|
|
1084
|
+
<div class="issue-main">
|
|
1085
|
+
<div class="panel-head issue-panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
|
|
1086
|
+
<h2 class="issue-title">Edit epic</h2>
|
|
1087
|
+
<div class="panel-head-actions">
|
|
1088
|
+
<button type="button" class="btn-subtle" id="btnEpicSeeStories">See stories</button>
|
|
1089
|
+
</div>
|
|
1090
|
+
</div>
|
|
1091
|
+
<label for="editEpicTitle">Title</label>
|
|
1092
|
+
<input type="text" id="editEpicTitle" value="${escapeHtml(String(row.title))}" />
|
|
1093
|
+
<label for="editEpicBody">Description</label>
|
|
1094
|
+
<textarea id="editEpicBody" rows="6">${escapeHtml(String(row.body ?? ""))}</textarea>
|
|
1095
|
+
<div class="row">
|
|
1096
|
+
<button type="button" class="primary" id="btnSaveEpic">Save changes</button>
|
|
1097
|
+
<button type="button" class="ghost" id="btnEpicCancelEdit">Cancel</button>
|
|
1098
|
+
<button type="button" class="danger" id="btnDeleteEpic">Delete epic</button>
|
|
1099
|
+
</div>
|
|
1100
|
+
</div>
|
|
1101
|
+
<aside class="issue-sidebar" aria-label="Epic fields">
|
|
1102
|
+
<div class="read-stack issue-meta-stack">
|
|
1103
|
+
${readRowHtml("Epic ID", idChip(id))}
|
|
1104
|
+
<div class="read-row">
|
|
1105
|
+
<div class="read-label">Status</div>
|
|
1106
|
+
<div class="read-value">${statusOptionsHtml("editEpicStatus", String(row.status))}</div>
|
|
1107
|
+
</div>
|
|
1108
|
+
</div>
|
|
1109
|
+
</aside>
|
|
1110
|
+
</div>`;
|
|
1111
|
+
const epicTopBar = `
|
|
1112
|
+
<nav class="detail-page-top" aria-label="Epic">
|
|
1113
|
+
<button type="button" class="ghost detail-back" id="btnBackEpics2">← Epics</button>
|
|
1114
|
+
</nav>`;
|
|
1115
|
+
main.innerHTML = `
|
|
1116
|
+
<div class="panel">
|
|
1117
|
+
${epicTopBar}
|
|
1118
|
+
${showForm ? formBlock : readBlock}
|
|
1119
|
+
</div>`;
|
|
1120
|
+
document.getElementById("btnBackEpics2")?.addEventListener("click", () => {
|
|
1121
|
+
void pushAppRoute({ kind: "epics" });
|
|
1122
|
+
});
|
|
1123
|
+
document
|
|
1124
|
+
.getElementById("btnEpicSeeStories")
|
|
1125
|
+
?.addEventListener("click", () => {
|
|
1126
|
+
navigateToStoriesForEpic(id);
|
|
1127
|
+
});
|
|
1128
|
+
if (!showForm) {
|
|
1129
|
+
document
|
|
1130
|
+
.getElementById("btnEpicHistory")
|
|
1131
|
+
?.addEventListener("click", () => {
|
|
1132
|
+
void openHistoryModal(id, "Epic");
|
|
1133
|
+
});
|
|
1134
|
+
document
|
|
1135
|
+
.getElementById("btnEpicEnterEdit")
|
|
1136
|
+
?.addEventListener("click", () => {
|
|
1137
|
+
void pushAppRoute({ kind: "epicEdit", epicId: id, epicForm: true });
|
|
1138
|
+
});
|
|
1139
|
+
} else {
|
|
1140
|
+
document
|
|
1141
|
+
.getElementById("btnEpicCancelEdit")
|
|
1142
|
+
?.addEventListener("click", () => {
|
|
1143
|
+
window.history.back();
|
|
1144
|
+
});
|
|
1145
|
+
document
|
|
1146
|
+
.getElementById("btnSaveEpic")
|
|
1147
|
+
?.addEventListener("click", async () => {
|
|
1148
|
+
const title = trimU(
|
|
1149
|
+
document.getElementById("editEpicTitle")?.value ?? "",
|
|
1150
|
+
);
|
|
1151
|
+
const body = document.getElementById("editEpicBody")?.value ?? "";
|
|
1152
|
+
const status =
|
|
1153
|
+
document.getElementById("editEpicStatus")?.value ?? "backlog";
|
|
1154
|
+
if (!title) {
|
|
1155
|
+
toast("Title is required", true);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
try {
|
|
1159
|
+
const prevTitle = trimU(String(row.title));
|
|
1160
|
+
const prevBody = String(row.body ?? "");
|
|
1161
|
+
const prevStatus = String(row.status);
|
|
1162
|
+
/** @type {string[]} */
|
|
1163
|
+
const argv = ["epic", "update", "--id", id];
|
|
1164
|
+
if (title !== prevTitle) {
|
|
1165
|
+
argv.push("--title", title);
|
|
1166
|
+
}
|
|
1167
|
+
if (body !== prevBody) {
|
|
1168
|
+
argv.push("--body", body);
|
|
1169
|
+
}
|
|
1170
|
+
if (status !== prevStatus) {
|
|
1171
|
+
argv.push("--status", status);
|
|
1172
|
+
}
|
|
1173
|
+
await runCli(argv);
|
|
1174
|
+
toast("Epic saved", false);
|
|
1175
|
+
replaceAppRoute({ kind: "epicEdit", epicId: id, epicForm: false });
|
|
1176
|
+
} catch (e) {
|
|
1177
|
+
toast(String(e), true);
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
document
|
|
1181
|
+
.getElementById("btnDeleteEpic")
|
|
1182
|
+
?.addEventListener("click", async () => {
|
|
1183
|
+
if (!window.confirm(`Delete epic ${id}? This cannot be undone.`))
|
|
1184
|
+
return;
|
|
1185
|
+
try {
|
|
1186
|
+
await runCli(["epic", "delete", "--id", id]);
|
|
1187
|
+
toast("Epic deleted", false);
|
|
1188
|
+
void pushAppRoute({ kind: "epics" });
|
|
1189
|
+
} catch (e) {
|
|
1190
|
+
toast(String(e), true);
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
} catch (e) {
|
|
1195
|
+
main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p><button type="button" class="ghost" id="btnEpicErrBack">← Epics</button></div>`;
|
|
1196
|
+
document.getElementById("btnEpicErrBack")?.addEventListener("click", () => {
|
|
1197
|
+
void pushAppRoute({ kind: "epics" });
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function renderStoriesList() {
|
|
1203
|
+
setPageTitle("Stories");
|
|
1204
|
+
state.view = "stories";
|
|
1205
|
+
delete state.storyId;
|
|
1206
|
+
delete state.storyDetailForm;
|
|
1207
|
+
setNavCurrent();
|
|
1208
|
+
const main = document.getElementById("main");
|
|
1209
|
+
if (!main) return;
|
|
1210
|
+
main.innerHTML = '<p class="muted">Loading…</p>';
|
|
1211
|
+
try {
|
|
1212
|
+
const ej = await runCli(["epic", "read"]);
|
|
1213
|
+
const epics = listFromJson(ej);
|
|
1214
|
+
const argv = ["story", "read"];
|
|
1215
|
+
const fe = trimU(state.storyFilterEpic);
|
|
1216
|
+
if (fe) {
|
|
1217
|
+
argv.push("--epic", fe);
|
|
1218
|
+
}
|
|
1219
|
+
const json = await runCli(argv);
|
|
1220
|
+
const items = listFromJson(json);
|
|
1221
|
+
/** @type {Record<string, string>} */
|
|
1222
|
+
const epicTitles = {};
|
|
1223
|
+
for (const e of epics) {
|
|
1224
|
+
epicTitles[String(/** @type {{id:string}} */ (e).id)] = String(
|
|
1225
|
+
/** @type {{title:string}} */ (e).title,
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
const epicOpts = [
|
|
1229
|
+
`<option value="">All epics</option>`,
|
|
1230
|
+
...epics.map(
|
|
1231
|
+
(e) =>
|
|
1232
|
+
`<option value="${escapeHtml(String(/** @type {{id:string}} */ (e).id))}"${String(/** @type {{id:string}} */ (e).id) === fe ? " selected" : ""}>${escapeHtml(String(/** @type {{title:string}} */ (e).title))}</option>`,
|
|
1233
|
+
),
|
|
1234
|
+
].join("");
|
|
1235
|
+
const filteredEpicRow = fe
|
|
1236
|
+
? epics.find((e) => String(/** @type {{id:string}} */ (e).id) === fe)
|
|
1237
|
+
: undefined;
|
|
1238
|
+
const storyFilterBanner = fe
|
|
1239
|
+
? `<div class="filter-context-bar"><span>Showing stories in </span><span class="filter-context-entity">${filteredEpicRow ? markdownInlineHtml(String(/** @type {{title:string}} */ (filteredEpicRow).title)) : escapeHtml(fe)}</span><div class="filter-actions"><button type="button" class="ghost" id="btnStoriesCtxEpic">View epic</button><button type="button" class="ghost" id="btnStoriesClearEpic">All stories</button></div></div>`
|
|
1240
|
+
: "";
|
|
1241
|
+
const storyTable =
|
|
1242
|
+
items.length === 0
|
|
1243
|
+
? '<div class="empty-state">No stories match this filter.</div>'
|
|
1244
|
+
: `<div class="table-wrap"><table class="data-table"><thead><tr><th>Title</th><th>Status</th><th>Epic</th><th>Id</th><th></th></tr></thead><tbody>
|
|
1245
|
+
${items
|
|
1246
|
+
.map((row) =>
|
|
1247
|
+
storyRowHtml(
|
|
1248
|
+
/** @type {Record<string, unknown>} */ (row),
|
|
1249
|
+
epicTitles,
|
|
1250
|
+
),
|
|
1251
|
+
)
|
|
1252
|
+
.join("")}
|
|
1253
|
+
</tbody></table></div>`;
|
|
1254
|
+
main.innerHTML = `
|
|
1255
|
+
<div class="panel">
|
|
1256
|
+
${storyFilterBanner}
|
|
1257
|
+
<div class="filter-bar">
|
|
1258
|
+
<label for="filterStoryEpic">Filter by epic</label>
|
|
1259
|
+
<select id="filterStoryEpic">${epicOpts}</select>
|
|
1260
|
+
</div>
|
|
1261
|
+
<div class="panel-head">
|
|
1262
|
+
<h2>Stories</h2>
|
|
1263
|
+
<button type="button" class="primary" id="btnNewStory">New story</button>
|
|
1264
|
+
</div>
|
|
1265
|
+
${storyTable}
|
|
1266
|
+
</div>`;
|
|
1267
|
+
document
|
|
1268
|
+
.getElementById("filterStoryEpic")
|
|
1269
|
+
?.addEventListener("change", (ev) => {
|
|
1270
|
+
const val = /** @type {HTMLSelectElement} */ (ev.target).value;
|
|
1271
|
+
replaceAppRoute({ kind: "stories", storyFilterEpic: val });
|
|
1272
|
+
});
|
|
1273
|
+
document.getElementById("btnNewStory")?.addEventListener("click", () => {
|
|
1274
|
+
void pushAppRoute({ kind: "storyNew" });
|
|
1275
|
+
});
|
|
1276
|
+
if (fe) {
|
|
1277
|
+
document
|
|
1278
|
+
.getElementById("btnStoriesCtxEpic")
|
|
1279
|
+
?.addEventListener("click", () => {
|
|
1280
|
+
navigateToEpic(fe);
|
|
1281
|
+
});
|
|
1282
|
+
document
|
|
1283
|
+
.getElementById("btnStoriesClearEpic")
|
|
1284
|
+
?.addEventListener("click", () => {
|
|
1285
|
+
void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
main.querySelectorAll(".btn-open-story").forEach((btn) => {
|
|
1289
|
+
btn.addEventListener("click", () => {
|
|
1290
|
+
const sid = /** @type {HTMLButtonElement} */ (btn).dataset.storyId;
|
|
1291
|
+
if (sid) navigateToStory(sid);
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
main.querySelectorAll(".btn-nav-epic").forEach((btn) => {
|
|
1295
|
+
btn.addEventListener("click", (ev) => {
|
|
1296
|
+
ev.stopPropagation();
|
|
1297
|
+
const eid = /** @type {HTMLButtonElement} */ (btn).dataset.epicId;
|
|
1298
|
+
if (eid) navigateToEpic(eid);
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1301
|
+
} catch (e) {
|
|
1302
|
+
main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p></div>`;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
async function renderStoryNew() {
|
|
1307
|
+
setPageTitle("New story");
|
|
1308
|
+
state.view = "storyNew";
|
|
1309
|
+
setNavCurrent();
|
|
1310
|
+
const main = document.getElementById("main");
|
|
1311
|
+
if (!main) return;
|
|
1312
|
+
const ej = await runCli(["epic", "read"]).catch(() => ({ items: [] }));
|
|
1313
|
+
const epics = listFromJson(ej);
|
|
1314
|
+
const epicOpts = epics
|
|
1315
|
+
.map(
|
|
1316
|
+
(e) =>
|
|
1317
|
+
`<option value="${escapeHtml(String(/** @type {{id:string}} */ (e).id))}">${escapeHtml(String(/** @type {{title:string}} */ (e).title))}</option>`,
|
|
1318
|
+
)
|
|
1319
|
+
.join("");
|
|
1320
|
+
main.innerHTML = `
|
|
1321
|
+
<div class="panel">
|
|
1322
|
+
<div class="back-link">
|
|
1323
|
+
<button type="button" class="ghost" id="btnBackStories">← Stories</button>
|
|
1324
|
+
</div>
|
|
1325
|
+
<div class="panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
|
|
1326
|
+
<h2>Create story</h2>
|
|
1327
|
+
</div>
|
|
1328
|
+
<label for="newStoryEpic">Epic</label>
|
|
1329
|
+
<select id="newStoryEpic" required><option value="">Select epic…</option>${epicOpts}</select>
|
|
1330
|
+
<label for="newStoryTitle">Title</label>
|
|
1331
|
+
<input type="text" id="newStoryTitle" />
|
|
1332
|
+
<label for="newStoryBody">Description</label>
|
|
1333
|
+
<textarea id="newStoryBody" rows="4"></textarea>
|
|
1334
|
+
<label for="newStoryStatus">Status</label>
|
|
1335
|
+
${statusOptionsHtml("newStoryStatus", "backlog")}
|
|
1336
|
+
<div class="row">
|
|
1337
|
+
<button type="button" class="primary" id="btnCreateStory">Create</button>
|
|
1338
|
+
</div>
|
|
1339
|
+
</div>`;
|
|
1340
|
+
document.getElementById("btnBackStories")?.addEventListener("click", () => {
|
|
1341
|
+
window.history.back();
|
|
1342
|
+
});
|
|
1343
|
+
document
|
|
1344
|
+
.getElementById("btnCreateStory")
|
|
1345
|
+
?.addEventListener("click", async () => {
|
|
1346
|
+
const epic = trimU(document.getElementById("newStoryEpic")?.value ?? "");
|
|
1347
|
+
const title = trimU(
|
|
1348
|
+
document.getElementById("newStoryTitle")?.value ?? "",
|
|
1349
|
+
);
|
|
1350
|
+
const body = document.getElementById("newStoryBody")?.value ?? "";
|
|
1351
|
+
const status =
|
|
1352
|
+
document.getElementById("newStoryStatus")?.value ?? "backlog";
|
|
1353
|
+
if (!epic || !title) {
|
|
1354
|
+
toast("Epic and title are required", true);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
try {
|
|
1358
|
+
await runCli([
|
|
1359
|
+
"story",
|
|
1360
|
+
"create",
|
|
1361
|
+
"--title",
|
|
1362
|
+
title,
|
|
1363
|
+
"--epic",
|
|
1364
|
+
epic,
|
|
1365
|
+
"--body",
|
|
1366
|
+
body,
|
|
1367
|
+
"--status",
|
|
1368
|
+
status,
|
|
1369
|
+
]);
|
|
1370
|
+
toast("Story created", false);
|
|
1371
|
+
void pushAppRoute({ kind: "stories", storyFilterEpic: epic });
|
|
1372
|
+
} catch (e) {
|
|
1373
|
+
toast(String(e), true);
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
async function renderStoryEdit() {
|
|
1379
|
+
const id = state.storyId;
|
|
1380
|
+
if (!id) {
|
|
1381
|
+
void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
state.view = "storyEdit";
|
|
1385
|
+
setNavCurrent();
|
|
1386
|
+
const main = document.getElementById("main");
|
|
1387
|
+
if (!main) return;
|
|
1388
|
+
main.innerHTML = '<p class="muted">Loading…</p>';
|
|
1389
|
+
try {
|
|
1390
|
+
const row = /** @type {Record<string, unknown>} */ (
|
|
1391
|
+
await runCli(["story", "read", "--id", id])
|
|
1392
|
+
);
|
|
1393
|
+
const epicId = String(row.epicId);
|
|
1394
|
+
let epicReadInner = idChip(epicId);
|
|
1395
|
+
let epicFormLine = `Epic id <code>${escapeHtml(epicId)}</code>`;
|
|
1396
|
+
try {
|
|
1397
|
+
const epicRow = /** @type {Record<string, unknown>} */ (
|
|
1398
|
+
await runCli(["epic", "read", "--id", epicId])
|
|
1399
|
+
);
|
|
1400
|
+
const t = markdownInlineHtml(String(epicRow.title));
|
|
1401
|
+
epicReadInner = `<span class="md-inline">${t}</span> · ${idChip(epicId)}`;
|
|
1402
|
+
epicFormLine = `Epic: <span class="md-inline">${t}</span> (<code>${escapeHtml(epicId)}</code>)`;
|
|
1403
|
+
} catch {
|
|
1404
|
+
/* keep defaults */
|
|
1405
|
+
}
|
|
1406
|
+
const showForm = Boolean(state.storyDetailForm);
|
|
1407
|
+
setPageTitle(
|
|
1408
|
+
showForm ? "Edit story" : markdownToPlainTextForUiChrome(row.title),
|
|
1409
|
+
);
|
|
1410
|
+
const readBlock = `
|
|
1411
|
+
<div class="issue-detail-layout">
|
|
1412
|
+
<div class="issue-main">
|
|
1413
|
+
<div class="panel-head issue-panel-head">
|
|
1414
|
+
<div>
|
|
1415
|
+
<p class="muted issue-kicker" style="margin:0 0 0.25rem">Story</p>
|
|
1416
|
+
<h2 class="issue-title"><span class="md-inline">${markdownInlineHtml(row.title)}</span></h2>
|
|
1417
|
+
</div>
|
|
1418
|
+
<div class="panel-head-actions">
|
|
1419
|
+
<button type="button" class="btn-subtle" id="btnStoryHistory">History</button>
|
|
1420
|
+
<button type="button" class="btn-subtle" id="btnStoryOpenEpic">Open epic</button>
|
|
1421
|
+
<button type="button" class="btn-subtle" id="btnStorySeeTickets">See tickets</button>
|
|
1422
|
+
<button type="button" class="btn-subtle" id="btnStoryEnterEdit">Edit</button>
|
|
1423
|
+
</div>
|
|
1424
|
+
</div>
|
|
1425
|
+
<div class="issue-body">
|
|
1426
|
+
${readBodyHtml(row.body)}
|
|
1427
|
+
</div>
|
|
1428
|
+
</div>
|
|
1429
|
+
<aside class="issue-sidebar" aria-label="Story metadata">
|
|
1430
|
+
<div class="read-stack issue-meta-stack">
|
|
1431
|
+
${readRowHtml("Story ID", idChip(id))}
|
|
1432
|
+
${readRowHtml("Epic", epicReadInner)}
|
|
1433
|
+
${readRowHtml("Status", badgeHtml(String(row.status)))}
|
|
1434
|
+
</div>
|
|
1435
|
+
</aside>
|
|
1436
|
+
</div>`;
|
|
1437
|
+
const formBlock = `
|
|
1438
|
+
<div class="issue-detail-layout">
|
|
1439
|
+
<div class="issue-main">
|
|
1440
|
+
<div class="panel-head issue-panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
|
|
1441
|
+
<h2 class="issue-title">Edit story</h2>
|
|
1442
|
+
<div class="panel-head-actions">
|
|
1443
|
+
<button type="button" class="btn-subtle" id="btnStoryOpenEpic">Open epic</button>
|
|
1444
|
+
<button type="button" class="btn-subtle" id="btnStorySeeTickets">See tickets</button>
|
|
1445
|
+
</div>
|
|
1446
|
+
</div>
|
|
1447
|
+
<p class="muted" style="font-size:0.85rem;margin-top:0">To move a story to another epic, delete and recreate it (CLI does not support changing epic on update).</p>
|
|
1448
|
+
<label for="editStoryTitle">Title</label>
|
|
1449
|
+
<input type="text" id="editStoryTitle" value="${escapeHtml(String(row.title))}" />
|
|
1450
|
+
<label for="editStoryBody">Description</label>
|
|
1451
|
+
<textarea id="editStoryBody" rows="6">${escapeHtml(String(row.body ?? ""))}</textarea>
|
|
1452
|
+
<div class="row">
|
|
1453
|
+
<button type="button" class="primary" id="btnSaveStory">Save</button>
|
|
1454
|
+
<button type="button" class="ghost" id="btnStoryCancelEdit">Cancel</button>
|
|
1455
|
+
<button type="button" class="danger" id="btnDeleteStory">Delete</button>
|
|
1456
|
+
</div>
|
|
1457
|
+
</div>
|
|
1458
|
+
<aside class="issue-sidebar" aria-label="Story fields">
|
|
1459
|
+
<div class="read-stack issue-meta-stack">
|
|
1460
|
+
${readRowHtml("Story ID", idChip(id))}
|
|
1461
|
+
${readRowHtml("Epic", epicFormLine)}
|
|
1462
|
+
<div class="read-row">
|
|
1463
|
+
<div class="read-label">Status</div>
|
|
1464
|
+
<div class="read-value">${statusOptionsHtml("editStoryStatus", String(row.status))}</div>
|
|
1465
|
+
</div>
|
|
1466
|
+
</div>
|
|
1467
|
+
</aside>
|
|
1468
|
+
</div>`;
|
|
1469
|
+
const storyTopBar = `
|
|
1470
|
+
<nav class="detail-page-top" aria-label="Story">
|
|
1471
|
+
<button type="button" class="ghost detail-back" id="btnBackStories2">← Stories</button>
|
|
1472
|
+
</nav>`;
|
|
1473
|
+
main.innerHTML = `
|
|
1474
|
+
<div class="panel">
|
|
1475
|
+
${storyTopBar}
|
|
1476
|
+
${showForm ? formBlock : readBlock}
|
|
1477
|
+
</div>`;
|
|
1478
|
+
document
|
|
1479
|
+
.getElementById("btnBackStories2")
|
|
1480
|
+
?.addEventListener("click", () => {
|
|
1481
|
+
void pushAppRoute({ kind: "stories", storyFilterEpic: epicId });
|
|
1482
|
+
});
|
|
1483
|
+
document
|
|
1484
|
+
.getElementById("btnStoryOpenEpic")
|
|
1485
|
+
?.addEventListener("click", () => {
|
|
1486
|
+
navigateToEpic(epicId);
|
|
1487
|
+
});
|
|
1488
|
+
document
|
|
1489
|
+
.getElementById("btnStorySeeTickets")
|
|
1490
|
+
?.addEventListener("click", () => {
|
|
1491
|
+
navigateToTicketsForStory(id);
|
|
1492
|
+
});
|
|
1493
|
+
if (!showForm) {
|
|
1494
|
+
document
|
|
1495
|
+
.getElementById("btnStoryHistory")
|
|
1496
|
+
?.addEventListener("click", () => {
|
|
1497
|
+
void openHistoryModal(id, "Story");
|
|
1498
|
+
});
|
|
1499
|
+
document
|
|
1500
|
+
.getElementById("btnStoryEnterEdit")
|
|
1501
|
+
?.addEventListener("click", () => {
|
|
1502
|
+
void pushAppRoute({
|
|
1503
|
+
kind: "storyEdit",
|
|
1504
|
+
storyId: id,
|
|
1505
|
+
storyForm: true,
|
|
1506
|
+
});
|
|
1507
|
+
});
|
|
1508
|
+
} else {
|
|
1509
|
+
document
|
|
1510
|
+
.getElementById("btnStoryCancelEdit")
|
|
1511
|
+
?.addEventListener("click", () => {
|
|
1512
|
+
window.history.back();
|
|
1513
|
+
});
|
|
1514
|
+
document
|
|
1515
|
+
.getElementById("btnSaveStory")
|
|
1516
|
+
?.addEventListener("click", async () => {
|
|
1517
|
+
const title = trimU(
|
|
1518
|
+
document.getElementById("editStoryTitle")?.value ?? "",
|
|
1519
|
+
);
|
|
1520
|
+
const body = document.getElementById("editStoryBody")?.value ?? "";
|
|
1521
|
+
const status =
|
|
1522
|
+
document.getElementById("editStoryStatus")?.value ?? "backlog";
|
|
1523
|
+
if (!title) {
|
|
1524
|
+
toast("Title is required", true);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
try {
|
|
1528
|
+
const prevTitle = trimU(String(row.title));
|
|
1529
|
+
const prevBody = String(row.body ?? "");
|
|
1530
|
+
const prevStatus = String(row.status);
|
|
1531
|
+
/** @type {string[]} */
|
|
1532
|
+
const argv = ["story", "update", "--id", id];
|
|
1533
|
+
if (title !== prevTitle) {
|
|
1534
|
+
argv.push("--title", title);
|
|
1535
|
+
}
|
|
1536
|
+
if (body !== prevBody) {
|
|
1537
|
+
argv.push("--body", body);
|
|
1538
|
+
}
|
|
1539
|
+
if (status !== prevStatus) {
|
|
1540
|
+
argv.push("--status", status);
|
|
1541
|
+
}
|
|
1542
|
+
await runCli(argv);
|
|
1543
|
+
toast("Story saved", false);
|
|
1544
|
+
replaceAppRoute({
|
|
1545
|
+
kind: "storyEdit",
|
|
1546
|
+
storyId: id,
|
|
1547
|
+
storyForm: false,
|
|
1548
|
+
});
|
|
1549
|
+
} catch (e) {
|
|
1550
|
+
toast(String(e), true);
|
|
1551
|
+
}
|
|
1552
|
+
});
|
|
1553
|
+
document
|
|
1554
|
+
.getElementById("btnDeleteStory")
|
|
1555
|
+
?.addEventListener("click", async () => {
|
|
1556
|
+
if (!window.confirm(`Delete story ${id}?`)) return;
|
|
1557
|
+
try {
|
|
1558
|
+
await runCli(["story", "delete", "--id", id]);
|
|
1559
|
+
toast("Story deleted", false);
|
|
1560
|
+
void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
|
|
1561
|
+
} catch (e) {
|
|
1562
|
+
toast(String(e), true);
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
} catch (e) {
|
|
1567
|
+
main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p><button type="button" class="ghost" id="btnStoryErrBack">← Stories</button></div>`;
|
|
1568
|
+
document
|
|
1569
|
+
.getElementById("btnStoryErrBack")
|
|
1570
|
+
?.addEventListener("click", () => {
|
|
1571
|
+
void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
async function renderTicketsList() {
|
|
1577
|
+
setPageTitle("Tickets");
|
|
1578
|
+
state.view = "tickets";
|
|
1579
|
+
delete state.ticketId;
|
|
1580
|
+
delete state.ticketDetailForm;
|
|
1581
|
+
setNavCurrent();
|
|
1582
|
+
const main = document.getElementById("main");
|
|
1583
|
+
if (!main) return;
|
|
1584
|
+
main.innerHTML = '<p class="muted">Loading…</p>';
|
|
1585
|
+
try {
|
|
1586
|
+
const sj = await runCli(["story", "read"]);
|
|
1587
|
+
const stories = listFromJson(sj);
|
|
1588
|
+
const argv = ["ticket", "read"];
|
|
1589
|
+
const fs = trimU(state.ticketFilterStory);
|
|
1590
|
+
if (fs) argv.push("--story", fs);
|
|
1591
|
+
const json = await runCli(argv);
|
|
1592
|
+
const items = listFromJson(json);
|
|
1593
|
+
/** @type {Record<string, string>} */
|
|
1594
|
+
const storyTitles = {};
|
|
1595
|
+
for (const s of stories) {
|
|
1596
|
+
storyTitles[String(/** @type {{id:string}} */ (s).id)] = String(
|
|
1597
|
+
/** @type {{title:string}} */ (s).title,
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
const storyOpts = [
|
|
1601
|
+
`<option value="">All tickets</option>`,
|
|
1602
|
+
...stories.map(
|
|
1603
|
+
(s) =>
|
|
1604
|
+
`<option value="${escapeHtml(String(/** @type {{id:string}} */ (s).id))}"${String(/** @type {{id:string}} */ (s).id) === fs ? " selected" : ""}>${escapeHtml(String(/** @type {{title:string}} */ (s).title))}</option>`,
|
|
1605
|
+
),
|
|
1606
|
+
].join("");
|
|
1607
|
+
const filteredStoryRow = fs
|
|
1608
|
+
? stories.find((s) => String(/** @type {{id:string}} */ (s).id) === fs)
|
|
1609
|
+
: undefined;
|
|
1610
|
+
const ticketFilterBanner = fs
|
|
1611
|
+
? `<div class="filter-context-bar"><span>Showing tickets for </span><span class="filter-context-entity">${filteredStoryRow ? markdownInlineHtml(String(/** @type {{title:string}} */ (filteredStoryRow).title)) : escapeHtml(fs)}</span><div class="filter-actions"><button type="button" class="ghost" id="btnTicketsCtxStory">View story</button><button type="button" class="ghost" id="btnTicketsClearStory">All tickets</button></div></div>`
|
|
1612
|
+
: "";
|
|
1613
|
+
const ticketTable =
|
|
1614
|
+
items.length === 0
|
|
1615
|
+
? '<div class="empty-state">No tickets match this filter.</div>'
|
|
1616
|
+
: `<div class="table-wrap"><table class="data-table"><thead><tr><th>Title</th><th>Status</th><th>Story</th><th>Id</th><th></th></tr></thead><tbody>
|
|
1617
|
+
${items
|
|
1618
|
+
.map((row) =>
|
|
1619
|
+
ticketRowHtml(
|
|
1620
|
+
/** @type {Record<string, unknown>} */ (row),
|
|
1621
|
+
storyTitles,
|
|
1622
|
+
),
|
|
1623
|
+
)
|
|
1624
|
+
.join("")}
|
|
1625
|
+
</tbody></table></div>`;
|
|
1626
|
+
main.innerHTML = `
|
|
1627
|
+
<div class="panel">
|
|
1628
|
+
${ticketFilterBanner}
|
|
1629
|
+
<div class="filter-bar">
|
|
1630
|
+
<label for="filterTicketStory">Filter by story</label>
|
|
1631
|
+
<select id="filterTicketStory">${storyOpts}</select>
|
|
1632
|
+
</div>
|
|
1633
|
+
<div class="panel-head">
|
|
1634
|
+
<h2>Tickets</h2>
|
|
1635
|
+
<button type="button" class="primary" id="btnNewTicket">New ticket</button>
|
|
1636
|
+
</div>
|
|
1637
|
+
${ticketTable}
|
|
1638
|
+
</div>`;
|
|
1639
|
+
document
|
|
1640
|
+
.getElementById("filterTicketStory")
|
|
1641
|
+
?.addEventListener("change", (ev) => {
|
|
1642
|
+
const val = /** @type {HTMLSelectElement} */ (ev.target).value;
|
|
1643
|
+
replaceAppRoute({ kind: "tickets", ticketFilterStory: val });
|
|
1644
|
+
});
|
|
1645
|
+
document.getElementById("btnNewTicket")?.addEventListener("click", () => {
|
|
1646
|
+
void pushAppRoute({ kind: "ticketNew" });
|
|
1647
|
+
});
|
|
1648
|
+
if (fs) {
|
|
1649
|
+
document
|
|
1650
|
+
.getElementById("btnTicketsCtxStory")
|
|
1651
|
+
?.addEventListener("click", () => {
|
|
1652
|
+
navigateToStory(fs);
|
|
1653
|
+
});
|
|
1654
|
+
document
|
|
1655
|
+
.getElementById("btnTicketsClearStory")
|
|
1656
|
+
?.addEventListener("click", () => {
|
|
1657
|
+
void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
main.querySelectorAll(".btn-open-ticket").forEach((btn) => {
|
|
1661
|
+
btn.addEventListener("click", () => {
|
|
1662
|
+
const tid = /** @type {HTMLButtonElement} */ (btn).dataset.ticketId;
|
|
1663
|
+
if (tid) navigateToTicket(tid);
|
|
1664
|
+
});
|
|
1665
|
+
});
|
|
1666
|
+
main.querySelectorAll(".btn-nav-story").forEach((btn) => {
|
|
1667
|
+
btn.addEventListener("click", (ev) => {
|
|
1668
|
+
ev.stopPropagation();
|
|
1669
|
+
const sid = /** @type {HTMLButtonElement} */ (btn).dataset.storyId;
|
|
1670
|
+
if (sid) navigateToStory(sid);
|
|
1671
|
+
});
|
|
1672
|
+
});
|
|
1673
|
+
} catch (e) {
|
|
1674
|
+
main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p></div>`;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
async function renderTicketNew() {
|
|
1679
|
+
setPageTitle("New ticket");
|
|
1680
|
+
state.view = "ticketNew";
|
|
1681
|
+
setNavCurrent();
|
|
1682
|
+
const main = document.getElementById("main");
|
|
1683
|
+
if (!main) return;
|
|
1684
|
+
const [sj, authorJson] = await Promise.all([
|
|
1685
|
+
runCli(["story", "read"]).catch(() => ({ items: [] })),
|
|
1686
|
+
runCli(["repo", "commit-authors"]).catch(() => ({ items: [] })),
|
|
1687
|
+
]);
|
|
1688
|
+
const stories = listFromJson(sj);
|
|
1689
|
+
const commitAuthors = commitAuthorsFromJson(authorJson);
|
|
1690
|
+
const authorOptsHtml = [
|
|
1691
|
+
`<option value="">Select commit author…</option>`,
|
|
1692
|
+
...commitAuthors.map((a, i) => {
|
|
1693
|
+
const label = `${a.name || "(no name)"} <${a.email}>`;
|
|
1694
|
+
return `<option value="${String(i)}">${escapeHtml(label)}</option>`;
|
|
1695
|
+
}),
|
|
1696
|
+
].join("");
|
|
1697
|
+
const storyOpts = [
|
|
1698
|
+
`<option value="">No story (unlinked)</option>`,
|
|
1699
|
+
...stories.map(
|
|
1700
|
+
(s) =>
|
|
1701
|
+
`<option value="${escapeHtml(String(/** @type {{id:string}} */ (s).id))}">${escapeHtml(String(/** @type {{title:string}} */ (s).title))}</option>`,
|
|
1702
|
+
),
|
|
1703
|
+
].join("");
|
|
1704
|
+
main.innerHTML = `
|
|
1705
|
+
<div class="panel">
|
|
1706
|
+
<div class="back-link">
|
|
1707
|
+
<button type="button" class="ghost" id="btnBackTickets">← Tickets</button>
|
|
1708
|
+
</div>
|
|
1709
|
+
<div class="panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
|
|
1710
|
+
<h2>Create ticket</h2>
|
|
1711
|
+
</div>
|
|
1712
|
+
<label for="newTicketStory">Story (optional)</label>
|
|
1713
|
+
<select id="newTicketStory">${storyOpts}</select>
|
|
1714
|
+
<label for="newTicketTitle">Title</label>
|
|
1715
|
+
<input type="text" id="newTicketTitle" />
|
|
1716
|
+
<label for="newTicketBody">Description</label>
|
|
1717
|
+
<textarea id="newTicketBody" rows="5"></textarea>
|
|
1718
|
+
<label for="newTicketStatus">Status</label>
|
|
1719
|
+
${statusOptionsHtml("newTicketStatus", "todo")}
|
|
1720
|
+
<h3 style="margin-top:1.25rem">Assignee (optional)</h3>
|
|
1721
|
+
<label for="newTicketAssigneeLogin">GitHub login</label>
|
|
1722
|
+
<input type="text" id="newTicketAssigneeLogin" autocomplete="off" placeholder="e.g. octocat" />
|
|
1723
|
+
<label for="newTicketCommitAuthor">Pick from commit authors</label>
|
|
1724
|
+
<select id="newTicketCommitAuthor">${authorOptsHtml}</select>
|
|
1725
|
+
<label for="newTicketAssigneeName">Or enter name</label>
|
|
1726
|
+
<input type="text" id="newTicketAssigneeName" autocomplete="name" placeholder="Display name" />
|
|
1727
|
+
<label for="newTicketAssigneeEmail">And email</label>
|
|
1728
|
+
<input type="email" id="newTicketAssigneeEmail" autocomplete="email" placeholder="name@example.com" />
|
|
1729
|
+
<div class="row">
|
|
1730
|
+
<button type="button" class="ghost" id="btnNewTicketSuggestAssignee">Suggest login from name and email</button>
|
|
1731
|
+
</div>
|
|
1732
|
+
<div class="row">
|
|
1733
|
+
<button type="button" class="primary" id="btnCreateTicket">Create</button>
|
|
1734
|
+
</div>
|
|
1735
|
+
</div>`;
|
|
1736
|
+
wireTicketAssigneeControls("new", commitAuthors);
|
|
1737
|
+
document.getElementById("btnBackTickets")?.addEventListener("click", () => {
|
|
1738
|
+
window.history.back();
|
|
1739
|
+
});
|
|
1740
|
+
document
|
|
1741
|
+
.getElementById("btnCreateTicket")
|
|
1742
|
+
?.addEventListener("click", async () => {
|
|
1743
|
+
const title = trimU(
|
|
1744
|
+
document.getElementById("newTicketTitle")?.value ?? "",
|
|
1745
|
+
);
|
|
1746
|
+
const body = document.getElementById("newTicketBody")?.value ?? "";
|
|
1747
|
+
const status =
|
|
1748
|
+
document.getElementById("newTicketStatus")?.value ?? "todo";
|
|
1749
|
+
const story = trimU(
|
|
1750
|
+
document.getElementById("newTicketStory")?.value ?? "",
|
|
1751
|
+
);
|
|
1752
|
+
if (!title) {
|
|
1753
|
+
toast("Title is required", true);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
const argv = [
|
|
1757
|
+
"ticket",
|
|
1758
|
+
"create",
|
|
1759
|
+
"--title",
|
|
1760
|
+
title,
|
|
1761
|
+
"--body",
|
|
1762
|
+
body,
|
|
1763
|
+
"--status",
|
|
1764
|
+
status,
|
|
1765
|
+
];
|
|
1766
|
+
if (story) argv.push("--story", story);
|
|
1767
|
+
const assigneeLogin = trimU(
|
|
1768
|
+
document.getElementById("newTicketAssigneeLogin")?.value ?? "",
|
|
1769
|
+
);
|
|
1770
|
+
if (assigneeLogin) argv.push("--assignee", assigneeLogin.toLowerCase());
|
|
1771
|
+
try {
|
|
1772
|
+
await runCli(argv);
|
|
1773
|
+
toast("Ticket created", false);
|
|
1774
|
+
void pushAppRoute({
|
|
1775
|
+
kind: "tickets",
|
|
1776
|
+
ticketFilterStory: story || "",
|
|
1777
|
+
});
|
|
1778
|
+
} catch (e) {
|
|
1779
|
+
toast(String(e), true);
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* @param {unknown[]} comments
|
|
1786
|
+
*/
|
|
1787
|
+
function commentsHtml(comments) {
|
|
1788
|
+
if (!comments || comments.length === 0) {
|
|
1789
|
+
return '<p class="muted">No comments yet.</p>';
|
|
1790
|
+
}
|
|
1791
|
+
return comments
|
|
1792
|
+
.map((c) => {
|
|
1793
|
+
const r = /** @type {{body:string;createdAt:string;createdBy:string}} */ (
|
|
1794
|
+
c
|
|
1795
|
+
);
|
|
1796
|
+
return `<div class="comment">
|
|
1797
|
+
<div class="comment-meta">${escapeHtml(r.createdAt)} · ${escapeHtml(r.createdBy)}</div>
|
|
1798
|
+
<div>${escapeHtml(r.body).replace(/\n/g, "<br />")}</div>
|
|
1799
|
+
</div>`;
|
|
1800
|
+
})
|
|
1801
|
+
.join("");
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
async function renderTicketEdit() {
|
|
1805
|
+
const id = state.ticketId;
|
|
1806
|
+
if (!id) {
|
|
1807
|
+
void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
state.view = "ticketEdit";
|
|
1811
|
+
setNavCurrent();
|
|
1812
|
+
const main = document.getElementById("main");
|
|
1813
|
+
if (!main) return;
|
|
1814
|
+
main.innerHTML = '<p class="muted">Loading…</p>';
|
|
1815
|
+
try {
|
|
1816
|
+
const [rowRaw, sj, authorJson] = await Promise.all([
|
|
1817
|
+
runCli(["ticket", "read", "--id", id]),
|
|
1818
|
+
runCli(["story", "read"]),
|
|
1819
|
+
runCli(["repo", "commit-authors"]).catch(() => ({ items: [] })),
|
|
1820
|
+
]);
|
|
1821
|
+
const row = /** @type {Record<string, unknown>} */ (rowRaw);
|
|
1822
|
+
const commitAuthors = commitAuthorsFromJson(authorJson);
|
|
1823
|
+
const stories = listFromJson(sj);
|
|
1824
|
+
const curStory = row.storyId == null ? "" : String(row.storyId);
|
|
1825
|
+
let storyReadInner = '<span class="muted">No linked story</span>';
|
|
1826
|
+
/** @type {string} */
|
|
1827
|
+
let storyTitleBtn = "";
|
|
1828
|
+
/** @type {unknown | undefined} */
|
|
1829
|
+
let linkedStoryRow;
|
|
1830
|
+
if (curStory) {
|
|
1831
|
+
linkedStoryRow = stories.find(
|
|
1832
|
+
(s) => String(/** @type {{id:string}} */ (s).id) === curStory,
|
|
1833
|
+
);
|
|
1834
|
+
const storyLinkLabel = linkedStoryRow
|
|
1835
|
+
? markdownInlineHtml(
|
|
1836
|
+
String(/** @type {{title:string}} */ (linkedStoryRow).title),
|
|
1837
|
+
)
|
|
1838
|
+
: escapeHtml(curStory);
|
|
1839
|
+
storyTitleBtn = `<button type="button" class="link-title issue-meta-link" id="ticketSidebarStoryLink"><span class="md-inline">${storyLinkLabel}</span></button>`;
|
|
1840
|
+
storyReadInner = linkedStoryRow
|
|
1841
|
+
? `${storyTitleBtn}<span class="issue-meta-sep"> · </span>${idChip(curStory)}`
|
|
1842
|
+
: storyTitleBtn;
|
|
1843
|
+
}
|
|
1844
|
+
/** @type {string} */
|
|
1845
|
+
let storyEpicId = "";
|
|
1846
|
+
if (
|
|
1847
|
+
linkedStoryRow &&
|
|
1848
|
+
/** @type {{epicId?: unknown}} */ (linkedStoryRow).epicId != null &&
|
|
1849
|
+
String(/** @type {{epicId?: unknown}} */ (linkedStoryRow).epicId) !== ""
|
|
1850
|
+
) {
|
|
1851
|
+
storyEpicId = String(
|
|
1852
|
+
/** @type {{epicId: string}} */ (linkedStoryRow).epicId,
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1855
|
+
let epicReadInner = '<span class="muted">—</span>';
|
|
1856
|
+
if (storyEpicId) {
|
|
1857
|
+
try {
|
|
1858
|
+
const epicRow = /** @type {Record<string, unknown>} */ (
|
|
1859
|
+
await runCli(["epic", "read", "--id", storyEpicId])
|
|
1860
|
+
);
|
|
1861
|
+
const et = markdownInlineHtml(String(epicRow.title));
|
|
1862
|
+
const epicLink = `<button type="button" class="link-title issue-meta-link" id="ticketSidebarEpicLink"><span class="md-inline">${et}</span></button>`;
|
|
1863
|
+
epicReadInner = `${epicLink}<span class="issue-meta-sep"> · </span>${idChip(storyEpicId)}`;
|
|
1864
|
+
} catch {
|
|
1865
|
+
epicReadInner = `<button type="button" class="link-title issue-meta-link" id="ticketSidebarEpicLink">${escapeHtml(storyEpicId)}</button>`;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
const rawLabels = Array.isArray(row.labels) ? row.labels : [];
|
|
1869
|
+
const labelStrs = rawLabels.map((x) => String(x));
|
|
1870
|
+
const labelsInner =
|
|
1871
|
+
labelStrs.length === 0
|
|
1872
|
+
? '<span class="muted">None</span>'
|
|
1873
|
+
: `<div class="label-pill-wrap">${labelStrs
|
|
1874
|
+
.map((lb) => `<span class="label-pill">${escapeHtml(lb)}</span>`)
|
|
1875
|
+
.join("")}</div>`;
|
|
1876
|
+
const storyOpts = [
|
|
1877
|
+
`<option value="">No story</option>`,
|
|
1878
|
+
...stories.map((s) => {
|
|
1879
|
+
const sid = String(/** @type {{id:string}} */ (s).id);
|
|
1880
|
+
const sel = sid === curStory ? " selected" : "";
|
|
1881
|
+
return `<option value="${escapeHtml(sid)}"${sel}>${escapeHtml(String(/** @type {{title:string}} */ (s).title))}</option>`;
|
|
1882
|
+
}),
|
|
1883
|
+
].join("");
|
|
1884
|
+
const comments = Array.isArray(row.comments) ? row.comments : [];
|
|
1885
|
+
const showForm = Boolean(state.ticketDetailForm);
|
|
1886
|
+
const curAssigneeRaw =
|
|
1887
|
+
row.assignee !== undefined && row.assignee !== null
|
|
1888
|
+
? String(row.assignee).trim().toLowerCase()
|
|
1889
|
+
: "";
|
|
1890
|
+
const authorOptsHtml = [
|
|
1891
|
+
`<option value="">Select commit author…</option>`,
|
|
1892
|
+
...commitAuthors.map((a, i) => {
|
|
1893
|
+
const label = `${a.name || "(no name)"} <${a.email}>`;
|
|
1894
|
+
return `<option value="${String(i)}">${escapeHtml(label)}</option>`;
|
|
1895
|
+
}),
|
|
1896
|
+
].join("");
|
|
1897
|
+
setPageTitle(
|
|
1898
|
+
showForm ? "Edit ticket" : markdownToPlainTextForUiChrome(row.title),
|
|
1899
|
+
);
|
|
1900
|
+
const readBlock = `
|
|
1901
|
+
<div class="issue-detail-layout">
|
|
1902
|
+
<div class="issue-main">
|
|
1903
|
+
<div class="panel-head issue-panel-head">
|
|
1904
|
+
<div>
|
|
1905
|
+
<p class="muted issue-kicker" style="margin:0 0 0.25rem">Ticket</p>
|
|
1906
|
+
<h2 class="issue-title"><span class="md-inline">${markdownInlineHtml(row.title)}</span></h2>
|
|
1907
|
+
</div>
|
|
1908
|
+
<div class="panel-head-actions">
|
|
1909
|
+
<button type="button" class="btn-subtle" id="btnTicketHistory">History</button>
|
|
1910
|
+
<button type="button" class="btn-subtle" id="btnTicketEnterEdit">Edit</button>
|
|
1911
|
+
</div>
|
|
1912
|
+
</div>
|
|
1913
|
+
<div class="issue-body">
|
|
1914
|
+
${readBodyHtml(row.body)}
|
|
1915
|
+
</div>
|
|
1916
|
+
</div>
|
|
1917
|
+
<aside class="issue-sidebar" aria-label="Ticket metadata">
|
|
1918
|
+
<div class="read-stack issue-meta-stack">
|
|
1919
|
+
${readRowHtml("Ticket ID", idChip(id))}
|
|
1920
|
+
${readRowHtml("Story", storyReadInner)}
|
|
1921
|
+
${readRowHtml("Epic", epicReadInner)}
|
|
1922
|
+
${readRowHtml("Status", badgeHtml(String(row.status)))}
|
|
1923
|
+
${readRowHtml(
|
|
1924
|
+
"Assignee",
|
|
1925
|
+
curAssigneeRaw
|
|
1926
|
+
? `<code>@${escapeHtml(curAssigneeRaw)}</code>`
|
|
1927
|
+
: '<span class="muted">Unassigned</span>',
|
|
1928
|
+
)}
|
|
1929
|
+
${readRowHtml("Labels", labelsInner)}
|
|
1930
|
+
</div>
|
|
1931
|
+
</aside>
|
|
1932
|
+
</div>`;
|
|
1933
|
+
const formBlock = `
|
|
1934
|
+
<div class="issue-detail-layout">
|
|
1935
|
+
<div class="issue-main">
|
|
1936
|
+
<div class="panel-head issue-panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
|
|
1937
|
+
<h2 class="issue-title">Edit ticket</h2>
|
|
1938
|
+
</div>
|
|
1939
|
+
<label for="editTicketTitle">Title</label>
|
|
1940
|
+
<input type="text" id="editTicketTitle" value="${escapeHtml(String(row.title))}" />
|
|
1941
|
+
<label for="editTicketBody">Description</label>
|
|
1942
|
+
<textarea id="editTicketBody" rows="8">${escapeHtml(String(row.body ?? ""))}</textarea>
|
|
1943
|
+
<div class="row">
|
|
1944
|
+
<button type="button" class="primary" id="btnSaveTicket">Save</button>
|
|
1945
|
+
<button type="button" class="ghost" id="btnTicketCancelEdit">Cancel</button>
|
|
1946
|
+
<button type="button" class="danger" id="btnDeleteTicket">Delete</button>
|
|
1947
|
+
</div>
|
|
1948
|
+
</div>
|
|
1949
|
+
<aside class="issue-sidebar" aria-label="Ticket fields">
|
|
1950
|
+
<div class="read-stack issue-meta-stack">
|
|
1951
|
+
${readRowHtml("Ticket ID", idChip(id))}
|
|
1952
|
+
<div class="read-row">
|
|
1953
|
+
<div class="read-label">Story</div>
|
|
1954
|
+
<div class="read-value">
|
|
1955
|
+
${
|
|
1956
|
+
curStory
|
|
1957
|
+
? `<div class="issue-sidebar-story-nav">${storyTitleBtn}</div>`
|
|
1958
|
+
: ""
|
|
1959
|
+
}
|
|
1960
|
+
<select id="editTicketStory">${storyOpts}</select>
|
|
1961
|
+
</div>
|
|
1962
|
+
</div>
|
|
1963
|
+
${readRowHtml("Epic", epicReadInner)}
|
|
1964
|
+
<div class="read-row">
|
|
1965
|
+
<div class="read-label">Status</div>
|
|
1966
|
+
<div class="read-value">${statusOptionsHtml("editTicketStatus", String(row.status))}</div>
|
|
1967
|
+
</div>
|
|
1968
|
+
<div class="read-row">
|
|
1969
|
+
<div class="read-label">Assignee</div>
|
|
1970
|
+
<div class="read-value">
|
|
1971
|
+
<label for="editTicketAssigneeLogin" class="muted" style="margin-top:0;font-size:0.75rem">GitHub login (synced to the issue assignee)</label>
|
|
1972
|
+
<input type="text" id="editTicketAssigneeLogin" autocomplete="off" placeholder="e.g. octocat" value="${escapeHtml(curAssigneeRaw)}" />
|
|
1973
|
+
<label for="editTicketCommitAuthor" style="margin-top:0.75rem">Pick from commit authors</label>
|
|
1974
|
+
<select id="editTicketCommitAuthor">${authorOptsHtml}</select>
|
|
1975
|
+
<label for="editTicketAssigneeName" style="margin-top:0.75rem">Or enter name</label>
|
|
1976
|
+
<input type="text" id="editTicketAssigneeName" autocomplete="name" placeholder="Display name" />
|
|
1977
|
+
<label for="editTicketAssigneeEmail">And email</label>
|
|
1978
|
+
<input type="email" id="editTicketAssigneeEmail" autocomplete="email" placeholder="name@example.com" />
|
|
1979
|
+
<div class="row" style="margin-top:0.5rem">
|
|
1980
|
+
<button type="button" class="ghost" id="btnEditTicketSuggestAssignee">Suggest login from name and email</button>
|
|
1981
|
+
</div>
|
|
1982
|
+
<p class="muted" style="font-size:0.8125rem;margin:0.5rem 0 0;line-height:1.45">Leave login empty and save to remove the assignee.</p>
|
|
1983
|
+
</div>
|
|
1984
|
+
</div>
|
|
1985
|
+
${readRowHtml("Labels", labelsInner)}
|
|
1986
|
+
<p class="muted" style="font-size:0.8125rem;margin:0;line-height:1.45">Change labels with <code>hyper-pm ticket update</code> in the CLI.</p>
|
|
1987
|
+
</div>
|
|
1988
|
+
</aside>
|
|
1989
|
+
</div>`;
|
|
1990
|
+
const ticketTopBar = `
|
|
1991
|
+
<nav class="detail-page-top" aria-label="Ticket">
|
|
1992
|
+
<button type="button" class="ghost detail-back" id="btnBackTickets2">← Tickets</button>
|
|
1993
|
+
</nav>`;
|
|
1994
|
+
main.innerHTML = `
|
|
1995
|
+
<div class="panel">
|
|
1996
|
+
${ticketTopBar}
|
|
1997
|
+
${showForm ? formBlock : readBlock}
|
|
1998
|
+
</div>
|
|
1999
|
+
<div class="panel">
|
|
2000
|
+
<div class="panel-head">
|
|
2001
|
+
<h2>Comments</h2>
|
|
2002
|
+
</div>
|
|
2003
|
+
${commentsHtml(comments)}
|
|
2004
|
+
<label for="newCommentBody">Add comment</label>
|
|
2005
|
+
<textarea id="newCommentBody" rows="3" placeholder="Write a comment…"></textarea>
|
|
2006
|
+
<div class="row">
|
|
2007
|
+
<button type="button" class="primary" id="btnAddComment">Post comment</button>
|
|
2008
|
+
</div>
|
|
2009
|
+
</div>`;
|
|
2010
|
+
if (showForm) {
|
|
2011
|
+
wireTicketAssigneeControls("edit", commitAuthors);
|
|
2012
|
+
}
|
|
2013
|
+
document
|
|
2014
|
+
.getElementById("btnBackTickets2")
|
|
2015
|
+
?.addEventListener("click", () => {
|
|
2016
|
+
void pushAppRoute({
|
|
2017
|
+
kind: "tickets",
|
|
2018
|
+
ticketFilterStory: curStory || "",
|
|
2019
|
+
});
|
|
2020
|
+
});
|
|
2021
|
+
document
|
|
2022
|
+
.getElementById("ticketSidebarStoryLink")
|
|
2023
|
+
?.addEventListener("click", () => {
|
|
2024
|
+
navigateToStory(curStory);
|
|
2025
|
+
});
|
|
2026
|
+
document
|
|
2027
|
+
.getElementById("ticketSidebarEpicLink")
|
|
2028
|
+
?.addEventListener("click", () => {
|
|
2029
|
+
navigateToEpic(storyEpicId);
|
|
2030
|
+
});
|
|
2031
|
+
if (!showForm) {
|
|
2032
|
+
document
|
|
2033
|
+
.getElementById("btnTicketHistory")
|
|
2034
|
+
?.addEventListener("click", () => {
|
|
2035
|
+
void openHistoryModal(id, "Ticket");
|
|
2036
|
+
});
|
|
2037
|
+
document
|
|
2038
|
+
.getElementById("btnTicketEnterEdit")
|
|
2039
|
+
?.addEventListener("click", () => {
|
|
2040
|
+
void pushAppRoute({
|
|
2041
|
+
kind: "ticketEdit",
|
|
2042
|
+
ticketId: id,
|
|
2043
|
+
ticketForm: true,
|
|
2044
|
+
});
|
|
2045
|
+
});
|
|
2046
|
+
} else {
|
|
2047
|
+
document
|
|
2048
|
+
.getElementById("btnTicketCancelEdit")
|
|
2049
|
+
?.addEventListener("click", () => {
|
|
2050
|
+
window.history.back();
|
|
2051
|
+
});
|
|
2052
|
+
document
|
|
2053
|
+
.getElementById("btnSaveTicket")
|
|
2054
|
+
?.addEventListener("click", async () => {
|
|
2055
|
+
const title = trimU(
|
|
2056
|
+
document.getElementById("editTicketTitle")?.value ?? "",
|
|
2057
|
+
);
|
|
2058
|
+
const body = document.getElementById("editTicketBody")?.value ?? "";
|
|
2059
|
+
const status =
|
|
2060
|
+
document.getElementById("editTicketStatus")?.value ?? "todo";
|
|
2061
|
+
const storySel =
|
|
2062
|
+
document.getElementById("editTicketStory")?.value ?? "";
|
|
2063
|
+
if (!title) {
|
|
2064
|
+
toast("Title is required", true);
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
const prevTitle = trimU(String(row.title));
|
|
2068
|
+
const prevBody = String(row.body ?? "");
|
|
2069
|
+
const prevStatus = String(row.status);
|
|
2070
|
+
/** @type {string[]} */
|
|
2071
|
+
const argv = ["ticket", "update", "--id", id];
|
|
2072
|
+
if (title !== prevTitle) {
|
|
2073
|
+
argv.push("--title", title);
|
|
2074
|
+
}
|
|
2075
|
+
if (body !== prevBody) {
|
|
2076
|
+
argv.push("--body", body);
|
|
2077
|
+
}
|
|
2078
|
+
if (status !== prevStatus) {
|
|
2079
|
+
argv.push("--status", status);
|
|
2080
|
+
}
|
|
2081
|
+
if (storySel !== curStory) {
|
|
2082
|
+
if (storySel) {
|
|
2083
|
+
argv.push("--story", storySel);
|
|
2084
|
+
} else {
|
|
2085
|
+
argv.push("--unlink-story");
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
const loginRaw = trimU(
|
|
2089
|
+
document.getElementById("editTicketAssigneeLogin")?.value ?? "",
|
|
2090
|
+
);
|
|
2091
|
+
const loginNorm = loginRaw ? loginRaw.toLowerCase() : "";
|
|
2092
|
+
if (loginNorm !== curAssigneeRaw) {
|
|
2093
|
+
if (loginNorm === "" && curAssigneeRaw !== "") {
|
|
2094
|
+
argv.push("--unassign");
|
|
2095
|
+
} else if (loginNorm !== "") {
|
|
2096
|
+
argv.push("--assignee", loginNorm);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
try {
|
|
2100
|
+
await runCli(argv);
|
|
2101
|
+
toast("Ticket saved", false);
|
|
2102
|
+
replaceAppRoute({
|
|
2103
|
+
kind: "ticketEdit",
|
|
2104
|
+
ticketId: id,
|
|
2105
|
+
ticketForm: false,
|
|
2106
|
+
});
|
|
2107
|
+
} catch (e) {
|
|
2108
|
+
toast(String(e), true);
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
document
|
|
2112
|
+
.getElementById("btnDeleteTicket")
|
|
2113
|
+
?.addEventListener("click", async () => {
|
|
2114
|
+
if (!window.confirm(`Delete ticket ${id}?`)) return;
|
|
2115
|
+
try {
|
|
2116
|
+
await runCli(["ticket", "delete", "--id", id]);
|
|
2117
|
+
toast("Ticket deleted", false);
|
|
2118
|
+
void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
|
|
2119
|
+
} catch (e) {
|
|
2120
|
+
toast(String(e), true);
|
|
2121
|
+
}
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
document
|
|
2125
|
+
.getElementById("btnAddComment")
|
|
2126
|
+
?.addEventListener("click", async () => {
|
|
2127
|
+
const text =
|
|
2128
|
+
document.getElementById("newCommentBody")?.value?.trim() ?? "";
|
|
2129
|
+
if (!text) {
|
|
2130
|
+
toast("Comment cannot be empty", true);
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
try {
|
|
2134
|
+
await runCli(["ticket", "comment", "--id", id, "--body", text]);
|
|
2135
|
+
toast("Comment added", false);
|
|
2136
|
+
void renderTicketEdit();
|
|
2137
|
+
} catch (e) {
|
|
2138
|
+
toast(String(e), true);
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
} catch (e) {
|
|
2142
|
+
main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p><button type="button" class="ghost" id="btnTicketErrBack">← Tickets</button></div>`;
|
|
2143
|
+
document
|
|
2144
|
+
.getElementById("btnTicketErrBack")
|
|
2145
|
+
?.addEventListener("click", () => {
|
|
2146
|
+
void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function renderTools() {
|
|
2152
|
+
setPageTitle("Tools");
|
|
2153
|
+
state.view = "tools";
|
|
2154
|
+
setNavCurrent();
|
|
2155
|
+
const main = document.getElementById("main");
|
|
2156
|
+
if (!main) return;
|
|
2157
|
+
main.innerHTML = `
|
|
2158
|
+
<div class="panel panel-tools">
|
|
2159
|
+
<div class="panel-head">
|
|
2160
|
+
<h2>Initialize repository</h2>
|
|
2161
|
+
</div>
|
|
2162
|
+
<p class="muted">Creates the hyper-pm data branch and config if missing.</p>
|
|
2163
|
+
<label><input type="checkbox" id="initSyncOff" checked /> Start with sync off</label>
|
|
2164
|
+
<div class="row">
|
|
2165
|
+
<button type="button" class="primary" id="btnInit">Run init</button>
|
|
2166
|
+
</div>
|
|
2167
|
+
</div>
|
|
2168
|
+
<div class="panel panel-tools">
|
|
2169
|
+
<div class="panel-head">
|
|
2170
|
+
<h2>Sync with GitHub</h2>
|
|
2171
|
+
</div>
|
|
2172
|
+
<label><input type="checkbox" id="syncNoGithub" /> Skip GitHub network (<code>--no-github</code>)</label>
|
|
2173
|
+
<div class="row">
|
|
2174
|
+
<button type="button" class="primary" id="btnSync">Run sync</button>
|
|
2175
|
+
</div>
|
|
2176
|
+
</div>
|
|
2177
|
+
<div class="panel panel-tools">
|
|
2178
|
+
<div class="panel-head">
|
|
2179
|
+
<h2>Audit & doctor</h2>
|
|
2180
|
+
</div>
|
|
2181
|
+
<label for="auditLimit">Audit limit</label>
|
|
2182
|
+
<input type="text" id="auditLimit" placeholder="e.g. 50" />
|
|
2183
|
+
<label for="auditType">Event type</label>
|
|
2184
|
+
<input type="text" id="auditType" placeholder="TicketUpdated" />
|
|
2185
|
+
<div class="row">
|
|
2186
|
+
<button type="button" id="btnAudit">Run audit</button>
|
|
2187
|
+
<button type="button" id="btnDoctor">Run doctor</button>
|
|
2188
|
+
</div>
|
|
2189
|
+
</div>`;
|
|
2190
|
+
document.getElementById("btnInit")?.addEventListener("click", async () => {
|
|
2191
|
+
const syncOff = document.getElementById("initSyncOff")?.checked;
|
|
2192
|
+
const argv = syncOff ? ["--sync", "off", "init"] : ["init"];
|
|
2193
|
+
try {
|
|
2194
|
+
await runCli(argv);
|
|
2195
|
+
toast("Init completed", false);
|
|
2196
|
+
} catch (e) {
|
|
2197
|
+
toast(String(e), true);
|
|
2198
|
+
}
|
|
2199
|
+
});
|
|
2200
|
+
document.getElementById("btnSync")?.addEventListener("click", async () => {
|
|
2201
|
+
const argv = ["sync"];
|
|
2202
|
+
if (document.getElementById("syncNoGithub")?.checked)
|
|
2203
|
+
argv.push("--no-github");
|
|
2204
|
+
try {
|
|
2205
|
+
await runCli(argv);
|
|
2206
|
+
toast("Sync finished", false);
|
|
2207
|
+
} catch (e) {
|
|
2208
|
+
toast(String(e), true);
|
|
2209
|
+
}
|
|
2210
|
+
});
|
|
2211
|
+
document.getElementById("btnAudit")?.addEventListener("click", async () => {
|
|
2212
|
+
const argv = ["audit"];
|
|
2213
|
+
const lim = trimU(document.getElementById("auditLimit")?.value ?? "");
|
|
2214
|
+
const typ = trimU(document.getElementById("auditType")?.value ?? "");
|
|
2215
|
+
if (lim) argv.push("--limit", lim);
|
|
2216
|
+
if (typ) argv.push("--type", typ);
|
|
2217
|
+
try {
|
|
2218
|
+
const j = await runCli(argv);
|
|
2219
|
+
window.alert(JSON.stringify(j, null, 2));
|
|
2220
|
+
} catch (e) {
|
|
2221
|
+
toast(String(e), true);
|
|
2222
|
+
}
|
|
2223
|
+
});
|
|
2224
|
+
document.getElementById("btnDoctor")?.addEventListener("click", async () => {
|
|
2225
|
+
try {
|
|
2226
|
+
const j = await runCli(["doctor"]);
|
|
2227
|
+
toast(JSON.stringify(j), false);
|
|
2228
|
+
} catch (e) {
|
|
2229
|
+
toast(String(e), true);
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
function renderAdvanced() {
|
|
2235
|
+
setPageTitle("Advanced CLI");
|
|
2236
|
+
state.view = "advanced";
|
|
2237
|
+
setNavCurrent();
|
|
2238
|
+
const main = document.getElementById("main");
|
|
2239
|
+
if (!main) return;
|
|
2240
|
+
main.innerHTML = `
|
|
2241
|
+
<div class="panel">
|
|
2242
|
+
<div class="panel-head">
|
|
2243
|
+
<h2>Raw argv</h2>
|
|
2244
|
+
</div>
|
|
2245
|
+
<p class="muted">JSON array of CLI tokens after global flags. Repo, temp dir, and format are still enforced by the server.</p>
|
|
2246
|
+
<textarea id="advArgv" rows="6" placeholder='["ticket", "read", "--id", "…"]'></textarea>
|
|
2247
|
+
<div class="row">
|
|
2248
|
+
<button type="button" class="primary" id="btnAdvRun">Run</button>
|
|
2249
|
+
</div>
|
|
2250
|
+
<h3>Output</h3>
|
|
2251
|
+
<pre id="advOut" class="pre-out muted"></pre>
|
|
2252
|
+
</div>`;
|
|
2253
|
+
document.getElementById("btnAdvRun")?.addEventListener("click", async () => {
|
|
2254
|
+
const raw = document.getElementById("advArgv")?.value ?? "";
|
|
2255
|
+
const out = document.getElementById("advOut");
|
|
2256
|
+
let argv;
|
|
2257
|
+
try {
|
|
2258
|
+
argv = JSON.parse(raw);
|
|
2259
|
+
} catch (e) {
|
|
2260
|
+
if (out) out.textContent = String(e);
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
if (!Array.isArray(argv) || !argv.every((x) => typeof x === "string")) {
|
|
2264
|
+
if (out) out.textContent = "Expected JSON array of strings.";
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
const r = await runApi({ argv });
|
|
2268
|
+
if (out) out.textContent = JSON.stringify(r.body, null, 2);
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function refreshCurrentView() {
|
|
2273
|
+
const r = parseHash();
|
|
2274
|
+
syncStateFromRoute(r);
|
|
2275
|
+
const out = renderForRoute(r);
|
|
2276
|
+
setNavCurrent();
|
|
2277
|
+
return out;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
function wireNav() {
|
|
2281
|
+
document.querySelectorAll(".nav-btn[data-nav]").forEach((btn) => {
|
|
2282
|
+
btn.addEventListener("click", () => {
|
|
2283
|
+
const v = /** @type {HTMLButtonElement} */ (btn).dataset.nav;
|
|
2284
|
+
if (!v) return;
|
|
2285
|
+
if (v === "dashboard") void pushAppRoute({ kind: "dashboard" });
|
|
2286
|
+
else if (v === "epics") void pushAppRoute({ kind: "epics" });
|
|
2287
|
+
else if (v === "stories") {
|
|
2288
|
+
void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
|
|
2289
|
+
} else if (v === "tickets") {
|
|
2290
|
+
void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
|
|
2291
|
+
} else if (v === "tools") void pushAppRoute({ kind: "tools" });
|
|
2292
|
+
else if (v === "advanced") void pushAppRoute({ kind: "advanced" });
|
|
2293
|
+
});
|
|
2294
|
+
});
|
|
2295
|
+
document.getElementById("btnRefresh")?.addEventListener("click", () => {
|
|
2296
|
+
void loadHealth();
|
|
2297
|
+
void refreshCurrentView();
|
|
2298
|
+
});
|
|
2299
|
+
document.getElementById("saveToken")?.addEventListener("click", () => {
|
|
2300
|
+
const v = document.getElementById("bearer")?.value ?? "";
|
|
2301
|
+
if (v.trim()) localStorage.setItem(TOKEN_KEY, v.trim());
|
|
2302
|
+
else localStorage.removeItem(TOKEN_KEY);
|
|
2303
|
+
toast("Token saved", false);
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
window.addEventListener("DOMContentLoaded", async () => {
|
|
2308
|
+
const existing = localStorage.getItem(TOKEN_KEY);
|
|
2309
|
+
const bearerInput = document.getElementById("bearer");
|
|
2310
|
+
if (bearerInput && existing) bearerInput.value = existing;
|
|
2311
|
+
wireHistoryNavigation();
|
|
2312
|
+
wireNav();
|
|
2313
|
+
await loadHealth();
|
|
2314
|
+
const h = window.location.hash;
|
|
2315
|
+
if (!h || h === "#") {
|
|
2316
|
+
history.replaceState(null, "", "#/");
|
|
2317
|
+
syncStateFromRoute({ kind: "dashboard" });
|
|
2318
|
+
setNavCurrent();
|
|
2319
|
+
await renderDashboard();
|
|
2320
|
+
} else {
|
|
2321
|
+
const initial = parseHash();
|
|
2322
|
+
syncStateFromRoute(initial);
|
|
2323
|
+
setNavCurrent();
|
|
2324
|
+
await renderForRoute(initial);
|
|
2325
|
+
}
|
|
2326
|
+
});
|