beads-ui 0.1.2 → 0.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/CHANGES.md +29 -2
- package/README.md +39 -45
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +25 -127
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/index.html +8 -0
- package/app/main.js +483 -61
- package/app/protocol.js +10 -14
- package/app/protocol.md +21 -19
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +373 -184
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +34 -0
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +439 -87
- package/app/views/detail.js +364 -154
- package/app/views/epics.js +128 -76
- package/app/views/issue-dialog.js +163 -0
- package/app/views/issue-row.js +10 -11
- package/app/views/list.js +164 -93
- package/app/views/new-issue-dialog.js +345 -0
- package/app/ws.js +36 -9
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +35 -85
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +11 -3
- package/server/bd.js +0 -2
- package/server/cli/commands.js +12 -5
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +34 -5
- package/server/cli/usage.js +2 -2
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +6 -9
- package/server/ws.js +466 -227
- package/docs/quickstart.md +0 -142
package/app/views/detail.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Issue Detail view implementation (lit-html based)
|
|
2
2
|
import { html, render } from 'lit-html';
|
|
3
|
+
import { parseView } from '../router.js';
|
|
3
4
|
import { issueDisplayId } from '../utils/issue-id.js';
|
|
5
|
+
import { issueHashFor } from '../utils/issue-url.js';
|
|
4
6
|
import { renderMarkdown } from '../utils/markdown.js';
|
|
5
7
|
import { emojiForPriority } from '../utils/priority-badge.js';
|
|
6
8
|
import { priority_levels } from '../utils/priority.js';
|
|
7
9
|
import { statusLabel } from '../utils/status.js';
|
|
10
|
+
import { showToast } from '../utils/toast.js';
|
|
8
11
|
import { createTypeBadge } from '../utils/type-badge.js';
|
|
9
12
|
|
|
10
13
|
/**
|
|
@@ -21,9 +24,11 @@ import { createTypeBadge } from '../utils/type-badge.js';
|
|
|
21
24
|
* @property {string} id
|
|
22
25
|
* @property {string} [title]
|
|
23
26
|
* @property {string} [description]
|
|
27
|
+
* @property {string} [design]
|
|
24
28
|
* @property {string} [acceptance]
|
|
25
29
|
* @property {string} [notes]
|
|
26
30
|
* @property {string} [status]
|
|
31
|
+
* @property {string} [assignee]
|
|
27
32
|
* @property {number} [priority]
|
|
28
33
|
* @property {string[]} [labels]
|
|
29
34
|
* @property {Dependency[]} [dependencies]
|
|
@@ -42,15 +47,19 @@ function defaultNavigateFn(hash) {
|
|
|
42
47
|
* @param {HTMLElement} mount_element - Element to render into.
|
|
43
48
|
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
44
49
|
* @param {(hash: string) => void} [navigateFn] - Navigation function; defaults to setting location.hash.
|
|
50
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores] - Optional issue stores for live updates.
|
|
45
51
|
* @returns {{ load: (id: string) => Promise<void>, clear: () => void, destroy: () => void }} View API.
|
|
46
52
|
*/
|
|
47
53
|
export function createDetailView(
|
|
48
54
|
mount_element,
|
|
49
55
|
sendFn,
|
|
50
|
-
navigateFn = defaultNavigateFn
|
|
56
|
+
navigateFn = defaultNavigateFn,
|
|
57
|
+
issue_stores = undefined
|
|
51
58
|
) {
|
|
52
59
|
/** @type {IssueDetail | null} */
|
|
53
60
|
let current = null;
|
|
61
|
+
/** @type {string | null} */
|
|
62
|
+
let current_id = null;
|
|
54
63
|
/** @type {boolean} */
|
|
55
64
|
let pending = false;
|
|
56
65
|
/** @type {boolean} */
|
|
@@ -58,42 +67,25 @@ export function createDetailView(
|
|
|
58
67
|
/** @type {boolean} */
|
|
59
68
|
let edit_desc = false;
|
|
60
69
|
/** @type {boolean} */
|
|
70
|
+
let edit_design = false;
|
|
71
|
+
/** @type {boolean} */
|
|
72
|
+
let edit_notes = false;
|
|
73
|
+
/** @type {boolean} */
|
|
61
74
|
let edit_accept = false;
|
|
62
75
|
/** @type {boolean} */
|
|
63
76
|
let edit_assignee = false;
|
|
64
77
|
/** @type {string} */
|
|
65
78
|
let new_label_text = '';
|
|
66
79
|
|
|
67
|
-
/**
|
|
68
|
-
* Show a transient toast message.
|
|
69
|
-
* @param {string} text
|
|
70
|
-
*/
|
|
71
|
-
function showToast(text) {
|
|
72
|
-
/** @type {HTMLDivElement} */
|
|
73
|
-
const toast = document.createElement('div');
|
|
74
|
-
toast.className = 'toast';
|
|
75
|
-
toast.textContent = text;
|
|
76
|
-
toast.style.position = 'absolute';
|
|
77
|
-
toast.style.right = '12px';
|
|
78
|
-
toast.style.bottom = '12px';
|
|
79
|
-
toast.style.background = 'rgba(0,0,0,0.8)';
|
|
80
|
-
toast.style.color = '#fff';
|
|
81
|
-
toast.style.padding = '8px 10px';
|
|
82
|
-
toast.style.borderRadius = '4px';
|
|
83
|
-
toast.style.fontSize = '12px';
|
|
84
|
-
mount_element.appendChild(toast);
|
|
85
|
-
setTimeout(() => {
|
|
86
|
-
try {
|
|
87
|
-
toast.remove();
|
|
88
|
-
} catch {
|
|
89
|
-
/* ignore */
|
|
90
|
-
}
|
|
91
|
-
}, 2800);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
80
|
/** @param {string} id */
|
|
95
81
|
function issueHref(id) {
|
|
96
|
-
|
|
82
|
+
try {
|
|
83
|
+
/** @type {'issues'|'epics'|'board'} */
|
|
84
|
+
const view = parseView(window.location.hash || '');
|
|
85
|
+
return issueHashFor(view, id);
|
|
86
|
+
} catch {
|
|
87
|
+
return issueHashFor('issues', id);
|
|
88
|
+
}
|
|
97
89
|
}
|
|
98
90
|
|
|
99
91
|
/**
|
|
@@ -110,6 +102,46 @@ export function createDetailView(
|
|
|
110
102
|
);
|
|
111
103
|
}
|
|
112
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Refresh current from subscription store snapshot if available.
|
|
107
|
+
*/
|
|
108
|
+
function refreshFromStore() {
|
|
109
|
+
if (
|
|
110
|
+
!current_id ||
|
|
111
|
+
!issue_stores ||
|
|
112
|
+
typeof issue_stores.snapshotFor !== 'function'
|
|
113
|
+
) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const arr = /** @type {IssueDetail[]} */ (
|
|
117
|
+
issue_stores.snapshotFor(`detail:${current_id}`)
|
|
118
|
+
);
|
|
119
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
120
|
+
// First item is the issue for this subscription
|
|
121
|
+
const found =
|
|
122
|
+
arr.find((it) => String(it.id) === String(current_id)) || arr[0];
|
|
123
|
+
current = /** @type {IssueDetail} */ (found);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Live updates: re-render when issue stores change
|
|
128
|
+
if (issue_stores && typeof issue_stores.subscribe === 'function') {
|
|
129
|
+
issue_stores.subscribe(() => {
|
|
130
|
+
try {
|
|
131
|
+
const prev_id = current && current.id ? String(current.id) : null;
|
|
132
|
+
refreshFromStore();
|
|
133
|
+
// Only re-render when the current entity changed or when we were loading
|
|
134
|
+
if (!prev_id || (current && String(current.id) === prev_id)) {
|
|
135
|
+
doRender();
|
|
136
|
+
} else {
|
|
137
|
+
doRender();
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
113
145
|
// Handlers
|
|
114
146
|
const onTitleSpanClick = () => {
|
|
115
147
|
edit_title = true;
|
|
@@ -131,8 +163,9 @@ export function createDetailView(
|
|
|
131
163
|
if (!current || pending) {
|
|
132
164
|
return;
|
|
133
165
|
}
|
|
134
|
-
/** @type {HTMLInputElement|null} */
|
|
135
|
-
|
|
166
|
+
const input = /** @type {HTMLInputElement|null} */ (
|
|
167
|
+
mount_element.querySelector('h2 input')
|
|
168
|
+
);
|
|
136
169
|
const prev = current.title || '';
|
|
137
170
|
const next = input ? input.value : '';
|
|
138
171
|
if (next === prev) {
|
|
@@ -159,7 +192,7 @@ export function createDetailView(
|
|
|
159
192
|
current.title = prev;
|
|
160
193
|
edit_title = false;
|
|
161
194
|
doRender();
|
|
162
|
-
showToast('Failed to save title');
|
|
195
|
+
showToast('Failed to save title', 'error');
|
|
163
196
|
} finally {
|
|
164
197
|
pending = false;
|
|
165
198
|
}
|
|
@@ -191,14 +224,11 @@ export function createDetailView(
|
|
|
191
224
|
if (!current || pending) {
|
|
192
225
|
return;
|
|
193
226
|
}
|
|
194
|
-
/** @type {HTMLInputElement|null} */
|
|
195
|
-
const input = /** @type {any} */ (
|
|
227
|
+
const input = /** @type {HTMLInputElement|null} */ (
|
|
196
228
|
mount_element.querySelector('#detail-root .prop.assignee input')
|
|
197
229
|
);
|
|
198
|
-
const prev =
|
|
199
|
-
|
|
200
|
-
);
|
|
201
|
-
const next = input ? String(input.value || '') : '';
|
|
230
|
+
const prev = current?.assignee ?? '';
|
|
231
|
+
const next = input?.value ?? '';
|
|
202
232
|
if (next === prev) {
|
|
203
233
|
edit_assignee = false;
|
|
204
234
|
doRender();
|
|
@@ -220,10 +250,10 @@ export function createDetailView(
|
|
|
220
250
|
}
|
|
221
251
|
} catch {
|
|
222
252
|
// revert visually
|
|
223
|
-
|
|
253
|
+
current.assignee = prev;
|
|
224
254
|
edit_assignee = false;
|
|
225
255
|
doRender();
|
|
226
|
-
showToast('Failed to update assignee');
|
|
256
|
+
showToast('Failed to update assignee', 'error');
|
|
227
257
|
} finally {
|
|
228
258
|
pending = false;
|
|
229
259
|
}
|
|
@@ -238,8 +268,7 @@ export function createDetailView(
|
|
|
238
268
|
* @param {Event} ev
|
|
239
269
|
*/
|
|
240
270
|
const onLabelInput = (ev) => {
|
|
241
|
-
/** @type {HTMLInputElement} */
|
|
242
|
-
const el = /** @type {any} */ (ev.currentTarget);
|
|
271
|
+
const el = /** @type {HTMLInputElement} */ (ev.currentTarget);
|
|
243
272
|
new_label_text = el.value || '';
|
|
244
273
|
};
|
|
245
274
|
/**
|
|
@@ -271,7 +300,7 @@ export function createDetailView(
|
|
|
271
300
|
doRender();
|
|
272
301
|
}
|
|
273
302
|
} catch {
|
|
274
|
-
showToast('Failed to add label');
|
|
303
|
+
showToast('Failed to add label', 'error');
|
|
275
304
|
} finally {
|
|
276
305
|
pending = false;
|
|
277
306
|
}
|
|
@@ -294,7 +323,7 @@ export function createDetailView(
|
|
|
294
323
|
doRender();
|
|
295
324
|
}
|
|
296
325
|
} catch {
|
|
297
|
-
showToast('Failed to remove label');
|
|
326
|
+
showToast('Failed to remove label', 'error');
|
|
298
327
|
} finally {
|
|
299
328
|
pending = false;
|
|
300
329
|
}
|
|
@@ -307,8 +336,7 @@ export function createDetailView(
|
|
|
307
336
|
doRender();
|
|
308
337
|
return;
|
|
309
338
|
}
|
|
310
|
-
/** @type {HTMLSelectElement} */
|
|
311
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
339
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
312
340
|
const prev = current.status || 'open';
|
|
313
341
|
const next = sel.value;
|
|
314
342
|
if (next === prev) {
|
|
@@ -329,7 +357,7 @@ export function createDetailView(
|
|
|
329
357
|
} catch {
|
|
330
358
|
current.status = prev;
|
|
331
359
|
doRender();
|
|
332
|
-
showToast('Failed to update status');
|
|
360
|
+
showToast('Failed to update status', 'error');
|
|
333
361
|
} finally {
|
|
334
362
|
pending = false;
|
|
335
363
|
}
|
|
@@ -342,8 +370,7 @@ export function createDetailView(
|
|
|
342
370
|
doRender();
|
|
343
371
|
return;
|
|
344
372
|
}
|
|
345
|
-
/** @type {HTMLSelectElement} */
|
|
346
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
373
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
347
374
|
const prev = typeof current.priority === 'number' ? current.priority : 2;
|
|
348
375
|
const next = Number(sel.value);
|
|
349
376
|
if (next === prev) {
|
|
@@ -364,7 +391,7 @@ export function createDetailView(
|
|
|
364
391
|
} catch {
|
|
365
392
|
current.priority = prev;
|
|
366
393
|
doRender();
|
|
367
|
-
showToast('Failed to update priority');
|
|
394
|
+
showToast('Failed to update priority', 'error');
|
|
368
395
|
} finally {
|
|
369
396
|
pending = false;
|
|
370
397
|
}
|
|
@@ -381,10 +408,7 @@ export function createDetailView(
|
|
|
381
408
|
if (ev.key === 'Escape') {
|
|
382
409
|
edit_desc = false;
|
|
383
410
|
doRender();
|
|
384
|
-
} else if (
|
|
385
|
-
ev.key === 'Enter' &&
|
|
386
|
-
/** @type {KeyboardEvent} */ (ev).ctrlKey
|
|
387
|
-
) {
|
|
411
|
+
} else if (ev.key === 'Enter' && ev.ctrlKey) {
|
|
388
412
|
const btn = /** @type {HTMLButtonElement|null} */ (
|
|
389
413
|
mount_element.querySelector('#detail-root .editable-actions button')
|
|
390
414
|
);
|
|
@@ -397,8 +421,7 @@ export function createDetailView(
|
|
|
397
421
|
if (!current || pending) {
|
|
398
422
|
return;
|
|
399
423
|
}
|
|
400
|
-
/** @type {HTMLTextAreaElement|null} */
|
|
401
|
-
const ta = /** @type {any} */ (
|
|
424
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
402
425
|
mount_element.querySelector('#detail-root textarea')
|
|
403
426
|
);
|
|
404
427
|
const prev = current.description || '';
|
|
@@ -427,7 +450,7 @@ export function createDetailView(
|
|
|
427
450
|
current.description = prev;
|
|
428
451
|
edit_desc = false;
|
|
429
452
|
doRender();
|
|
430
|
-
showToast('Failed to save description');
|
|
453
|
+
showToast('Failed to save description', 'error');
|
|
431
454
|
} finally {
|
|
432
455
|
pending = false;
|
|
433
456
|
}
|
|
@@ -437,6 +460,148 @@ export function createDetailView(
|
|
|
437
460
|
doRender();
|
|
438
461
|
};
|
|
439
462
|
|
|
463
|
+
// Design inline edit handlers (same UX as Description)
|
|
464
|
+
const onDesignEdit = () => {
|
|
465
|
+
edit_design = true;
|
|
466
|
+
doRender();
|
|
467
|
+
try {
|
|
468
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
469
|
+
mount_element.querySelector('#detail-root .design textarea')
|
|
470
|
+
);
|
|
471
|
+
if (ta) {
|
|
472
|
+
ta.focus();
|
|
473
|
+
}
|
|
474
|
+
} catch {
|
|
475
|
+
// ignore focus errors
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
/**
|
|
479
|
+
* @param {KeyboardEvent} ev
|
|
480
|
+
*/
|
|
481
|
+
const onDesignKeydown = (ev) => {
|
|
482
|
+
if (ev.key === 'Escape') {
|
|
483
|
+
edit_design = false;
|
|
484
|
+
doRender();
|
|
485
|
+
} else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
|
|
486
|
+
const btn = /** @type {HTMLButtonElement|null} */ (
|
|
487
|
+
mount_element.querySelector(
|
|
488
|
+
'#detail-root .design .editable-actions button'
|
|
489
|
+
)
|
|
490
|
+
);
|
|
491
|
+
if (btn) {
|
|
492
|
+
btn.click();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
const onDesignSave = async () => {
|
|
497
|
+
if (!current || pending) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
501
|
+
mount_element.querySelector('#detail-root .design textarea')
|
|
502
|
+
);
|
|
503
|
+
const prev = current.design || '';
|
|
504
|
+
const next = ta ? ta.value : '';
|
|
505
|
+
if (next === prev) {
|
|
506
|
+
edit_design = false;
|
|
507
|
+
doRender();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
pending = true;
|
|
511
|
+
if (ta) {
|
|
512
|
+
ta.disabled = true;
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
const updated = await sendFn('edit-text', {
|
|
516
|
+
id: current.id,
|
|
517
|
+
field: 'design',
|
|
518
|
+
value: next
|
|
519
|
+
});
|
|
520
|
+
if (updated && typeof updated === 'object') {
|
|
521
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
522
|
+
edit_design = false;
|
|
523
|
+
doRender();
|
|
524
|
+
}
|
|
525
|
+
} catch {
|
|
526
|
+
current.design = prev;
|
|
527
|
+
edit_design = false;
|
|
528
|
+
doRender();
|
|
529
|
+
showToast('Failed to save design', 'error');
|
|
530
|
+
} finally {
|
|
531
|
+
pending = false;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
const onDesignCancel = () => {
|
|
535
|
+
edit_design = false;
|
|
536
|
+
doRender();
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// Notes inline edit handlers
|
|
540
|
+
const onNotesEdit = () => {
|
|
541
|
+
edit_notes = true;
|
|
542
|
+
doRender();
|
|
543
|
+
};
|
|
544
|
+
/**
|
|
545
|
+
* @param {KeyboardEvent} ev
|
|
546
|
+
*/
|
|
547
|
+
const onNotesKeydown = (ev) => {
|
|
548
|
+
if (ev.key === 'Escape') {
|
|
549
|
+
edit_notes = false;
|
|
550
|
+
doRender();
|
|
551
|
+
} else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
|
|
552
|
+
const btn = /** @type {HTMLButtonElement|null} */ (
|
|
553
|
+
mount_element.querySelector(
|
|
554
|
+
'#detail-root .notes .editable-actions button'
|
|
555
|
+
)
|
|
556
|
+
);
|
|
557
|
+
if (btn) {
|
|
558
|
+
btn.click();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
const onNotesSave = async () => {
|
|
563
|
+
if (!current || pending) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
567
|
+
mount_element.querySelector('#detail-root .notes textarea')
|
|
568
|
+
);
|
|
569
|
+
const prev = current.notes || '';
|
|
570
|
+
const next = ta ? ta.value : '';
|
|
571
|
+
if (next === prev) {
|
|
572
|
+
edit_notes = false;
|
|
573
|
+
doRender();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
pending = true;
|
|
577
|
+
if (ta) {
|
|
578
|
+
ta.disabled = true;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
const updated = await sendFn('edit-text', {
|
|
582
|
+
id: current.id,
|
|
583
|
+
field: 'notes',
|
|
584
|
+
value: next
|
|
585
|
+
});
|
|
586
|
+
if (updated && typeof updated === 'object') {
|
|
587
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
588
|
+
edit_notes = false;
|
|
589
|
+
doRender();
|
|
590
|
+
}
|
|
591
|
+
} catch {
|
|
592
|
+
current.notes = prev;
|
|
593
|
+
edit_notes = false;
|
|
594
|
+
doRender();
|
|
595
|
+
showToast('Failed to save notes', 'error');
|
|
596
|
+
} finally {
|
|
597
|
+
pending = false;
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
const onNotesCancel = () => {
|
|
601
|
+
edit_notes = false;
|
|
602
|
+
doRender();
|
|
603
|
+
};
|
|
604
|
+
|
|
440
605
|
const onAcceptEdit = () => {
|
|
441
606
|
edit_accept = true;
|
|
442
607
|
doRender();
|
|
@@ -448,10 +613,7 @@ export function createDetailView(
|
|
|
448
613
|
if (ev.key === 'Escape') {
|
|
449
614
|
edit_accept = false;
|
|
450
615
|
doRender();
|
|
451
|
-
} else if (
|
|
452
|
-
ev.key === 'Enter' &&
|
|
453
|
-
/** @type {KeyboardEvent} */ (ev).ctrlKey
|
|
454
|
-
) {
|
|
616
|
+
} else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
|
|
455
617
|
const btn = /** @type {HTMLButtonElement|null} */ (
|
|
456
618
|
mount_element.querySelector(
|
|
457
619
|
'#detail-root .acceptance .editable-actions button'
|
|
@@ -466,8 +628,7 @@ export function createDetailView(
|
|
|
466
628
|
if (!current || pending) {
|
|
467
629
|
return;
|
|
468
630
|
}
|
|
469
|
-
/** @type {HTMLTextAreaElement|null} */
|
|
470
|
-
const ta = /** @type {any} */ (
|
|
631
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
471
632
|
mount_element.querySelector('#detail-root .acceptance textarea')
|
|
472
633
|
);
|
|
473
634
|
const prev = current.acceptance || '';
|
|
@@ -496,7 +657,7 @@ export function createDetailView(
|
|
|
496
657
|
current.acceptance = prev;
|
|
497
658
|
edit_accept = false;
|
|
498
659
|
doRender();
|
|
499
|
-
showToast('Failed to save acceptance');
|
|
660
|
+
showToast('Failed to save acceptance', 'error');
|
|
500
661
|
} finally {
|
|
501
662
|
pending = false;
|
|
502
663
|
}
|
|
@@ -524,13 +685,10 @@ export function createDetailView(
|
|
|
524
685
|
: items.map((dep) => {
|
|
525
686
|
const did = dep.id;
|
|
526
687
|
const href = issueHref(did);
|
|
527
|
-
return html
|
|
528
|
-
|
|
688
|
+
return html`<li
|
|
689
|
+
data-href=${href}
|
|
529
690
|
@click=${() => navigateFn(href)}
|
|
530
691
|
>
|
|
531
|
-
<a href=${href} @click=${makeDepLinkClick(href)}
|
|
532
|
-
>${issueDisplayId(did)}</a
|
|
533
|
-
>
|
|
534
692
|
${createTypeBadge(dep.issue_type || '')}
|
|
535
693
|
<span class="text-truncate">${dep.title || ''}</span>
|
|
536
694
|
<button
|
|
@@ -562,14 +720,10 @@ export function createDetailView(
|
|
|
562
720
|
aria-label="Edit title"
|
|
563
721
|
.value=${issue.title || ''}
|
|
564
722
|
@keydown=${onTitleInputKeydown}
|
|
565
|
-
style="width:100%;font-size:inherit;line-height:inherit;"
|
|
566
723
|
/>
|
|
567
|
-
<button
|
|
568
|
-
<button
|
|
569
|
-
Cancel
|
|
570
|
-
</button>
|
|
724
|
+
<button @click=${onTitleSave}>Save</button>
|
|
725
|
+
<button @click=${onTitleCancel}>Cancel</button>
|
|
571
726
|
</h2>
|
|
572
|
-
<span class="mono detail-id">${issueDisplayId(issue.id)}</span>
|
|
573
727
|
</div>`
|
|
574
728
|
: html`<div class="detail-title">
|
|
575
729
|
<h2>
|
|
@@ -583,7 +737,6 @@ export function createDetailView(
|
|
|
583
737
|
>${issue.title || ''}</span
|
|
584
738
|
>
|
|
585
739
|
</h2>
|
|
586
|
-
<span class="mono detail-id">${issueDisplayId(issue.id)}</span>
|
|
587
740
|
</div>`;
|
|
588
741
|
|
|
589
742
|
const status_select = html`<select
|
|
@@ -667,7 +820,7 @@ export function createDetailView(
|
|
|
667
820
|
const accept_block = edit_accept
|
|
668
821
|
? html`<div class="acceptance">
|
|
669
822
|
${acceptance_text.trim().length > 0
|
|
670
|
-
? html`<div class="props-card__title">Acceptance</div>`
|
|
823
|
+
? html`<div class="props-card__title">Acceptance Criteria</div>`
|
|
671
824
|
: ''}
|
|
672
825
|
<textarea
|
|
673
826
|
@keydown=${onAcceptKeydown}
|
|
@@ -681,29 +834,66 @@ export function createDetailView(
|
|
|
681
834
|
</div>
|
|
682
835
|
</div>`
|
|
683
836
|
: html`<div class="acceptance">
|
|
684
|
-
${
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
837
|
+
${(() => {
|
|
838
|
+
const text = acceptance_text;
|
|
839
|
+
const has = text.trim().length > 0;
|
|
840
|
+
return html`${has
|
|
841
|
+
? html`<div class="props-card__title">Acceptance Criteria</div>`
|
|
842
|
+
: ''}
|
|
843
|
+
<div
|
|
844
|
+
class="md editable"
|
|
845
|
+
tabindex="0"
|
|
846
|
+
role="button"
|
|
847
|
+
aria-label="Edit acceptance criteria"
|
|
848
|
+
@click=${onAcceptEdit}
|
|
849
|
+
@keydown=${onAcceptEditableKeydown}
|
|
850
|
+
>
|
|
851
|
+
${has
|
|
852
|
+
? renderMarkdown(text)
|
|
853
|
+
: html`<div class="muted">Add acceptance criteria…</div>`}
|
|
854
|
+
</div>`;
|
|
855
|
+
})()}
|
|
697
856
|
</div>`;
|
|
698
857
|
|
|
699
|
-
// Notes
|
|
858
|
+
// Notes: editable in-place similar to Description
|
|
700
859
|
const notes_text = String(issue.notes || '');
|
|
701
|
-
const notes_block =
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
860
|
+
const notes_block = edit_notes
|
|
861
|
+
? html`<div class="notes">
|
|
862
|
+
${notes_text.trim().length > 0
|
|
863
|
+
? html`<div class="props-card__title">Notes</div>`
|
|
864
|
+
: ''}
|
|
865
|
+
<textarea
|
|
866
|
+
@keydown=${onNotesKeydown}
|
|
867
|
+
.value=${notes_text}
|
|
868
|
+
rows="6"
|
|
869
|
+
style="width:100%"
|
|
870
|
+
></textarea>
|
|
871
|
+
<div class="editable-actions">
|
|
872
|
+
<button @click=${onNotesSave}>Save</button>
|
|
873
|
+
<button @click=${onNotesCancel}>Cancel</button>
|
|
874
|
+
</div>
|
|
875
|
+
</div>`
|
|
876
|
+
: html`<div class="notes">
|
|
877
|
+
${(() => {
|
|
878
|
+
const text = notes_text;
|
|
879
|
+
const has = text.trim().length > 0;
|
|
880
|
+
return html`${has
|
|
881
|
+
? html`<div class="props-card__title">Notes</div>`
|
|
882
|
+
: ''}
|
|
883
|
+
<div
|
|
884
|
+
class="md editable"
|
|
885
|
+
tabindex="0"
|
|
886
|
+
role="button"
|
|
887
|
+
aria-label="Edit notes"
|
|
888
|
+
@click=${onNotesEdit}
|
|
889
|
+
@keydown=${onNotesEditableKeydown}
|
|
890
|
+
>
|
|
891
|
+
${has
|
|
892
|
+
? renderMarkdown(text)
|
|
893
|
+
: html`<div class="muted">Add notes…</div>`}
|
|
894
|
+
</div>`;
|
|
895
|
+
})()}
|
|
896
|
+
</div>`;
|
|
707
897
|
|
|
708
898
|
// Labels section
|
|
709
899
|
const labels = Array.isArray(issue.labels) ? issue.labels : [];
|
|
@@ -729,7 +919,7 @@ export function createDetailView(
|
|
|
729
919
|
<input
|
|
730
920
|
type="text"
|
|
731
921
|
aria-label="Add label"
|
|
732
|
-
placeholder="Add label
|
|
922
|
+
placeholder="Add label"
|
|
733
923
|
.value=${new_label_text}
|
|
734
924
|
@input=${onLabelInput}
|
|
735
925
|
@keydown=${onLabelKeydown}
|
|
@@ -739,12 +929,53 @@ export function createDetailView(
|
|
|
739
929
|
</div>
|
|
740
930
|
</div>`;
|
|
741
931
|
|
|
932
|
+
// Design section block
|
|
933
|
+
const design_text = String(issue.design || '');
|
|
934
|
+
const design_block = edit_design
|
|
935
|
+
? html`<div class="design">
|
|
936
|
+
${design_text.trim().length > 0
|
|
937
|
+
? html`<div class="props-card__title">Design</div>`
|
|
938
|
+
: ''}
|
|
939
|
+
<textarea
|
|
940
|
+
@keydown=${onDesignKeydown}
|
|
941
|
+
.value=${design_text}
|
|
942
|
+
rows="6"
|
|
943
|
+
style="width:100%"
|
|
944
|
+
></textarea>
|
|
945
|
+
<div class="editable-actions">
|
|
946
|
+
<button @click=${onDesignSave}>Save</button>
|
|
947
|
+
<button @click=${onDesignCancel}>Cancel</button>
|
|
948
|
+
</div>
|
|
949
|
+
</div>`
|
|
950
|
+
: html`<div class="design">
|
|
951
|
+
${(() => {
|
|
952
|
+
const text = design_text;
|
|
953
|
+
const has = text.trim().length > 0;
|
|
954
|
+
return html`${has
|
|
955
|
+
? html`<div class="props-card__title">Design</div>`
|
|
956
|
+
: ''}
|
|
957
|
+
<div
|
|
958
|
+
class="md editable"
|
|
959
|
+
tabindex="0"
|
|
960
|
+
role="button"
|
|
961
|
+
aria-label="Edit design"
|
|
962
|
+
@click=${onDesignEdit}
|
|
963
|
+
@keydown=${onDesignEditableKeydown}
|
|
964
|
+
>
|
|
965
|
+
${has
|
|
966
|
+
? renderMarkdown(text)
|
|
967
|
+
: html`<div class="muted">Add design…</div>`}
|
|
968
|
+
</div>`;
|
|
969
|
+
})()}
|
|
970
|
+
</div>`;
|
|
971
|
+
|
|
742
972
|
return html`
|
|
743
973
|
<div class="panel__body" id="detail-root">
|
|
744
974
|
<div style="position:relative">
|
|
745
975
|
<div class="detail-layout">
|
|
746
976
|
<div class="detail-main">
|
|
747
|
-
${title_zone} ${desc_block} ${
|
|
977
|
+
${title_zone} ${desc_block} ${design_block} ${notes_block}
|
|
978
|
+
${accept_block}
|
|
748
979
|
</div>
|
|
749
980
|
<div class="detail-side">
|
|
750
981
|
<div class="props-card">
|
|
@@ -773,12 +1004,7 @@ export function createDetailView(
|
|
|
773
1004
|
.value=${/** @type {any} */ (issue).assignee || ''}
|
|
774
1005
|
size=${Math.min(
|
|
775
1006
|
40,
|
|
776
|
-
Math.max(
|
|
777
|
-
12,
|
|
778
|
-
String(
|
|
779
|
-
/** @type {any} */ (issue).assignee || ''
|
|
780
|
-
).length + 3
|
|
781
|
-
)
|
|
1007
|
+
Math.max(12, (issue.assignee || '').length + 3)
|
|
782
1008
|
)}
|
|
783
1009
|
@keydown=${
|
|
784
1010
|
/** @param {KeyboardEvent} e */ (e) => {
|
|
@@ -807,9 +1033,7 @@ export function createDetailView(
|
|
|
807
1033
|
Cancel
|
|
808
1034
|
</button>`
|
|
809
1035
|
: html`${(() => {
|
|
810
|
-
const raw =
|
|
811
|
-
/** @type {any} */ (issue).assignee || ''
|
|
812
|
-
);
|
|
1036
|
+
const raw = issue.assignee || '';
|
|
813
1037
|
const has = raw.trim().length > 0;
|
|
814
1038
|
const text = has ? raw : 'Unassigned';
|
|
815
1039
|
const cls = has ? 'editable' : 'editable muted';
|
|
@@ -838,29 +1062,13 @@ export function createDetailView(
|
|
|
838
1062
|
|
|
839
1063
|
function doRender() {
|
|
840
1064
|
if (!current) {
|
|
841
|
-
renderPlaceholder('No issue selected');
|
|
1065
|
+
renderPlaceholder(current_id ? 'Loading…' : 'No issue selected');
|
|
842
1066
|
return;
|
|
843
1067
|
}
|
|
844
1068
|
render(detailTemplate(current), mount_element);
|
|
845
1069
|
// panel header removed for detail view; ID is shown inline with title
|
|
846
1070
|
}
|
|
847
1071
|
|
|
848
|
-
/**
|
|
849
|
-
* Create an anchor click handler for dependency links.
|
|
850
|
-
* @param {string} href
|
|
851
|
-
* @returns {(ev: Event) => void}
|
|
852
|
-
*/
|
|
853
|
-
function makeDepLinkClick(href) {
|
|
854
|
-
return (ev) => {
|
|
855
|
-
ev.preventDefault();
|
|
856
|
-
/** @type {Event} */
|
|
857
|
-
const e = ev;
|
|
858
|
-
// stop bubbling to the li row click
|
|
859
|
-
e.stopPropagation();
|
|
860
|
-
navigateFn(href);
|
|
861
|
-
};
|
|
862
|
-
}
|
|
863
|
-
|
|
864
1072
|
/**
|
|
865
1073
|
* Create a click handler for the remove button of a dependency row.
|
|
866
1074
|
* @param {string} did
|
|
@@ -869,9 +1077,7 @@ export function createDetailView(
|
|
|
869
1077
|
*/
|
|
870
1078
|
function makeDepRemoveClick(did, title) {
|
|
871
1079
|
return async (ev) => {
|
|
872
|
-
|
|
873
|
-
const e = ev;
|
|
874
|
-
e.stopPropagation();
|
|
1080
|
+
ev.stopPropagation();
|
|
875
1081
|
if (!current || pending) {
|
|
876
1082
|
return;
|
|
877
1083
|
}
|
|
@@ -917,10 +1123,10 @@ export function createDetailView(
|
|
|
917
1123
|
if (!current || pending) {
|
|
918
1124
|
return;
|
|
919
1125
|
}
|
|
920
|
-
/** @type {HTMLButtonElement} */
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
1126
|
+
const btn = /** @type {HTMLButtonElement} */ (ev.currentTarget);
|
|
1127
|
+
const input = /** @type {HTMLInputElement|null} */ (
|
|
1128
|
+
btn.previousElementSibling
|
|
1129
|
+
);
|
|
924
1130
|
const target = input ? input.value.trim() : '';
|
|
925
1131
|
if (!target || target === current.id) {
|
|
926
1132
|
showToast('Enter a different issue id');
|
|
@@ -961,7 +1167,7 @@ export function createDetailView(
|
|
|
961
1167
|
}
|
|
962
1168
|
}
|
|
963
1169
|
} catch {
|
|
964
|
-
showToast('Failed to add dependency');
|
|
1170
|
+
showToast('Failed to add dependency', 'error');
|
|
965
1171
|
} finally {
|
|
966
1172
|
pending = false;
|
|
967
1173
|
}
|
|
@@ -998,34 +1204,38 @@ export function createDetailView(
|
|
|
998
1204
|
}
|
|
999
1205
|
}
|
|
1000
1206
|
|
|
1207
|
+
/**
|
|
1208
|
+
* @param {KeyboardEvent} ev
|
|
1209
|
+
*/
|
|
1210
|
+
function onNotesEditableKeydown(ev) {
|
|
1211
|
+
if (ev.key === 'Enter') {
|
|
1212
|
+
onNotesEdit();
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* @param {KeyboardEvent} ev
|
|
1218
|
+
*/
|
|
1219
|
+
function onDesignEditableKeydown(ev) {
|
|
1220
|
+
if (ev.key === 'Enter') {
|
|
1221
|
+
onDesignEdit();
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1001
1225
|
return {
|
|
1002
1226
|
async load(id) {
|
|
1003
1227
|
if (!id) {
|
|
1004
1228
|
renderPlaceholder('No issue selected');
|
|
1005
1229
|
return;
|
|
1006
1230
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
}
|
|
1014
|
-
if (!result || typeof result !== 'object') {
|
|
1015
|
-
renderPlaceholder('Issue not found');
|
|
1016
|
-
return;
|
|
1017
|
-
}
|
|
1018
|
-
const issue = /** @type {IssueDetail} */ (result);
|
|
1019
|
-
// Some backends may normalize ID casing (e.g., UI-1 vs ui-1).
|
|
1020
|
-
// Treat IDs case-insensitively to avoid false negatives on deep links.
|
|
1021
|
-
if (
|
|
1022
|
-
!issue ||
|
|
1023
|
-
String(issue.id || '').toLowerCase() !== String(id || '').toLowerCase()
|
|
1024
|
-
) {
|
|
1025
|
-
renderPlaceholder('Issue not found');
|
|
1026
|
-
return;
|
|
1231
|
+
current_id = String(id);
|
|
1232
|
+
// Try from store first; show placeholder while waiting for snapshot
|
|
1233
|
+
current = null;
|
|
1234
|
+
refreshFromStore();
|
|
1235
|
+
if (!current) {
|
|
1236
|
+
renderPlaceholder('Loading…');
|
|
1027
1237
|
}
|
|
1028
|
-
current
|
|
1238
|
+
// Render from current (if available) or keep placeholder until push arrives
|
|
1029
1239
|
pending = false;
|
|
1030
1240
|
doRender();
|
|
1031
1241
|
},
|