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,393 @@
|
|
|
1
|
+
/* global NodeListOf */
|
|
2
|
+
import { html, render } from 'lit-html';
|
|
3
|
+
import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
|
|
4
|
+
// issueDisplayId not used directly in this file; rendered in shared row
|
|
5
|
+
import { statusLabel } from '../utils/status.js';
|
|
6
|
+
import { createIssueRowRenderer } from './issue-row.js';
|
|
7
|
+
|
|
8
|
+
// List view implementation; requires a transport send function.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create the Issues List view.
|
|
16
|
+
* @param {HTMLElement} mount_element - Element to render into.
|
|
17
|
+
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
18
|
+
* @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
|
|
19
|
+
* @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store] - Optional state store.
|
|
20
|
+
* @returns {{ load: () => Promise<void>, destroy: () => void }} View API.
|
|
21
|
+
*/
|
|
22
|
+
export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
23
|
+
/** @type {string} */
|
|
24
|
+
let status_filter = 'all';
|
|
25
|
+
/** @type {string} */
|
|
26
|
+
let search_text = '';
|
|
27
|
+
/** @type {Issue[]} */
|
|
28
|
+
let issues_cache = [];
|
|
29
|
+
/** @type {string} */
|
|
30
|
+
let type_filter = '';
|
|
31
|
+
/** @type {string | null} */
|
|
32
|
+
let selected_id = store ? store.getState().selected_id : null;
|
|
33
|
+
/** @type {null | (() => void)} */
|
|
34
|
+
let unsubscribe = null;
|
|
35
|
+
// Shared row renderer (used in template below)
|
|
36
|
+
const row_renderer = createIssueRowRenderer({
|
|
37
|
+
navigate: (id) => {
|
|
38
|
+
const nav = navigate_fn || ((h) => (window.location.hash = h));
|
|
39
|
+
nav(`#/issue/${id}`);
|
|
40
|
+
},
|
|
41
|
+
onUpdate: updateInline,
|
|
42
|
+
requestRender: doRender,
|
|
43
|
+
getSelectedId: () => selected_id,
|
|
44
|
+
row_class: 'issue-row'
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Event: select status change.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* @param {Event} ev
|
|
52
|
+
*/
|
|
53
|
+
const onStatusChange = async (ev) => {
|
|
54
|
+
/** @type {HTMLSelectElement} */
|
|
55
|
+
const sel = /** @type {any} */ (ev.currentTarget);
|
|
56
|
+
status_filter = sel.value;
|
|
57
|
+
if (store) {
|
|
58
|
+
store.setState({
|
|
59
|
+
filters: { status: /** @type {any} */ (status_filter) }
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Always reload on status changes
|
|
63
|
+
await load();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Event: search input.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* @param {Event} ev
|
|
71
|
+
*/
|
|
72
|
+
const onSearchInput = (ev) => {
|
|
73
|
+
/** @type {HTMLInputElement} */
|
|
74
|
+
const input = /** @type {any} */ (ev.currentTarget);
|
|
75
|
+
search_text = input.value;
|
|
76
|
+
if (store) {
|
|
77
|
+
store.setState({ filters: { search: search_text } });
|
|
78
|
+
}
|
|
79
|
+
doRender();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Event: type select change.
|
|
84
|
+
* @param {Event} ev
|
|
85
|
+
*/
|
|
86
|
+
const onTypeChange = (ev) => {
|
|
87
|
+
/** @type {HTMLSelectElement} */
|
|
88
|
+
const sel = /** @type {any} */ (ev.currentTarget);
|
|
89
|
+
type_filter = sel.value || '';
|
|
90
|
+
if (store) {
|
|
91
|
+
store.setState({ filters: { type: type_filter } });
|
|
92
|
+
}
|
|
93
|
+
doRender();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Initialize filters from store on first render so reload applies persisted state
|
|
97
|
+
if (store) {
|
|
98
|
+
const s = store.getState();
|
|
99
|
+
if (s && s.filters && typeof s.filters === 'object') {
|
|
100
|
+
status_filter = s.filters.status || 'all';
|
|
101
|
+
search_text = s.filters.search || '';
|
|
102
|
+
type_filter = typeof s.filters.type === 'string' ? s.filters.type : '';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Initial values are reflected via bound `.value` in the template
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build lit-html template for the list view.
|
|
109
|
+
*/
|
|
110
|
+
function template() {
|
|
111
|
+
/** @type {Issue[]} */
|
|
112
|
+
let filtered = issues_cache;
|
|
113
|
+
if (status_filter !== 'all' && status_filter !== 'ready') {
|
|
114
|
+
filtered = filtered.filter(
|
|
115
|
+
(it) => String(it.status || '') === status_filter
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (search_text) {
|
|
119
|
+
const needle = search_text.toLowerCase();
|
|
120
|
+
filtered = filtered.filter((it) => {
|
|
121
|
+
const a = String(it.id).toLowerCase();
|
|
122
|
+
const b = String(it.title || '').toLowerCase();
|
|
123
|
+
return a.includes(needle) || b.includes(needle);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (type_filter) {
|
|
127
|
+
filtered = filtered.filter(
|
|
128
|
+
(it) => String(it.issue_type || '') === String(type_filter)
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return html`
|
|
133
|
+
<div class="panel__header">
|
|
134
|
+
<select @change=${onStatusChange} .value=${status_filter}>
|
|
135
|
+
<option value="all">All</option>
|
|
136
|
+
<option value="ready">Ready</option>
|
|
137
|
+
<option value="open">${statusLabel('open')}</option>
|
|
138
|
+
<option value="in_progress">${statusLabel('in_progress')}</option>
|
|
139
|
+
<option value="closed">${statusLabel('closed')}</option>
|
|
140
|
+
</select>
|
|
141
|
+
<select
|
|
142
|
+
@change=${onTypeChange}
|
|
143
|
+
.value=${type_filter}
|
|
144
|
+
aria-label="Filter by type"
|
|
145
|
+
>
|
|
146
|
+
<option value="">All types</option>
|
|
147
|
+
${ISSUE_TYPES.map(
|
|
148
|
+
(t) =>
|
|
149
|
+
html`<option value=${t} ?selected=${type_filter === t}>
|
|
150
|
+
${typeLabel(t)}
|
|
151
|
+
</option>`
|
|
152
|
+
)}
|
|
153
|
+
</select>
|
|
154
|
+
<input
|
|
155
|
+
type="search"
|
|
156
|
+
placeholder="Search…"
|
|
157
|
+
@input=${onSearchInput}
|
|
158
|
+
.value=${search_text}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
<div class="panel__body" id="list-root">
|
|
162
|
+
${filtered.length === 0
|
|
163
|
+
? html`<div class="issues-block">
|
|
164
|
+
<div class="muted" style="padding:10px 12px;">No issues</div>
|
|
165
|
+
</div>`
|
|
166
|
+
: html`<div class="issues-block">
|
|
167
|
+
<table class="table">
|
|
168
|
+
<colgroup>
|
|
169
|
+
<col style="width: 100px" />
|
|
170
|
+
<col style="width: 120px" />
|
|
171
|
+
<col />
|
|
172
|
+
<col style="width: 120px" />
|
|
173
|
+
<col style="width: 160px" />
|
|
174
|
+
<col style="width: 130px" />
|
|
175
|
+
</colgroup>
|
|
176
|
+
<thead>
|
|
177
|
+
<tr>
|
|
178
|
+
<th>ID</th>
|
|
179
|
+
<th>Type</th>
|
|
180
|
+
<th>Title</th>
|
|
181
|
+
<th>Status</th>
|
|
182
|
+
<th>Assignee</th>
|
|
183
|
+
<th>Priority</th>
|
|
184
|
+
</tr>
|
|
185
|
+
</thead>
|
|
186
|
+
<tbody>
|
|
187
|
+
${filtered.map((it) => row_renderer(it))}
|
|
188
|
+
</tbody>
|
|
189
|
+
</table>
|
|
190
|
+
</div>`}
|
|
191
|
+
</div>
|
|
192
|
+
`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Render the current issues_cache with filters applied.
|
|
197
|
+
*/
|
|
198
|
+
function doRender() {
|
|
199
|
+
render(template(), mount_element);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Initial render (header + body shell with current state)
|
|
203
|
+
doRender();
|
|
204
|
+
// no separate ready checkbox when using select option
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Load issues from backend and re-render.
|
|
208
|
+
*/
|
|
209
|
+
async function load() {
|
|
210
|
+
// Preserve scroll position to avoid jarring jumps on live refresh
|
|
211
|
+
/** @type {HTMLElement|null} */
|
|
212
|
+
const beforeEl = /** @type {any} */ (
|
|
213
|
+
mount_element.querySelector('#list-root')
|
|
214
|
+
);
|
|
215
|
+
const prevScroll = beforeEl ? beforeEl.scrollTop : 0;
|
|
216
|
+
/** @type {any} */
|
|
217
|
+
const filters = {};
|
|
218
|
+
if (status_filter !== 'all' && status_filter !== 'ready') {
|
|
219
|
+
filters.status = status_filter;
|
|
220
|
+
}
|
|
221
|
+
if (status_filter === 'ready') {
|
|
222
|
+
filters.ready = true;
|
|
223
|
+
}
|
|
224
|
+
/** @type {unknown} */
|
|
225
|
+
let result;
|
|
226
|
+
try {
|
|
227
|
+
result = await sendFn('list-issues', { filters });
|
|
228
|
+
} catch {
|
|
229
|
+
result = [];
|
|
230
|
+
}
|
|
231
|
+
if (!Array.isArray(result)) {
|
|
232
|
+
issues_cache = [];
|
|
233
|
+
} else {
|
|
234
|
+
issues_cache = /** @type {Issue[]} */ (result);
|
|
235
|
+
}
|
|
236
|
+
doRender();
|
|
237
|
+
// Restore scroll position if possible
|
|
238
|
+
try {
|
|
239
|
+
/** @type {HTMLElement|null} */
|
|
240
|
+
const afterEl = /** @type {any} */ (
|
|
241
|
+
mount_element.querySelector('#list-root')
|
|
242
|
+
);
|
|
243
|
+
if (afterEl && prevScroll > 0) {
|
|
244
|
+
afterEl.scrollTop = prevScroll;
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
// ignore
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Keyboard navigation
|
|
252
|
+
mount_element.tabIndex = 0;
|
|
253
|
+
mount_element.addEventListener('keydown', (ev) => {
|
|
254
|
+
/** @type {HTMLTableSectionElement|null} */
|
|
255
|
+
const tbody = /** @type {any} */ (
|
|
256
|
+
mount_element.querySelector('#list-root tbody')
|
|
257
|
+
);
|
|
258
|
+
/** @type {NodeListOf<HTMLTableRowElement>} */
|
|
259
|
+
const items = tbody
|
|
260
|
+
? tbody.querySelectorAll('tr')
|
|
261
|
+
: /** @type {any} */ ([]);
|
|
262
|
+
if (items.length === 0) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
let idx = 0;
|
|
266
|
+
if (selected_id) {
|
|
267
|
+
const arr = Array.from(items);
|
|
268
|
+
idx = arr.findIndex((el) => {
|
|
269
|
+
const did = el.getAttribute('data-issue-id') || '';
|
|
270
|
+
return did === selected_id;
|
|
271
|
+
});
|
|
272
|
+
if (idx < 0) {
|
|
273
|
+
idx = 0;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (ev.key === 'ArrowDown') {
|
|
277
|
+
ev.preventDefault();
|
|
278
|
+
const next = items[Math.min(idx + 1, items.length - 1)];
|
|
279
|
+
const next_id = next ? next.getAttribute('data-issue-id') : '';
|
|
280
|
+
const set = next_id ? next_id : null;
|
|
281
|
+
if (store && set) {
|
|
282
|
+
store.setState({ selected_id: set });
|
|
283
|
+
}
|
|
284
|
+
selected_id = set;
|
|
285
|
+
doRender();
|
|
286
|
+
} else if (ev.key === 'ArrowUp') {
|
|
287
|
+
ev.preventDefault();
|
|
288
|
+
const prev = items[Math.max(idx - 1, 0)];
|
|
289
|
+
const prev_id = prev ? prev.getAttribute('data-issue-id') : '';
|
|
290
|
+
const set = prev_id ? prev_id : null;
|
|
291
|
+
if (store && set) {
|
|
292
|
+
store.setState({ selected_id: set });
|
|
293
|
+
}
|
|
294
|
+
selected_id = set;
|
|
295
|
+
doRender();
|
|
296
|
+
} else if (ev.key === 'Enter') {
|
|
297
|
+
ev.preventDefault();
|
|
298
|
+
const current = items[idx];
|
|
299
|
+
const id = current ? current.getAttribute('data-issue-id') : '';
|
|
300
|
+
if (id) {
|
|
301
|
+
const nav = navigate_fn || ((h) => (window.location.hash = h));
|
|
302
|
+
nav(`#/issue/${id}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Keep selection in sync with store
|
|
308
|
+
if (store) {
|
|
309
|
+
unsubscribe = store.subscribe((s) => {
|
|
310
|
+
if (s.selected_id !== selected_id) {
|
|
311
|
+
selected_id = s.selected_id;
|
|
312
|
+
doRender();
|
|
313
|
+
}
|
|
314
|
+
if (s.filters && typeof s.filters === 'object') {
|
|
315
|
+
const next_status = s.filters.status;
|
|
316
|
+
const next_search = s.filters.search || '';
|
|
317
|
+
const next_type =
|
|
318
|
+
typeof s.filters.type === 'string' ? s.filters.type : '';
|
|
319
|
+
let needs_render = false;
|
|
320
|
+
if (next_status !== status_filter) {
|
|
321
|
+
status_filter = next_status;
|
|
322
|
+
// Reload on any status scope change to keep cache correct
|
|
323
|
+
void load();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (next_search !== search_text) {
|
|
327
|
+
search_text = next_search;
|
|
328
|
+
needs_render = true;
|
|
329
|
+
}
|
|
330
|
+
if (next_type !== type_filter) {
|
|
331
|
+
type_filter = next_type;
|
|
332
|
+
needs_render = true;
|
|
333
|
+
}
|
|
334
|
+
if (needs_render) {
|
|
335
|
+
doRender();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
load,
|
|
343
|
+
destroy() {
|
|
344
|
+
mount_element.replaceChildren();
|
|
345
|
+
if (unsubscribe) {
|
|
346
|
+
unsubscribe();
|
|
347
|
+
unsubscribe = null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Update minimal fields inline via ws mutations and refresh that row's data.
|
|
354
|
+
* @param {string} id
|
|
355
|
+
* @param {{ [k: string]: any }} patch
|
|
356
|
+
*/
|
|
357
|
+
async function updateInline(id, patch) {
|
|
358
|
+
try {
|
|
359
|
+
// Dispatch specific mutations based on provided keys
|
|
360
|
+
if (typeof patch.title === 'string') {
|
|
361
|
+
await sendFn('edit-text', { id, field: 'title', value: patch.title });
|
|
362
|
+
}
|
|
363
|
+
if (typeof patch.assignee === 'string') {
|
|
364
|
+
await sendFn('update-assignee', { id, assignee: patch.assignee });
|
|
365
|
+
}
|
|
366
|
+
if (typeof patch.status === 'string') {
|
|
367
|
+
await sendFn('update-status', { id, status: patch.status });
|
|
368
|
+
}
|
|
369
|
+
if (typeof patch.priority === 'number') {
|
|
370
|
+
await sendFn('update-priority', { id, priority: patch.priority });
|
|
371
|
+
}
|
|
372
|
+
// Refresh the item from backend
|
|
373
|
+
/** @type {any} */
|
|
374
|
+
const full = await sendFn('show-issue', { id });
|
|
375
|
+
// Replace in cache
|
|
376
|
+
const idx = issues_cache.findIndex((x) => x.id === id);
|
|
377
|
+
if (idx >= 0 && full && typeof full === 'object') {
|
|
378
|
+
issues_cache[idx] = /** @type {Issue} */ ({
|
|
379
|
+
id: full.id,
|
|
380
|
+
title: full.title,
|
|
381
|
+
status: full.status,
|
|
382
|
+
priority: full.priority,
|
|
383
|
+
issue_type: full.issue_type,
|
|
384
|
+
assignee: full.assignee,
|
|
385
|
+
labels: Array.isArray(full.labels) ? full.labels : []
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
doRender();
|
|
389
|
+
} catch {
|
|
390
|
+
// ignore failures; UI state remains as-is
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|