beads-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.beads/issues.jsonl +107 -0
- package/.editorconfig +10 -0
- package/.eslintrc.json +36 -0
- package/.github/workflows/ci.yml +38 -0
- package/.prettierignore +5 -0
- package/AGENTS.md +85 -0
- package/CHANGES.md +5 -0
- package/LICENSE +22 -0
- package/README.md +75 -0
- package/app/data/providers.js +178 -0
- package/app/data/providers.test.js +126 -0
- package/app/index.html +29 -0
- package/app/main.board-switch.test.js +94 -0
- package/app/main.deep-link.test.js +64 -0
- package/app/main.js +280 -0
- package/app/main.live-updates.test.js +229 -0
- package/app/main.test.js +17 -0
- package/app/main.theme.test.js +41 -0
- package/app/main.view-sync.test.js +54 -0
- package/app/protocol.js +200 -0
- package/app/protocol.md +64 -0
- package/app/protocol.test.js +57 -0
- package/app/router.js +78 -0
- package/app/router.test.js +34 -0
- package/app/state.js +87 -0
- package/app/state.test.js +21 -0
- package/app/styles.css +1343 -0
- package/app/utils/issue-id.js +10 -0
- package/app/utils/issue-type.js +27 -0
- package/app/utils/markdown.js +201 -0
- package/app/utils/markdown.test.js +103 -0
- package/app/utils/priority-badge.js +49 -0
- package/app/utils/priority.js +1 -0
- package/app/utils/status-badge.js +33 -0
- package/app/utils/status.js +23 -0
- package/app/utils/type-badge.js +36 -0
- package/app/utils/type-badge.test.js +30 -0
- package/app/views/board.js +183 -0
- package/app/views/board.test.js +184 -0
- package/app/views/detail.acceptance-notes.test.js +67 -0
- package/app/views/detail.assignee.test.js +161 -0
- package/app/views/detail.deps.test.js +97 -0
- package/app/views/detail.edits.test.js +146 -0
- package/app/views/detail.js +1039 -0
- package/app/views/detail.labels.test.js +73 -0
- package/app/views/detail.priority.test.js +86 -0
- package/app/views/detail.test.js +188 -0
- package/app/views/detail.ui47.test.js +78 -0
- package/app/views/epics.js +228 -0
- package/app/views/epics.test.js +283 -0
- package/app/views/issue-row.js +191 -0
- package/app/views/list.inline-edits.test.js +84 -0
- package/app/views/list.js +393 -0
- package/app/views/list.test.js +479 -0
- package/app/views/nav.js +67 -0
- package/app/views/nav.test.js +43 -0
- package/app/ws.js +252 -0
- package/app/ws.test.js +168 -0
- package/bin/bdui.js +18 -0
- package/docs/architecture.md +244 -0
- package/docs/db-watching.md +29 -0
- package/docs/quickstart.md +142 -0
- package/eslint.config.js +59 -0
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/package.json +48 -0
- package/prettier.config.js +13 -0
- package/server/app.js +80 -0
- package/server/app.test.js +29 -0
- package/server/bd.js +125 -0
- package/server/bd.test.js +93 -0
- package/server/cli/cli.test.js +109 -0
- package/server/cli/commands.integration.test.js +155 -0
- package/server/cli/commands.js +91 -0
- package/server/cli/commands.unit.test.js +94 -0
- package/server/cli/daemon.js +239 -0
- package/server/cli/index.js +74 -0
- package/server/cli/open.js +96 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +22 -0
- package/server/config.js +29 -0
- package/server/db.js +100 -0
- package/server/db.test.js +70 -0
- package/server/index.js +29 -0
- package/server/protocol.js +3 -0
- package/server/protocol.test.js +87 -0
- package/server/watcher.js +107 -0
- package/server/watcher.test.js +100 -0
- package/server/ws.handlers.test.js +174 -0
- package/server/ws.js +784 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.mutations.test.js +261 -0
- package/server/ws.subscriptions.test.js +116 -0
- package/server/ws.test.js +52 -0
- package/test/setup-vitest.js +12 -0
- package/tsconfig.json +23 -0
- package/vitest.config.mjs +14 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { createDetailView } from './detail.js';
|
|
3
|
+
|
|
4
|
+
function mountDiv() {
|
|
5
|
+
const div = document.createElement('div');
|
|
6
|
+
document.body.appendChild(div);
|
|
7
|
+
return div;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('detail view labels', () => {
|
|
11
|
+
test('shows labels and allows add/remove', async () => {
|
|
12
|
+
const mount = mountDiv();
|
|
13
|
+
let current = {
|
|
14
|
+
id: 'UI-5',
|
|
15
|
+
title: 'With labels',
|
|
16
|
+
status: 'open',
|
|
17
|
+
priority: 2,
|
|
18
|
+
labels: ['frontend']
|
|
19
|
+
};
|
|
20
|
+
const sendFn = vi.fn(async (type, payload) => {
|
|
21
|
+
if (type === 'show-issue') {
|
|
22
|
+
return current;
|
|
23
|
+
}
|
|
24
|
+
if (type === 'label-add') {
|
|
25
|
+
current = { ...current, labels: [...current.labels, payload.label] };
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
if (type === 'label-remove') {
|
|
29
|
+
current = {
|
|
30
|
+
...current,
|
|
31
|
+
labels: current.labels.filter((l) => l !== payload.label)
|
|
32
|
+
};
|
|
33
|
+
return current;
|
|
34
|
+
}
|
|
35
|
+
return current;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const view = createDetailView(mount, sendFn);
|
|
39
|
+
await view.load('UI-5');
|
|
40
|
+
|
|
41
|
+
// Initial chip present
|
|
42
|
+
expect(mount.querySelectorAll('.prop.labels .badge').length).toBe(1);
|
|
43
|
+
|
|
44
|
+
// Add a label via input + Enter
|
|
45
|
+
const input = /** @type {HTMLInputElement} */ (
|
|
46
|
+
mount.querySelector('.prop.labels input')
|
|
47
|
+
);
|
|
48
|
+
input.value = 'backend';
|
|
49
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
50
|
+
input.dispatchEvent(
|
|
51
|
+
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
|
52
|
+
);
|
|
53
|
+
await Promise.resolve();
|
|
54
|
+
|
|
55
|
+
expect(sendFn).toHaveBeenCalledWith('label-add', {
|
|
56
|
+
id: 'UI-5',
|
|
57
|
+
label: 'backend'
|
|
58
|
+
});
|
|
59
|
+
expect(mount.querySelectorAll('.prop.labels .badge').length).toBe(2);
|
|
60
|
+
|
|
61
|
+
// Remove the first label by clicking the Ă— button
|
|
62
|
+
const removeBtn = /** @type {HTMLButtonElement} */ (
|
|
63
|
+
mount.querySelector('.prop.labels .badge button')
|
|
64
|
+
);
|
|
65
|
+
removeBtn.click();
|
|
66
|
+
await Promise.resolve();
|
|
67
|
+
expect(sendFn).toHaveBeenCalledWith('label-remove', {
|
|
68
|
+
id: 'UI-5',
|
|
69
|
+
label: 'frontend'
|
|
70
|
+
});
|
|
71
|
+
expect(mount.querySelectorAll('.prop.labels .badge').length).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { createDetailView } from './detail.js';
|
|
3
|
+
|
|
4
|
+
/** @type {(impl: (type: string, payload?: unknown) => Promise<any>) => (type: string, payload?: unknown) => Promise<any>} */
|
|
5
|
+
const mockSend = (impl) => vi.fn(impl);
|
|
6
|
+
|
|
7
|
+
describe('views/detail priority edit', () => {
|
|
8
|
+
test('updates priority via dropdown and re-renders from reply', async () => {
|
|
9
|
+
document.body.innerHTML =
|
|
10
|
+
'<section class="panel"><div id="mount"></div></section>';
|
|
11
|
+
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
12
|
+
|
|
13
|
+
const initial = {
|
|
14
|
+
id: 'UI-70',
|
|
15
|
+
title: 'P',
|
|
16
|
+
description: '',
|
|
17
|
+
status: 'open',
|
|
18
|
+
priority: 2
|
|
19
|
+
};
|
|
20
|
+
const send = mockSend(async (type, payload) => {
|
|
21
|
+
if (type === 'show-issue') {
|
|
22
|
+
return initial;
|
|
23
|
+
}
|
|
24
|
+
if (type === 'update-priority') {
|
|
25
|
+
expect(payload).toEqual({ id: 'UI-70', priority: 4 });
|
|
26
|
+
return { ...initial, priority: 4 };
|
|
27
|
+
}
|
|
28
|
+
throw new Error('Unexpected');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const view = createDetailView(mount, send);
|
|
32
|
+
await view.load('UI-70');
|
|
33
|
+
|
|
34
|
+
const selects = mount.querySelectorAll('select.badge--priority');
|
|
35
|
+
expect(selects.length).toBe(1);
|
|
36
|
+
const prio = /** @type {HTMLSelectElement} */ (selects[0]);
|
|
37
|
+
expect(prio.value).toBe('2');
|
|
38
|
+
|
|
39
|
+
prio.value = '4';
|
|
40
|
+
prio.dispatchEvent(new Event('change'));
|
|
41
|
+
|
|
42
|
+
await Promise.resolve();
|
|
43
|
+
|
|
44
|
+
const prio2 = /** @type {HTMLSelectElement} */ (
|
|
45
|
+
mount.querySelector('select.badge--priority')
|
|
46
|
+
);
|
|
47
|
+
expect(prio2.value).toBe('4');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('shows toast on error and restores previous value', async () => {
|
|
51
|
+
document.body.innerHTML =
|
|
52
|
+
'<section class="panel"><div id="mount"></div></section>';
|
|
53
|
+
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
54
|
+
|
|
55
|
+
const initial = { id: 'UI-71', title: 'Q', status: 'open', priority: 1 };
|
|
56
|
+
const send = mockSend(async (type) => {
|
|
57
|
+
if (type === 'show-issue') {
|
|
58
|
+
return initial;
|
|
59
|
+
}
|
|
60
|
+
if (type === 'update-priority') {
|
|
61
|
+
throw new Error('oops');
|
|
62
|
+
}
|
|
63
|
+
throw new Error('Unexpected');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const view = createDetailView(mount, send);
|
|
67
|
+
await view.load('UI-71');
|
|
68
|
+
const prio = /** @type {HTMLSelectElement} */ (
|
|
69
|
+
mount.querySelector('select.badge--priority')
|
|
70
|
+
);
|
|
71
|
+
expect(prio.value).toBe('1');
|
|
72
|
+
|
|
73
|
+
prio.value = '3';
|
|
74
|
+
prio.dispatchEvent(new Event('change'));
|
|
75
|
+
|
|
76
|
+
await Promise.resolve();
|
|
77
|
+
|
|
78
|
+
const toast = mount.querySelector('.toast');
|
|
79
|
+
expect(toast).toBeTruthy();
|
|
80
|
+
// Should restore previous value
|
|
81
|
+
const prio2 = /** @type {HTMLSelectElement} */ (
|
|
82
|
+
mount.querySelector('select.badge--priority')
|
|
83
|
+
);
|
|
84
|
+
expect(prio2.value).toBe('1');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/* global NodeListOf */
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { createDetailView } from './detail.js';
|
|
4
|
+
|
|
5
|
+
/** @type {(map: Record<string, any>) => (type: string, payload?: unknown) => Promise<any>} */
|
|
6
|
+
const stubSend = (map) => async (type, payload) => {
|
|
7
|
+
if (type !== 'show-issue') {
|
|
8
|
+
throw new Error('Unexpected type');
|
|
9
|
+
}
|
|
10
|
+
const id = /** @type {any} */ (payload).id;
|
|
11
|
+
return map[id] || null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('views/detail', () => {
|
|
15
|
+
test('renders fields, markdown description, and dependency links', async () => {
|
|
16
|
+
document.body.innerHTML =
|
|
17
|
+
'<section class="panel"><div id="mount"></div></section>';
|
|
18
|
+
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
19
|
+
|
|
20
|
+
/** @type {any} */
|
|
21
|
+
const issue = {
|
|
22
|
+
id: 'UI-29',
|
|
23
|
+
title: 'Issue detail view',
|
|
24
|
+
description:
|
|
25
|
+
'# Heading\n\nImplement detail view with a [link](https://example.com) and `code`.',
|
|
26
|
+
status: 'open',
|
|
27
|
+
priority: 2,
|
|
28
|
+
dependencies: [{ id: 'UI-25' }, { id: 'UI-27' }],
|
|
29
|
+
dependents: [{ id: 'UI-34' }]
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** @type {string[]} */
|
|
33
|
+
const navigations = [];
|
|
34
|
+
const view = createDetailView(
|
|
35
|
+
mount,
|
|
36
|
+
stubSend({ 'UI-29': issue }),
|
|
37
|
+
(hash) => {
|
|
38
|
+
navigations.push(hash);
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
await view.load('UI-29');
|
|
43
|
+
|
|
44
|
+
const idMono = /** @type {HTMLElement|null} */ (
|
|
45
|
+
mount.querySelector('.detail-title .detail-id')
|
|
46
|
+
);
|
|
47
|
+
expect(idMono && idMono.textContent).toBe('#29');
|
|
48
|
+
const titleSpan = /** @type {HTMLSpanElement} */ (
|
|
49
|
+
mount.querySelector('h2 .editable')
|
|
50
|
+
);
|
|
51
|
+
expect(titleSpan.textContent).toBe('Issue detail view');
|
|
52
|
+
// status select + priority select exist
|
|
53
|
+
const selects = mount.querySelectorAll('select');
|
|
54
|
+
expect(selects.length).toBeGreaterThanOrEqual(2);
|
|
55
|
+
// description rendered as markdown in read mode
|
|
56
|
+
const md = /** @type {HTMLDivElement} */ (mount.querySelector('.md'));
|
|
57
|
+
expect(md).toBeTruthy();
|
|
58
|
+
const a = /** @type {HTMLAnchorElement|null} */ (md.querySelector('a'));
|
|
59
|
+
expect(a && a.getAttribute('href')).toBe('https://example.com');
|
|
60
|
+
const code = md.querySelector('code');
|
|
61
|
+
expect(code && code.textContent).toBe('code');
|
|
62
|
+
|
|
63
|
+
const links = /** @type {NodeListOf<HTMLAnchorElement>} */ (
|
|
64
|
+
mount.querySelectorAll('a')
|
|
65
|
+
);
|
|
66
|
+
const hrefs = Array.from(links)
|
|
67
|
+
.map((a) => a.getAttribute('href') || '')
|
|
68
|
+
.filter((h) => h.startsWith('#/issue/'));
|
|
69
|
+
expect(hrefs).toEqual(['#/issue/UI-25', '#/issue/UI-27', '#/issue/UI-34']);
|
|
70
|
+
|
|
71
|
+
// No textarea in read mode
|
|
72
|
+
const descInput0 = /** @type {HTMLTextAreaElement|null} */ (
|
|
73
|
+
mount.querySelector('textarea')
|
|
74
|
+
);
|
|
75
|
+
expect(descInput0).toBeNull();
|
|
76
|
+
|
|
77
|
+
// Simulate clicking the first internal link, ensure navigate_fn is used
|
|
78
|
+
const firstInternal = Array.from(links).find((a) =>
|
|
79
|
+
(a.getAttribute('href') || '').startsWith('#/issue/')
|
|
80
|
+
);
|
|
81
|
+
if (!firstInternal) {
|
|
82
|
+
throw new Error('No internal link found');
|
|
83
|
+
}
|
|
84
|
+
firstInternal.click();
|
|
85
|
+
expect(navigations[navigations.length - 1]).toBe('#/issue/UI-25');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('renders type in Properties sidebar', async () => {
|
|
89
|
+
document.body.innerHTML =
|
|
90
|
+
'<section class="panel"><div id="mount"></div></section>';
|
|
91
|
+
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
92
|
+
/** @type {any} */
|
|
93
|
+
const issue = {
|
|
94
|
+
id: 'UI-50',
|
|
95
|
+
title: 'With type',
|
|
96
|
+
issue_type: 'feature',
|
|
97
|
+
dependencies: [],
|
|
98
|
+
dependents: []
|
|
99
|
+
};
|
|
100
|
+
const view = createDetailView(mount, async (type) => {
|
|
101
|
+
if (type === 'show-issue') {
|
|
102
|
+
return issue;
|
|
103
|
+
}
|
|
104
|
+
throw new Error('Unexpected');
|
|
105
|
+
});
|
|
106
|
+
await view.load('UI-50');
|
|
107
|
+
const badge = mount.querySelector('.props-card .type-badge');
|
|
108
|
+
expect(badge).toBeTruthy();
|
|
109
|
+
expect(badge && badge.textContent).toBe('Feature');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('inline editing toggles for title and description', async () => {
|
|
113
|
+
document.body.innerHTML =
|
|
114
|
+
'<section class="panel"><div id="mount"></div></section>';
|
|
115
|
+
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
116
|
+
|
|
117
|
+
/** @type {any} */
|
|
118
|
+
const issue = {
|
|
119
|
+
id: 'UI-29',
|
|
120
|
+
title: 'Issue detail view',
|
|
121
|
+
description: 'Some text',
|
|
122
|
+
status: 'open',
|
|
123
|
+
priority: 2,
|
|
124
|
+
dependencies: [],
|
|
125
|
+
dependents: []
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const view = createDetailView(mount, async (type, payload) => {
|
|
129
|
+
if (type === 'show-issue') {
|
|
130
|
+
return issue;
|
|
131
|
+
}
|
|
132
|
+
if (type === 'edit-text') {
|
|
133
|
+
const f = /** @type {any} */ (payload).field;
|
|
134
|
+
const v = /** @type {any} */ (payload).value;
|
|
135
|
+
issue[f] = v;
|
|
136
|
+
return issue;
|
|
137
|
+
}
|
|
138
|
+
throw new Error('Unexpected type');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await view.load('UI-29');
|
|
142
|
+
|
|
143
|
+
// Title: click to edit -> input appears, Esc cancels
|
|
144
|
+
const titleSpan = /** @type {HTMLSpanElement} */ (
|
|
145
|
+
mount.querySelector('h2 .editable')
|
|
146
|
+
);
|
|
147
|
+
titleSpan.click();
|
|
148
|
+
let titleInput = /** @type {HTMLInputElement} */ (
|
|
149
|
+
mount.querySelector('h2 input')
|
|
150
|
+
);
|
|
151
|
+
expect(titleInput).toBeTruthy();
|
|
152
|
+
const esc = new KeyboardEvent('keydown', { key: 'Escape' });
|
|
153
|
+
titleInput.dispatchEvent(esc);
|
|
154
|
+
expect(
|
|
155
|
+
/** @type {HTMLInputElement|null} */ (mount.querySelector('h2 input'))
|
|
156
|
+
).toBeNull();
|
|
157
|
+
|
|
158
|
+
// Description: click to edit -> textarea appears, Ctrl+Enter saves
|
|
159
|
+
const md = /** @type {HTMLDivElement} */ (mount.querySelector('.md'));
|
|
160
|
+
md.click();
|
|
161
|
+
const area = /** @type {HTMLTextAreaElement} */ (
|
|
162
|
+
mount.querySelector('textarea')
|
|
163
|
+
);
|
|
164
|
+
area.value = 'Changed';
|
|
165
|
+
const key = new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true });
|
|
166
|
+
area.dispatchEvent(key);
|
|
167
|
+
// After save, returns to read mode (allow microtask flush)
|
|
168
|
+
await Promise.resolve();
|
|
169
|
+
expect(
|
|
170
|
+
/** @type {HTMLTextAreaElement|null} */ (mount.querySelector('textarea'))
|
|
171
|
+
).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('shows placeholder when not found or bad payload', async () => {
|
|
175
|
+
document.body.innerHTML =
|
|
176
|
+
'<section class="panel"><div id="mount"></div></section>';
|
|
177
|
+
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
178
|
+
const view = createDetailView(mount, stubSend({}));
|
|
179
|
+
|
|
180
|
+
await view.load('UI-404');
|
|
181
|
+
expect((mount.textContent || '').toLowerCase()).toContain('not found');
|
|
182
|
+
|
|
183
|
+
view.clear();
|
|
184
|
+
expect((mount.textContent || '').toLowerCase()).toContain(
|
|
185
|
+
'select an issue'
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { createDetailView } from './detail.js';
|
|
3
|
+
|
|
4
|
+
describe('detail deps UI (UI-47)', () => {
|
|
5
|
+
test('renders id, type and title for dependency items', async () => {
|
|
6
|
+
document.body.innerHTML =
|
|
7
|
+
'<section class="panel"><div id="mount"></div></section>';
|
|
8
|
+
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
9
|
+
|
|
10
|
+
/** @type {any} */
|
|
11
|
+
const issue = {
|
|
12
|
+
id: 'UI-100',
|
|
13
|
+
title: 'Parent',
|
|
14
|
+
dependencies: [
|
|
15
|
+
{ id: 'UI-1', issue_type: 'feature', title: 'Alpha' },
|
|
16
|
+
{ id: 'UI-2', issue_type: 'bug', title: 'Beta' }
|
|
17
|
+
],
|
|
18
|
+
dependents: [{ id: 'UI-3', issue_type: 'task', title: 'Gamma' }]
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const view = createDetailView(mount, async (type) => {
|
|
22
|
+
if (type === 'show-issue') {
|
|
23
|
+
return issue;
|
|
24
|
+
}
|
|
25
|
+
throw new Error('Unexpected');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await view.load('UI-100');
|
|
29
|
+
|
|
30
|
+
const text = mount.textContent || '';
|
|
31
|
+
expect(text).toContain('#1');
|
|
32
|
+
expect(text).toContain('Alpha');
|
|
33
|
+
expect(text).toContain('#3');
|
|
34
|
+
expect(text).toContain('Gamma');
|
|
35
|
+
const badges = mount.querySelectorAll('ul .type-badge');
|
|
36
|
+
expect(badges.length).toBeGreaterThanOrEqual(2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('clicking a dependency row triggers navigation', async () => {
|
|
40
|
+
document.body.innerHTML =
|
|
41
|
+
'<section class="panel"><div id="mount"></div></section>';
|
|
42
|
+
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
43
|
+
const navs = /** @type {string[]} */ ([]);
|
|
44
|
+
const send = vi.fn().mockResolvedValue({
|
|
45
|
+
id: 'UI-200',
|
|
46
|
+
dependencies: [{ id: 'UI-9', issue_type: 'feature', title: 'Z' }],
|
|
47
|
+
dependents: []
|
|
48
|
+
});
|
|
49
|
+
const view = createDetailView(mount, /** @type {any} */ (send), (hash) =>
|
|
50
|
+
navs.push(hash)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
await view.load('UI-200');
|
|
54
|
+
|
|
55
|
+
const row = /** @type {HTMLLIElement} */ (mount.querySelector('ul li'));
|
|
56
|
+
row.click();
|
|
57
|
+
expect(navs[navs.length - 1]).toBe('#/issue/UI-9');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('add input is placed at the bottom of the section', async () => {
|
|
61
|
+
document.body.innerHTML =
|
|
62
|
+
'<section class="panel"><div id="mount"></div></section>';
|
|
63
|
+
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
64
|
+
const send = vi
|
|
65
|
+
.fn()
|
|
66
|
+
.mockResolvedValue({ id: 'UI-300', dependencies: [], dependents: [] });
|
|
67
|
+
const view = createDetailView(mount, /** @type {any} */ (send));
|
|
68
|
+
await view.load('UI-300');
|
|
69
|
+
|
|
70
|
+
const input = /** @type {HTMLInputElement} */ (
|
|
71
|
+
mount.querySelector('[data-testid="add-dependency"]')
|
|
72
|
+
);
|
|
73
|
+
expect(input).toBeTruthy();
|
|
74
|
+
const prev = input.parentElement?.previousElementSibling;
|
|
75
|
+
// Expect the add controls to follow the list (ul)
|
|
76
|
+
expect(prev && prev.tagName).toBe('UL');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { html, render } from 'lit-html';
|
|
2
|
+
import { issueDisplayId } from '../utils/issue-id.js';
|
|
3
|
+
import { createIssueRowRenderer } from './issue-row.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, updated_at?: string }} IssueLite
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Epics view: grouped table using `bd epic status --json`. Expanding a group loads
|
|
11
|
+
* the epic via `getIssue(id)` and then loads each dependent issue to filter out
|
|
12
|
+
* closed items. Provides inline editing for type, title, priority, status, assignee.
|
|
13
|
+
* @param {HTMLElement} mount_element
|
|
14
|
+
* @param {{ getEpicStatus: () => Promise<any[]>, getIssue: (id: string) => Promise<any>, updateIssue: (input: any) => Promise<any> }} data
|
|
15
|
+
* @param {(id: string) => void} goto_issue - Navigate to issue detail.
|
|
16
|
+
*/
|
|
17
|
+
export function createEpicsView(mount_element, data, goto_issue) {
|
|
18
|
+
/** @type {any[]} */
|
|
19
|
+
let groups = [];
|
|
20
|
+
/** @type {Set<string>} */
|
|
21
|
+
const expanded = new Set();
|
|
22
|
+
/** @type {Map<string, IssueLite[]>} */
|
|
23
|
+
const children = new Map();
|
|
24
|
+
/** @type {Set<string>} */
|
|
25
|
+
const loading = new Set();
|
|
26
|
+
|
|
27
|
+
// Shared row renderer used for children rows
|
|
28
|
+
const renderRow = createIssueRowRenderer({
|
|
29
|
+
navigate: (id) => goto_issue(id),
|
|
30
|
+
onUpdate: updateInline,
|
|
31
|
+
requestRender: doRender,
|
|
32
|
+
getSelectedId: () => null,
|
|
33
|
+
row_class: 'epic-row'
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function doRender() {
|
|
37
|
+
render(template(), mount_element);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function template() {
|
|
41
|
+
if (!groups.length) {
|
|
42
|
+
return html`<div class="panel__header muted">No epics found.</div>`;
|
|
43
|
+
}
|
|
44
|
+
return html`${groups.map((g) => groupTemplate(g))}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {any} g
|
|
49
|
+
*/
|
|
50
|
+
function groupTemplate(g) {
|
|
51
|
+
const epic = g.epic || {};
|
|
52
|
+
const id = String(epic.id || '');
|
|
53
|
+
const is_open = expanded.has(id);
|
|
54
|
+
const list = children.get(id) || [];
|
|
55
|
+
const is_loading = loading.has(id);
|
|
56
|
+
return html`
|
|
57
|
+
<div class="epic-group" data-epic-id=${id}>
|
|
58
|
+
<div
|
|
59
|
+
class="epic-header"
|
|
60
|
+
@click=${() => toggle(id)}
|
|
61
|
+
role="button"
|
|
62
|
+
tabindex="0"
|
|
63
|
+
aria-expanded=${is_open}
|
|
64
|
+
>
|
|
65
|
+
<span class="mono">${issueDisplayId(id)}</span>
|
|
66
|
+
<span class="text-truncate" style="margin-left:8px"
|
|
67
|
+
>${epic.title || '(no title)'}</span
|
|
68
|
+
>
|
|
69
|
+
<span
|
|
70
|
+
class="epic-progress"
|
|
71
|
+
style="margin-left:auto; display:flex; align-items:center; gap:8px;"
|
|
72
|
+
>
|
|
73
|
+
<progress
|
|
74
|
+
value=${Number(g.closed_children || 0)}
|
|
75
|
+
max=${Math.max(1, Number(g.total_children || 0))}
|
|
76
|
+
></progress>
|
|
77
|
+
<span class="muted mono"
|
|
78
|
+
>${g.closed_children}/${g.total_children}</span
|
|
79
|
+
>
|
|
80
|
+
</span>
|
|
81
|
+
</div>
|
|
82
|
+
${is_open
|
|
83
|
+
? html`<div class="epic-children">
|
|
84
|
+
${is_loading
|
|
85
|
+
? html`<div class="muted">Loading…</div>`
|
|
86
|
+
: list.length === 0
|
|
87
|
+
? html`<div class="muted">No open issues</div>`
|
|
88
|
+
: html`<table class="table">
|
|
89
|
+
<colgroup>
|
|
90
|
+
<col style="width: 100px" />
|
|
91
|
+
<col style="width: 120px" />
|
|
92
|
+
<col />
|
|
93
|
+
<col style="width: 120px" />
|
|
94
|
+
<col style="width: 160px" />
|
|
95
|
+
<col style="width: 130px" />
|
|
96
|
+
</colgroup>
|
|
97
|
+
<thead>
|
|
98
|
+
<tr>
|
|
99
|
+
<th>ID</th>
|
|
100
|
+
<th>Type</th>
|
|
101
|
+
<th>Title</th>
|
|
102
|
+
<th>Status</th>
|
|
103
|
+
<th>Assignee</th>
|
|
104
|
+
<th>Priority</th>
|
|
105
|
+
</tr>
|
|
106
|
+
</thead>
|
|
107
|
+
<tbody>
|
|
108
|
+
${list.map((it) => renderRow(it))}
|
|
109
|
+
</tbody>
|
|
110
|
+
</table>`}
|
|
111
|
+
</div>`
|
|
112
|
+
: null}
|
|
113
|
+
</div>
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {string} id
|
|
119
|
+
* @param {{ [k: string]: any }} patch
|
|
120
|
+
*/
|
|
121
|
+
async function updateInline(id, patch) {
|
|
122
|
+
try {
|
|
123
|
+
await data.updateIssue({ id, ...patch });
|
|
124
|
+
// Opportunistic refresh for that row
|
|
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
|
+
}
|
|
142
|
+
doRender();
|
|
143
|
+
} catch {
|
|
144
|
+
// swallow; UI remains
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} epic_id
|
|
150
|
+
*/
|
|
151
|
+
async function toggle(epic_id) {
|
|
152
|
+
if (!expanded.has(epic_id)) {
|
|
153
|
+
expanded.add(epic_id);
|
|
154
|
+
// Load children if not present
|
|
155
|
+
if (!children.has(epic_id)) {
|
|
156
|
+
loading.add(epic_id);
|
|
157
|
+
doRender();
|
|
158
|
+
try {
|
|
159
|
+
const epic = await data.getIssue(epic_id);
|
|
160
|
+
// Children for the Epics view come from dependents: issues that list
|
|
161
|
+
// the epic as a dependency. This matches how progress is tracked.
|
|
162
|
+
/** @type {{ id: string }[]} */
|
|
163
|
+
const deps = Array.isArray(epic.dependents) ? epic.dependents : [];
|
|
164
|
+
/** @type {IssueLite[]} */
|
|
165
|
+
const list = [];
|
|
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
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Sort by priority then updated_at (if present)
|
|
186
|
+
list.sort((a, b) => {
|
|
187
|
+
const pa = a.priority ?? 2;
|
|
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;
|
|
197
|
+
});
|
|
198
|
+
children.set(epic_id, list);
|
|
199
|
+
} finally {
|
|
200
|
+
loading.delete(epic_id);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
expanded.delete(epic_id);
|
|
205
|
+
}
|
|
206
|
+
doRender();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
async load() {
|
|
211
|
+
const res = await data.getEpicStatus();
|
|
212
|
+
groups = Array.isArray(res) ? res : [];
|
|
213
|
+
doRender();
|
|
214
|
+
// Auto-expand first epic on screen
|
|
215
|
+
try {
|
|
216
|
+
if (groups.length > 0) {
|
|
217
|
+
const first_id = String((groups[0].epic && groups[0].epic.id) || '');
|
|
218
|
+
if (first_id && !expanded.has(first_id)) {
|
|
219
|
+
// This will render and load children lazily
|
|
220
|
+
await toggle(first_id);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
// ignore auto-expand failures
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|