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.
Files changed (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. package/vitest.config.mjs +14 -0
@@ -0,0 +1,183 @@
1
+ import { html, render } from 'lit-html';
2
+ import { issueDisplayId } from '../utils/issue-id.js';
3
+ import { createPriorityBadge } from '../utils/priority-badge.js';
4
+ import { createTypeBadge } from '../utils/type-badge.js';
5
+
6
+ /**
7
+ * @typedef {{ id: string, title?: string, status?: 'open'|'in_progress'|'closed', priority?: number, issue_type?: string, updated_at?: string }} IssueLite
8
+ */
9
+
10
+ /**
11
+ * Create the Board view with four columns: Open, Ready, In progress, Closed.
12
+ * Data providers are expected to return raw arrays; this view applies sorting.
13
+ *
14
+ * Sorting rules:
15
+ * - Open: updated_at desc
16
+ * - Ready: priority asc, then updated_at desc when present
17
+ * - In progress: updated_at desc
18
+ * - Closed: updated_at desc
19
+ * @param {HTMLElement} mount_element
20
+ * @param {{ getOpen: () => Promise<any[]>, getReady: () => Promise<any[]>, getInProgress: () => Promise<any[]>, getClosed: (limit?: number) => Promise<any[]> }} data
21
+ * @param {(id: string) => void} goto_issue - Navigate to issue detail.
22
+ * @returns {{ load: () => Promise<void>, clear: () => void }}
23
+ */
24
+ export function createBoardView(mount_element, data, goto_issue) {
25
+ /** @type {IssueLite[]} */
26
+ let list_open = [];
27
+ /** @type {IssueLite[]} */
28
+ let list_ready = [];
29
+ /** @type {IssueLite[]} */
30
+ let list_in_progress = [];
31
+ /** @type {IssueLite[]} */
32
+ let list_closed = [];
33
+
34
+ function template() {
35
+ return html`
36
+ <div class="panel__body board-root">
37
+ ${columnTemplate('Open', 'open-col', list_open)}
38
+ ${columnTemplate('Ready', 'ready-col', list_ready)}
39
+ ${columnTemplate('In Progress', 'in-progress-col', list_in_progress)}
40
+ ${columnTemplate('Closed', 'closed-col', list_closed)}
41
+ </div>
42
+ `;
43
+ }
44
+
45
+ /**
46
+ * @param {string} title
47
+ * @param {string} id
48
+ * @param {IssueLite[]} items
49
+ */
50
+ function columnTemplate(title, id, items) {
51
+ return html`
52
+ <section class="board-column" id=${id}>
53
+ <header class="board-column__header" role="heading" aria-level="2">
54
+ ${title}
55
+ </header>
56
+ <div class="board-column__body">
57
+ ${items.map((it) => cardTemplate(it))}
58
+ </div>
59
+ </section>
60
+ `;
61
+ }
62
+
63
+ /**
64
+ * @param {IssueLite} it
65
+ */
66
+ function cardTemplate(it) {
67
+ return html`
68
+ <article
69
+ class="board-card"
70
+ data-issue-id=${it.id}
71
+ @click=${() => goto_issue(it.id)}
72
+ >
73
+ <div class="board-card__title text-truncate">
74
+ ${it.title || '(no title)'}
75
+ </div>
76
+ <div class="board-card__meta">
77
+ ${createTypeBadge(/** @type {any} */ (it).issue_type)}
78
+ ${createPriorityBadge(/** @type {any} */ (it).priority)}
79
+ <span class="mono">${issueDisplayId(it.id)}</span>
80
+ </div>
81
+ </article>
82
+ `;
83
+ }
84
+
85
+ function doRender() {
86
+ render(template(), mount_element);
87
+ }
88
+
89
+ /**
90
+ * Sort helpers.
91
+ */
92
+ /**
93
+ * @param {IssueLite[]} arr
94
+ */
95
+ function sortReady(arr) {
96
+ arr.sort((a, b) => {
97
+ const pa = a.priority ?? 2;
98
+ const pb = b.priority ?? 2;
99
+ if (pa !== pb) {
100
+ return pa - pb;
101
+ }
102
+ const ua = a.updated_at || '';
103
+ const ub = b.updated_at || '';
104
+ return ua < ub ? 1 : ua > ub ? -1 : 0;
105
+ });
106
+ }
107
+
108
+ /**
109
+ * @param {IssueLite[]} arr
110
+ */
111
+ function sortByUpdatedDesc(arr) {
112
+ arr.sort((a, b) => {
113
+ const ua = a.updated_at || '';
114
+ const ub = b.updated_at || '';
115
+ return ua < ub ? 1 : ua > ub ? -1 : 0;
116
+ });
117
+ }
118
+
119
+ return {
120
+ async load() {
121
+ /** @type {IssueLite[]} */
122
+ let o = [];
123
+ /** @type {IssueLite[]} */
124
+ let r = [];
125
+ /** @type {IssueLite[]} */
126
+ let p = [];
127
+ /** @type {IssueLite[]} */
128
+ let c = [];
129
+ try {
130
+ o = /** @type {any} */ (await data.getOpen());
131
+ } catch {
132
+ o = [];
133
+ }
134
+ try {
135
+ r = /** @type {any} */ (await data.getReady());
136
+ } catch {
137
+ r = [];
138
+ }
139
+ try {
140
+ p = /** @type {any} */ (await data.getInProgress());
141
+ } catch {
142
+ p = [];
143
+ }
144
+ try {
145
+ c = /** @type {any} */ (await data.getClosed());
146
+ } catch {
147
+ c = [];
148
+ }
149
+
150
+ // Remove items from Open that are already in Ready by id
151
+ if (o.length > 0 && r.length > 0) {
152
+ /** @type {Set<string>} */
153
+ const ready_ids = new Set(r.map((it) => it.id));
154
+ o = o.filter((it) => !ready_ids.has(it.id));
155
+ }
156
+
157
+ // Remove items from Ready that are already In Progress by id
158
+ if (r.length > 0 && p.length > 0) {
159
+ /** @type {Set<string>} */
160
+ const in_progress_ids = new Set(p.map((it) => it.id));
161
+ r = r.filter((it) => !in_progress_ids.has(it.id));
162
+ }
163
+
164
+ sortByUpdatedDesc(o);
165
+ sortReady(r);
166
+ sortByUpdatedDesc(p);
167
+ sortByUpdatedDesc(c);
168
+
169
+ list_open = o;
170
+ list_ready = r;
171
+ list_in_progress = p;
172
+ list_closed = c;
173
+ doRender();
174
+ },
175
+ clear() {
176
+ mount_element.replaceChildren();
177
+ list_open = [];
178
+ list_ready = [];
179
+ list_in_progress = [];
180
+ list_closed = [];
181
+ }
182
+ };
183
+ }
@@ -0,0 +1,184 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createBoardView } from './board.js';
3
+
4
+ describe('views/board', () => {
5
+ test('renders four columns with sorted cards and navigates on click', async () => {
6
+ document.body.innerHTML = '<div id="m"></div>';
7
+ const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
8
+
9
+ /** @type {{ getOpen: () => Promise<any[]>, getReady: () => Promise<any[]>, getInProgress: () => Promise<any[]>, getClosed: () => Promise<any[]> }} */
10
+ const data = {
11
+ async getOpen() {
12
+ return [
13
+ {
14
+ id: 'O-1',
15
+ title: 'o1',
16
+ updated_at: '2025-10-23T07:00:00.000Z',
17
+ issue_type: 'task'
18
+ },
19
+ {
20
+ id: 'R-2', // also present in Ready, should be filtered from Open
21
+ title: 'dup-ready',
22
+ updated_at: '2025-10-22T07:00:00.000Z',
23
+ issue_type: 'task'
24
+ }
25
+ ];
26
+ },
27
+ async getReady() {
28
+ return [
29
+ {
30
+ id: 'R-2',
31
+ title: 'r2',
32
+ priority: 1,
33
+ updated_at: '2025-10-20T08:00:00.000Z',
34
+ issue_type: 'task'
35
+ },
36
+ {
37
+ id: 'R-1',
38
+ title: 'r1',
39
+ priority: 0,
40
+ updated_at: '2025-10-21T08:00:00.000Z',
41
+ issue_type: 'bug'
42
+ },
43
+ {
44
+ id: 'R-3',
45
+ title: 'r3',
46
+ priority: 1,
47
+ updated_at: '2025-10-22T08:00:00.000Z',
48
+ issue_type: 'feature'
49
+ }
50
+ ];
51
+ },
52
+ async getInProgress() {
53
+ return [
54
+ {
55
+ id: 'P-1',
56
+ title: 'p1',
57
+ updated_at: '2025-10-23T09:00:00.000Z',
58
+ issue_type: 'task'
59
+ },
60
+ {
61
+ id: 'P-2',
62
+ title: 'p2',
63
+ updated_at: '2025-10-22T09:00:00.000Z',
64
+ issue_type: 'feature'
65
+ }
66
+ ];
67
+ },
68
+ async getClosed() {
69
+ return [
70
+ {
71
+ id: 'C-2',
72
+ title: 'c2',
73
+ updated_at: '2025-10-20T09:00:00.000Z',
74
+ issue_type: 'task'
75
+ },
76
+ {
77
+ id: 'C-1',
78
+ title: 'c1',
79
+ updated_at: '2025-10-21T09:00:00.000Z',
80
+ issue_type: 'bug'
81
+ }
82
+ ];
83
+ }
84
+ };
85
+
86
+ /** @type {string[]} */
87
+ const navigations = [];
88
+ const view = createBoardView(mount, /** @type {any} */ (data), (id) => {
89
+ navigations.push(id);
90
+ });
91
+
92
+ await view.load();
93
+
94
+ // Open: updated_at desc; excludes items present in Ready
95
+ const open_ids = Array.from(
96
+ mount.querySelectorAll('#open-col .board-card .mono')
97
+ ).map((el) => el.textContent?.trim());
98
+ expect(open_ids).toEqual(['#1']);
99
+
100
+ // Ready: priority asc, then updated_at desc for equal priority
101
+ const ready_ids = Array.from(
102
+ mount.querySelectorAll('#ready-col .board-card .mono')
103
+ ).map((el) => el.textContent?.trim());
104
+ expect(ready_ids).toEqual(['#1', '#3', '#2']);
105
+
106
+ // In progress: updated_at desc
107
+ const prog_ids = Array.from(
108
+ mount.querySelectorAll('#in-progress-col .board-card .mono')
109
+ ).map((el) => el.textContent?.trim());
110
+ expect(prog_ids).toEqual(['#1', '#2']);
111
+
112
+ // Closed: updated_at desc
113
+ const closed_ids = Array.from(
114
+ mount.querySelectorAll('#closed-col .board-card .mono')
115
+ ).map((el) => el.textContent?.trim());
116
+ expect(closed_ids).toEqual(['#1', '#2']);
117
+
118
+ // Click navigates
119
+ const first_ready = /** @type {HTMLElement|null} */ (
120
+ mount.querySelector('#ready-col .board-card')
121
+ );
122
+ first_ready?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
123
+ expect(navigations[0]).toBe('R-1');
124
+ });
125
+
126
+ test('filters Ready to exclude items that are In Progress', async () => {
127
+ document.body.innerHTML = '<div id="m"></div>';
128
+ const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
129
+
130
+ /** @type {{ getOpen: () => Promise<any[]>, getReady: () => Promise<any[]>, getInProgress: () => Promise<any[]>, getClosed: () => Promise<any[]> }} */
131
+ const data = {
132
+ async getOpen() {
133
+ return [];
134
+ },
135
+ async getReady() {
136
+ return [
137
+ {
138
+ id: 'X-1',
139
+ title: 'x1',
140
+ priority: 1,
141
+ updated_at: '2025-10-23T10:00:00.000Z',
142
+ issue_type: 'task'
143
+ },
144
+ {
145
+ id: 'X-2',
146
+ title: 'x2',
147
+ priority: 1,
148
+ updated_at: '2025-10-23T09:00:00.000Z',
149
+ issue_type: 'task'
150
+ }
151
+ ];
152
+ },
153
+ async getInProgress() {
154
+ return [
155
+ {
156
+ id: 'X-2',
157
+ title: 'x2',
158
+ updated_at: '2025-10-23T11:00:00.000Z',
159
+ issue_type: 'task'
160
+ }
161
+ ];
162
+ },
163
+ async getClosed() {
164
+ return [];
165
+ }
166
+ };
167
+
168
+ const view = createBoardView(mount, /** @type {any} */ (data), () => {});
169
+
170
+ await view.load();
171
+
172
+ const ready_ids = Array.from(
173
+ mount.querySelectorAll('#ready-col .board-card .mono')
174
+ ).map((el) => el.textContent?.trim());
175
+
176
+ // X-2 is in progress, so Ready should only show X-1
177
+ expect(ready_ids).toEqual(['#1']);
178
+
179
+ const prog_ids = Array.from(
180
+ mount.querySelectorAll('#in-progress-col .board-card .mono')
181
+ ).map((el) => el.textContent?.trim());
182
+ expect(prog_ids).toEqual(['#2']);
183
+ });
184
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createDetailView } from './detail.js';
3
+
4
+ /** @type {(map: Record<string, any>) => (type: string, payload?: unknown) => Promise<any>} */
5
+ const stubSend = (map) => async (type, payload) => {
6
+ if (type !== 'show-issue') {
7
+ throw new Error('Unexpected type');
8
+ }
9
+ const id = /** @type {any} */ (payload).id;
10
+ return map[id] || null;
11
+ };
12
+
13
+ describe('views/detail acceptance + notes', () => {
14
+ test('renders acceptance from acceptance_criteria and notes markdown', async () => {
15
+ document.body.innerHTML =
16
+ '<section class="panel"><div id="mount"></div></section>';
17
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
18
+
19
+ /** @type {any} */
20
+ const issue = {
21
+ id: 'UI-71',
22
+ title: 'Has acceptance + notes',
23
+ acceptance_criteria: '- step A\n- step B',
24
+ notes: 'Plain note text',
25
+ status: 'open',
26
+ priority: 2
27
+ };
28
+
29
+ const view = createDetailView(mount, stubSend({ 'UI-71': issue }));
30
+ await view.load('UI-71');
31
+
32
+ const accTitle = mount.querySelector('.acceptance .props-card__title');
33
+ expect(accTitle && accTitle.textContent).toBe('Acceptance');
34
+ const accMd = mount.querySelector('.acceptance .md');
35
+ expect(accMd && (accMd.textContent || '').toLowerCase()).toContain(
36
+ 'step a'
37
+ );
38
+
39
+ const notesTitle = mount.querySelector('.notes .props-card__title');
40
+ expect(notesTitle && notesTitle.textContent).toBe('Notes');
41
+ const notesMd = mount.querySelector('.notes .md');
42
+ expect(notesMd && (notesMd.textContent || '')).toContain('Plain note text');
43
+ });
44
+
45
+ test('gates headings when acceptance and notes are empty', async () => {
46
+ document.body.innerHTML =
47
+ '<section class="panel"><div id="mount"></div></section>';
48
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
49
+
50
+ /** @type {any} */
51
+ const issue = {
52
+ id: 'UI-72',
53
+ title: 'No acceptance/notes',
54
+ acceptance_criteria: '',
55
+ notes: '',
56
+ status: 'open',
57
+ priority: 2
58
+ };
59
+
60
+ const view = createDetailView(mount, stubSend({ 'UI-72': issue }));
61
+ await view.load('UI-72');
62
+
63
+ // Headings should not be present
64
+ expect(mount.querySelector('.acceptance .props-card__title')).toBeNull();
65
+ expect(mount.querySelector('.notes .props-card__title')).toBeNull();
66
+ });
67
+ });
@@ -0,0 +1,161 @@
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 assignee edit', () => {
8
+ test('edits assignee via Properties control', 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 issue = {
14
+ id: 'UI-57',
15
+ title: 'Detail screen',
16
+ description: '',
17
+ status: 'open',
18
+ priority: 2,
19
+ assignee: 'alice',
20
+ dependencies: [],
21
+ dependents: []
22
+ };
23
+
24
+ const send = mockSend(async (type, payload) => {
25
+ if (type === 'show-issue') {
26
+ return issue;
27
+ }
28
+ if (type === 'update-assignee') {
29
+ expect(payload).toEqual({ id: 'UI-57', assignee: 'max' });
30
+ const next = { ...issue, assignee: 'max' };
31
+ return next;
32
+ }
33
+ throw new Error('Unexpected');
34
+ });
35
+
36
+ const view = createDetailView(mount, send);
37
+ await view.load('UI-57');
38
+
39
+ const assigneeSpan = /** @type {HTMLSpanElement} */ (
40
+ mount.querySelector('#detail-root .prop.assignee .value .editable')
41
+ );
42
+ expect(assigneeSpan).toBeTruthy();
43
+ expect(assigneeSpan.textContent).toBe('alice');
44
+
45
+ assigneeSpan.click();
46
+ const input = /** @type {HTMLInputElement} */ (
47
+ mount.querySelector('#detail-root .prop.assignee input')
48
+ );
49
+ const saveBtn = /** @type {HTMLButtonElement} */ (
50
+ mount.querySelector('#detail-root .prop.assignee button')
51
+ );
52
+ input.value = 'max';
53
+ saveBtn.click();
54
+
55
+ await Promise.resolve();
56
+
57
+ const assigneeSpan2 = /** @type {HTMLSpanElement} */ (
58
+ mount.querySelector('#detail-root .prop.assignee .value .editable')
59
+ );
60
+ expect(assigneeSpan2.textContent).toBe('max');
61
+ });
62
+
63
+ test('shows editable placeholder when unassigned', async () => {
64
+ document.body.innerHTML =
65
+ '<section class="panel"><div id="mount"></div></section>';
66
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
67
+
68
+ const issue = {
69
+ id: 'UI-88',
70
+ title: 'No assignee yet',
71
+ description: '',
72
+ status: 'open',
73
+ priority: 2,
74
+ // no assignee field
75
+ dependencies: [],
76
+ dependents: []
77
+ };
78
+
79
+ const send = mockSend(async (type, payload) => {
80
+ if (type === 'show-issue') {
81
+ return issue;
82
+ }
83
+ if (type === 'update-assignee') {
84
+ const next = {
85
+ ...issue,
86
+ assignee: /** @type {any} */ (payload).assignee
87
+ };
88
+ return next;
89
+ }
90
+ throw new Error('Unexpected');
91
+ });
92
+
93
+ const view = createDetailView(mount, send);
94
+ await view.load('UI-88');
95
+
96
+ const ph = /** @type {HTMLSpanElement} */ (
97
+ mount.querySelector('#detail-root .prop.assignee .value .editable')
98
+ );
99
+ expect(ph).toBeTruthy();
100
+ expect(ph.className).toContain('muted');
101
+ expect(ph.textContent).toBe('Unassigned');
102
+
103
+ ph.click();
104
+ const input = /** @type {HTMLInputElement} */ (
105
+ mount.querySelector('#detail-root .prop.assignee input')
106
+ );
107
+ expect(input).toBeTruthy();
108
+ });
109
+
110
+ test('clears assignee to empty string and shows placeholder', async () => {
111
+ document.body.innerHTML =
112
+ '<section class="panel"><div id="mount"></div></section>';
113
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
114
+
115
+ const issue = {
116
+ id: 'UI-31',
117
+ title: 'Clearable',
118
+ status: 'open',
119
+ priority: 2,
120
+ assignee: 'bob',
121
+ dependencies: [],
122
+ dependents: []
123
+ };
124
+
125
+ const send = mockSend(async (type, payload) => {
126
+ if (type === 'show-issue') {
127
+ return issue;
128
+ }
129
+ if (type === 'update-assignee') {
130
+ const next = {
131
+ ...issue,
132
+ assignee: /** @type {any} */ (payload).assignee
133
+ };
134
+ return next;
135
+ }
136
+ throw new Error('Unexpected');
137
+ });
138
+
139
+ const view = createDetailView(mount, send);
140
+ await view.load('UI-31');
141
+
142
+ const span = /** @type {HTMLSpanElement} */ (
143
+ mount.querySelector('#detail-root .prop.assignee .value .editable')
144
+ );
145
+ span.click();
146
+ const input = /** @type {HTMLInputElement} */ (
147
+ mount.querySelector('#detail-root .prop.assignee input')
148
+ );
149
+ const save = /** @type {HTMLButtonElement} */ (
150
+ mount.querySelector('#detail-root .prop.assignee button')
151
+ );
152
+ input.value = '';
153
+ save.click();
154
+ await Promise.resolve();
155
+ const span2 = /** @type {HTMLSpanElement} */ (
156
+ mount.querySelector('#detail-root .prop.assignee .value .editable')
157
+ );
158
+ expect(span2.textContent).toBe('Unassigned');
159
+ expect(span2.className).toContain('muted');
160
+ });
161
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { createDetailView } from './detail.js';
3
+
4
+ function setupDom() {
5
+ const root = document.createElement('div');
6
+ document.body.appendChild(root);
7
+ return root;
8
+ }
9
+
10
+ describe('views/detail dependencies', () => {
11
+ test('adds Dependencies link and re-renders', async () => {
12
+ const mount = setupDom();
13
+ const send = vi
14
+ .fn()
15
+ // initial show
16
+ .mockResolvedValueOnce({
17
+ id: 'UI-10',
18
+ title: 'X',
19
+ dependencies: [],
20
+ dependents: []
21
+ })
22
+ // dep-add returns updated issue
23
+ .mockResolvedValueOnce({
24
+ id: 'UI-10',
25
+ dependencies: [{ id: 'UI-2' }],
26
+ dependents: []
27
+ });
28
+ const view = createDetailView(mount, /** @type {any} */ (send));
29
+ await view.load('UI-10');
30
+
31
+ const input = mount.querySelector('[data-testid="add-dependency"]');
32
+ expect(input).toBeTruthy();
33
+ /** @type {HTMLInputElement} */
34
+ const el = /** @type {any} */ (input);
35
+ el.value = 'UI-2';
36
+ const addBtn = el.nextElementSibling;
37
+ addBtn?.dispatchEvent(new window.Event('click'));
38
+
39
+ // Next tick
40
+ await Promise.resolve();
41
+
42
+ // Should have called dep-add
43
+ const calls = send.mock.calls.map((c) => c[0]);
44
+ expect(calls.includes('dep-add')).toBe(true);
45
+ });
46
+
47
+ test('removes Blocks link', async () => {
48
+ const mount = setupDom();
49
+ const send = vi
50
+ .fn()
51
+ // initial show
52
+ .mockResolvedValueOnce({
53
+ id: 'UI-20',
54
+ title: 'Y',
55
+ dependencies: [],
56
+ dependents: [{ id: 'UI-5' }]
57
+ })
58
+ // dep-remove returns updated issue
59
+ .mockResolvedValueOnce({ id: 'UI-20', dependencies: [], dependents: [] });
60
+ const view = createDetailView(mount, /** @type {any} */ (send));
61
+ await view.load('UI-20');
62
+
63
+ // Find the remove button next to link #5
64
+ const btns = mount.querySelectorAll('button');
65
+ const rm = Array.from(btns).find((b) =>
66
+ b.getAttribute('aria-label')?.includes('#5')
67
+ );
68
+ expect(rm).toBeTruthy();
69
+ rm?.dispatchEvent(new window.Event('click'));
70
+
71
+ await Promise.resolve();
72
+ const calls = send.mock.calls.map((c) => c[0]);
73
+ expect(calls.includes('dep-remove')).toBe(true);
74
+ });
75
+
76
+ test('prevents duplicate link add', async () => {
77
+ const mount = setupDom();
78
+ const send = vi.fn().mockResolvedValueOnce({
79
+ id: 'UI-30',
80
+ dependencies: [{ id: 'UI-9' }],
81
+ dependents: []
82
+ });
83
+ const view = createDetailView(mount, /** @type {any} */ (send));
84
+ await view.load('UI-30');
85
+
86
+ const input = mount.querySelector('[data-testid="add-dependency"]');
87
+ const el = /** @type {HTMLInputElement} */ (/** @type {any} */ (input));
88
+ el.value = 'UI-9';
89
+ const addBtn = el.nextElementSibling;
90
+ addBtn?.dispatchEvent(new window.Event('click'));
91
+
92
+ await Promise.resolve();
93
+ // send should not be called with dep-add
94
+ const calls = send.mock.calls.map((c) => c[0]);
95
+ expect(calls.includes('dep-add')).toBe(false);
96
+ });
97
+ });