@starlove/orbit 1.2.1 → 1.3.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/Dockerfile +2 -2
- package/README.md +16 -17
- package/SKILL-ORBIT.md +1 -1
- package/dist/full/index.html +225 -29
- package/dist/full/js/app.b8296a78.js +742 -0
- package/dist/full/js/app.b8296a78.js.map +7 -0
- package/dist/full/styles.e2284569.css +1 -0
- package/dist/full/version.json +2 -2
- package/docs/MCP_SETUP.md +6 -5
- package/docs/setup/orbit-init-wizard.md +3 -3
- package/package.json +16 -18
- package/public/index.html +223 -27
- package/public/js/app.js +4 -1
- package/public/js/board-menu.js +21 -8
- package/public/js/help-menu.js +31 -0
- package/public/js/main.js +4 -0
- package/public/js/settings.js +95 -3
- package/public/js/ticket-detail.js +240 -93
- package/public/js/topbar-notes.js +155 -0
- package/public/styles.css +220 -487
- package/src/cli/orbit.js +48 -10
- package/src/core/agents-md.js +15 -0
- package/src/core/board.js +6 -3
- package/src/core/boards.js +54 -11
- package/src/core/db.js +13 -0
- package/src/core/migrate.js +1 -1
- package/src/core/paths.js +1 -1
- package/src/core/registry.js +4 -3
- package/src/server.js +24 -2
- package/dist/full/js/app.ccc442f7.js +0 -706
- package/dist/full/js/app.ccc442f7.js.map +0 -7
- package/dist/full/styles.b7c19a07.css +0 -1
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
// related cards. Also owns the small render helpers (status history,
|
|
5
5
|
// comments, detail-card) that are only used inside the drawer.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
state,
|
|
9
|
+
ticketsForProject,
|
|
10
|
+
selectableStatesForProject,
|
|
11
|
+
boardLabelCatalog,
|
|
12
|
+
currentBoard,
|
|
13
|
+
upsertTicket,
|
|
14
|
+
} from "./state.js";
|
|
8
15
|
import { drawerInner, drawer, $ } from "./dom.js";
|
|
9
16
|
import {
|
|
10
17
|
escapeHtml,
|
|
@@ -18,7 +25,7 @@ import {
|
|
|
18
25
|
priorityKeyFor,
|
|
19
26
|
renderPreservedText,
|
|
20
27
|
stateClassFor,
|
|
21
|
-
cleanText
|
|
28
|
+
cleanText,
|
|
22
29
|
} from "./format.js";
|
|
23
30
|
import { renderTypeIcon, renderPriorityPill, renderBoard } from "./kanban.js";
|
|
24
31
|
import { renderDrawerShell, openDrawer, closeDrawer } from "./drawer.js";
|
|
@@ -38,17 +45,25 @@ import { formatActorLabel, formatCommentAuthor } from "./actor-labels.js";
|
|
|
38
45
|
* board card itself changed, also rebuild the board from the patched local
|
|
39
46
|
* cache; otherwise leave the board DOM alone.
|
|
40
47
|
*/
|
|
41
|
-
async function refreshTicketDetail(
|
|
48
|
+
async function refreshTicketDetail(
|
|
49
|
+
ticketId,
|
|
50
|
+
{ renderBoardAfter = false } = {},
|
|
51
|
+
) {
|
|
42
52
|
const [context, statusHistory, commentsResult] = await Promise.all([
|
|
43
53
|
api(withBoardQuery(`/api/tickets/${ticketId}/context?depth=1`)),
|
|
44
54
|
api(withBoardQuery(`/api/tickets/${ticketId}/history`)),
|
|
45
|
-
api(withBoardQuery(`/api/tickets/${ticketId}/comments`))
|
|
55
|
+
api(withBoardQuery(`/api/tickets/${ticketId}/comments`)),
|
|
46
56
|
]);
|
|
47
57
|
if (context.ticket?.id !== ticketId) return;
|
|
48
58
|
upsertTicket(context.ticket);
|
|
49
59
|
if (renderBoardAfter) renderBoard();
|
|
50
|
-
if (state.detailMode !== "ticket" || state.selectedTicketId !== ticketId)
|
|
51
|
-
|
|
60
|
+
if (state.detailMode !== "ticket" || state.selectedTicketId !== ticketId)
|
|
61
|
+
return;
|
|
62
|
+
await renderDetail({
|
|
63
|
+
context,
|
|
64
|
+
statusHistory,
|
|
65
|
+
comments: commentsResult.comments || [],
|
|
66
|
+
});
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
/**
|
|
@@ -57,7 +72,10 @@ async function refreshTicketDetail(ticketId, { renderBoardAfter = false } = {})
|
|
|
57
72
|
* every drawer edit.
|
|
58
73
|
*/
|
|
59
74
|
async function patchTicket(ticketId, body) {
|
|
60
|
-
await api(withBoardQuery(`/api/tickets/${ticketId}`), {
|
|
75
|
+
await api(withBoardQuery(`/api/tickets/${ticketId}`), {
|
|
76
|
+
method: "PATCH",
|
|
77
|
+
body,
|
|
78
|
+
});
|
|
61
79
|
await refreshTicketDetail(ticketId, { renderBoardAfter: true });
|
|
62
80
|
}
|
|
63
81
|
|
|
@@ -78,19 +96,38 @@ async function patchTicket(ticketId, body) {
|
|
|
78
96
|
* trimming, cleanText, or PATCH wiring.
|
|
79
97
|
*/
|
|
80
98
|
function inlineTextOffsetFromPoint(node, sourceEvent, maxLength) {
|
|
81
|
-
if (
|
|
99
|
+
if (
|
|
100
|
+
!node ||
|
|
101
|
+
!sourceEvent ||
|
|
102
|
+
typeof sourceEvent.clientX !== "number" ||
|
|
103
|
+
typeof sourceEvent.clientY !== "number"
|
|
104
|
+
)
|
|
105
|
+
return null;
|
|
82
106
|
|
|
83
107
|
let range = null;
|
|
84
108
|
if (document.caretPositionFromPoint) {
|
|
85
|
-
const position = document.caretPositionFromPoint(
|
|
86
|
-
|
|
109
|
+
const position = document.caretPositionFromPoint(
|
|
110
|
+
sourceEvent.clientX,
|
|
111
|
+
sourceEvent.clientY,
|
|
112
|
+
);
|
|
113
|
+
if (
|
|
114
|
+
position?.offsetNode &&
|
|
115
|
+
(position.offsetNode === node || node.contains(position.offsetNode))
|
|
116
|
+
) {
|
|
87
117
|
range = document.createRange();
|
|
88
118
|
range.setStart(node, 0);
|
|
89
119
|
range.setEnd(position.offsetNode, position.offset);
|
|
90
120
|
}
|
|
91
121
|
} else if (document.caretRangeFromPoint) {
|
|
92
|
-
const caretRange = document.caretRangeFromPoint(
|
|
93
|
-
|
|
122
|
+
const caretRange = document.caretRangeFromPoint(
|
|
123
|
+
sourceEvent.clientX,
|
|
124
|
+
sourceEvent.clientY,
|
|
125
|
+
);
|
|
126
|
+
if (
|
|
127
|
+
caretRange?.startContainer &&
|
|
128
|
+
(caretRange.startContainer === node ||
|
|
129
|
+
node.contains(caretRange.startContainer))
|
|
130
|
+
) {
|
|
94
131
|
range = document.createRange();
|
|
95
132
|
range.setStart(node, 0);
|
|
96
133
|
range.setEnd(caretRange.startContainer, caretRange.startOffset);
|
|
@@ -107,15 +144,20 @@ function captureInlineEditScrollState(node) {
|
|
|
107
144
|
return {
|
|
108
145
|
scrollContainer,
|
|
109
146
|
nodeRect: node.getBoundingClientRect(),
|
|
110
|
-
scrollTop: scrollContainer.scrollTop
|
|
147
|
+
scrollTop: scrollContainer.scrollTop,
|
|
111
148
|
};
|
|
112
149
|
}
|
|
113
150
|
|
|
114
151
|
function restoreInlineEditScroll(scrollState, editor) {
|
|
115
152
|
if (!scrollState?.scrollContainer || !editor?.isConnected) return;
|
|
116
153
|
const editorRect = editor.getBoundingClientRect();
|
|
117
|
-
const nextScrollTop = Math.max(
|
|
118
|
-
|
|
154
|
+
const nextScrollTop = Math.max(
|
|
155
|
+
0,
|
|
156
|
+
scrollState.scrollContainer.scrollTop +
|
|
157
|
+
(editorRect.top - scrollState.nodeRect.top),
|
|
158
|
+
);
|
|
159
|
+
if (Number.isFinite(nextScrollTop))
|
|
160
|
+
scrollState.scrollContainer.scrollTop = nextScrollTop;
|
|
119
161
|
else scrollState.scrollContainer.scrollTop = scrollState.scrollTop;
|
|
120
162
|
}
|
|
121
163
|
|
|
@@ -137,12 +179,14 @@ export function startInlineEdit(node, opts) {
|
|
|
137
179
|
emptyMessage,
|
|
138
180
|
sourceEvent,
|
|
139
181
|
rerender,
|
|
140
|
-
commit: commitHandler
|
|
182
|
+
commit: commitHandler,
|
|
141
183
|
} = opts;
|
|
142
184
|
|
|
143
185
|
const scrollState = multiline ? captureInlineEditScrollState(node) : null;
|
|
144
186
|
const initialString = String(initialValue);
|
|
145
|
-
const clickedCaretOffset = multiline
|
|
187
|
+
const clickedCaretOffset = multiline
|
|
188
|
+
? inlineTextOffsetFromPoint(node, sourceEvent, initialString.length)
|
|
189
|
+
: null;
|
|
146
190
|
|
|
147
191
|
node.dataset.editing = "true";
|
|
148
192
|
|
|
@@ -195,7 +239,8 @@ export function startInlineEdit(node, opts) {
|
|
|
195
239
|
};
|
|
196
240
|
|
|
197
241
|
function handleOutsidePointerDown(event) {
|
|
198
|
-
if (done || event.target === editor || editor.contains(event.target))
|
|
242
|
+
if (done || event.target === editor || editor.contains(event.target))
|
|
243
|
+
return;
|
|
199
244
|
// Some drawer/header surfaces are not focusable, so clicking them does not
|
|
200
245
|
// reliably blur the input in every browser. Force the blur path used on
|
|
201
246
|
// commit so the edit styling cannot get stuck.
|
|
@@ -257,7 +302,7 @@ function wireTicketDetailEditors(ticket) {
|
|
|
257
302
|
ariaLabel: "Edit ticket title",
|
|
258
303
|
emptyMessage: "Title cannot be empty",
|
|
259
304
|
rerender: () => renderDetail(),
|
|
260
|
-
commit: (next) => patchTicket(ticketId, { title: next })
|
|
305
|
+
commit: (next) => patchTicket(ticketId, { title: next }),
|
|
261
306
|
});
|
|
262
307
|
|
|
263
308
|
const titleEl = drawer.querySelector('[data-edit-field="title"]');
|
|
@@ -280,7 +325,7 @@ function wireTicketDetailEditors(ticket) {
|
|
|
280
325
|
ariaLabel: "Edit ticket description",
|
|
281
326
|
sourceEvent,
|
|
282
327
|
rerender: () => renderDetail(),
|
|
283
|
-
commit: (next) => patchTicket(ticketId, { description: cleanText(next) })
|
|
328
|
+
commit: (next) => patchTicket(ticketId, { description: cleanText(next) }),
|
|
284
329
|
});
|
|
285
330
|
|
|
286
331
|
if (descEl) {
|
|
@@ -304,7 +349,10 @@ function wireTicketDetailEditors(ticket) {
|
|
|
304
349
|
const AI_FIELDS = [
|
|
305
350
|
{ key: "ai_plan", label: "AI plan" },
|
|
306
351
|
{ key: "implementation_summary", label: "Implementation summary" },
|
|
307
|
-
{
|
|
352
|
+
{
|
|
353
|
+
key: "implementation_updates",
|
|
354
|
+
label: "Implementation updates / lessons",
|
|
355
|
+
},
|
|
308
356
|
];
|
|
309
357
|
for (const { key, label } of AI_FIELDS) {
|
|
310
358
|
const fieldEl = drawerInner.querySelector(`[data-edit-field="${key}"]`);
|
|
@@ -317,7 +365,7 @@ function wireTicketDetailEditors(ticket) {
|
|
|
317
365
|
ariaLabel: `Edit ${label}`,
|
|
318
366
|
sourceEvent,
|
|
319
367
|
rerender: () => renderDetail(),
|
|
320
|
-
commit: (next) => patchTicket(ticketId, { [key]: cleanText(next) })
|
|
368
|
+
commit: (next) => patchTicket(ticketId, { [key]: cleanText(next) }),
|
|
321
369
|
});
|
|
322
370
|
fieldEl.addEventListener("click", (event) => {
|
|
323
371
|
event.preventDefault();
|
|
@@ -359,7 +407,9 @@ function wireTicketDetailEditors(ticket) {
|
|
|
359
407
|
drawerInner.querySelectorAll("[data-remove-label]").forEach((btn) => {
|
|
360
408
|
btn.addEventListener("click", async () => {
|
|
361
409
|
const removeName = btn.getAttribute("data-remove-label");
|
|
362
|
-
const names = (ticket.labels || [])
|
|
410
|
+
const names = (ticket.labels || [])
|
|
411
|
+
.map((l) => l.name)
|
|
412
|
+
.filter((n) => n !== removeName);
|
|
363
413
|
await applyLabelPatch(names);
|
|
364
414
|
});
|
|
365
415
|
});
|
|
@@ -379,15 +429,19 @@ function wireTicketDetailEditors(ticket) {
|
|
|
379
429
|
await applyLabelPatch(names);
|
|
380
430
|
};
|
|
381
431
|
|
|
382
|
-
drawerInner
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
drawerInner.querySelector("[data-label-input]")?.addEventListener("keydown", (event) => {
|
|
386
|
-
if (event.key === "Enter") {
|
|
387
|
-
event.preventDefault();
|
|
432
|
+
drawerInner
|
|
433
|
+
.querySelector("[data-label-add]")
|
|
434
|
+
?.addEventListener("click", () => {
|
|
388
435
|
void addLabel();
|
|
389
|
-
}
|
|
390
|
-
|
|
436
|
+
});
|
|
437
|
+
drawerInner
|
|
438
|
+
.querySelector("[data-label-input]")
|
|
439
|
+
?.addEventListener("keydown", (event) => {
|
|
440
|
+
if (event.key === "Enter") {
|
|
441
|
+
event.preventDefault();
|
|
442
|
+
void addLabel();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
391
445
|
}
|
|
392
446
|
|
|
393
447
|
export async function renderDetail(options = {}) {
|
|
@@ -407,27 +461,41 @@ export async function renderDetail(options = {}) {
|
|
|
407
461
|
const [context, statusHistory, comments, attachmentList] = await Promise.all([
|
|
408
462
|
hasOption("context")
|
|
409
463
|
? Promise.resolve(options.context)
|
|
410
|
-
: api(
|
|
464
|
+
: api(
|
|
465
|
+
withBoardQuery(`/api/tickets/${requestedTicketId}/context?depth=1`),
|
|
466
|
+
),
|
|
411
467
|
hasOption("statusHistory")
|
|
412
468
|
? Promise.resolve(options.statusHistory)
|
|
413
469
|
: api(withBoardQuery(`/api/tickets/${requestedTicketId}/history`)),
|
|
414
470
|
hasOption("comments")
|
|
415
471
|
? Promise.resolve(options.comments)
|
|
416
|
-
: api(withBoardQuery(`/api/tickets/${requestedTicketId}/comments`)).then(
|
|
472
|
+
: api(withBoardQuery(`/api/tickets/${requestedTicketId}/comments`)).then(
|
|
473
|
+
(result) => result.comments || [],
|
|
474
|
+
),
|
|
417
475
|
hasOption("attachmentList")
|
|
418
476
|
? Promise.resolve(options.attachmentList)
|
|
419
477
|
: features.attachments
|
|
420
|
-
? api(
|
|
421
|
-
|
|
478
|
+
? api(
|
|
479
|
+
withBoardQuery(`/api/tickets/${requestedTicketId}/attachments`),
|
|
480
|
+
).catch(() => ({ attachments: [] }))
|
|
481
|
+
: Promise.resolve({ attachments: [] }),
|
|
422
482
|
]);
|
|
423
|
-
if (
|
|
483
|
+
if (
|
|
484
|
+
state.detailMode !== "ticket" ||
|
|
485
|
+
state.selectedTicketId !== requestedTicketId
|
|
486
|
+
)
|
|
487
|
+
return;
|
|
424
488
|
if (context.ticket?.id !== requestedTicketId) return;
|
|
425
489
|
const ticket = context.ticket;
|
|
426
490
|
// Acknowledge the read receipt: clear the unread dot on this card.
|
|
427
491
|
// Also wipe the dot from the already-rendered card in the board so the
|
|
428
492
|
// user doesn't see it lingering until the next full re-render.
|
|
429
493
|
markRead(ticket.id, ticket.updated_at);
|
|
430
|
-
document
|
|
494
|
+
document
|
|
495
|
+
.querySelector(
|
|
496
|
+
`.card[data-ticket-id="${CSS.escape(ticket.id)}"] .card-unread-dot`,
|
|
497
|
+
)
|
|
498
|
+
?.remove();
|
|
431
499
|
const states = selectableStatesForProject(ticket.state_id);
|
|
432
500
|
|
|
433
501
|
// Ordered by the canonical hierarchy so the dropdown visually reinforces scale:
|
|
@@ -435,21 +503,21 @@ export async function renderDetail(options = {}) {
|
|
|
435
503
|
const typeOptions = ["epic", "feature", "task", "bug"]
|
|
436
504
|
.map(
|
|
437
505
|
(value) =>
|
|
438
|
-
`<option value="${escapeHtml(value)}" ${canonicalTicketType(ticket.type) === value ? "selected" : ""}>${escapeHtml(typeLabelLong(value))}</option
|
|
506
|
+
`<option value="${escapeHtml(value)}" ${canonicalTicketType(ticket.type) === value ? "selected" : ""}>${escapeHtml(typeLabelLong(value))}</option>`,
|
|
439
507
|
)
|
|
440
508
|
.join("");
|
|
441
509
|
|
|
442
510
|
const stateOptions = states
|
|
443
511
|
.map(
|
|
444
512
|
(s) =>
|
|
445
|
-
`<option value="${escapeHtml(s.id)}" ${s.id === ticket.state_id ? "selected" : ""}>${escapeHtml(s.name)}</option
|
|
513
|
+
`<option value="${escapeHtml(s.id)}" ${s.id === ticket.state_id ? "selected" : ""}>${escapeHtml(s.name)}</option>`,
|
|
446
514
|
)
|
|
447
515
|
.join("");
|
|
448
516
|
|
|
449
517
|
const priorityOptions = [0, 1, 2, 3, 4]
|
|
450
518
|
.map(
|
|
451
519
|
(p) =>
|
|
452
|
-
`<option value="${p}" ${Number(ticket.priority) === p ? "selected" : ""}>${escapeHtml(priorityLabel(p))}</option
|
|
520
|
+
`<option value="${p}" ${Number(ticket.priority) === p ? "selected" : ""}>${escapeHtml(priorityLabel(p))}</option>`,
|
|
453
521
|
)
|
|
454
522
|
.join("");
|
|
455
523
|
|
|
@@ -459,7 +527,7 @@ export async function renderDetail(options = {}) {
|
|
|
459
527
|
<span class="label-pill-removable" style="--label-color: ${escapeHtml(label.color)}">
|
|
460
528
|
${escapeHtml(label.name)}
|
|
461
529
|
<button type="button" class="label-pill-remove" data-remove-label="${escapeHtml(label.name)}" title="Remove label">×</button>
|
|
462
|
-
</span
|
|
530
|
+
</span>`,
|
|
463
531
|
)
|
|
464
532
|
.join("");
|
|
465
533
|
|
|
@@ -467,13 +535,17 @@ export async function renderDetail(options = {}) {
|
|
|
467
535
|
.map((l) => l.name)
|
|
468
536
|
.filter((name) => !ticket.labels?.some((tl) => tl.name === name));
|
|
469
537
|
|
|
470
|
-
const datalistOptions = catalogNames
|
|
538
|
+
const datalistOptions = catalogNames
|
|
539
|
+
.map((name) => `<option value="${escapeHtml(name)}"></option>`)
|
|
540
|
+
.join("");
|
|
471
541
|
|
|
472
542
|
const detailCanonicalType = canonicalTicketType(ticket.type);
|
|
473
543
|
const priorityKey = priorityKeyFor(ticket.priority);
|
|
474
544
|
const currentState = states.find((s) => s.id === ticket.state_id);
|
|
475
545
|
const stateClass = stateClassFor(currentState);
|
|
476
|
-
const descriptionHtml = ticket.description
|
|
546
|
+
const descriptionHtml = ticket.description
|
|
547
|
+
? renderPreservedText(ticket.description)
|
|
548
|
+
: escapeHtml("No description yet.");
|
|
477
549
|
const descriptionClass = ticket.description
|
|
478
550
|
? "description preserved-text-body editable-field"
|
|
479
551
|
: "description editable-field is-placeholder";
|
|
@@ -495,7 +567,7 @@ export async function renderDetail(options = {}) {
|
|
|
495
567
|
role: "button",
|
|
496
568
|
tabindex: "0",
|
|
497
569
|
title: "Click or press Enter to edit ticket title",
|
|
498
|
-
"aria-label": "Edit ticket title"
|
|
570
|
+
"aria-label": "Edit ticket title",
|
|
499
571
|
},
|
|
500
572
|
subtitleHtml: detailSubtitleHtml,
|
|
501
573
|
body: `
|
|
@@ -528,7 +600,9 @@ export async function renderDetail(options = {}) {
|
|
|
528
600
|
${renderRelated(context.relations, ticket)}
|
|
529
601
|
</div>
|
|
530
602
|
|
|
531
|
-
${
|
|
603
|
+
${
|
|
604
|
+
currentBoard()?.ai_enabled !== 0
|
|
605
|
+
? `
|
|
532
606
|
<details class="section ai-fields" data-ai-plan-toggle data-ticket-id="${escapeHtml(ticket.id)}"${aiPlanOpen(ticket.id) ? " open" : ""}>
|
|
533
607
|
<summary><h3>AI Plan / Implementation Record</h3></summary>
|
|
534
608
|
<div class="ai-fields-grid">
|
|
@@ -536,22 +610,24 @@ export async function renderDetail(options = {}) {
|
|
|
536
610
|
fieldName: "ai_plan",
|
|
537
611
|
label: "AI-Written Plan",
|
|
538
612
|
value: ticket.ai_plan,
|
|
539
|
-
placeholder: "Paste or let an AI write the plan..."
|
|
613
|
+
placeholder: "Paste or let an AI write the plan...",
|
|
540
614
|
})}
|
|
541
615
|
${renderInlinePreservedTextField({
|
|
542
616
|
fieldName: "implementation_summary",
|
|
543
617
|
label: "Implementation Summary",
|
|
544
618
|
value: ticket.implementation_summary,
|
|
545
|
-
placeholder: "What changed, what shipped, what remains..."
|
|
619
|
+
placeholder: "What changed, what shipped, what remains...",
|
|
546
620
|
})}
|
|
547
621
|
${renderInlinePreservedTextField({
|
|
548
622
|
fieldName: "implementation_updates",
|
|
549
623
|
label: "Implementation Updates / Lessons",
|
|
550
624
|
value: ticket.implementation_updates,
|
|
551
|
-
placeholder: "Progress notes, mistakes to avoid, discoveries..."
|
|
625
|
+
placeholder: "Progress notes, mistakes to avoid, discoveries...",
|
|
552
626
|
})}
|
|
553
627
|
</div>
|
|
554
|
-
</details>`
|
|
628
|
+
</details>`
|
|
629
|
+
: ""
|
|
630
|
+
}
|
|
555
631
|
|
|
556
632
|
<div class="section">
|
|
557
633
|
<h3>Status History</h3>
|
|
@@ -566,7 +642,7 @@ export async function renderDetail(options = {}) {
|
|
|
566
642
|
<button type="submit">Comment</button>
|
|
567
643
|
</form>
|
|
568
644
|
</div>
|
|
569
|
-
|
|
645
|
+
`,
|
|
570
646
|
});
|
|
571
647
|
|
|
572
648
|
wireTicketDetailEditors(ticket);
|
|
@@ -574,22 +650,29 @@ export async function renderDetail(options = {}) {
|
|
|
574
650
|
|
|
575
651
|
$("#detailCommentForm").addEventListener("submit", async (event) => {
|
|
576
652
|
event.preventDefault();
|
|
577
|
-
const body = cleanText(
|
|
653
|
+
const body = cleanText(
|
|
654
|
+
new FormData(event.currentTarget).get("body"),
|
|
655
|
+
).trim();
|
|
578
656
|
if (!body) return;
|
|
579
657
|
await api(withBoardQuery(`/api/tickets/${ticket.id}/comments`), {
|
|
580
658
|
method: "POST",
|
|
581
|
-
body: { body }
|
|
659
|
+
body: { body },
|
|
582
660
|
});
|
|
583
661
|
await refreshTicketDetail(ticket.id);
|
|
584
662
|
});
|
|
585
663
|
|
|
586
664
|
drawerInner.querySelectorAll("[data-open-ticket]").forEach((card) => {
|
|
587
665
|
card.addEventListener("click", async (event) => {
|
|
588
|
-
if (
|
|
666
|
+
if (
|
|
667
|
+
event.target.closest(
|
|
668
|
+
"[data-detach-child],[data-detach-parent],[data-detach-relation]",
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
return;
|
|
589
672
|
navigate({
|
|
590
673
|
boardId: state.boardId,
|
|
591
674
|
view: "ticket",
|
|
592
|
-
ticketId: card.dataset.openTicket
|
|
675
|
+
ticketId: card.dataset.openTicket,
|
|
593
676
|
});
|
|
594
677
|
});
|
|
595
678
|
});
|
|
@@ -601,7 +684,7 @@ export async function renderDetail(options = {}) {
|
|
|
601
684
|
try {
|
|
602
685
|
await api(withBoardQuery(`/api/tickets/${childId}`), {
|
|
603
686
|
method: "PATCH",
|
|
604
|
-
body: { parent_ticket_id: null }
|
|
687
|
+
body: { parent_ticket_id: null },
|
|
605
688
|
});
|
|
606
689
|
} catch (err) {
|
|
607
690
|
toast.error(err.message || "Failed to remove child");
|
|
@@ -618,7 +701,7 @@ export async function renderDetail(options = {}) {
|
|
|
618
701
|
try {
|
|
619
702
|
await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
|
|
620
703
|
method: "PATCH",
|
|
621
|
-
body: { parent_ticket_id: null }
|
|
704
|
+
body: { parent_ticket_id: null },
|
|
622
705
|
});
|
|
623
706
|
} catch (err) {
|
|
624
707
|
toast.error(err.message || "Failed to remove parent");
|
|
@@ -634,7 +717,9 @@ export async function renderDetail(options = {}) {
|
|
|
634
717
|
event.stopPropagation();
|
|
635
718
|
const relationId = btn.dataset.detachRelation;
|
|
636
719
|
try {
|
|
637
|
-
await api(withBoardQuery(`/api/relations/${relationId}`), {
|
|
720
|
+
await api(withBoardQuery(`/api/relations/${relationId}`), {
|
|
721
|
+
method: "DELETE",
|
|
722
|
+
});
|
|
638
723
|
} catch (err) {
|
|
639
724
|
toast.error(err.message || "Failed to remove relation");
|
|
640
725
|
return;
|
|
@@ -656,9 +741,14 @@ export async function renderDetail(options = {}) {
|
|
|
656
741
|
const parentEpicForm = drawerInner.querySelector("#parentEpicForm");
|
|
657
742
|
if (parentEpicForm) {
|
|
658
743
|
const parentInput = parentEpicForm.querySelector("[name=target]");
|
|
659
|
-
const parentClear = parentEpicForm.querySelector(
|
|
744
|
+
const parentClear = parentEpicForm.querySelector(
|
|
745
|
+
"[data-parent-epic-clear]",
|
|
746
|
+
);
|
|
660
747
|
const syncParentClear = () => {
|
|
661
|
-
parentEpicForm.classList.toggle(
|
|
748
|
+
parentEpicForm.classList.toggle(
|
|
749
|
+
"has-value",
|
|
750
|
+
parentInput.value.length > 0,
|
|
751
|
+
);
|
|
662
752
|
};
|
|
663
753
|
parentInput.addEventListener("input", syncParentClear);
|
|
664
754
|
syncParentClear();
|
|
@@ -669,7 +759,9 @@ export async function renderDetail(options = {}) {
|
|
|
669
759
|
});
|
|
670
760
|
parentEpicForm.addEventListener("submit", async (event) => {
|
|
671
761
|
event.preventDefault();
|
|
672
|
-
const targetLabel = String(
|
|
762
|
+
const targetLabel = String(
|
|
763
|
+
new FormData(event.currentTarget).get("target") || "",
|
|
764
|
+
).trim();
|
|
673
765
|
if (!targetLabel) return;
|
|
674
766
|
const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
|
|
675
767
|
if (!targetId) {
|
|
@@ -684,7 +776,7 @@ export async function renderDetail(options = {}) {
|
|
|
684
776
|
try {
|
|
685
777
|
await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
|
|
686
778
|
method: "PATCH",
|
|
687
|
-
body: { parent_ticket_id: targetId }
|
|
779
|
+
body: { parent_ticket_id: targetId },
|
|
688
780
|
});
|
|
689
781
|
} catch (err) {
|
|
690
782
|
toast.error(err.message || "Failed to set parent epic");
|
|
@@ -700,7 +792,10 @@ export async function renderDetail(options = {}) {
|
|
|
700
792
|
const relatedInput = relatedAddForm.querySelector("[name=target]");
|
|
701
793
|
const relatedClear = relatedAddForm.querySelector("[data-related-clear]");
|
|
702
794
|
const syncRelatedClear = () => {
|
|
703
|
-
relatedAddForm.classList.toggle(
|
|
795
|
+
relatedAddForm.classList.toggle(
|
|
796
|
+
"has-value",
|
|
797
|
+
relatedInput.value.length > 0,
|
|
798
|
+
);
|
|
704
799
|
};
|
|
705
800
|
relatedInput.addEventListener("input", syncRelatedClear);
|
|
706
801
|
syncRelatedClear();
|
|
@@ -723,7 +818,11 @@ export async function renderDetail(options = {}) {
|
|
|
723
818
|
try {
|
|
724
819
|
await api(withBoardQuery(`/api/relations`), {
|
|
725
820
|
method: "POST",
|
|
726
|
-
body: {
|
|
821
|
+
body: {
|
|
822
|
+
source_ticket_id: ticket.id,
|
|
823
|
+
target_ticket_id: targetId,
|
|
824
|
+
type: relationType,
|
|
825
|
+
},
|
|
727
826
|
});
|
|
728
827
|
} catch (err) {
|
|
729
828
|
toast.error(err.message || "Failed to add relation");
|
|
@@ -774,7 +873,10 @@ function renderAttachmentCard(ticket, attachment) {
|
|
|
774
873
|
</article>
|
|
775
874
|
`;
|
|
776
875
|
}
|
|
777
|
-
const src = withBoardQuery(
|
|
876
|
+
const src = withBoardQuery(
|
|
877
|
+
attachment.content_url ||
|
|
878
|
+
`/api/tickets/${ticket.id}/attachments/${attachment.id}/content`,
|
|
879
|
+
);
|
|
778
880
|
return `
|
|
779
881
|
<article class="attachment-card">
|
|
780
882
|
<button type="button" class="attachment-thumb-button" data-open-lightbox="${escapeHtml(src)}" data-lightbox-title="${name}">
|
|
@@ -794,7 +896,9 @@ function formatBytes(value) {
|
|
|
794
896
|
}
|
|
795
897
|
|
|
796
898
|
function imageFilesFromList(files) {
|
|
797
|
-
return [...(files || [])].filter(
|
|
899
|
+
return [...(files || [])].filter(
|
|
900
|
+
(file) => file && String(file.type || "").startsWith("image/"),
|
|
901
|
+
);
|
|
798
902
|
}
|
|
799
903
|
|
|
800
904
|
async function uploadAttachmentFiles(ticket, files) {
|
|
@@ -804,22 +908,33 @@ async function uploadAttachmentFiles(ticket, files) {
|
|
|
804
908
|
return;
|
|
805
909
|
}
|
|
806
910
|
for (const file of imageFiles) {
|
|
807
|
-
const response = await fetch(
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
911
|
+
const response = await fetch(
|
|
912
|
+
withBoardQuery(
|
|
913
|
+
`/api/tickets/${ticket.id}/attachments?filename=${encodeURIComponent(file.name || "image")}`,
|
|
914
|
+
),
|
|
915
|
+
{
|
|
916
|
+
method: "POST",
|
|
917
|
+
headers: {
|
|
918
|
+
"Content-Type": file.type || "application/octet-stream",
|
|
919
|
+
"X-File-Name": file.name || "image",
|
|
920
|
+
},
|
|
921
|
+
body: file,
|
|
812
922
|
},
|
|
813
|
-
|
|
814
|
-
});
|
|
923
|
+
);
|
|
815
924
|
if (!response.ok) {
|
|
816
925
|
let payload = {};
|
|
817
|
-
try {
|
|
926
|
+
try {
|
|
927
|
+
payload = await response.json();
|
|
928
|
+
} catch {}
|
|
818
929
|
throw new Error(payload.error || `Upload failed (${response.status})`);
|
|
819
930
|
}
|
|
820
931
|
}
|
|
821
932
|
await refreshTicketDetail(ticket.id);
|
|
822
|
-
toast.success(
|
|
933
|
+
toast.success(
|
|
934
|
+
imageFiles.length === 1
|
|
935
|
+
? "Image attached"
|
|
936
|
+
: `${imageFiles.length} images attached`,
|
|
937
|
+
);
|
|
823
938
|
}
|
|
824
939
|
|
|
825
940
|
function wireAttachmentControls(ticket) {
|
|
@@ -840,7 +955,9 @@ function wireAttachmentControls(ticket) {
|
|
|
840
955
|
event.preventDefault();
|
|
841
956
|
dropzone.classList.add("is-dragover");
|
|
842
957
|
});
|
|
843
|
-
dropzone?.addEventListener("dragleave", () =>
|
|
958
|
+
dropzone?.addEventListener("dragleave", () =>
|
|
959
|
+
dropzone.classList.remove("is-dragover"),
|
|
960
|
+
);
|
|
844
961
|
dropzone?.addEventListener("drop", async (event) => {
|
|
845
962
|
event.preventDefault();
|
|
846
963
|
dropzone.classList.remove("is-dragover");
|
|
@@ -852,7 +969,8 @@ function wireAttachmentControls(ticket) {
|
|
|
852
969
|
});
|
|
853
970
|
|
|
854
971
|
drawerInner.onpaste = async (event) => {
|
|
855
|
-
if (event.target.closest("input, textarea, [contenteditable='true']"))
|
|
972
|
+
if (event.target.closest("input, textarea, [contenteditable='true']"))
|
|
973
|
+
return;
|
|
856
974
|
const files = imageFilesFromList(event.clipboardData?.files || []);
|
|
857
975
|
if (!files.length) return;
|
|
858
976
|
event.preventDefault();
|
|
@@ -866,7 +984,12 @@ function wireAttachmentControls(ticket) {
|
|
|
866
984
|
section.querySelectorAll("[data-delete-attachment]").forEach((btn) => {
|
|
867
985
|
btn.addEventListener("click", async () => {
|
|
868
986
|
try {
|
|
869
|
-
await api(
|
|
987
|
+
await api(
|
|
988
|
+
withBoardQuery(
|
|
989
|
+
`/api/tickets/${ticket.id}/attachments/${btn.dataset.deleteAttachment}`,
|
|
990
|
+
),
|
|
991
|
+
{ method: "DELETE" },
|
|
992
|
+
);
|
|
870
993
|
await refreshTicketDetail(ticket.id);
|
|
871
994
|
toast.success("Image removed");
|
|
872
995
|
} catch (err) {
|
|
@@ -876,7 +999,12 @@ function wireAttachmentControls(ticket) {
|
|
|
876
999
|
});
|
|
877
1000
|
|
|
878
1001
|
section.querySelectorAll("[data-open-lightbox]").forEach((btn) => {
|
|
879
|
-
btn.addEventListener("click", () =>
|
|
1002
|
+
btn.addEventListener("click", () =>
|
|
1003
|
+
openAttachmentLightbox(
|
|
1004
|
+
btn.dataset.openLightbox,
|
|
1005
|
+
btn.dataset.lightboxTitle || "Image",
|
|
1006
|
+
),
|
|
1007
|
+
);
|
|
880
1008
|
});
|
|
881
1009
|
}
|
|
882
1010
|
|
|
@@ -900,7 +1028,11 @@ function openAttachmentLightbox(src, title) {
|
|
|
900
1028
|
if (event.key === "Escape") close();
|
|
901
1029
|
}
|
|
902
1030
|
overlay.addEventListener("click", (event) => {
|
|
903
|
-
if (
|
|
1031
|
+
if (
|
|
1032
|
+
event.target === overlay ||
|
|
1033
|
+
event.target.closest(".attachment-lightbox-close")
|
|
1034
|
+
)
|
|
1035
|
+
close();
|
|
904
1036
|
});
|
|
905
1037
|
document.addEventListener("keydown", onKeyDown);
|
|
906
1038
|
document.body.append(overlay);
|
|
@@ -932,8 +1064,8 @@ function renderStatusHistory(history) {
|
|
|
932
1064
|
formatActorLabel({
|
|
933
1065
|
actor: event.actor,
|
|
934
1066
|
actorType: body.actor_type || "human",
|
|
935
|
-
actorId: body.actor_id
|
|
936
|
-
})
|
|
1067
|
+
actorId: body.actor_id,
|
|
1068
|
+
}),
|
|
937
1069
|
);
|
|
938
1070
|
return `<div class="comment status-change">
|
|
939
1071
|
<div class="comment-meta">
|
|
@@ -963,10 +1095,17 @@ function renderComment(comment) {
|
|
|
963
1095
|
* a textarea on click. The wiring (event handlers + commit) lives in
|
|
964
1096
|
* wireTicketDetailEditors so this helper stays a pure template.
|
|
965
1097
|
*/
|
|
966
|
-
export function renderInlinePreservedTextField({
|
|
1098
|
+
export function renderInlinePreservedTextField({
|
|
1099
|
+
fieldName,
|
|
1100
|
+
label,
|
|
1101
|
+
value,
|
|
1102
|
+
placeholder,
|
|
1103
|
+
}) {
|
|
967
1104
|
const text = value || "";
|
|
968
1105
|
const hasValue = Boolean(text.trim());
|
|
969
|
-
const inner = hasValue
|
|
1106
|
+
const inner = hasValue
|
|
1107
|
+
? renderPreservedText(text)
|
|
1108
|
+
: escapeHtml(placeholder || "");
|
|
970
1109
|
const placeholderClass = hasValue ? "" : "is-placeholder";
|
|
971
1110
|
return `
|
|
972
1111
|
<div class="inline-md-field">
|
|
@@ -993,10 +1132,13 @@ function renderRelated(relations = [], ticket) {
|
|
|
993
1132
|
}
|
|
994
1133
|
}
|
|
995
1134
|
const candidates = ticketsForProject().filter(
|
|
996
|
-
(t) =>
|
|
1135
|
+
(t) =>
|
|
1136
|
+
t.id !== ticket?.id && !relatedIds.has(t.id) && !hierarchyIds.has(t.id),
|
|
997
1137
|
);
|
|
998
1138
|
const datalistOptions = candidates
|
|
999
|
-
.map(
|
|
1139
|
+
.map(
|
|
1140
|
+
(t) => `<option value="#${t.number} — ${escapeHtml(t.title)}"></option>`,
|
|
1141
|
+
)
|
|
1000
1142
|
.join("");
|
|
1001
1143
|
|
|
1002
1144
|
const list = relations.length
|
|
@@ -1042,9 +1184,13 @@ function renderHierarchySection(ticket, context) {
|
|
|
1042
1184
|
function renderParentEpicSection(ticket, context) {
|
|
1043
1185
|
if (canonicalTicketType(ticket.type) === "epic") return "";
|
|
1044
1186
|
|
|
1045
|
-
const epics = ticketsForProject().filter(
|
|
1187
|
+
const epics = ticketsForProject().filter(
|
|
1188
|
+
(t) => t.type === "epic" && t.id !== ticket.id,
|
|
1189
|
+
);
|
|
1046
1190
|
const datalistOptions = epics
|
|
1047
|
-
.map(
|
|
1191
|
+
.map(
|
|
1192
|
+
(t) => `<option value="#${t.number} — ${escapeHtml(t.title)}"></option>`,
|
|
1193
|
+
)
|
|
1048
1194
|
.join("");
|
|
1049
1195
|
|
|
1050
1196
|
const body = context.parent_ticket
|
|
@@ -1104,11 +1250,12 @@ export function renderDetailCard(ticket, options = {}) {
|
|
|
1104
1250
|
`;
|
|
1105
1251
|
}
|
|
1106
1252
|
const unreadN = unreadCount(ticket);
|
|
1107
|
-
const unreadDot =
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1253
|
+
const unreadDot =
|
|
1254
|
+
unreadN === 0
|
|
1255
|
+
? ""
|
|
1256
|
+
: unreadN === 1
|
|
1257
|
+
? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
|
|
1258
|
+
: `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
|
|
1112
1259
|
return `
|
|
1113
1260
|
<article class="card card--detail type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}" data-variant="${escapeHtml(canonicalType)}"${options.disableOpen ? "" : ` data-open-ticket="${escapeHtml(ticket.id)}"`}>
|
|
1114
1261
|
${unreadDot}
|
|
@@ -1135,6 +1282,6 @@ function renderRelatedCard(relation) {
|
|
|
1135
1282
|
const direction = relation.direction === "incoming" ? "from" : "to";
|
|
1136
1283
|
return renderDetailCard(relation.other_ticket, {
|
|
1137
1284
|
eyebrow: `${verb} ${direction}`,
|
|
1138
|
-
detach: { mode: "relation", id: relation.id }
|
|
1285
|
+
detach: { mode: "relation", id: relation.id },
|
|
1139
1286
|
});
|
|
1140
1287
|
}
|