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,479 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createListView } from './list.js';
3
+
4
+ /** @type {(expected: any[]) => (type: string, payload?: unknown) => Promise<any[]>} */
5
+ const stubSend = (expected) => async (type) => {
6
+ if (type !== 'list-issues') {
7
+ throw new Error('Unexpected type');
8
+ }
9
+ return expected;
10
+ };
11
+
12
+ describe('views/list', () => {
13
+ test('renders issues in table and navigates on row click', async () => {
14
+ document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
15
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
16
+ const issues = [
17
+ {
18
+ id: 'UI-1',
19
+ title: 'One',
20
+ status: 'open',
21
+ priority: 1,
22
+ issue_type: 'task'
23
+ },
24
+ {
25
+ id: 'UI-2',
26
+ title: 'Two',
27
+ status: 'closed',
28
+ priority: 2,
29
+ issue_type: 'bug'
30
+ }
31
+ ];
32
+ const view = createListView(mount, stubSend(issues), (hash) => {
33
+ window.location.hash = hash;
34
+ });
35
+ await view.load();
36
+ const rows = mount.querySelectorAll('tr.issue-row');
37
+ expect(rows.length).toBe(2);
38
+
39
+ // badge present
40
+ const badges = mount.querySelectorAll('.type-badge');
41
+ expect(badges.length).toBeGreaterThanOrEqual(2);
42
+
43
+ const first = /** @type {HTMLElement} */ (rows[0]);
44
+ first.dispatchEvent(new MouseEvent('click', { bubbles: true }));
45
+ expect(window.location.hash).toBe('#/issue/UI-1');
46
+ });
47
+
48
+ test('filters by status and search', async () => {
49
+ document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
50
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
51
+ const issues = [
52
+ { id: 'UI-1', title: 'Alpha', status: 'open', priority: 1 },
53
+ { id: 'UI-2', title: 'Beta', status: 'in_progress', priority: 2 },
54
+ { id: 'UI-3', title: 'Gamma', status: 'closed', priority: 3 }
55
+ ];
56
+ const view = createListView(mount, stubSend(issues));
57
+ await view.load();
58
+ const select = /** @type {HTMLSelectElement} */ (
59
+ mount.querySelector('select')
60
+ );
61
+ const input = /** @type {HTMLInputElement} */ (
62
+ mount.querySelector('input[type="search"]')
63
+ );
64
+
65
+ // Filter by status
66
+ select.value = 'open';
67
+ select.dispatchEvent(new Event('change'));
68
+ await Promise.resolve();
69
+ expect(mount.querySelectorAll('tr.issue-row').length).toBe(1);
70
+
71
+ // Search filters further
72
+ select.value = 'all';
73
+ select.dispatchEvent(new Event('change'));
74
+ input.value = 'ga';
75
+ input.dispatchEvent(new Event('input'));
76
+ const visible = Array.from(mount.querySelectorAll('tr.issue-row')).map(
77
+ (el) => ({
78
+ id: el.getAttribute('data-issue-id') || '',
79
+ text: el.textContent || ''
80
+ })
81
+ );
82
+ expect(visible.length).toBe(1);
83
+ expect(visible[0].id).toBe('UI-3');
84
+ expect(visible[0].text.toLowerCase()).toContain('gamma');
85
+ });
86
+
87
+ test('filters by issue type and combines with search', async () => {
88
+ document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
89
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
90
+ const issues = [
91
+ {
92
+ id: 'UI-1',
93
+ title: 'Alpha',
94
+ status: 'open',
95
+ priority: 1,
96
+ issue_type: 'bug'
97
+ },
98
+ {
99
+ id: 'UI-2',
100
+ title: 'Beta',
101
+ status: 'open',
102
+ priority: 2,
103
+ issue_type: 'feature'
104
+ },
105
+ {
106
+ id: 'UI-3',
107
+ title: 'Gamma',
108
+ status: 'open',
109
+ priority: 3,
110
+ issue_type: 'bug'
111
+ },
112
+ {
113
+ id: 'UI-4',
114
+ title: 'Delta',
115
+ status: 'open',
116
+ priority: 2,
117
+ issue_type: 'task'
118
+ }
119
+ ];
120
+ const view = createListView(mount, stubSend(issues));
121
+ await view.load();
122
+
123
+ // Initially shows all
124
+ expect(mount.querySelectorAll('tr.issue-row').length).toBe(4);
125
+
126
+ const typeSelect = /** @type {HTMLSelectElement} */ (
127
+ mount.querySelector('select[aria-label="Filter by type"]')
128
+ );
129
+ // Select bug
130
+ typeSelect.value = 'bug';
131
+ typeSelect.dispatchEvent(new Event('change'));
132
+ await Promise.resolve();
133
+ const bug_only = Array.from(mount.querySelectorAll('tr.issue-row')).map(
134
+ (el) => el.getAttribute('data-issue-id') || ''
135
+ );
136
+ expect(bug_only).toEqual(['UI-1', 'UI-3']);
137
+
138
+ // Switch to feature
139
+ typeSelect.value = 'feature';
140
+ typeSelect.dispatchEvent(new Event('change'));
141
+ await Promise.resolve();
142
+ const feature_only = Array.from(mount.querySelectorAll('tr.issue-row')).map(
143
+ (el) => el.getAttribute('data-issue-id') || ''
144
+ );
145
+ expect(feature_only).toEqual(['UI-2']);
146
+
147
+ // Combine with search while bug selected
148
+ typeSelect.value = 'bug';
149
+ typeSelect.dispatchEvent(new Event('change'));
150
+ const input = /** @type {HTMLInputElement} */ (
151
+ mount.querySelector('input[type="search"]')
152
+ );
153
+ input.value = 'ga';
154
+ input.dispatchEvent(new Event('input'));
155
+ await Promise.resolve();
156
+ const filtered = Array.from(mount.querySelectorAll('tr.issue-row')).map(
157
+ (el) => el.getAttribute('data-issue-id') || ''
158
+ );
159
+ expect(filtered).toEqual(['UI-3']);
160
+ });
161
+
162
+ test('applies type filters after Ready reload', async () => {
163
+ document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
164
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
165
+
166
+ const allIssues = [
167
+ {
168
+ id: 'UI-1',
169
+ title: 'One',
170
+ status: 'open',
171
+ priority: 1,
172
+ issue_type: 'task'
173
+ },
174
+ {
175
+ id: 'UI-2',
176
+ title: 'Two',
177
+ status: 'open',
178
+ priority: 2,
179
+ issue_type: 'feature'
180
+ },
181
+ {
182
+ id: 'UI-3',
183
+ title: 'Three',
184
+ status: 'open',
185
+ priority: 2,
186
+ issue_type: 'bug'
187
+ }
188
+ ];
189
+ const readyIssues = [
190
+ {
191
+ id: 'UI-2',
192
+ title: 'Two',
193
+ status: 'open',
194
+ priority: 2,
195
+ issue_type: 'feature'
196
+ },
197
+ {
198
+ id: 'UI-3',
199
+ title: 'Three',
200
+ status: 'open',
201
+ priority: 2,
202
+ issue_type: 'bug'
203
+ }
204
+ ];
205
+
206
+ /** @type {{ calls: any[] }} */
207
+ const spy = { calls: [] };
208
+ /** @type {(type: string, payload?: unknown) => Promise<any[]>} */
209
+ const send = async (type, payload) => {
210
+ spy.calls.push({ type, payload });
211
+ const p = /** @type {any} */ (payload);
212
+ if (p && p.filters && p.filters.ready === true) {
213
+ return readyIssues;
214
+ }
215
+ return allIssues;
216
+ };
217
+
218
+ const view = createListView(mount, send);
219
+ await view.load();
220
+ const statusSelect = /** @type {HTMLSelectElement} */ (
221
+ mount.querySelector('select')
222
+ );
223
+ statusSelect.value = 'ready';
224
+ statusSelect.dispatchEvent(new Event('change'));
225
+ await Promise.resolve();
226
+
227
+ // Apply type filter (feature)
228
+ const typeSelect = /** @type {HTMLSelectElement} */ (
229
+ mount.querySelector('select[aria-label="Filter by type"]')
230
+ );
231
+ typeSelect.value = 'feature';
232
+ typeSelect.dispatchEvent(new Event('change'));
233
+ await Promise.resolve();
234
+
235
+ const rows = Array.from(mount.querySelectorAll('tr.issue-row')).map(
236
+ (el) => el.getAttribute('data-issue-id') || ''
237
+ );
238
+ expect(rows).toEqual(['UI-2']);
239
+
240
+ // Ensure ready call happened
241
+ const has_ready = spy.calls.some(
242
+ (c) =>
243
+ c.type === 'list-issues' &&
244
+ c.payload &&
245
+ c.payload.filters &&
246
+ c.payload.filters.ready === true
247
+ );
248
+ expect(has_ready).toBe(true);
249
+ });
250
+
251
+ test('initializes type filter from store and reflects in controls', async () => {
252
+ document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
253
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
254
+
255
+ const issues = [
256
+ {
257
+ id: 'UI-1',
258
+ title: 'Alpha',
259
+ status: 'open',
260
+ priority: 1,
261
+ issue_type: 'bug'
262
+ },
263
+ {
264
+ id: 'UI-2',
265
+ title: 'Beta',
266
+ status: 'open',
267
+ priority: 2,
268
+ issue_type: 'feature'
269
+ },
270
+ {
271
+ id: 'UI-3',
272
+ title: 'Gamma closed',
273
+ status: 'closed',
274
+ priority: 3,
275
+ issue_type: 'bug'
276
+ }
277
+ ];
278
+
279
+ /** @type {{ state: any, subs: ((s:any)=>void)[], getState: () => any, setState: (patch:any)=>void, subscribe: (fn:(s:any)=>void)=>()=>void }} */
280
+ const store = {
281
+ state: {
282
+ selected_id: null,
283
+ filters: { status: 'all', search: '', type: 'bug' }
284
+ },
285
+ subs: [],
286
+ getState() {
287
+ return this.state;
288
+ },
289
+ setState(patch) {
290
+ this.state = {
291
+ ...this.state,
292
+ ...(patch || {}),
293
+ filters: { ...this.state.filters, ...(patch.filters || {}) }
294
+ };
295
+ for (const fn of this.subs) {
296
+ fn(this.state);
297
+ }
298
+ },
299
+ subscribe(fn) {
300
+ this.subs.push(fn);
301
+ return () => {
302
+ this.subs = this.subs.filter((f) => f !== fn);
303
+ };
304
+ }
305
+ };
306
+
307
+ const view = createListView(mount, stubSend(issues), undefined, store);
308
+ await view.load();
309
+
310
+ // Only bug issues visible
311
+ const rows = Array.from(mount.querySelectorAll('tr.issue-row')).map(
312
+ (el) => el.getAttribute('data-issue-id') || ''
313
+ );
314
+ expect(rows).toEqual(['UI-1', 'UI-3']);
315
+
316
+ const typeSelect = /** @type {HTMLSelectElement} */ (
317
+ mount.querySelector('select[aria-label="Filter by type"]')
318
+ );
319
+ expect(typeSelect.value).toBe('bug');
320
+ });
321
+
322
+ test('ready filter via select triggers backend reload', async () => {
323
+ document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
324
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
325
+
326
+ const allIssues = [
327
+ { id: 'UI-1', title: 'One', status: 'open', priority: 1 },
328
+ { id: 'UI-2', title: 'Two', status: 'open', priority: 2 }
329
+ ];
330
+ const readyIssues = [
331
+ { id: 'UI-2', title: 'Two', status: 'open', priority: 2 }
332
+ ];
333
+
334
+ /** @type {{ calls: any[] }} */
335
+ const spy = { calls: [] };
336
+ /** @type {(type: string, payload?: unknown) => Promise<any[]>} */
337
+ const send = async (type, payload) => {
338
+ spy.calls.push({ type, payload });
339
+ const p = /** @type {any} */ (payload);
340
+ if (p && p.filters && p.filters.ready === true) {
341
+ return readyIssues;
342
+ }
343
+ return allIssues;
344
+ };
345
+
346
+ const view = createListView(mount, send);
347
+ await view.load();
348
+ expect(mount.querySelectorAll('tr.issue-row').length).toBe(2);
349
+
350
+ const select = /** @type {HTMLSelectElement} */ (
351
+ mount.querySelector('select')
352
+ );
353
+ select.value = 'ready';
354
+ select.dispatchEvent(new Event('change'));
355
+ // Await a microtask to allow load to complete in jsdom
356
+ await Promise.resolve();
357
+
358
+ // A call should include filters.ready = true
359
+ const has_ready = spy.calls.some(
360
+ (c) =>
361
+ c.type === 'list-issues' &&
362
+ c.payload &&
363
+ c.payload.filters &&
364
+ c.payload.filters.ready === true
365
+ );
366
+ expect(has_ready).toBe(true);
367
+ expect(mount.querySelectorAll('tr.issue-row').length).toBe(1);
368
+ });
369
+
370
+ test('switching ready → all reloads full list', async () => {
371
+ document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
372
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
373
+
374
+ const allIssues = [
375
+ { id: 'UI-1', title: 'One', status: 'open', priority: 1 },
376
+ { id: 'UI-2', title: 'Two', status: 'closed', priority: 2 }
377
+ ];
378
+ const readyIssues = [
379
+ { id: 'UI-2', title: 'Two', status: 'closed', priority: 2 }
380
+ ];
381
+
382
+ /** @type {{ calls: any[] }} */
383
+ const spy = { calls: [] };
384
+ /** @type {(type: string, payload?: unknown) => Promise<any[]>} */
385
+ const send = async (type, payload) => {
386
+ spy.calls.push({ type, payload });
387
+ const p = /** @type {any} */ (payload);
388
+ if (p && p.filters && p.filters.ready === true) {
389
+ return readyIssues;
390
+ }
391
+ return allIssues;
392
+ };
393
+
394
+ const view = createListView(mount, send);
395
+ await view.load();
396
+ expect(mount.querySelectorAll('tr.issue-row').length).toBe(2);
397
+
398
+ const select = /** @type {HTMLSelectElement} */ (
399
+ mount.querySelector('select')
400
+ );
401
+
402
+ // Switch to ready (backend should return the smaller set)
403
+ select.value = 'ready';
404
+ select.dispatchEvent(new Event('change'));
405
+ await Promise.resolve();
406
+ expect(mount.querySelectorAll('tr.issue-row').length).toBe(1);
407
+
408
+ // Switch back to all; view should reload full list from backend
409
+ select.value = 'all';
410
+ select.dispatchEvent(new Event('change'));
411
+ await Promise.resolve();
412
+ expect(mount.querySelectorAll('tr.issue-row').length).toBe(2);
413
+
414
+ // Verify that a request without ready=true was made after switching to all
415
+ const lastCall = spy.calls[spy.calls.length - 1];
416
+ expect(lastCall.type).toBe('list-issues');
417
+ const payload = /** @type {any} */ (lastCall.payload);
418
+ expect(payload && payload.filters && payload.filters.ready).not.toBe(true);
419
+ });
420
+
421
+ test('applies persisted filters from store on initial load', async () => {
422
+ document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
423
+ const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
424
+
425
+ const issues = [
426
+ { id: 'UI-1', title: 'Alpha', status: 'open', priority: 1 },
427
+ { id: 'UI-2', title: 'Gamma', status: 'open', priority: 2 },
428
+ { id: 'UI-3', title: 'Gamma closed', status: 'closed', priority: 3 }
429
+ ];
430
+
431
+ /** @type {{ state: any, subs: ((s:any)=>void)[], getState: () => any, setState: (patch:any)=>void, subscribe: (fn:(s:any)=>void)=>()=>void }} */
432
+ const store = {
433
+ state: { selected_id: null, filters: { status: 'open', search: 'ga' } },
434
+ subs: [],
435
+ getState() {
436
+ return this.state;
437
+ },
438
+ setState(patch) {
439
+ this.state = {
440
+ ...this.state,
441
+ ...(patch || {}),
442
+ filters: { ...this.state.filters, ...(patch.filters || {}) }
443
+ };
444
+ for (const fn of this.subs) {
445
+ fn(this.state);
446
+ }
447
+ },
448
+ subscribe(fn) {
449
+ this.subs.push(fn);
450
+ return () => {
451
+ this.subs = this.subs.filter((f) => f !== fn);
452
+ };
453
+ }
454
+ };
455
+
456
+ const view = createListView(mount, stubSend(issues), undefined, store);
457
+ await view.load();
458
+
459
+ // Expect only UI-2 ("Gamma" open) to be visible
460
+ const items = Array.from(mount.querySelectorAll('tr.issue-row')).map(
461
+ (el) => ({
462
+ id: el.getAttribute('data-issue-id') || '',
463
+ text: el.textContent || ''
464
+ })
465
+ );
466
+ expect(items.length).toBe(1);
467
+ expect(items[0].id).toBe('UI-2');
468
+
469
+ // Controls reflect persisted filters
470
+ const select = /** @type {HTMLSelectElement} */ (
471
+ mount.querySelector('select')
472
+ );
473
+ const input = /** @type {HTMLInputElement} */ (
474
+ mount.querySelector('input[type="search"]')
475
+ );
476
+ expect(select.value).toBe('open');
477
+ expect(input.value).toBe('ga');
478
+ });
479
+ });
@@ -0,0 +1,67 @@
1
+ import { html, render } from 'lit-html';
2
+
3
+ /**
4
+ * Render the top navigation with three tabs and handle route changes.
5
+ * @param {HTMLElement} mount_element
6
+ * @param {{ getState: () => any, subscribe: (fn: (s: any) => void) => () => void }} store
7
+ * @param {{ gotoView: (v: 'issues'|'epics'|'board') => void }} router
8
+ */
9
+ export function createTopNav(mount_element, store, router) {
10
+ /** @type {(() => void) | null} */
11
+ let unsubscribe = null;
12
+
13
+ /**
14
+ * @param {'issues'|'epics'|'board'} view
15
+ * @returns {(ev: MouseEvent) => void}
16
+ */
17
+ function onClick(view) {
18
+ return (ev) => {
19
+ ev.preventDefault();
20
+ router.gotoView(view);
21
+ };
22
+ }
23
+
24
+ function template() {
25
+ const s = store.getState();
26
+ const active = s.view || 'issues';
27
+ return html`
28
+ <nav class="header-nav" aria-label="Primary">
29
+ <a
30
+ href="#/issues"
31
+ class="tab ${active === 'issues' ? 'active' : ''}"
32
+ @click=${onClick('issues')}
33
+ >Issues</a
34
+ >
35
+ <a
36
+ href="#/epics"
37
+ class="tab ${active === 'epics' ? 'active' : ''}"
38
+ @click=${onClick('epics')}
39
+ >Epics</a
40
+ >
41
+ <a
42
+ href="#/board"
43
+ class="tab ${active === 'board' ? 'active' : ''}"
44
+ @click=${onClick('board')}
45
+ >Board</a
46
+ >
47
+ </nav>
48
+ `;
49
+ }
50
+
51
+ function doRender() {
52
+ render(template(), mount_element);
53
+ }
54
+
55
+ doRender();
56
+ unsubscribe = store.subscribe(() => doRender());
57
+
58
+ return {
59
+ destroy() {
60
+ if (unsubscribe) {
61
+ unsubscribe();
62
+ unsubscribe = null;
63
+ }
64
+ render(html``, mount_element);
65
+ }
66
+ };
67
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { createTopNav } from './nav.js';
3
+
4
+ function setup() {
5
+ document.body.innerHTML = '<div id="m"></div>';
6
+ const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
7
+ const store = {
8
+ state: { view: 'issues' },
9
+ getState() {
10
+ return this.state;
11
+ },
12
+ /** @param {any} v */
13
+ set(v) {
14
+ this.state = { ...this.state, ...v };
15
+ },
16
+ /** @param {(s: any) => void} fn */
17
+ subscribe(fn) {
18
+ // simplistic subscription for test
19
+ this._fn = fn;
20
+ return () => void 0;
21
+ },
22
+ _fn: /** @type {(s: any) => void} */ (() => {})
23
+ };
24
+ const router = { gotoView: vi.fn() };
25
+ return { mount, store, router };
26
+ }
27
+
28
+ describe('views/nav', () => {
29
+ test('renders and routes between tabs', async () => {
30
+ const { mount, store, router } = setup();
31
+ createTopNav(
32
+ mount,
33
+ /** @type {any} */ (store),
34
+ /** @type {any} */ (router)
35
+ );
36
+ const links = mount.querySelectorAll('a.tab');
37
+ expect(links.length).toBe(3);
38
+ links[1].dispatchEvent(new MouseEvent('click', { bubbles: true }));
39
+ expect(router.gotoView).toHaveBeenCalledWith('epics');
40
+ links[2].dispatchEvent(new MouseEvent('click', { bubbles: true }));
41
+ expect(router.gotoView).toHaveBeenCalledWith('board');
42
+ });
43
+ });