beads-ui 0.2.0 → 0.3.1
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 +14 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +103 -0
- package/app/data/providers.js +7 -138
- package/app/data/sort.js +47 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +128 -0
- package/app/data/subscriptions-store.js +227 -0
- package/app/main.js +346 -66
- package/app/protocol.js +23 -17
- package/app/protocol.md +18 -15
- package/app/router.js +3 -0
- package/app/state.js +2 -0
- package/app/styles.css +222 -197
- package/app/utils/issue-id-renderer.js +2 -1
- package/app/utils/issue-id.js +1 -0
- package/app/utils/issue-type.js +2 -0
- package/app/utils/issue-url.js +1 -0
- package/app/utils/markdown.js +13 -198
- package/app/utils/priority-badge.js +1 -2
- package/app/utils/status-badge.js +1 -1
- package/app/utils/status.js +2 -0
- package/app/utils/toast.js +1 -1
- package/app/utils/type-badge.js +1 -3
- package/app/views/board.js +172 -148
- package/app/views/detail.js +79 -66
- package/app/views/epics.js +127 -74
- package/app/views/issue-dialog.js +9 -15
- package/app/views/issue-row.js +2 -3
- package/app/views/list.js +105 -104
- package/app/views/nav.js +1 -0
- package/app/views/new-issue-dialog.js +30 -34
- package/app/ws.js +10 -10
- 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 +34 -84
- 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 +5 -4
- package/server/app.js +2 -0
- package/server/bd.js +4 -2
- package/server/cli/commands.js +5 -2
- package/server/cli/daemon.js +19 -5
- package/server/cli/index.js +2 -2
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +2 -1
- package/server/config.js +13 -6
- package/server/db.js +3 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +224 -0
- package/server/subscriptions.js +289 -0
- package/server/validators.js +113 -0
- package/server/watcher.js +8 -8
- package/server/ws.js +457 -229
package/app/views/detail.js
CHANGED
|
@@ -28,6 +28,7 @@ import { createTypeBadge } from '../utils/type-badge.js';
|
|
|
28
28
|
* @property {string} [acceptance]
|
|
29
29
|
* @property {string} [notes]
|
|
30
30
|
* @property {string} [status]
|
|
31
|
+
* @property {string} [assignee]
|
|
31
32
|
* @property {number} [priority]
|
|
32
33
|
* @property {string[]} [labels]
|
|
33
34
|
* @property {Dependency[]} [dependencies]
|
|
@@ -43,18 +44,23 @@ function defaultNavigateFn(hash) {
|
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Create the Issue Detail view.
|
|
47
|
+
*
|
|
46
48
|
* @param {HTMLElement} mount_element - Element to render into.
|
|
47
49
|
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
48
50
|
* @param {(hash: string) => void} [navigateFn] - Navigation function; defaults to setting location.hash.
|
|
51
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores] - Optional issue stores for live updates.
|
|
49
52
|
* @returns {{ load: (id: string) => Promise<void>, clear: () => void, destroy: () => void }} View API.
|
|
50
53
|
*/
|
|
51
54
|
export function createDetailView(
|
|
52
55
|
mount_element,
|
|
53
56
|
sendFn,
|
|
54
|
-
navigateFn = defaultNavigateFn
|
|
57
|
+
navigateFn = defaultNavigateFn,
|
|
58
|
+
issue_stores = undefined
|
|
55
59
|
) {
|
|
56
60
|
/** @type {IssueDetail | null} */
|
|
57
61
|
let current = null;
|
|
62
|
+
/** @type {string | null} */
|
|
63
|
+
let current_id = null;
|
|
58
64
|
/** @type {boolean} */
|
|
59
65
|
let pending = false;
|
|
60
66
|
/** @type {boolean} */
|
|
@@ -97,6 +103,46 @@ export function createDetailView(
|
|
|
97
103
|
);
|
|
98
104
|
}
|
|
99
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Refresh current from subscription store snapshot if available.
|
|
108
|
+
*/
|
|
109
|
+
function refreshFromStore() {
|
|
110
|
+
if (
|
|
111
|
+
!current_id ||
|
|
112
|
+
!issue_stores ||
|
|
113
|
+
typeof issue_stores.snapshotFor !== 'function'
|
|
114
|
+
) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const arr = /** @type {IssueDetail[]} */ (
|
|
118
|
+
issue_stores.snapshotFor(`detail:${current_id}`)
|
|
119
|
+
);
|
|
120
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
121
|
+
// First item is the issue for this subscription
|
|
122
|
+
const found =
|
|
123
|
+
arr.find((it) => String(it.id) === String(current_id)) || arr[0];
|
|
124
|
+
current = /** @type {IssueDetail} */ (found);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Live updates: re-render when issue stores change
|
|
129
|
+
if (issue_stores && typeof issue_stores.subscribe === 'function') {
|
|
130
|
+
issue_stores.subscribe(() => {
|
|
131
|
+
try {
|
|
132
|
+
const prev_id = current && current.id ? String(current.id) : null;
|
|
133
|
+
refreshFromStore();
|
|
134
|
+
// Only re-render when the current entity changed or when we were loading
|
|
135
|
+
if (!prev_id || (current && String(current.id) === prev_id)) {
|
|
136
|
+
doRender();
|
|
137
|
+
} else {
|
|
138
|
+
doRender();
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// ignore
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
100
146
|
// Handlers
|
|
101
147
|
const onTitleSpanClick = () => {
|
|
102
148
|
edit_title = true;
|
|
@@ -118,8 +164,9 @@ export function createDetailView(
|
|
|
118
164
|
if (!current || pending) {
|
|
119
165
|
return;
|
|
120
166
|
}
|
|
121
|
-
/** @type {HTMLInputElement|null} */
|
|
122
|
-
|
|
167
|
+
const input = /** @type {HTMLInputElement|null} */ (
|
|
168
|
+
mount_element.querySelector('h2 input')
|
|
169
|
+
);
|
|
123
170
|
const prev = current.title || '';
|
|
124
171
|
const next = input ? input.value : '';
|
|
125
172
|
if (next === prev) {
|
|
@@ -178,14 +225,11 @@ export function createDetailView(
|
|
|
178
225
|
if (!current || pending) {
|
|
179
226
|
return;
|
|
180
227
|
}
|
|
181
|
-
/** @type {HTMLInputElement|null} */
|
|
182
|
-
const input = /** @type {any} */ (
|
|
228
|
+
const input = /** @type {HTMLInputElement|null} */ (
|
|
183
229
|
mount_element.querySelector('#detail-root .prop.assignee input')
|
|
184
230
|
);
|
|
185
|
-
const prev =
|
|
186
|
-
|
|
187
|
-
);
|
|
188
|
-
const next = input ? String(input.value || '') : '';
|
|
231
|
+
const prev = current?.assignee ?? '';
|
|
232
|
+
const next = input?.value ?? '';
|
|
189
233
|
if (next === prev) {
|
|
190
234
|
edit_assignee = false;
|
|
191
235
|
doRender();
|
|
@@ -207,7 +251,7 @@ export function createDetailView(
|
|
|
207
251
|
}
|
|
208
252
|
} catch {
|
|
209
253
|
// revert visually
|
|
210
|
-
|
|
254
|
+
current.assignee = prev;
|
|
211
255
|
edit_assignee = false;
|
|
212
256
|
doRender();
|
|
213
257
|
showToast('Failed to update assignee', 'error');
|
|
@@ -225,8 +269,7 @@ export function createDetailView(
|
|
|
225
269
|
* @param {Event} ev
|
|
226
270
|
*/
|
|
227
271
|
const onLabelInput = (ev) => {
|
|
228
|
-
/** @type {HTMLInputElement} */
|
|
229
|
-
const el = /** @type {any} */ (ev.currentTarget);
|
|
272
|
+
const el = /** @type {HTMLInputElement} */ (ev.currentTarget);
|
|
230
273
|
new_label_text = el.value || '';
|
|
231
274
|
};
|
|
232
275
|
/**
|
|
@@ -294,8 +337,7 @@ export function createDetailView(
|
|
|
294
337
|
doRender();
|
|
295
338
|
return;
|
|
296
339
|
}
|
|
297
|
-
/** @type {HTMLSelectElement} */
|
|
298
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
340
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
299
341
|
const prev = current.status || 'open';
|
|
300
342
|
const next = sel.value;
|
|
301
343
|
if (next === prev) {
|
|
@@ -329,8 +371,7 @@ export function createDetailView(
|
|
|
329
371
|
doRender();
|
|
330
372
|
return;
|
|
331
373
|
}
|
|
332
|
-
/** @type {HTMLSelectElement} */
|
|
333
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
374
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
334
375
|
const prev = typeof current.priority === 'number' ? current.priority : 2;
|
|
335
376
|
const next = Number(sel.value);
|
|
336
377
|
if (next === prev) {
|
|
@@ -368,10 +409,7 @@ export function createDetailView(
|
|
|
368
409
|
if (ev.key === 'Escape') {
|
|
369
410
|
edit_desc = false;
|
|
370
411
|
doRender();
|
|
371
|
-
} else if (
|
|
372
|
-
ev.key === 'Enter' &&
|
|
373
|
-
/** @type {KeyboardEvent} */ (ev).ctrlKey
|
|
374
|
-
) {
|
|
412
|
+
} else if (ev.key === 'Enter' && ev.ctrlKey) {
|
|
375
413
|
const btn = /** @type {HTMLButtonElement|null} */ (
|
|
376
414
|
mount_element.querySelector('#detail-root .editable-actions button')
|
|
377
415
|
);
|
|
@@ -384,8 +422,7 @@ export function createDetailView(
|
|
|
384
422
|
if (!current || pending) {
|
|
385
423
|
return;
|
|
386
424
|
}
|
|
387
|
-
/** @type {HTMLTextAreaElement|null} */
|
|
388
|
-
const ta = /** @type {any} */ (
|
|
425
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
389
426
|
mount_element.querySelector('#detail-root textarea')
|
|
390
427
|
);
|
|
391
428
|
const prev = current.description || '';
|
|
@@ -461,8 +498,7 @@ export function createDetailView(
|
|
|
461
498
|
if (!current || pending) {
|
|
462
499
|
return;
|
|
463
500
|
}
|
|
464
|
-
/** @type {HTMLTextAreaElement|null} */
|
|
465
|
-
const ta = /** @type {any} */ (
|
|
501
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
466
502
|
mount_element.querySelector('#detail-root .design textarea')
|
|
467
503
|
);
|
|
468
504
|
const prev = current.design || '';
|
|
@@ -528,8 +564,7 @@ export function createDetailView(
|
|
|
528
564
|
if (!current || pending) {
|
|
529
565
|
return;
|
|
530
566
|
}
|
|
531
|
-
/** @type {HTMLTextAreaElement|null} */
|
|
532
|
-
const ta = /** @type {any} */ (
|
|
567
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
533
568
|
mount_element.querySelector('#detail-root .notes textarea')
|
|
534
569
|
);
|
|
535
570
|
const prev = current.notes || '';
|
|
@@ -594,8 +629,7 @@ export function createDetailView(
|
|
|
594
629
|
if (!current || pending) {
|
|
595
630
|
return;
|
|
596
631
|
}
|
|
597
|
-
/** @type {HTMLTextAreaElement|null} */
|
|
598
|
-
const ta = /** @type {any} */ (
|
|
632
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
599
633
|
mount_element.querySelector('#detail-root .acceptance textarea')
|
|
600
634
|
);
|
|
601
635
|
const prev = current.acceptance || '';
|
|
@@ -971,12 +1005,7 @@ export function createDetailView(
|
|
|
971
1005
|
.value=${/** @type {any} */ (issue).assignee || ''}
|
|
972
1006
|
size=${Math.min(
|
|
973
1007
|
40,
|
|
974
|
-
Math.max(
|
|
975
|
-
12,
|
|
976
|
-
String(
|
|
977
|
-
/** @type {any} */ (issue).assignee || ''
|
|
978
|
-
).length + 3
|
|
979
|
-
)
|
|
1008
|
+
Math.max(12, (issue.assignee || '').length + 3)
|
|
980
1009
|
)}
|
|
981
1010
|
@keydown=${
|
|
982
1011
|
/** @param {KeyboardEvent} e */ (e) => {
|
|
@@ -1005,9 +1034,7 @@ export function createDetailView(
|
|
|
1005
1034
|
Cancel
|
|
1006
1035
|
</button>`
|
|
1007
1036
|
: html`${(() => {
|
|
1008
|
-
const raw =
|
|
1009
|
-
/** @type {any} */ (issue).assignee || ''
|
|
1010
|
-
);
|
|
1037
|
+
const raw = issue.assignee || '';
|
|
1011
1038
|
const has = raw.trim().length > 0;
|
|
1012
1039
|
const text = has ? raw : 'Unassigned';
|
|
1013
1040
|
const cls = has ? 'editable' : 'editable muted';
|
|
@@ -1036,7 +1063,7 @@ export function createDetailView(
|
|
|
1036
1063
|
|
|
1037
1064
|
function doRender() {
|
|
1038
1065
|
if (!current) {
|
|
1039
|
-
renderPlaceholder('No issue selected');
|
|
1066
|
+
renderPlaceholder(current_id ? 'Loading…' : 'No issue selected');
|
|
1040
1067
|
return;
|
|
1041
1068
|
}
|
|
1042
1069
|
render(detailTemplate(current), mount_element);
|
|
@@ -1045,15 +1072,14 @@ export function createDetailView(
|
|
|
1045
1072
|
|
|
1046
1073
|
/**
|
|
1047
1074
|
* Create a click handler for the remove button of a dependency row.
|
|
1075
|
+
*
|
|
1048
1076
|
* @param {string} did
|
|
1049
1077
|
* @param {'Dependencies'|'Dependents'} title
|
|
1050
1078
|
* @returns {(ev: Event) => Promise<void>}
|
|
1051
1079
|
*/
|
|
1052
1080
|
function makeDepRemoveClick(did, title) {
|
|
1053
1081
|
return async (ev) => {
|
|
1054
|
-
|
|
1055
|
-
const e = ev;
|
|
1056
|
-
e.stopPropagation();
|
|
1082
|
+
ev.stopPropagation();
|
|
1057
1083
|
if (!current || pending) {
|
|
1058
1084
|
return;
|
|
1059
1085
|
}
|
|
@@ -1090,6 +1116,7 @@ export function createDetailView(
|
|
|
1090
1116
|
|
|
1091
1117
|
/**
|
|
1092
1118
|
* Create a click handler for the Add button in a dependency section.
|
|
1119
|
+
*
|
|
1093
1120
|
* @param {Dependency[]} items
|
|
1094
1121
|
* @param {'Dependencies'|'Dependents'} title
|
|
1095
1122
|
* @returns {(ev: Event) => Promise<void>}
|
|
@@ -1099,10 +1126,10 @@ export function createDetailView(
|
|
|
1099
1126
|
if (!current || pending) {
|
|
1100
1127
|
return;
|
|
1101
1128
|
}
|
|
1102
|
-
/** @type {HTMLButtonElement} */
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
|
|
1129
|
+
const btn = /** @type {HTMLButtonElement} */ (ev.currentTarget);
|
|
1130
|
+
const input = /** @type {HTMLInputElement|null} */ (
|
|
1131
|
+
btn.previousElementSibling
|
|
1132
|
+
);
|
|
1106
1133
|
const target = input ? input.value.trim() : '';
|
|
1107
1134
|
if (!target || target === current.id) {
|
|
1108
1135
|
showToast('Enter a different issue id');
|
|
@@ -1204,28 +1231,14 @@ export function createDetailView(
|
|
|
1204
1231
|
renderPlaceholder('No issue selected');
|
|
1205
1232
|
return;
|
|
1206
1233
|
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
}
|
|
1214
|
-
if (!result || typeof result !== 'object') {
|
|
1215
|
-
renderPlaceholder('Issue not found');
|
|
1216
|
-
return;
|
|
1217
|
-
}
|
|
1218
|
-
const issue = /** @type {IssueDetail} */ (result);
|
|
1219
|
-
// Some backends may normalize ID casing (e.g., UI-1 vs ui-1).
|
|
1220
|
-
// Treat IDs case-insensitively to avoid false negatives on deep links.
|
|
1221
|
-
if (
|
|
1222
|
-
!issue ||
|
|
1223
|
-
String(issue.id || '').toLowerCase() !== String(id || '').toLowerCase()
|
|
1224
|
-
) {
|
|
1225
|
-
renderPlaceholder('Issue not found');
|
|
1226
|
-
return;
|
|
1234
|
+
current_id = String(id);
|
|
1235
|
+
// Try from store first; show placeholder while waiting for snapshot
|
|
1236
|
+
current = null;
|
|
1237
|
+
refreshFromStore();
|
|
1238
|
+
if (!current) {
|
|
1239
|
+
renderPlaceholder('Loading…');
|
|
1227
1240
|
}
|
|
1228
|
-
current
|
|
1241
|
+
// Render from current (if available) or keep placeholder until push arrives
|
|
1229
1242
|
pending = false;
|
|
1230
1243
|
doRender();
|
|
1231
1244
|
},
|
package/app/views/epics.js
CHANGED
|
@@ -1,28 +1,58 @@
|
|
|
1
1
|
import { html, render } from 'lit-html';
|
|
2
|
+
import { createListSelectors } from '../data/list-selectors.js';
|
|
2
3
|
import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
3
4
|
import { createIssueRowRenderer } from './issue-row.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, updated_at?:
|
|
7
|
+
* @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, created_at?: number, updated_at?: number }} IssueLite
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
* Epics view:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Epics view (push-only):
|
|
12
|
+
* - Derives epic groups from the local issues store (no RPC reads).
|
|
13
|
+
* - Subscribes to `tab:epics` for top-level membership.
|
|
14
|
+
* - On expand, subscribes to `detail:{id}` (issue-detail) for the epic.
|
|
15
|
+
* - Renders children from the epic detail's `dependents` list.
|
|
16
|
+
* - Provides inline edits via mutations; UI re-renders on push.
|
|
17
|
+
*
|
|
13
18
|
* @param {HTMLElement} mount_element
|
|
14
|
-
* @param {{
|
|
19
|
+
* @param {{ updateIssue: (input: any) => Promise<any> }} data
|
|
15
20
|
* @param {(id: string) => void} goto_issue - Navigate to issue detail.
|
|
21
|
+
* @param {{ subscribeList: (client_id: string, spec: { type: string, params?: Record<string, string|number|boolean> }) => Promise<() => Promise<void>>, selectors: { getIds: (client_id: string) => string[], count?: (client_id: string) => number } }} [subscriptions]
|
|
22
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
|
|
16
23
|
*/
|
|
17
|
-
export function createEpicsView(
|
|
24
|
+
export function createEpicsView(
|
|
25
|
+
mount_element,
|
|
26
|
+
data,
|
|
27
|
+
goto_issue,
|
|
28
|
+
subscriptions = undefined,
|
|
29
|
+
issue_stores = undefined
|
|
30
|
+
) {
|
|
18
31
|
/** @type {any[]} */
|
|
19
32
|
let groups = [];
|
|
20
33
|
/** @type {Set<string>} */
|
|
21
34
|
const expanded = new Set();
|
|
22
|
-
/** @type {Map<string, IssueLite[]>} */
|
|
23
|
-
const children = new Map();
|
|
24
35
|
/** @type {Set<string>} */
|
|
25
36
|
const loading = new Set();
|
|
37
|
+
/** @type {Map<string, () => Promise<void>>} */
|
|
38
|
+
const epic_unsubs = new Map();
|
|
39
|
+
// Centralized selection helpers
|
|
40
|
+
const selectors = issue_stores ? createListSelectors(issue_stores) : null;
|
|
41
|
+
// Live re-render on pushes: recompute groups when stores change
|
|
42
|
+
if (selectors) {
|
|
43
|
+
selectors.subscribe(() => {
|
|
44
|
+
const had_none = groups.length === 0;
|
|
45
|
+
groups = buildGroupsFromSnapshot();
|
|
46
|
+
doRender();
|
|
47
|
+
// Auto-expand first epic when transitioning from empty to non-empty
|
|
48
|
+
if (had_none && groups.length > 0) {
|
|
49
|
+
const first_id = String(groups[0].epic?.id || '');
|
|
50
|
+
if (first_id && !expanded.has(first_id)) {
|
|
51
|
+
void toggle(first_id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
26
56
|
|
|
27
57
|
// Shared row renderer used for children rows
|
|
28
58
|
const renderRow = createIssueRowRenderer({
|
|
@@ -51,7 +81,8 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
51
81
|
const epic = g.epic || {};
|
|
52
82
|
const id = String(epic.id || '');
|
|
53
83
|
const is_open = expanded.has(id);
|
|
54
|
-
|
|
84
|
+
// Compose children via selectors
|
|
85
|
+
const list = selectors ? selectors.selectEpicChildren(id) : [];
|
|
55
86
|
const is_loading = loading.has(id);
|
|
56
87
|
return html`
|
|
57
88
|
<div class="epic-group" data-epic-id=${id}>
|
|
@@ -84,7 +115,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
84
115
|
${is_loading
|
|
85
116
|
? html`<div class="muted">Loading…</div>`
|
|
86
117
|
: list.length === 0
|
|
87
|
-
? html`<div class="muted">No
|
|
118
|
+
? html`<div class="muted">No issues found</div>`
|
|
88
119
|
: html`<table class="table">
|
|
89
120
|
<colgroup>
|
|
90
121
|
<col style="width: 100px" />
|
|
@@ -121,24 +152,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
121
152
|
async function updateInline(id, patch) {
|
|
122
153
|
try {
|
|
123
154
|
await data.updateIssue({ id, ...patch });
|
|
124
|
-
//
|
|
125
|
-
const full = await data.getIssue(id);
|
|
126
|
-
/** @type {IssueLite} */
|
|
127
|
-
const lite = {
|
|
128
|
-
id: full.id,
|
|
129
|
-
title: full.title,
|
|
130
|
-
status: full.status,
|
|
131
|
-
priority: full.priority,
|
|
132
|
-
issue_type: full.issue_type,
|
|
133
|
-
assignee: full.assignee
|
|
134
|
-
};
|
|
135
|
-
// Replace in children map
|
|
136
|
-
for (const arr of children.values()) {
|
|
137
|
-
const idx = arr.findIndex((x) => x.id === id);
|
|
138
|
-
if (idx >= 0) {
|
|
139
|
-
arr[idx] = lite;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
155
|
+
// Re-render; view will update on subsequent push
|
|
142
156
|
doRender();
|
|
143
157
|
} catch {
|
|
144
158
|
// swallow; UI remains
|
|
@@ -151,70 +165,109 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
151
165
|
async function toggle(epic_id) {
|
|
152
166
|
if (!expanded.has(epic_id)) {
|
|
153
167
|
expanded.add(epic_id);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
loading.add(epic_id);
|
|
169
|
+
doRender();
|
|
170
|
+
// Subscribe to epic detail; children are rendered from `dependents`
|
|
171
|
+
if (subscriptions && typeof subscriptions.subscribeList === 'function') {
|
|
158
172
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
for (const d of deps) {
|
|
167
|
-
try {
|
|
168
|
-
const full = await data.getIssue(d.id);
|
|
169
|
-
if (full.status !== 'closed') {
|
|
170
|
-
list.push({
|
|
171
|
-
id: full.id,
|
|
172
|
-
title: full.title,
|
|
173
|
-
status: full.status,
|
|
174
|
-
priority: full.priority,
|
|
175
|
-
issue_type: full.issue_type,
|
|
176
|
-
assignee: full.assignee,
|
|
177
|
-
// include updated_at for secondary sort within same priority
|
|
178
|
-
updated_at: /** @type {any} */ (full).updated_at
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
} catch {
|
|
182
|
-
// ignore individual failures
|
|
173
|
+
// Register store first to avoid dropping the initial snapshot
|
|
174
|
+
try {
|
|
175
|
+
if (issue_stores && /** @type {any} */ (issue_stores).register) {
|
|
176
|
+
/** @type {any} */ (issue_stores).register(`detail:${epic_id}`, {
|
|
177
|
+
type: 'issue-detail',
|
|
178
|
+
params: { id: epic_id }
|
|
179
|
+
});
|
|
183
180
|
}
|
|
181
|
+
} catch {
|
|
182
|
+
// ignore
|
|
184
183
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const pb = b.priority ?? 2;
|
|
189
|
-
if (pa !== pb) {
|
|
190
|
-
return pa - pb;
|
|
191
|
-
}
|
|
192
|
-
// @ts-ignore optional updated_at if present
|
|
193
|
-
const ua = a.updated_at || '';
|
|
194
|
-
// @ts-ignore
|
|
195
|
-
const ub = b.updated_at || '';
|
|
196
|
-
return ua < ub ? 1 : ua > ub ? -1 : 0;
|
|
184
|
+
const u = await subscriptions.subscribeList(`detail:${epic_id}`, {
|
|
185
|
+
type: 'issue-detail',
|
|
186
|
+
params: { id: epic_id }
|
|
197
187
|
});
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
188
|
+
epic_unsubs.set(epic_id, u);
|
|
189
|
+
} catch {
|
|
190
|
+
// ignore subscription failures
|
|
201
191
|
}
|
|
202
192
|
}
|
|
193
|
+
// Mark as not loading after subscribe attempt; membership will stream in
|
|
194
|
+
loading.delete(epic_id);
|
|
203
195
|
} else {
|
|
204
196
|
expanded.delete(epic_id);
|
|
197
|
+
// Unsubscribe when collapsing
|
|
198
|
+
if (epic_unsubs.has(epic_id)) {
|
|
199
|
+
try {
|
|
200
|
+
const u = epic_unsubs.get(epic_id);
|
|
201
|
+
if (u) {
|
|
202
|
+
await u();
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// ignore
|
|
206
|
+
}
|
|
207
|
+
epic_unsubs.delete(epic_id);
|
|
208
|
+
try {
|
|
209
|
+
if (issue_stores && /** @type {any} */ (issue_stores).unregister) {
|
|
210
|
+
/** @type {any} */ (issue_stores).unregister(`detail:${epic_id}`);
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// ignore
|
|
214
|
+
}
|
|
215
|
+
}
|
|
205
216
|
}
|
|
206
217
|
doRender();
|
|
207
218
|
}
|
|
208
219
|
|
|
220
|
+
/** Build groups from the current `tab:epics` snapshot. */
|
|
221
|
+
function buildGroupsFromSnapshot() {
|
|
222
|
+
/** @type {IssueLite[]} */
|
|
223
|
+
const epic_entities =
|
|
224
|
+
issue_stores && issue_stores.snapshotFor
|
|
225
|
+
? /** @type {IssueLite[]} */ (
|
|
226
|
+
issue_stores.snapshotFor('tab:epics') || []
|
|
227
|
+
)
|
|
228
|
+
: [];
|
|
229
|
+
const next_groups = [];
|
|
230
|
+
for (const epic of epic_entities) {
|
|
231
|
+
const dependents = Array.isArray(/** @type {any} */ (epic).dependents)
|
|
232
|
+
? /** @type {any[]} */ (/** @type {any} */ (epic).dependents)
|
|
233
|
+
: [];
|
|
234
|
+
// Prefer explicit counters when provided by server; otherwise derive
|
|
235
|
+
const has_total = Number.isFinite(
|
|
236
|
+
/** @type {any} */ (epic).total_children
|
|
237
|
+
);
|
|
238
|
+
const has_closed = Number.isFinite(
|
|
239
|
+
/** @type {any} */ (epic).closed_children
|
|
240
|
+
);
|
|
241
|
+
const total = has_total
|
|
242
|
+
? Number(/** @type {any} */ (epic).total_children) || 0
|
|
243
|
+
: dependents.length;
|
|
244
|
+
let closed = has_closed
|
|
245
|
+
? Number(/** @type {any} */ (epic).closed_children) || 0
|
|
246
|
+
: 0;
|
|
247
|
+
if (!has_closed) {
|
|
248
|
+
for (const d of dependents) {
|
|
249
|
+
if (String(d.status || '') === 'closed') {
|
|
250
|
+
closed++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
next_groups.push({
|
|
255
|
+
epic,
|
|
256
|
+
total_children: total,
|
|
257
|
+
closed_children: closed
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return next_groups;
|
|
261
|
+
}
|
|
262
|
+
|
|
209
263
|
return {
|
|
210
264
|
async load() {
|
|
211
|
-
|
|
212
|
-
groups = Array.isArray(res) ? res : [];
|
|
265
|
+
groups = buildGroupsFromSnapshot();
|
|
213
266
|
doRender();
|
|
214
267
|
// Auto-expand first epic on screen
|
|
215
268
|
try {
|
|
216
269
|
if (groups.length > 0) {
|
|
217
|
-
const first_id = String(
|
|
270
|
+
const first_id = String(groups[0].epic?.id || '');
|
|
218
271
|
if (first_id && !expanded.has(first_id)) {
|
|
219
272
|
// This will render and load children lazily
|
|
220
273
|
await toggle(first_id);
|
|
@@ -10,14 +10,14 @@ import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Create and manage the Issue Details dialog.
|
|
13
|
+
*
|
|
13
14
|
* @param {HTMLElement} mount_element - Container to attach the <dialog> to (e.g., #detail-panel)
|
|
14
15
|
* @param {Store} store - Read-only access to app state
|
|
15
16
|
* @param {() => void} onClose - Called when dialog requests close (backdrop/esc/button)
|
|
16
17
|
* @returns {{ open: (id: string) => void, close: () => void, getMount: () => HTMLElement }}
|
|
17
18
|
*/
|
|
18
19
|
export function createIssueDialog(mount_element, store, onClose) {
|
|
19
|
-
|
|
20
|
-
const dialog = /** @type {any} */ (document.createElement('dialog'));
|
|
20
|
+
const dialog = document.createElement('dialog');
|
|
21
21
|
dialog.id = 'issue-dialog';
|
|
22
22
|
dialog.setAttribute('role', 'dialog');
|
|
23
23
|
dialog.setAttribute('aria-modal', 'true');
|
|
@@ -37,16 +37,13 @@ export function createIssueDialog(mount_element, store, onClose) {
|
|
|
37
37
|
|
|
38
38
|
mount_element.appendChild(dialog);
|
|
39
39
|
|
|
40
|
-
/** @type {HTMLElement} */
|
|
41
|
-
const body_mount = /** @type {any} */ (
|
|
40
|
+
const body_mount = /** @type {HTMLElement} */ (
|
|
42
41
|
dialog.querySelector('#issue-dialog-body')
|
|
43
42
|
);
|
|
44
|
-
/** @type {HTMLElement} */
|
|
45
|
-
const title_el = /** @type {any} */ (
|
|
43
|
+
const title_el = /** @type {HTMLElement} */ (
|
|
46
44
|
dialog.querySelector('#issue-dialog-title')
|
|
47
45
|
);
|
|
48
|
-
/** @type {HTMLButtonElement} */
|
|
49
|
-
const btn_close = /** @type {any} */ (
|
|
46
|
+
const btn_close = /** @type {HTMLButtonElement} */ (
|
|
50
47
|
dialog.querySelector('.issue-dialog__close')
|
|
51
48
|
);
|
|
52
49
|
|
|
@@ -56,7 +53,7 @@ export function createIssueDialog(mount_element, store, onClose) {
|
|
|
56
53
|
function setTitle(id) {
|
|
57
54
|
// Use copyable ID renderer but keep visible text as raw id for tests/clarity
|
|
58
55
|
title_el.replaceChildren();
|
|
59
|
-
title_el.appendChild(createIssueIdRenderer(
|
|
56
|
+
title_el.appendChild(createIssueIdRenderer(id));
|
|
60
57
|
}
|
|
61
58
|
|
|
62
59
|
// Backdrop click: when clicking the dialog itself (outside container), close
|
|
@@ -102,7 +99,7 @@ export function createIssueDialog(mount_element, store, onClose) {
|
|
|
102
99
|
function open(id) {
|
|
103
100
|
// Capture currently focused element to restore after closing
|
|
104
101
|
try {
|
|
105
|
-
const ae =
|
|
102
|
+
const ae = document.activeElement;
|
|
106
103
|
if (ae && ae instanceof HTMLElement) {
|
|
107
104
|
last_focus = ae;
|
|
108
105
|
} else {
|
|
@@ -113,11 +110,8 @@ export function createIssueDialog(mount_element, store, onClose) {
|
|
|
113
110
|
}
|
|
114
111
|
setTitle(id);
|
|
115
112
|
try {
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
typeof (/** @type {any} */ (dialog).showModal) === 'function'
|
|
119
|
-
) {
|
|
120
|
-
/** @type {any} */ (dialog).showModal();
|
|
113
|
+
if ('showModal' in dialog && typeof dialog.showModal === 'function') {
|
|
114
|
+
dialog.showModal();
|
|
121
115
|
} else {
|
|
122
116
|
dialog.setAttribute('open', '');
|
|
123
117
|
}
|
package/app/views/issue-row.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createTypeBadge } from '../utils/type-badge.js';
|
|
|
12
12
|
/**
|
|
13
13
|
* Create a reusable issue row renderer used by list and epics views.
|
|
14
14
|
* Handles inline editing for title/assignee and selects for status/priority.
|
|
15
|
+
*
|
|
15
16
|
* @param {{
|
|
16
17
|
* navigate: (id: string) => void,
|
|
17
18
|
* onUpdate: (id: string, patch: { title?: string, assignee?: string, status?: 'open'|'in_progress'|'closed', priority?: number }) => Promise<void>,
|
|
@@ -38,7 +39,6 @@ export function createIssueRowRenderer(options) {
|
|
|
38
39
|
* @param {string} [placeholder]
|
|
39
40
|
*/
|
|
40
41
|
function editableText(id, key, value, placeholder = '') {
|
|
41
|
-
/** @type {string} */
|
|
42
42
|
const k = `${id}:${key}`;
|
|
43
43
|
const is_edit = editing.has(k);
|
|
44
44
|
if (is_edit) {
|
|
@@ -111,8 +111,7 @@ export function createIssueRowRenderer(options) {
|
|
|
111
111
|
*/
|
|
112
112
|
function makeSelectChange(id, key) {
|
|
113
113
|
return async (ev) => {
|
|
114
|
-
/** @type {HTMLSelectElement} */
|
|
115
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
114
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
116
115
|
const val = sel.value || '';
|
|
117
116
|
/** @type {{ [k:string]: any }} */
|
|
118
117
|
const patch = {};
|