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,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
|
+
});
|
package/app/views/nav.js
ADDED
|
@@ -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
|
+
});
|