beads-ui 0.2.0 → 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 +5 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +5 -138
- 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/main.js +342 -66
- package/app/protocol.js +10 -14
- package/app/protocol.md +18 -15
- package/app/styles.css +222 -197
- 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 +0 -1
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +166 -144
- package/app/views/detail.js +76 -66
- package/app/views/epics.js +126 -74
- package/app/views/issue-dialog.js +8 -15
- package/app/views/issue-row.js +1 -3
- package/app/views/list.js +101 -104
- package/app/views/new-issue-dialog.js +27 -34
- package/app/ws.js +6 -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 +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 +4 -2
- package/server/bd.js +0 -2
- package/server/cli/commands.js +1 -2
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +0 -2
- package/server/cli/usage.js +1 -1
- 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 +5 -8
- package/server/ws.js +449 -230
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]
|
|
@@ -46,15 +47,19 @@ function defaultNavigateFn(hash) {
|
|
|
46
47
|
* @param {HTMLElement} mount_element - Element to render into.
|
|
47
48
|
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
48
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.
|
|
49
51
|
* @returns {{ load: (id: string) => Promise<void>, clear: () => void, destroy: () => void }} View API.
|
|
50
52
|
*/
|
|
51
53
|
export function createDetailView(
|
|
52
54
|
mount_element,
|
|
53
55
|
sendFn,
|
|
54
|
-
navigateFn = defaultNavigateFn
|
|
56
|
+
navigateFn = defaultNavigateFn,
|
|
57
|
+
issue_stores = undefined
|
|
55
58
|
) {
|
|
56
59
|
/** @type {IssueDetail | null} */
|
|
57
60
|
let current = null;
|
|
61
|
+
/** @type {string | null} */
|
|
62
|
+
let current_id = null;
|
|
58
63
|
/** @type {boolean} */
|
|
59
64
|
let pending = false;
|
|
60
65
|
/** @type {boolean} */
|
|
@@ -97,6 +102,46 @@ export function createDetailView(
|
|
|
97
102
|
);
|
|
98
103
|
}
|
|
99
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
|
+
|
|
100
145
|
// Handlers
|
|
101
146
|
const onTitleSpanClick = () => {
|
|
102
147
|
edit_title = true;
|
|
@@ -118,8 +163,9 @@ export function createDetailView(
|
|
|
118
163
|
if (!current || pending) {
|
|
119
164
|
return;
|
|
120
165
|
}
|
|
121
|
-
/** @type {HTMLInputElement|null} */
|
|
122
|
-
|
|
166
|
+
const input = /** @type {HTMLInputElement|null} */ (
|
|
167
|
+
mount_element.querySelector('h2 input')
|
|
168
|
+
);
|
|
123
169
|
const prev = current.title || '';
|
|
124
170
|
const next = input ? input.value : '';
|
|
125
171
|
if (next === prev) {
|
|
@@ -178,14 +224,11 @@ export function createDetailView(
|
|
|
178
224
|
if (!current || pending) {
|
|
179
225
|
return;
|
|
180
226
|
}
|
|
181
|
-
/** @type {HTMLInputElement|null} */
|
|
182
|
-
const input = /** @type {any} */ (
|
|
227
|
+
const input = /** @type {HTMLInputElement|null} */ (
|
|
183
228
|
mount_element.querySelector('#detail-root .prop.assignee input')
|
|
184
229
|
);
|
|
185
|
-
const prev =
|
|
186
|
-
|
|
187
|
-
);
|
|
188
|
-
const next = input ? String(input.value || '') : '';
|
|
230
|
+
const prev = current?.assignee ?? '';
|
|
231
|
+
const next = input?.value ?? '';
|
|
189
232
|
if (next === prev) {
|
|
190
233
|
edit_assignee = false;
|
|
191
234
|
doRender();
|
|
@@ -207,7 +250,7 @@ export function createDetailView(
|
|
|
207
250
|
}
|
|
208
251
|
} catch {
|
|
209
252
|
// revert visually
|
|
210
|
-
|
|
253
|
+
current.assignee = prev;
|
|
211
254
|
edit_assignee = false;
|
|
212
255
|
doRender();
|
|
213
256
|
showToast('Failed to update assignee', 'error');
|
|
@@ -225,8 +268,7 @@ export function createDetailView(
|
|
|
225
268
|
* @param {Event} ev
|
|
226
269
|
*/
|
|
227
270
|
const onLabelInput = (ev) => {
|
|
228
|
-
/** @type {HTMLInputElement} */
|
|
229
|
-
const el = /** @type {any} */ (ev.currentTarget);
|
|
271
|
+
const el = /** @type {HTMLInputElement} */ (ev.currentTarget);
|
|
230
272
|
new_label_text = el.value || '';
|
|
231
273
|
};
|
|
232
274
|
/**
|
|
@@ -294,8 +336,7 @@ export function createDetailView(
|
|
|
294
336
|
doRender();
|
|
295
337
|
return;
|
|
296
338
|
}
|
|
297
|
-
/** @type {HTMLSelectElement} */
|
|
298
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
339
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
299
340
|
const prev = current.status || 'open';
|
|
300
341
|
const next = sel.value;
|
|
301
342
|
if (next === prev) {
|
|
@@ -329,8 +370,7 @@ export function createDetailView(
|
|
|
329
370
|
doRender();
|
|
330
371
|
return;
|
|
331
372
|
}
|
|
332
|
-
/** @type {HTMLSelectElement} */
|
|
333
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
373
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
334
374
|
const prev = typeof current.priority === 'number' ? current.priority : 2;
|
|
335
375
|
const next = Number(sel.value);
|
|
336
376
|
if (next === prev) {
|
|
@@ -368,10 +408,7 @@ export function createDetailView(
|
|
|
368
408
|
if (ev.key === 'Escape') {
|
|
369
409
|
edit_desc = false;
|
|
370
410
|
doRender();
|
|
371
|
-
} else if (
|
|
372
|
-
ev.key === 'Enter' &&
|
|
373
|
-
/** @type {KeyboardEvent} */ (ev).ctrlKey
|
|
374
|
-
) {
|
|
411
|
+
} else if (ev.key === 'Enter' && ev.ctrlKey) {
|
|
375
412
|
const btn = /** @type {HTMLButtonElement|null} */ (
|
|
376
413
|
mount_element.querySelector('#detail-root .editable-actions button')
|
|
377
414
|
);
|
|
@@ -384,8 +421,7 @@ export function createDetailView(
|
|
|
384
421
|
if (!current || pending) {
|
|
385
422
|
return;
|
|
386
423
|
}
|
|
387
|
-
/** @type {HTMLTextAreaElement|null} */
|
|
388
|
-
const ta = /** @type {any} */ (
|
|
424
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
389
425
|
mount_element.querySelector('#detail-root textarea')
|
|
390
426
|
);
|
|
391
427
|
const prev = current.description || '';
|
|
@@ -461,8 +497,7 @@ export function createDetailView(
|
|
|
461
497
|
if (!current || pending) {
|
|
462
498
|
return;
|
|
463
499
|
}
|
|
464
|
-
/** @type {HTMLTextAreaElement|null} */
|
|
465
|
-
const ta = /** @type {any} */ (
|
|
500
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
466
501
|
mount_element.querySelector('#detail-root .design textarea')
|
|
467
502
|
);
|
|
468
503
|
const prev = current.design || '';
|
|
@@ -528,8 +563,7 @@ export function createDetailView(
|
|
|
528
563
|
if (!current || pending) {
|
|
529
564
|
return;
|
|
530
565
|
}
|
|
531
|
-
/** @type {HTMLTextAreaElement|null} */
|
|
532
|
-
const ta = /** @type {any} */ (
|
|
566
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
533
567
|
mount_element.querySelector('#detail-root .notes textarea')
|
|
534
568
|
);
|
|
535
569
|
const prev = current.notes || '';
|
|
@@ -594,8 +628,7 @@ export function createDetailView(
|
|
|
594
628
|
if (!current || pending) {
|
|
595
629
|
return;
|
|
596
630
|
}
|
|
597
|
-
/** @type {HTMLTextAreaElement|null} */
|
|
598
|
-
const ta = /** @type {any} */ (
|
|
631
|
+
const ta = /** @type {HTMLTextAreaElement|null} */ (
|
|
599
632
|
mount_element.querySelector('#detail-root .acceptance textarea')
|
|
600
633
|
);
|
|
601
634
|
const prev = current.acceptance || '';
|
|
@@ -971,12 +1004,7 @@ export function createDetailView(
|
|
|
971
1004
|
.value=${/** @type {any} */ (issue).assignee || ''}
|
|
972
1005
|
size=${Math.min(
|
|
973
1006
|
40,
|
|
974
|
-
Math.max(
|
|
975
|
-
12,
|
|
976
|
-
String(
|
|
977
|
-
/** @type {any} */ (issue).assignee || ''
|
|
978
|
-
).length + 3
|
|
979
|
-
)
|
|
1007
|
+
Math.max(12, (issue.assignee || '').length + 3)
|
|
980
1008
|
)}
|
|
981
1009
|
@keydown=${
|
|
982
1010
|
/** @param {KeyboardEvent} e */ (e) => {
|
|
@@ -1005,9 +1033,7 @@ export function createDetailView(
|
|
|
1005
1033
|
Cancel
|
|
1006
1034
|
</button>`
|
|
1007
1035
|
: html`${(() => {
|
|
1008
|
-
const raw =
|
|
1009
|
-
/** @type {any} */ (issue).assignee || ''
|
|
1010
|
-
);
|
|
1036
|
+
const raw = issue.assignee || '';
|
|
1011
1037
|
const has = raw.trim().length > 0;
|
|
1012
1038
|
const text = has ? raw : 'Unassigned';
|
|
1013
1039
|
const cls = has ? 'editable' : 'editable muted';
|
|
@@ -1036,7 +1062,7 @@ export function createDetailView(
|
|
|
1036
1062
|
|
|
1037
1063
|
function doRender() {
|
|
1038
1064
|
if (!current) {
|
|
1039
|
-
renderPlaceholder('No issue selected');
|
|
1065
|
+
renderPlaceholder(current_id ? 'Loading…' : 'No issue selected');
|
|
1040
1066
|
return;
|
|
1041
1067
|
}
|
|
1042
1068
|
render(detailTemplate(current), mount_element);
|
|
@@ -1051,9 +1077,7 @@ export function createDetailView(
|
|
|
1051
1077
|
*/
|
|
1052
1078
|
function makeDepRemoveClick(did, title) {
|
|
1053
1079
|
return async (ev) => {
|
|
1054
|
-
|
|
1055
|
-
const e = ev;
|
|
1056
|
-
e.stopPropagation();
|
|
1080
|
+
ev.stopPropagation();
|
|
1057
1081
|
if (!current || pending) {
|
|
1058
1082
|
return;
|
|
1059
1083
|
}
|
|
@@ -1099,10 +1123,10 @@ export function createDetailView(
|
|
|
1099
1123
|
if (!current || pending) {
|
|
1100
1124
|
return;
|
|
1101
1125
|
}
|
|
1102
|
-
/** @type {HTMLButtonElement} */
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
|
|
1126
|
+
const btn = /** @type {HTMLButtonElement} */ (ev.currentTarget);
|
|
1127
|
+
const input = /** @type {HTMLInputElement|null} */ (
|
|
1128
|
+
btn.previousElementSibling
|
|
1129
|
+
);
|
|
1106
1130
|
const target = input ? input.value.trim() : '';
|
|
1107
1131
|
if (!target || target === current.id) {
|
|
1108
1132
|
showToast('Enter a different issue id');
|
|
@@ -1204,28 +1228,14 @@ export function createDetailView(
|
|
|
1204
1228
|
renderPlaceholder('No issue selected');
|
|
1205
1229
|
return;
|
|
1206
1230
|
}
|
|
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;
|
|
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…');
|
|
1227
1237
|
}
|
|
1228
|
-
current
|
|
1238
|
+
// Render from current (if available) or keep placeholder until push arrives
|
|
1229
1239
|
pending = false;
|
|
1230
1240
|
doRender();
|
|
1231
1241
|
},
|
package/app/views/epics.js
CHANGED
|
@@ -1,28 +1,57 @@
|
|
|
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
|
|
13
17
|
* @param {HTMLElement} mount_element
|
|
14
|
-
* @param {{
|
|
18
|
+
* @param {{ updateIssue: (input: any) => Promise<any> }} data
|
|
15
19
|
* @param {(id: string) => void} goto_issue - Navigate to issue detail.
|
|
20
|
+
* @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]
|
|
21
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
|
|
16
22
|
*/
|
|
17
|
-
export function createEpicsView(
|
|
23
|
+
export function createEpicsView(
|
|
24
|
+
mount_element,
|
|
25
|
+
data,
|
|
26
|
+
goto_issue,
|
|
27
|
+
subscriptions = undefined,
|
|
28
|
+
issue_stores = undefined
|
|
29
|
+
) {
|
|
18
30
|
/** @type {any[]} */
|
|
19
31
|
let groups = [];
|
|
20
32
|
/** @type {Set<string>} */
|
|
21
33
|
const expanded = new Set();
|
|
22
|
-
/** @type {Map<string, IssueLite[]>} */
|
|
23
|
-
const children = new Map();
|
|
24
34
|
/** @type {Set<string>} */
|
|
25
35
|
const loading = new Set();
|
|
36
|
+
/** @type {Map<string, () => Promise<void>>} */
|
|
37
|
+
const epic_unsubs = new Map();
|
|
38
|
+
// Centralized selection helpers
|
|
39
|
+
const selectors = issue_stores ? createListSelectors(issue_stores) : null;
|
|
40
|
+
// Live re-render on pushes: recompute groups when stores change
|
|
41
|
+
if (selectors) {
|
|
42
|
+
selectors.subscribe(() => {
|
|
43
|
+
const had_none = groups.length === 0;
|
|
44
|
+
groups = buildGroupsFromSnapshot();
|
|
45
|
+
doRender();
|
|
46
|
+
// Auto-expand first epic when transitioning from empty to non-empty
|
|
47
|
+
if (had_none && groups.length > 0) {
|
|
48
|
+
const first_id = String(groups[0].epic?.id || '');
|
|
49
|
+
if (first_id && !expanded.has(first_id)) {
|
|
50
|
+
void toggle(first_id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
26
55
|
|
|
27
56
|
// Shared row renderer used for children rows
|
|
28
57
|
const renderRow = createIssueRowRenderer({
|
|
@@ -51,7 +80,8 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
51
80
|
const epic = g.epic || {};
|
|
52
81
|
const id = String(epic.id || '');
|
|
53
82
|
const is_open = expanded.has(id);
|
|
54
|
-
|
|
83
|
+
// Compose children via selectors
|
|
84
|
+
const list = selectors ? selectors.selectEpicChildren(id) : [];
|
|
55
85
|
const is_loading = loading.has(id);
|
|
56
86
|
return html`
|
|
57
87
|
<div class="epic-group" data-epic-id=${id}>
|
|
@@ -84,7 +114,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
84
114
|
${is_loading
|
|
85
115
|
? html`<div class="muted">Loading…</div>`
|
|
86
116
|
: list.length === 0
|
|
87
|
-
? html`<div class="muted">No
|
|
117
|
+
? html`<div class="muted">No issues found</div>`
|
|
88
118
|
: html`<table class="table">
|
|
89
119
|
<colgroup>
|
|
90
120
|
<col style="width: 100px" />
|
|
@@ -121,24 +151,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
121
151
|
async function updateInline(id, patch) {
|
|
122
152
|
try {
|
|
123
153
|
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
|
-
}
|
|
154
|
+
// Re-render; view will update on subsequent push
|
|
142
155
|
doRender();
|
|
143
156
|
} catch {
|
|
144
157
|
// swallow; UI remains
|
|
@@ -151,70 +164,109 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
151
164
|
async function toggle(epic_id) {
|
|
152
165
|
if (!expanded.has(epic_id)) {
|
|
153
166
|
expanded.add(epic_id);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
loading.add(epic_id);
|
|
168
|
+
doRender();
|
|
169
|
+
// Subscribe to epic detail; children are rendered from `dependents`
|
|
170
|
+
if (subscriptions && typeof subscriptions.subscribeList === 'function') {
|
|
158
171
|
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
|
|
172
|
+
// Register store first to avoid dropping the initial snapshot
|
|
173
|
+
try {
|
|
174
|
+
if (issue_stores && /** @type {any} */ (issue_stores).register) {
|
|
175
|
+
/** @type {any} */ (issue_stores).register(`detail:${epic_id}`, {
|
|
176
|
+
type: 'issue-detail',
|
|
177
|
+
params: { id: epic_id }
|
|
178
|
+
});
|
|
183
179
|
}
|
|
180
|
+
} catch {
|
|
181
|
+
// ignore
|
|
184
182
|
}
|
|
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;
|
|
183
|
+
const u = await subscriptions.subscribeList(`detail:${epic_id}`, {
|
|
184
|
+
type: 'issue-detail',
|
|
185
|
+
params: { id: epic_id }
|
|
197
186
|
});
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
187
|
+
epic_unsubs.set(epic_id, u);
|
|
188
|
+
} catch {
|
|
189
|
+
// ignore subscription failures
|
|
201
190
|
}
|
|
202
191
|
}
|
|
192
|
+
// Mark as not loading after subscribe attempt; membership will stream in
|
|
193
|
+
loading.delete(epic_id);
|
|
203
194
|
} else {
|
|
204
195
|
expanded.delete(epic_id);
|
|
196
|
+
// Unsubscribe when collapsing
|
|
197
|
+
if (epic_unsubs.has(epic_id)) {
|
|
198
|
+
try {
|
|
199
|
+
const u = epic_unsubs.get(epic_id);
|
|
200
|
+
if (u) {
|
|
201
|
+
await u();
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// ignore
|
|
205
|
+
}
|
|
206
|
+
epic_unsubs.delete(epic_id);
|
|
207
|
+
try {
|
|
208
|
+
if (issue_stores && /** @type {any} */ (issue_stores).unregister) {
|
|
209
|
+
/** @type {any} */ (issue_stores).unregister(`detail:${epic_id}`);
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// ignore
|
|
213
|
+
}
|
|
214
|
+
}
|
|
205
215
|
}
|
|
206
216
|
doRender();
|
|
207
217
|
}
|
|
208
218
|
|
|
219
|
+
/** Build groups from the current `tab:epics` snapshot. */
|
|
220
|
+
function buildGroupsFromSnapshot() {
|
|
221
|
+
/** @type {IssueLite[]} */
|
|
222
|
+
const epic_entities =
|
|
223
|
+
issue_stores && issue_stores.snapshotFor
|
|
224
|
+
? /** @type {IssueLite[]} */ (
|
|
225
|
+
issue_stores.snapshotFor('tab:epics') || []
|
|
226
|
+
)
|
|
227
|
+
: [];
|
|
228
|
+
const next_groups = [];
|
|
229
|
+
for (const epic of epic_entities) {
|
|
230
|
+
const dependents = Array.isArray(/** @type {any} */ (epic).dependents)
|
|
231
|
+
? /** @type {any[]} */ (/** @type {any} */ (epic).dependents)
|
|
232
|
+
: [];
|
|
233
|
+
// Prefer explicit counters when provided by server; otherwise derive
|
|
234
|
+
const has_total = Number.isFinite(
|
|
235
|
+
/** @type {any} */ (epic).total_children
|
|
236
|
+
);
|
|
237
|
+
const has_closed = Number.isFinite(
|
|
238
|
+
/** @type {any} */ (epic).closed_children
|
|
239
|
+
);
|
|
240
|
+
const total = has_total
|
|
241
|
+
? Number(/** @type {any} */ (epic).total_children) || 0
|
|
242
|
+
: dependents.length;
|
|
243
|
+
let closed = has_closed
|
|
244
|
+
? Number(/** @type {any} */ (epic).closed_children) || 0
|
|
245
|
+
: 0;
|
|
246
|
+
if (!has_closed) {
|
|
247
|
+
for (const d of dependents) {
|
|
248
|
+
if (String(d.status || '') === 'closed') {
|
|
249
|
+
closed++;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
next_groups.push({
|
|
254
|
+
epic,
|
|
255
|
+
total_children: total,
|
|
256
|
+
closed_children: closed
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return next_groups;
|
|
260
|
+
}
|
|
261
|
+
|
|
209
262
|
return {
|
|
210
263
|
async load() {
|
|
211
|
-
|
|
212
|
-
groups = Array.isArray(res) ? res : [];
|
|
264
|
+
groups = buildGroupsFromSnapshot();
|
|
213
265
|
doRender();
|
|
214
266
|
// Auto-expand first epic on screen
|
|
215
267
|
try {
|
|
216
268
|
if (groups.length > 0) {
|
|
217
|
-
const first_id = String(
|
|
269
|
+
const first_id = String(groups[0].epic?.id || '');
|
|
218
270
|
if (first_id && !expanded.has(first_id)) {
|
|
219
271
|
// This will render and load children lazily
|
|
220
272
|
await toggle(first_id);
|
|
@@ -16,8 +16,7 @@ import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
|
16
16
|
* @returns {{ open: (id: string) => void, close: () => void, getMount: () => HTMLElement }}
|
|
17
17
|
*/
|
|
18
18
|
export function createIssueDialog(mount_element, store, onClose) {
|
|
19
|
-
|
|
20
|
-
const dialog = /** @type {any} */ (document.createElement('dialog'));
|
|
19
|
+
const dialog = document.createElement('dialog');
|
|
21
20
|
dialog.id = 'issue-dialog';
|
|
22
21
|
dialog.setAttribute('role', 'dialog');
|
|
23
22
|
dialog.setAttribute('aria-modal', 'true');
|
|
@@ -37,16 +36,13 @@ export function createIssueDialog(mount_element, store, onClose) {
|
|
|
37
36
|
|
|
38
37
|
mount_element.appendChild(dialog);
|
|
39
38
|
|
|
40
|
-
/** @type {HTMLElement} */
|
|
41
|
-
const body_mount = /** @type {any} */ (
|
|
39
|
+
const body_mount = /** @type {HTMLElement} */ (
|
|
42
40
|
dialog.querySelector('#issue-dialog-body')
|
|
43
41
|
);
|
|
44
|
-
/** @type {HTMLElement} */
|
|
45
|
-
const title_el = /** @type {any} */ (
|
|
42
|
+
const title_el = /** @type {HTMLElement} */ (
|
|
46
43
|
dialog.querySelector('#issue-dialog-title')
|
|
47
44
|
);
|
|
48
|
-
/** @type {HTMLButtonElement} */
|
|
49
|
-
const btn_close = /** @type {any} */ (
|
|
45
|
+
const btn_close = /** @type {HTMLButtonElement} */ (
|
|
50
46
|
dialog.querySelector('.issue-dialog__close')
|
|
51
47
|
);
|
|
52
48
|
|
|
@@ -56,7 +52,7 @@ export function createIssueDialog(mount_element, store, onClose) {
|
|
|
56
52
|
function setTitle(id) {
|
|
57
53
|
// Use copyable ID renderer but keep visible text as raw id for tests/clarity
|
|
58
54
|
title_el.replaceChildren();
|
|
59
|
-
title_el.appendChild(createIssueIdRenderer(
|
|
55
|
+
title_el.appendChild(createIssueIdRenderer(id));
|
|
60
56
|
}
|
|
61
57
|
|
|
62
58
|
// Backdrop click: when clicking the dialog itself (outside container), close
|
|
@@ -102,7 +98,7 @@ export function createIssueDialog(mount_element, store, onClose) {
|
|
|
102
98
|
function open(id) {
|
|
103
99
|
// Capture currently focused element to restore after closing
|
|
104
100
|
try {
|
|
105
|
-
const ae =
|
|
101
|
+
const ae = document.activeElement;
|
|
106
102
|
if (ae && ae instanceof HTMLElement) {
|
|
107
103
|
last_focus = ae;
|
|
108
104
|
} else {
|
|
@@ -113,11 +109,8 @@ export function createIssueDialog(mount_element, store, onClose) {
|
|
|
113
109
|
}
|
|
114
110
|
setTitle(id);
|
|
115
111
|
try {
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
typeof (/** @type {any} */ (dialog).showModal) === 'function'
|
|
119
|
-
) {
|
|
120
|
-
/** @type {any} */ (dialog).showModal();
|
|
112
|
+
if ('showModal' in dialog && typeof dialog.showModal === 'function') {
|
|
113
|
+
dialog.showModal();
|
|
121
114
|
} else {
|
|
122
115
|
dialog.setAttribute('open', '');
|
|
123
116
|
}
|
package/app/views/issue-row.js
CHANGED
|
@@ -38,7 +38,6 @@ export function createIssueRowRenderer(options) {
|
|
|
38
38
|
* @param {string} [placeholder]
|
|
39
39
|
*/
|
|
40
40
|
function editableText(id, key, value, placeholder = '') {
|
|
41
|
-
/** @type {string} */
|
|
42
41
|
const k = `${id}:${key}`;
|
|
43
42
|
const is_edit = editing.has(k);
|
|
44
43
|
if (is_edit) {
|
|
@@ -111,8 +110,7 @@ export function createIssueRowRenderer(options) {
|
|
|
111
110
|
*/
|
|
112
111
|
function makeSelectChange(id, key) {
|
|
113
112
|
return async (ev) => {
|
|
114
|
-
/** @type {HTMLSelectElement} */
|
|
115
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
113
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
116
114
|
const val = sel.value || '';
|
|
117
115
|
/** @type {{ [k:string]: any }} */
|
|
118
116
|
const patch = {};
|