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,283 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { createEpicsView } from './epics.js';
3
+
4
+ describe('views/epics', () => {
5
+ test('loads groups and expands to show non-closed children, navigates on click', async () => {
6
+ document.body.innerHTML = '<div id="m"></div>';
7
+ const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
8
+ const data = {
9
+ async getEpicStatus() {
10
+ return [
11
+ {
12
+ epic: { id: 'UI-1', title: 'Epic One' },
13
+ total_children: 2,
14
+ closed_children: 1,
15
+ eligible_for_close: false
16
+ }
17
+ ];
18
+ },
19
+ /** @param {string} id */
20
+ async getIssue(id) {
21
+ if (id === 'UI-1') {
22
+ return { id: 'UI-1', dependents: [{ id: 'UI-2' }, { id: 'UI-3' }] };
23
+ }
24
+ if (id === 'UI-2') {
25
+ return {
26
+ id: 'UI-2',
27
+ title: 'Alpha',
28
+ status: 'open',
29
+ priority: 1,
30
+ issue_type: 'task'
31
+ };
32
+ }
33
+ return {
34
+ id: 'UI-3',
35
+ title: 'Beta',
36
+ status: 'closed',
37
+ priority: 2,
38
+ issue_type: 'task'
39
+ };
40
+ },
41
+ updateIssue: vi.fn()
42
+ };
43
+ /** @type {string[]} */
44
+ const navCalls = [];
45
+ const view = createEpicsView(mount, /** @type {any} */ (data), (id) =>
46
+ navCalls.push(id)
47
+ );
48
+ await view.load();
49
+ const header = mount.querySelector('.epic-header');
50
+ expect(header).not.toBeNull();
51
+ // After expansion, only non-closed child should be present
52
+ const rows = mount.querySelectorAll('tr.epic-row');
53
+ expect(rows.length).toBe(1);
54
+ rows[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
55
+ expect(navCalls[0]).toBe('UI-2');
56
+ });
57
+
58
+ test('sorts children by priority then updated_at', async () => {
59
+ document.body.innerHTML = '<div id="m"></div>';
60
+ const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
61
+ const data = {
62
+ async getEpicStatus() {
63
+ return [
64
+ {
65
+ epic: { id: 'UI-10', title: 'Epic Sort' },
66
+ total_children: 3,
67
+ closed_children: 0,
68
+ eligible_for_close: false
69
+ }
70
+ ];
71
+ },
72
+ /** @param {string} id */
73
+ async getIssue(id) {
74
+ if (id === 'UI-10') {
75
+ return {
76
+ id: 'UI-10',
77
+ dependents: [{ id: 'UI-11' }, { id: 'UI-12' }, { id: 'UI-13' }]
78
+ };
79
+ }
80
+ if (id === 'UI-11') {
81
+ return {
82
+ id: 'UI-11',
83
+ title: 'Low priority, newest within p1',
84
+ status: 'open',
85
+ priority: 1,
86
+ issue_type: 'task',
87
+ updated_at: '2025-10-22T10:00:00.000Z'
88
+ };
89
+ }
90
+ if (id === 'UI-12') {
91
+ return {
92
+ id: 'UI-12',
93
+ title: 'Low priority, older',
94
+ status: 'open',
95
+ priority: 1,
96
+ issue_type: 'task',
97
+ updated_at: '2025-10-20T10:00:00.000Z'
98
+ };
99
+ }
100
+ return {
101
+ id: 'UI-13',
102
+ title: 'Higher priority number (lower precedence)',
103
+ status: 'open',
104
+ priority: 2,
105
+ issue_type: 'task',
106
+ updated_at: '2025-10-23T10:00:00.000Z'
107
+ };
108
+ },
109
+ updateIssue: vi.fn()
110
+ };
111
+ const view = createEpicsView(mount, /** @type {any} */ (data), () => {});
112
+ await view.load();
113
+ const rows = Array.from(mount.querySelectorAll('tr.epic-row'));
114
+ const ids = rows.map((r) =>
115
+ /** @type {HTMLElement} */ (
116
+ r.querySelector('td.mono')
117
+ )?.textContent?.trim()
118
+ );
119
+ expect(ids).toEqual(['#11', '#12', '#13']);
120
+ });
121
+
122
+ test('clicking inputs/selects inside a row does not navigate', async () => {
123
+ document.body.innerHTML = '<div id="m"></div>';
124
+ const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
125
+ const data = {
126
+ async getEpicStatus() {
127
+ return [
128
+ {
129
+ epic: { id: 'UI-20', title: 'Epic Click Guard' },
130
+ total_children: 1,
131
+ closed_children: 0,
132
+ eligible_for_close: false
133
+ }
134
+ ];
135
+ },
136
+ /** @param {string} id */
137
+ async getIssue(id) {
138
+ if (id === 'UI-20') {
139
+ return { id: 'UI-20', dependents: [{ id: 'UI-21' }] };
140
+ }
141
+ return {
142
+ id: 'UI-21',
143
+ title: 'Editable',
144
+ status: 'open',
145
+ priority: 2,
146
+ issue_type: 'task',
147
+ updated_at: '2025-10-21T10:00:00.000Z'
148
+ };
149
+ },
150
+ updateIssue: vi.fn()
151
+ };
152
+ /** @type {string[]} */
153
+ const navCalls = [];
154
+ const view = createEpicsView(mount, /** @type {any} */ (data), (id) =>
155
+ navCalls.push(id)
156
+ );
157
+ await view.load();
158
+ // Click a select inside the row; should not navigate
159
+ const sel = /** @type {HTMLSelectElement|null} */ (
160
+ mount.querySelector('tr.epic-row select')
161
+ );
162
+ sel?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
163
+ expect(navCalls.length).toBe(0);
164
+ });
165
+
166
+ test('shows Loading… while fetching children on manual expansion (no flicker)', async () => {
167
+ document.body.innerHTML = '<div id="m"></div>';
168
+ const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
169
+ let resolveEpic;
170
+ const epicPromise = new Promise((r) => {
171
+ resolveEpic = r;
172
+ });
173
+ const data = {
174
+ async getEpicStatus() {
175
+ return [
176
+ {
177
+ epic: { id: 'UI-40', title: 'Auto Expanded' },
178
+ total_children: 0,
179
+ closed_children: 0,
180
+ eligible_for_close: false
181
+ },
182
+ {
183
+ epic: { id: 'UI-41', title: 'Manual Expand' },
184
+ total_children: 1,
185
+ closed_children: 0,
186
+ eligible_for_close: false
187
+ }
188
+ ];
189
+ },
190
+ /** @param {string} id */
191
+ async getIssue(id) {
192
+ if (id === 'UI-40') {
193
+ return { id: 'UI-40', dependents: [] };
194
+ }
195
+ if (id === 'UI-41') {
196
+ // Delay to simulate loading
197
+ await epicPromise;
198
+ return { id: 'UI-41', dependents: [{ id: 'UI-42' }] };
199
+ }
200
+ return {
201
+ id: 'UI-42',
202
+ title: 'Child',
203
+ status: 'open',
204
+ priority: 2,
205
+ issue_type: 'task'
206
+ };
207
+ },
208
+ updateIssue: vi.fn()
209
+ };
210
+ const view = createEpicsView(mount, /** @type {any} */ (data), () => {});
211
+ await view.load();
212
+ // Expand the second group manually
213
+ const groups = Array.from(mount.querySelectorAll('.epic-group'));
214
+ const manual = groups.find(
215
+ (g) => g.getAttribute('data-epic-id') === 'UI-41'
216
+ );
217
+ expect(manual).toBeDefined();
218
+ manual
219
+ ?.querySelector('.epic-header')
220
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
221
+
222
+ // Immediately after click, expect Loading…
223
+ const text = manual?.querySelector('.epic-children')?.textContent || '';
224
+ expect(text.includes('Loading…')).toBe(true);
225
+
226
+ // Resolve and ensure a row appears
227
+ // @ts-ignore
228
+ resolveEpic();
229
+ await new Promise((r) => setTimeout(r, 0));
230
+ await new Promise((r) => setTimeout(r, 0));
231
+ const rows = manual?.querySelectorAll('tr.epic-row') || [];
232
+ expect(rows.length).toBe(1);
233
+ });
234
+
235
+ test('clicking the editable title does not navigate and enters edit mode', async () => {
236
+ document.body.innerHTML = '<div id="m"></div>';
237
+ const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
238
+ const data = {
239
+ async getEpicStatus() {
240
+ return [
241
+ {
242
+ epic: { id: 'UI-30', title: 'Epic Title Click' },
243
+ total_children: 1,
244
+ closed_children: 0,
245
+ eligible_for_close: false
246
+ }
247
+ ];
248
+ },
249
+ /** @param {string} id */
250
+ async getIssue(id) {
251
+ if (id === 'UI-30') {
252
+ return { id: 'UI-30', dependents: [{ id: 'UI-31' }] };
253
+ }
254
+ return {
255
+ id: 'UI-31',
256
+ title: 'Clickable Title',
257
+ status: 'open',
258
+ priority: 2,
259
+ issue_type: 'task'
260
+ };
261
+ },
262
+ updateIssue: vi.fn()
263
+ };
264
+ /** @type {string[]} */
265
+ const navCalls = [];
266
+ const view = createEpicsView(mount, /** @type {any} */ (data), (id) =>
267
+ navCalls.push(id)
268
+ );
269
+ await view.load();
270
+ const titleSpan = /** @type {HTMLElement|null} */ (
271
+ mount.querySelector('tr.epic-row td:nth-child(3) .editable')
272
+ );
273
+ expect(titleSpan).not.toBeNull();
274
+ titleSpan?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
275
+ // Should not have navigated
276
+ expect(navCalls.length).toBe(0);
277
+ // Should render an input for title now
278
+ const input = /** @type {HTMLInputElement|null} */ (
279
+ mount.querySelector('tr.epic-row td:nth-child(3) input[type="text"]')
280
+ );
281
+ expect(input).not.toBeNull();
282
+ });
283
+ });
@@ -0,0 +1,191 @@
1
+ import { html } from 'lit-html';
2
+ import { issueDisplayId } from '../utils/issue-id.js';
3
+ import { emojiForPriority } from '../utils/priority-badge.js';
4
+ import { priority_levels } from '../utils/priority.js';
5
+ import { statusLabel } from '../utils/status.js';
6
+ import { createTypeBadge } from '../utils/type-badge.js';
7
+
8
+ /**
9
+ * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string }} IssueRowData
10
+ */
11
+
12
+ /**
13
+ * Create a reusable issue row renderer used by list and epics views.
14
+ * Handles inline editing for title/assignee and selects for status/priority.
15
+ * @param {{
16
+ * navigate: (id: string) => void,
17
+ * onUpdate: (id: string, patch: { title?: string, assignee?: string, status?: 'open'|'in_progress'|'closed', priority?: number }) => Promise<void>,
18
+ * requestRender: () => void,
19
+ * getSelectedId?: () => string | null,
20
+ * row_class?: string
21
+ * }} options
22
+ * @returns {(it: IssueRowData) => import('lit-html').TemplateResult<1>}
23
+ */
24
+ export function createIssueRowRenderer(options) {
25
+ const navigate = options.navigate;
26
+ const on_update = options.onUpdate;
27
+ const request_render = options.requestRender;
28
+ const get_selected_id = options.getSelectedId || (() => null);
29
+ const row_class = options.row_class || 'issue-row';
30
+
31
+ /** @type {Set<string>} */
32
+ const editing = new Set();
33
+
34
+ /**
35
+ * @param {string} id
36
+ * @param {'title'|'assignee'} key
37
+ * @param {string} value
38
+ * @param {string} [placeholder]
39
+ */
40
+ function editableText(id, key, value, placeholder = '') {
41
+ /** @type {string} */
42
+ const k = `${id}:${key}`;
43
+ const is_edit = editing.has(k);
44
+ if (is_edit) {
45
+ return html`<span>
46
+ <input
47
+ type="text"
48
+ .value=${value}
49
+ class="inline-edit"
50
+ @keydown=${
51
+ /** @param {KeyboardEvent} e */ async (e) => {
52
+ if (e.key === 'Escape') {
53
+ editing.delete(k);
54
+ request_render();
55
+ } else if (e.key === 'Enter') {
56
+ const el = /** @type {HTMLInputElement} */ (e.currentTarget);
57
+ const next = el.value || '';
58
+ if (next !== value) {
59
+ await on_update(id, { [key]: next });
60
+ }
61
+ editing.delete(k);
62
+ request_render();
63
+ }
64
+ }
65
+ }
66
+ @blur=${
67
+ /** @param {Event} ev */ async (ev) => {
68
+ const el = /** @type {HTMLInputElement} */ (ev.currentTarget);
69
+ const next = el.value || '';
70
+ if (next !== value) {
71
+ await on_update(id, { [key]: next });
72
+ }
73
+ editing.delete(k);
74
+ request_render();
75
+ }
76
+ }
77
+ autofocus
78
+ />
79
+ </span>`;
80
+ }
81
+ return html`<span
82
+ class="editable text-truncate ${value ? '' : 'muted'}"
83
+ tabindex="0"
84
+ role="button"
85
+ @click=${
86
+ /** @param {MouseEvent} e */ (e) => {
87
+ e.stopPropagation();
88
+ e.preventDefault();
89
+ editing.add(k);
90
+ request_render();
91
+ }
92
+ }
93
+ @keydown=${
94
+ /** @param {KeyboardEvent} e */ (e) => {
95
+ e.stopPropagation();
96
+ if (e.key === 'Enter') {
97
+ e.preventDefault();
98
+ editing.add(k);
99
+ request_render();
100
+ }
101
+ }
102
+ }
103
+ >${value || placeholder}</span
104
+ >`;
105
+ }
106
+
107
+ /**
108
+ * @param {string} id
109
+ * @param {'priority'|'status'} key
110
+ * @returns {(ev: Event) => Promise<void>}
111
+ */
112
+ function makeSelectChange(id, key) {
113
+ return async (ev) => {
114
+ /** @type {HTMLSelectElement} */
115
+ const sel = /** @type {any} */ (ev.currentTarget);
116
+ const val = sel.value || '';
117
+ /** @type {{ [k:string]: any }} */
118
+ const patch = {};
119
+ patch[key] = key === 'priority' ? Number(val) : val;
120
+ await on_update(id, patch);
121
+ };
122
+ }
123
+
124
+ /**
125
+ * @param {string} id
126
+ * @returns {(ev: Event) => void}
127
+ */
128
+ function makeRowClick(id) {
129
+ return (ev) => {
130
+ const el = /** @type {HTMLElement|null} */ (ev.target);
131
+ if (el && (el.tagName === 'INPUT' || el.tagName === 'SELECT')) {
132
+ return;
133
+ }
134
+ navigate(id);
135
+ };
136
+ }
137
+
138
+ /**
139
+ * @param {IssueRowData} it
140
+ */
141
+ function rowTemplate(it) {
142
+ const cur_status = String(it.status || 'open');
143
+ const cur_prio = String(it.priority ?? 2);
144
+ const is_selected = get_selected_id() === it.id;
145
+ return html`<tr
146
+ class="${row_class} ${is_selected ? 'selected' : ''}"
147
+ data-issue-id=${it.id}
148
+ @click=${makeRowClick(it.id)}
149
+ >
150
+ <td class="mono">${issueDisplayId(it.id)}</td>
151
+ <td>${createTypeBadge(it.issue_type)}</td>
152
+ <td>${editableText(it.id, 'title', it.title || '')}</td>
153
+ <td>
154
+ <select
155
+ class="badge-select badge--status is-${cur_status}"
156
+ .value=${cur_status}
157
+ @change=${makeSelectChange(it.id, 'status')}
158
+ >
159
+ ${['open', 'in_progress', 'closed'].map(
160
+ (s) =>
161
+ html`<option value=${s} ?selected=${cur_status === s}>
162
+ ${statusLabel(s)}
163
+ </option>`
164
+ )}
165
+ </select>
166
+ </td>
167
+ <td>
168
+ ${editableText(it.id, 'assignee', it.assignee || '', 'Unassigned')}
169
+ </td>
170
+ <td>
171
+ <select
172
+ class="badge-select badge--priority ${'is-p' + cur_prio}"
173
+ .value=${cur_prio}
174
+ @change=${makeSelectChange(it.id, 'priority')}
175
+ >
176
+ ${priority_levels.map(
177
+ (p, i) =>
178
+ html`<option
179
+ value=${String(i)}
180
+ ?selected=${cur_prio === String(i)}
181
+ >
182
+ ${emojiForPriority(i)} ${p}
183
+ </option>`
184
+ )}
185
+ </select>
186
+ </td>
187
+ </tr>`;
188
+ }
189
+
190
+ return rowTemplate;
191
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { createListView } from './list.js';
3
+
4
+ describe('views/list inline edits', () => {
5
+ test('priority select dispatches update and refreshes row', async () => {
6
+ document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
7
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
8
+
9
+ const initial = [
10
+ {
11
+ id: 'UI-1',
12
+ title: 'One',
13
+ status: 'open',
14
+ priority: 1,
15
+ issue_type: 'task'
16
+ },
17
+ {
18
+ id: 'UI-2',
19
+ title: 'Two',
20
+ status: 'open',
21
+ priority: 2,
22
+ issue_type: 'bug'
23
+ }
24
+ ];
25
+
26
+ /** @type {{ calls: Array<{ type: string, payload: any }> }} */
27
+ const spy = { calls: [] };
28
+ let current = [...initial];
29
+
30
+ /** @type {(type: string, payload?: any) => Promise<any>} */
31
+ const send = vi.fn(async (type, payload) => {
32
+ spy.calls.push({ type, payload });
33
+ if (type === 'list-issues') {
34
+ return current;
35
+ }
36
+ if (type === 'update-priority') {
37
+ // no-op; list refresh happens via show-issue below
38
+ return {};
39
+ }
40
+ if (type === 'show-issue') {
41
+ const id = payload.id;
42
+ const idx = current.findIndex((x) => x.id === id);
43
+ if (idx >= 0) {
44
+ // Return an updated item with a different priority to simulate backend
45
+ const updated = { ...current[idx], priority: 4 };
46
+ // and reflect it into the list that will be rendered after refresh
47
+ current[idx] = updated;
48
+ return updated;
49
+ }
50
+ return null;
51
+ }
52
+ throw new Error('Unexpected');
53
+ });
54
+
55
+ const view = createListView(mount, send);
56
+ await view.load();
57
+
58
+ const firstRow = /** @type {HTMLElement} */ (
59
+ mount.querySelector('tr.issue-row[data-issue-id="UI-1"]')
60
+ );
61
+ expect(firstRow).toBeTruthy();
62
+ const prio = /** @type {HTMLSelectElement} */ (
63
+ firstRow.querySelector('select.badge--priority')
64
+ );
65
+ expect(prio.value).toBe('1');
66
+
67
+ // Change to a different priority; handler should call update-priority then show-issue
68
+ prio.value = '4';
69
+ prio.dispatchEvent(new Event('change'));
70
+
71
+ await Promise.resolve();
72
+
73
+ const types = spy.calls.map((c) => c.type);
74
+ expect(types).toContain('update-priority');
75
+ expect(types).toContain('show-issue');
76
+
77
+ const prio2 = /** @type {HTMLSelectElement} */ (
78
+ mount.querySelector(
79
+ 'tr.issue-row[data-issue-id="UI-1"] select.badge--priority'
80
+ )
81
+ );
82
+ expect(prio2.value).toBe('4');
83
+ });
84
+ });