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
package/app/main.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { html, render } from 'lit-html';
|
|
2
|
+
import { createDataLayer } from './data/providers.js';
|
|
3
|
+
import { createHashRouter } from './router.js';
|
|
4
|
+
import { createStore } from './state.js';
|
|
5
|
+
import { createBoardView } from './views/board.js';
|
|
6
|
+
import { createDetailView } from './views/detail.js';
|
|
7
|
+
import { createEpicsView } from './views/epics.js';
|
|
8
|
+
import { createListView } from './views/list.js';
|
|
9
|
+
import { createTopNav } from './views/nav.js';
|
|
10
|
+
import { createWsClient } from './ws.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Bootstrap the SPA shell with two panels.
|
|
14
|
+
* @param {HTMLElement} root_element - The container element to render into.
|
|
15
|
+
*/
|
|
16
|
+
export function bootstrap(root_element) {
|
|
17
|
+
// Render route shells (nav is mounted in header)
|
|
18
|
+
const shell = html`
|
|
19
|
+
<section id="issues-root" class="route issues">
|
|
20
|
+
<aside id="list-panel" class="panel"></aside>
|
|
21
|
+
</section>
|
|
22
|
+
<section id="epics-root" class="route epics" hidden></section>
|
|
23
|
+
<section id="board-root" class="route board" hidden></section>
|
|
24
|
+
<section id="detail-panel" class="route detail" hidden></section>
|
|
25
|
+
`;
|
|
26
|
+
render(shell, root_element);
|
|
27
|
+
|
|
28
|
+
/** @type {HTMLElement|null} */
|
|
29
|
+
const nav_mount = document.getElementById('top-nav');
|
|
30
|
+
/** @type {HTMLElement|null} */
|
|
31
|
+
const issues_root = document.getElementById('issues-root');
|
|
32
|
+
/** @type {HTMLElement|null} */
|
|
33
|
+
const epics_root = document.getElementById('epics-root');
|
|
34
|
+
/** @type {HTMLElement|null} */
|
|
35
|
+
const board_root = document.getElementById('board-root');
|
|
36
|
+
|
|
37
|
+
/** @type {HTMLElement|null} */
|
|
38
|
+
const list_mount = document.getElementById('list-panel');
|
|
39
|
+
/** @type {HTMLElement|null} */
|
|
40
|
+
const detail_mount = document.getElementById('detail-panel');
|
|
41
|
+
if (list_mount && issues_root && epics_root && board_root && detail_mount) {
|
|
42
|
+
const client = createWsClient();
|
|
43
|
+
// Load persisted filters (status/search/type) from localStorage
|
|
44
|
+
/** @type {{ status: 'all'|'open'|'in_progress'|'closed'|'ready', search: string, type: string }} */
|
|
45
|
+
let persistedFilters = { status: 'all', search: '', type: '' };
|
|
46
|
+
try {
|
|
47
|
+
const raw = window.localStorage.getItem('beads-ui.filters');
|
|
48
|
+
if (raw) {
|
|
49
|
+
const obj = JSON.parse(raw);
|
|
50
|
+
if (obj && typeof obj === 'object') {
|
|
51
|
+
const ALLOWED = ['bug', 'feature', 'task', 'epic', 'chore'];
|
|
52
|
+
/** @type {string} */
|
|
53
|
+
let parsed_type = '';
|
|
54
|
+
if (typeof obj.type === 'string' && ALLOWED.includes(obj.type)) {
|
|
55
|
+
parsed_type = obj.type;
|
|
56
|
+
} else if (Array.isArray(obj.types)) {
|
|
57
|
+
// Backwards compatibility: pick first valid from previous array format
|
|
58
|
+
/** @type {string} */
|
|
59
|
+
let first_valid = '';
|
|
60
|
+
for (const it of obj.types) {
|
|
61
|
+
if (ALLOWED.includes(String(it))) {
|
|
62
|
+
first_valid = /** @type {string} */ (it);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
parsed_type = first_valid;
|
|
67
|
+
}
|
|
68
|
+
persistedFilters = {
|
|
69
|
+
status: ['all', 'open', 'in_progress', 'closed', 'ready'].includes(
|
|
70
|
+
obj.status
|
|
71
|
+
)
|
|
72
|
+
? obj.status
|
|
73
|
+
: 'all',
|
|
74
|
+
search: typeof obj.search === 'string' ? obj.search : '',
|
|
75
|
+
type: parsed_type
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// ignore parse errors
|
|
81
|
+
}
|
|
82
|
+
// Load last-view from storage
|
|
83
|
+
/** @type {'issues'|'epics'|'board'} */
|
|
84
|
+
let last_view = 'issues';
|
|
85
|
+
try {
|
|
86
|
+
const raw_view = window.localStorage.getItem('beads-ui.view');
|
|
87
|
+
if (
|
|
88
|
+
raw_view === 'issues' ||
|
|
89
|
+
raw_view === 'epics' ||
|
|
90
|
+
raw_view === 'board'
|
|
91
|
+
) {
|
|
92
|
+
last_view = raw_view;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore
|
|
96
|
+
}
|
|
97
|
+
const store = createStore({ filters: persistedFilters, view: last_view });
|
|
98
|
+
const router = createHashRouter(store);
|
|
99
|
+
router.start();
|
|
100
|
+
/**
|
|
101
|
+
* @param {string} type
|
|
102
|
+
* @param {unknown} payload
|
|
103
|
+
*/
|
|
104
|
+
const transport = async (type, payload) => {
|
|
105
|
+
try {
|
|
106
|
+
return await client.send(/** @type {any} */ (type), payload);
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
// Top navigation (optional mount)
|
|
112
|
+
if (nav_mount) {
|
|
113
|
+
createTopNav(nav_mount, store, router);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const issues_view = createListView(
|
|
117
|
+
list_mount,
|
|
118
|
+
transport,
|
|
119
|
+
(hash) => {
|
|
120
|
+
const id = hash.replace('#/issue/', '');
|
|
121
|
+
if (id) {
|
|
122
|
+
router.gotoIssue(id);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
store
|
|
126
|
+
);
|
|
127
|
+
// Persist filter changes to localStorage
|
|
128
|
+
store.subscribe((s) => {
|
|
129
|
+
try {
|
|
130
|
+
const data = {
|
|
131
|
+
status: s.filters.status,
|
|
132
|
+
search: s.filters.search,
|
|
133
|
+
type: typeof s.filters.type === 'string' ? s.filters.type : ''
|
|
134
|
+
};
|
|
135
|
+
window.localStorage.setItem('beads-ui.filters', JSON.stringify(data));
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
void issues_view.load();
|
|
141
|
+
const detail = createDetailView(detail_mount, transport, (hash) => {
|
|
142
|
+
const id = hash.replace('#/issue/', '');
|
|
143
|
+
if (id) {
|
|
144
|
+
router.gotoIssue(id);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// React to selectedId changes -> show detail page full-width
|
|
149
|
+
store.subscribe((s) => {
|
|
150
|
+
const id = s.selected_id;
|
|
151
|
+
if (id) {
|
|
152
|
+
void detail.load(id);
|
|
153
|
+
} else {
|
|
154
|
+
detail.clear();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Initial deep-link: if router set a selectedId before subscription, load it now
|
|
159
|
+
const initialId = store.getState().selected_id;
|
|
160
|
+
if (initialId) {
|
|
161
|
+
void detail.load(initialId);
|
|
162
|
+
} else {
|
|
163
|
+
detail.clear();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Refresh views on push updates (target minimally and avoid flicker)
|
|
167
|
+
client.on('issues-changed', (payload) => {
|
|
168
|
+
const s = store.getState();
|
|
169
|
+
const hintIds =
|
|
170
|
+
payload && payload.hint && Array.isArray(payload.hint.ids)
|
|
171
|
+
? /** @type {string[]} */ (payload.hint.ids)
|
|
172
|
+
: null;
|
|
173
|
+
|
|
174
|
+
const showingDetail = Boolean(s.selected_id);
|
|
175
|
+
|
|
176
|
+
// If a top-level view is visible (and not detail), refresh that view
|
|
177
|
+
if (!showingDetail) {
|
|
178
|
+
if (s.view === 'issues') {
|
|
179
|
+
void issues_view.load();
|
|
180
|
+
} else if (s.view === 'epics') {
|
|
181
|
+
void epics_view.load();
|
|
182
|
+
} else if (s.view === 'board') {
|
|
183
|
+
void board_view.load();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// If a detail is visible, re-fetch it when relevant or when hints are absent
|
|
188
|
+
if (showingDetail && s.selected_id) {
|
|
189
|
+
if (!hintIds || hintIds.includes(s.selected_id)) {
|
|
190
|
+
void detail.load(s.selected_id);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Toggle route shells on view/detail change and persist
|
|
196
|
+
const data = createDataLayer(/** @type {any} */ (transport), client.on);
|
|
197
|
+
const epics_view = createEpicsView(epics_root, data, (id) =>
|
|
198
|
+
router.gotoIssue(id)
|
|
199
|
+
);
|
|
200
|
+
const board_view = createBoardView(board_root, data, (id) =>
|
|
201
|
+
router.gotoIssue(id)
|
|
202
|
+
);
|
|
203
|
+
// Preload epics when switching to view
|
|
204
|
+
/**
|
|
205
|
+
* @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
|
|
206
|
+
*/
|
|
207
|
+
const onRouteChange = (s) => {
|
|
208
|
+
const showDetail = Boolean(s.selected_id);
|
|
209
|
+
if (issues_root && epics_root && board_root && detail_mount) {
|
|
210
|
+
issues_root.hidden = showDetail || s.view !== 'issues';
|
|
211
|
+
epics_root.hidden = showDetail || s.view !== 'epics';
|
|
212
|
+
board_root.hidden = showDetail || s.view !== 'board';
|
|
213
|
+
detail_mount.hidden = !showDetail;
|
|
214
|
+
}
|
|
215
|
+
if (!showDetail && s.view === 'epics') {
|
|
216
|
+
void epics_view.load();
|
|
217
|
+
}
|
|
218
|
+
if (!showDetail && s.view === 'board') {
|
|
219
|
+
void board_view.load();
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
window.localStorage.setItem('beads-ui.view', s.view);
|
|
223
|
+
} catch {
|
|
224
|
+
// ignore
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
store.subscribe(onRouteChange);
|
|
228
|
+
// Ensure initial state is reflected (fixes reload on #/epics)
|
|
229
|
+
onRouteChange(store.getState());
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
234
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
235
|
+
// Initialize theme from saved preference or OS preference
|
|
236
|
+
try {
|
|
237
|
+
const saved = window.localStorage.getItem('beads-ui.theme');
|
|
238
|
+
const prefersDark =
|
|
239
|
+
window.matchMedia &&
|
|
240
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
241
|
+
const initial =
|
|
242
|
+
saved === 'dark' || saved === 'light'
|
|
243
|
+
? saved
|
|
244
|
+
: prefersDark
|
|
245
|
+
? 'dark'
|
|
246
|
+
: 'light';
|
|
247
|
+
document.documentElement.setAttribute('data-theme', initial);
|
|
248
|
+
const sw = /** @type {HTMLInputElement|null} */ (
|
|
249
|
+
document.getElementById('theme-switch')
|
|
250
|
+
);
|
|
251
|
+
if (sw) {
|
|
252
|
+
sw.checked = initial === 'dark';
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
// ignore theme init errors
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Wire up theme switch in header
|
|
259
|
+
const themeSwitch = /** @type {HTMLInputElement|null} */ (
|
|
260
|
+
document.getElementById('theme-switch')
|
|
261
|
+
);
|
|
262
|
+
if (themeSwitch) {
|
|
263
|
+
themeSwitch.addEventListener('change', () => {
|
|
264
|
+
const mode = themeSwitch.checked ? 'dark' : 'light';
|
|
265
|
+
document.documentElement.setAttribute('data-theme', mode);
|
|
266
|
+
try {
|
|
267
|
+
window.localStorage.setItem('beads-ui.theme', mode);
|
|
268
|
+
} catch {
|
|
269
|
+
// ignore persistence errors
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** @type {HTMLElement|null} */
|
|
275
|
+
const app_root = document.getElementById('app');
|
|
276
|
+
if (app_root) {
|
|
277
|
+
bootstrap(app_root);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { bootstrap } from './main.js';
|
|
3
|
+
|
|
4
|
+
// Provide a mutable client instance for module-level mock
|
|
5
|
+
/** @type {any} */
|
|
6
|
+
let CLIENT = null;
|
|
7
|
+
vi.mock('./ws.js', () => ({
|
|
8
|
+
createWsClient: () => CLIENT
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe('live updates: issues-changed handling', () => {
|
|
12
|
+
test('refreshes list only when on issues view and preserves scroll', async () => {
|
|
13
|
+
/** @type {{ send: import('vitest').Mock, on: (t: string, h: (p:any)=>void)=>void, trigger: (t:string, p:any)=>void }} */
|
|
14
|
+
CLIENT = {
|
|
15
|
+
send: vi.fn(async (type) => {
|
|
16
|
+
if (type === 'list-issues') {
|
|
17
|
+
return [
|
|
18
|
+
{ id: 'UI-1', title: 'A', status: 'open' },
|
|
19
|
+
{ id: 'UI-2', title: 'B', status: 'open' }
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
if (type === 'show-issue') {
|
|
23
|
+
return { id: 'UI-1' };
|
|
24
|
+
}
|
|
25
|
+
if (type === 'epic-status') {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}),
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} _type
|
|
32
|
+
* @param {(p:any)=>void} handler
|
|
33
|
+
*/
|
|
34
|
+
on(_type, handler) {
|
|
35
|
+
this._handler = handler;
|
|
36
|
+
return () => {};
|
|
37
|
+
},
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} type
|
|
40
|
+
* @param {any} payload
|
|
41
|
+
*/
|
|
42
|
+
trigger(type, payload) {
|
|
43
|
+
if (type === 'issues-changed' && this._handler) this._handler(payload);
|
|
44
|
+
},
|
|
45
|
+
close() {},
|
|
46
|
+
getState() {
|
|
47
|
+
return 'open';
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
document.body.innerHTML = '<main id="app"></main>';
|
|
52
|
+
const root = /** @type {HTMLElement} */ (document.getElementById('app'));
|
|
53
|
+
|
|
54
|
+
bootstrap(root);
|
|
55
|
+
await Promise.resolve();
|
|
56
|
+
|
|
57
|
+
// Simulate a scrolled list container
|
|
58
|
+
const listRoot = /** @type {HTMLElement} */ (
|
|
59
|
+
document.getElementById('list-root')
|
|
60
|
+
);
|
|
61
|
+
if (listRoot) {
|
|
62
|
+
listRoot.scrollTop = 120;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const callsBefore = CLIENT.send.mock.calls.length;
|
|
66
|
+
CLIENT.trigger('issues-changed', { ts: Date.now() });
|
|
67
|
+
await Promise.resolve();
|
|
68
|
+
|
|
69
|
+
const callsAfter = CLIENT.send.mock.calls.length;
|
|
70
|
+
// One additional list-issues request, no detail fetch
|
|
71
|
+
const newCalls = CLIENT.send.mock.calls.slice(callsBefore);
|
|
72
|
+
const types = newCalls.map(/** @param {any} c */ (c) => c[0]);
|
|
73
|
+
expect(types).toEqual(['list-issues']);
|
|
74
|
+
|
|
75
|
+
// Scroll should remain
|
|
76
|
+
const listRootAfter = /** @type {HTMLElement} */ (
|
|
77
|
+
document.getElementById('list-root')
|
|
78
|
+
);
|
|
79
|
+
expect(listRootAfter.scrollTop).toBe(120);
|
|
80
|
+
expect(callsAfter).toBe(callsBefore + 1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('refreshes detail only when detail is visible and id matches hint', async () => {
|
|
84
|
+
/** @type {{ send: import('vitest').Mock, on: (t: string, h: (p:any)=>void)=>void, trigger: (t:string, p:any)=>void }} */
|
|
85
|
+
CLIENT = {
|
|
86
|
+
send: vi.fn(async (type, payload) => {
|
|
87
|
+
if (type === 'list-issues') {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
if (type === 'show-issue') {
|
|
91
|
+
return { id: payload.id };
|
|
92
|
+
}
|
|
93
|
+
if (type === 'epic-status') {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}),
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} _type
|
|
100
|
+
* @param {(p:any)=>void} handler
|
|
101
|
+
*/
|
|
102
|
+
on(_type, handler) {
|
|
103
|
+
this._handler = handler;
|
|
104
|
+
return () => {};
|
|
105
|
+
},
|
|
106
|
+
/**
|
|
107
|
+
* @param {string} type
|
|
108
|
+
* @param {any} payload
|
|
109
|
+
*/
|
|
110
|
+
trigger(type, payload) {
|
|
111
|
+
if (type === 'issues-changed' && this._handler) this._handler(payload);
|
|
112
|
+
},
|
|
113
|
+
close() {},
|
|
114
|
+
getState() {
|
|
115
|
+
return 'open';
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Navigate to detail view
|
|
120
|
+
window.location.hash = '#/issue/UI-1';
|
|
121
|
+
document.body.innerHTML = '<main id="app"></main>';
|
|
122
|
+
const root = /** @type {HTMLElement} */ (document.getElementById('app'));
|
|
123
|
+
|
|
124
|
+
bootstrap(root);
|
|
125
|
+
await Promise.resolve();
|
|
126
|
+
|
|
127
|
+
CLIENT.send.mockClear();
|
|
128
|
+
CLIENT.trigger('issues-changed', {
|
|
129
|
+
ts: Date.now(),
|
|
130
|
+
hint: { ids: ['UI-1'] }
|
|
131
|
+
});
|
|
132
|
+
await Promise.resolve();
|
|
133
|
+
|
|
134
|
+
const calls = CLIENT.send.mock.calls.map(/** @param {any} c */ (c) => c[0]);
|
|
135
|
+
expect(calls).toEqual(['show-issue']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('refreshes epics when epics view visible', async () => {
|
|
139
|
+
CLIENT = {
|
|
140
|
+
send: vi.fn(async (type) => {
|
|
141
|
+
if (type === 'epic-status') {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
return [];
|
|
145
|
+
}),
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} _type
|
|
148
|
+
* @param {(p:any)=>void} handler
|
|
149
|
+
*/
|
|
150
|
+
on(_type, handler) {
|
|
151
|
+
this._handler = handler;
|
|
152
|
+
return () => {};
|
|
153
|
+
},
|
|
154
|
+
/**
|
|
155
|
+
* @param {string} type
|
|
156
|
+
* @param {any} payload
|
|
157
|
+
*/
|
|
158
|
+
trigger(type, payload) {
|
|
159
|
+
if (type === 'issues-changed' && this._handler) this._handler(payload);
|
|
160
|
+
},
|
|
161
|
+
close() {},
|
|
162
|
+
getState() {
|
|
163
|
+
return 'open';
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
window.location.hash = '#/epics';
|
|
168
|
+
document.body.innerHTML = '<main id="app"></main>';
|
|
169
|
+
const root = /** @type {HTMLElement} */ (document.getElementById('app'));
|
|
170
|
+
bootstrap(root);
|
|
171
|
+
await Promise.resolve();
|
|
172
|
+
|
|
173
|
+
// Ignore initial load
|
|
174
|
+
CLIENT.send.mockClear();
|
|
175
|
+
CLIENT.trigger('issues-changed', { ts: Date.now() });
|
|
176
|
+
await Promise.resolve();
|
|
177
|
+
|
|
178
|
+
const calls = CLIENT.send.mock.calls.map(/** @param {any} c */ (c) => c[0]);
|
|
179
|
+
expect(calls).toEqual(['epic-status']);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('refreshes board when board view visible', async () => {
|
|
183
|
+
CLIENT = {
|
|
184
|
+
send: vi.fn(async (type) => {
|
|
185
|
+
if (type === 'list-issues') {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
if (type === 'epic-status') {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
return [];
|
|
192
|
+
}),
|
|
193
|
+
/**
|
|
194
|
+
* @param {string} _type
|
|
195
|
+
* @param {(p:any)=>void} handler
|
|
196
|
+
*/
|
|
197
|
+
on(_type, handler) {
|
|
198
|
+
this._handler = handler;
|
|
199
|
+
return () => {};
|
|
200
|
+
},
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} type
|
|
203
|
+
* @param {any} payload
|
|
204
|
+
*/
|
|
205
|
+
trigger(type, payload) {
|
|
206
|
+
if (type === 'issues-changed' && this._handler) this._handler(payload);
|
|
207
|
+
},
|
|
208
|
+
close() {},
|
|
209
|
+
getState() {
|
|
210
|
+
return 'open';
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
window.location.hash = '#/board';
|
|
215
|
+
document.body.innerHTML = '<main id="app"></main>';
|
|
216
|
+
const root = /** @type {HTMLElement} */ (document.getElementById('app'));
|
|
217
|
+
bootstrap(root);
|
|
218
|
+
await Promise.resolve();
|
|
219
|
+
|
|
220
|
+
CLIENT.send.mockClear();
|
|
221
|
+
CLIENT.trigger('issues-changed', { ts: Date.now() });
|
|
222
|
+
await Promise.resolve();
|
|
223
|
+
|
|
224
|
+
const calls = CLIENT.send.mock.calls.map(/** @param {any} c */ (c) => c[0]);
|
|
225
|
+
// Board loads multiple list-issues, assert at least one
|
|
226
|
+
expect(calls.length > 0).toBe(true);
|
|
227
|
+
expect(new Set(calls).has('list-issues')).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
});
|
package/app/main.test.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { bootstrap } from './main.js';
|
|
3
|
+
|
|
4
|
+
describe('app/main (jsdom)', () => {
|
|
5
|
+
test('renders two-panel shell into root', () => {
|
|
6
|
+
document.body.innerHTML = '<main id="app"></main>';
|
|
7
|
+
const root_element = /** @type {HTMLElement} */ (
|
|
8
|
+
document.getElementById('app')
|
|
9
|
+
);
|
|
10
|
+
bootstrap(root_element);
|
|
11
|
+
|
|
12
|
+
const list_panel = root_element.querySelector('#list-panel');
|
|
13
|
+
const detail_panel = root_element.querySelector('#detail-panel');
|
|
14
|
+
expect(list_panel).not.toBeNull();
|
|
15
|
+
expect(detail_panel).not.toBeNull();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
describe('theme toggle', () => {
|
|
4
|
+
test('sets dark data-theme and persists preference', async () => {
|
|
5
|
+
document.body.innerHTML = `
|
|
6
|
+
<header class="app-header">
|
|
7
|
+
<h1 class="app-title">beads-ui</h1>
|
|
8
|
+
<div class="header-actions">
|
|
9
|
+
<label class="theme-toggle">
|
|
10
|
+
<span>Dark</span>
|
|
11
|
+
<input id="theme-switch" type="checkbox" />
|
|
12
|
+
</label>
|
|
13
|
+
</div>
|
|
14
|
+
</header>
|
|
15
|
+
<main id="app"></main>`;
|
|
16
|
+
|
|
17
|
+
// Simulate the DOMContentLoaded logic from main.js
|
|
18
|
+
const themeSwitch = /** @type {HTMLInputElement} */ (
|
|
19
|
+
document.getElementById('theme-switch')
|
|
20
|
+
);
|
|
21
|
+
themeSwitch.checked = true;
|
|
22
|
+
themeSwitch.dispatchEvent(new Event('change'));
|
|
23
|
+
|
|
24
|
+
// Apply attribute as in main.js handler
|
|
25
|
+
document.documentElement.setAttribute('data-theme', 'dark');
|
|
26
|
+
window.localStorage.setItem('beads-ui.theme', 'dark');
|
|
27
|
+
|
|
28
|
+
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
|
29
|
+
expect(window.localStorage.getItem('beads-ui.theme')).toBe('dark');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('can switch back to light explicitly', async () => {
|
|
33
|
+
document.documentElement.setAttribute('data-theme', 'dark');
|
|
34
|
+
window.localStorage.setItem('beads-ui.theme', 'dark');
|
|
35
|
+
// Simulate toggle off
|
|
36
|
+
document.documentElement.setAttribute('data-theme', 'light');
|
|
37
|
+
window.localStorage.setItem('beads-ui.theme', 'light');
|
|
38
|
+
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
|
39
|
+
expect(window.localStorage.getItem('beads-ui.theme')).toBe('light');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { bootstrap } from './main.js';
|
|
3
|
+
|
|
4
|
+
// Mock WS client before importing the app
|
|
5
|
+
vi.mock('./ws.js', () => ({
|
|
6
|
+
createWsClient: () => ({
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} type
|
|
9
|
+
*/
|
|
10
|
+
async send(type) {
|
|
11
|
+
// Return minimal data for the list view
|
|
12
|
+
if (type === 'list-issues') {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
if (type === 'show-issue') {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (type === 'epic-status') {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
},
|
|
23
|
+
on() {
|
|
24
|
+
return () => {};
|
|
25
|
+
},
|
|
26
|
+
close() {},
|
|
27
|
+
getState() {
|
|
28
|
+
return 'open';
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
describe('initial view sync on reload (#/epics)', () => {
|
|
34
|
+
test('shows Epics view when hash is #/epics', async () => {
|
|
35
|
+
window.location.hash = '#/epics';
|
|
36
|
+
document.body.innerHTML = '<main id="app"></main>';
|
|
37
|
+
const root = /** @type {HTMLElement} */ (document.getElementById('app'));
|
|
38
|
+
|
|
39
|
+
bootstrap(root);
|
|
40
|
+
|
|
41
|
+
// Allow any microtasks to flush
|
|
42
|
+
await Promise.resolve();
|
|
43
|
+
|
|
44
|
+
const issuesRoot = /** @type {HTMLElement} */ (
|
|
45
|
+
document.getElementById('issues-root')
|
|
46
|
+
);
|
|
47
|
+
const epicsRoot = /** @type {HTMLElement} */ (
|
|
48
|
+
document.getElementById('epics-root')
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(issuesRoot.hidden).toBe(true);
|
|
52
|
+
expect(epicsRoot.hidden).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|