beads-ui 0.2.0 → 0.3.1
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/CHANGES.md +14 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +103 -0
- package/app/data/providers.js +7 -138
- package/app/data/sort.js +47 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +128 -0
- package/app/data/subscriptions-store.js +227 -0
- package/app/main.js +346 -66
- package/app/protocol.js +23 -17
- package/app/protocol.md +18 -15
- package/app/router.js +3 -0
- package/app/state.js +2 -0
- package/app/styles.css +222 -197
- package/app/utils/issue-id-renderer.js +2 -1
- package/app/utils/issue-id.js +1 -0
- package/app/utils/issue-type.js +2 -0
- package/app/utils/issue-url.js +1 -0
- package/app/utils/markdown.js +13 -198
- package/app/utils/priority-badge.js +1 -2
- package/app/utils/status-badge.js +1 -1
- package/app/utils/status.js +2 -0
- package/app/utils/toast.js +1 -1
- package/app/utils/type-badge.js +1 -3
- package/app/views/board.js +172 -148
- package/app/views/detail.js +79 -66
- package/app/views/epics.js +127 -74
- package/app/views/issue-dialog.js +9 -15
- package/app/views/issue-row.js +2 -3
- package/app/views/list.js +105 -104
- package/app/views/nav.js +1 -0
- package/app/views/new-issue-dialog.js +30 -34
- package/app/ws.js +10 -10
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +34 -84
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +5 -4
- package/server/app.js +2 -0
- package/server/bd.js +4 -2
- package/server/cli/commands.js +5 -2
- package/server/cli/daemon.js +19 -5
- package/server/cli/index.js +2 -2
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +2 -1
- package/server/config.js +13 -6
- package/server/db.js +3 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +224 -0
- package/server/subscriptions.js +289 -0
- package/server/validators.js +113 -0
- package/server/watcher.js +8 -8
- package/server/ws.js +457 -229
package/app/views/list.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
/* global NodeListOf */
|
|
2
1
|
import { html, render } from 'lit-html';
|
|
2
|
+
import { createListSelectors } from '../data/list-selectors.js';
|
|
3
|
+
import { cmpClosedDesc } from '../data/sort.js';
|
|
3
4
|
import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
|
|
4
5
|
import { issueHashFor } from '../utils/issue-url.js';
|
|
5
6
|
// issueDisplayId not used directly in this file; rendered in shared row
|
|
@@ -9,18 +10,41 @@ import { createIssueRowRenderer } from './issue-row.js';
|
|
|
9
10
|
// List view implementation; requires a transport send function.
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
* @typedef {{ id: string, title?: string, status?:
|
|
13
|
+
* @typedef {{ id: string, title?: string, status?: 'closed'|'open'|'in_progress', priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Create the Issues List view.
|
|
18
|
+
*
|
|
17
19
|
* @param {HTMLElement} mount_element - Element to render into.
|
|
18
20
|
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
19
21
|
* @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
|
|
20
22
|
* @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store] - Optional state store.
|
|
23
|
+
* @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
|
|
24
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issueStores]
|
|
21
25
|
* @returns {{ load: () => Promise<void>, destroy: () => void }} View API.
|
|
22
26
|
*/
|
|
23
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Create the Issues List view.
|
|
29
|
+
*
|
|
30
|
+
* @param {HTMLElement} mount_element
|
|
31
|
+
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn
|
|
32
|
+
* @param {(hash: string) => void} [navigateFn]
|
|
33
|
+
* @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store]
|
|
34
|
+
* @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
|
|
35
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
|
|
36
|
+
* @returns {{ load: () => Promise<void>, destroy: () => void }}
|
|
37
|
+
*/
|
|
38
|
+
export function createListView(
|
|
39
|
+
mount_element,
|
|
40
|
+
sendFn,
|
|
41
|
+
navigateFn,
|
|
42
|
+
store,
|
|
43
|
+
_subscriptions = undefined,
|
|
44
|
+
issue_stores = undefined
|
|
45
|
+
) {
|
|
46
|
+
// Touch unused param to satisfy lint rules without impacting behavior
|
|
47
|
+
/** @type {any} */ (void _subscriptions);
|
|
24
48
|
/** @type {string} */
|
|
25
49
|
let status_filter = 'all';
|
|
26
50
|
/** @type {string} */
|
|
@@ -36,7 +60,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
36
60
|
// Shared row renderer (used in template below)
|
|
37
61
|
const row_renderer = createIssueRowRenderer({
|
|
38
62
|
navigate: (id) => {
|
|
39
|
-
const nav =
|
|
63
|
+
const nav = navigateFn || ((h) => (window.location.hash = h));
|
|
40
64
|
/** @type {'issues'|'epics'|'board'} */
|
|
41
65
|
const view = store ? store.getState().view : 'issues';
|
|
42
66
|
nav(issueHashFor(view, id));
|
|
@@ -54,12 +78,11 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
54
78
|
* @param {Event} ev
|
|
55
79
|
*/
|
|
56
80
|
const onStatusChange = async (ev) => {
|
|
57
|
-
/** @type {HTMLSelectElement} */
|
|
58
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
81
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
59
82
|
status_filter = sel.value;
|
|
60
83
|
if (store) {
|
|
61
84
|
store.setState({
|
|
62
|
-
filters: { status:
|
|
85
|
+
filters: { status: status_filter }
|
|
63
86
|
});
|
|
64
87
|
}
|
|
65
88
|
// Always reload on status changes
|
|
@@ -73,8 +96,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
73
96
|
* @param {Event} ev
|
|
74
97
|
*/
|
|
75
98
|
const onSearchInput = (ev) => {
|
|
76
|
-
/** @type {HTMLInputElement} */
|
|
77
|
-
const input = /** @type {any} */ (ev.currentTarget);
|
|
99
|
+
const input = /** @type {HTMLInputElement} */ (ev.currentTarget);
|
|
78
100
|
search_text = input.value;
|
|
79
101
|
if (store) {
|
|
80
102
|
store.setState({ filters: { search: search_text } });
|
|
@@ -84,11 +106,11 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
84
106
|
|
|
85
107
|
/**
|
|
86
108
|
* Event: type select change.
|
|
109
|
+
*
|
|
87
110
|
* @param {Event} ev
|
|
88
111
|
*/
|
|
89
112
|
const onTypeChange = (ev) => {
|
|
90
|
-
/** @type {HTMLSelectElement} */
|
|
91
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
113
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
92
114
|
type_filter = sel.value || '';
|
|
93
115
|
if (store) {
|
|
94
116
|
store.setState({ filters: { type: type_filter } });
|
|
@@ -106,12 +128,13 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
106
128
|
}
|
|
107
129
|
}
|
|
108
130
|
// Initial values are reflected via bound `.value` in the template
|
|
131
|
+
// Compose helpers: centralize membership + entity selection + sorting
|
|
132
|
+
const selectors = issue_stores ? createListSelectors(issue_stores) : null;
|
|
109
133
|
|
|
110
134
|
/**
|
|
111
135
|
* Build lit-html template for the list view.
|
|
112
136
|
*/
|
|
113
137
|
function template() {
|
|
114
|
-
/** @type {Issue[]} */
|
|
115
138
|
let filtered = issues_cache;
|
|
116
139
|
if (status_filter !== 'all' && status_filter !== 'ready') {
|
|
117
140
|
filtered = filtered.filter(
|
|
@@ -131,6 +154,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
131
154
|
(it) => String(it.issue_type || '') === String(type_filter)
|
|
132
155
|
);
|
|
133
156
|
}
|
|
157
|
+
// Sorting: closed list is a special case → sort by closed_at desc only
|
|
158
|
+
if (status_filter === 'closed') {
|
|
159
|
+
filtered = filtered.slice().sort(cmpClosedDesc);
|
|
160
|
+
}
|
|
134
161
|
|
|
135
162
|
return html`
|
|
136
163
|
<div class="panel__header">
|
|
@@ -212,40 +239,56 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
212
239
|
// no separate ready checkbox when using select option
|
|
213
240
|
|
|
214
241
|
/**
|
|
215
|
-
*
|
|
242
|
+
* Update minimal fields inline via ws mutations and refresh that row's data.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} id
|
|
245
|
+
* @param {{ [k: string]: any }} patch
|
|
246
|
+
*/
|
|
247
|
+
async function updateInline(id, patch) {
|
|
248
|
+
try {
|
|
249
|
+
// Dispatch specific mutations based on provided keys
|
|
250
|
+
if (typeof patch.title === 'string') {
|
|
251
|
+
await sendFn('edit-text', { id, field: 'title', value: patch.title });
|
|
252
|
+
}
|
|
253
|
+
if (typeof patch.assignee === 'string') {
|
|
254
|
+
await sendFn('update-assignee', { id, assignee: patch.assignee });
|
|
255
|
+
}
|
|
256
|
+
if (typeof patch.status === 'string') {
|
|
257
|
+
await sendFn('update-status', { id, status: patch.status });
|
|
258
|
+
}
|
|
259
|
+
if (typeof patch.priority === 'number') {
|
|
260
|
+
await sendFn('update-priority', { id, priority: patch.priority });
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// ignore failures; UI state remains as-is
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Load issues from local push stores and re-render.
|
|
216
269
|
*/
|
|
217
270
|
async function load() {
|
|
218
271
|
// Preserve scroll position to avoid jarring jumps on live refresh
|
|
219
|
-
/** @type {HTMLElement|null} */
|
|
220
|
-
const beforeEl = /** @type {any} */ (
|
|
272
|
+
const beforeEl = /** @type {HTMLElement|null} */ (
|
|
221
273
|
mount_element.querySelector('#list-root')
|
|
222
274
|
);
|
|
223
275
|
const prevScroll = beforeEl ? beforeEl.scrollTop : 0;
|
|
224
|
-
|
|
225
|
-
const filters = {};
|
|
226
|
-
if (status_filter !== 'all' && status_filter !== 'ready') {
|
|
227
|
-
filters.status = status_filter;
|
|
228
|
-
}
|
|
229
|
-
if (status_filter === 'ready') {
|
|
230
|
-
filters.ready = true;
|
|
231
|
-
}
|
|
232
|
-
/** @type {unknown} */
|
|
233
|
-
let result;
|
|
276
|
+
// Compose items from subscriptions membership and issues store entities
|
|
234
277
|
try {
|
|
235
|
-
|
|
278
|
+
if (selectors) {
|
|
279
|
+
issues_cache = /** @type {Issue[]} */ (
|
|
280
|
+
selectors.selectIssuesFor('tab:issues')
|
|
281
|
+
);
|
|
282
|
+
} else {
|
|
283
|
+
issues_cache = [];
|
|
284
|
+
}
|
|
236
285
|
} catch {
|
|
237
|
-
result = [];
|
|
238
|
-
}
|
|
239
|
-
if (!Array.isArray(result)) {
|
|
240
286
|
issues_cache = [];
|
|
241
|
-
} else {
|
|
242
|
-
issues_cache = /** @type {Issue[]} */ (result);
|
|
243
287
|
}
|
|
244
288
|
doRender();
|
|
245
289
|
// Restore scroll position if possible
|
|
246
290
|
try {
|
|
247
|
-
/** @type {HTMLElement|null} */
|
|
248
|
-
const afterEl = /** @type {any} */ (
|
|
291
|
+
const afterEl = /** @type {HTMLElement|null} */ (
|
|
249
292
|
mount_element.querySelector('#list-root')
|
|
250
293
|
);
|
|
251
294
|
if (afterEl && prevScroll > 0) {
|
|
@@ -262,12 +305,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
262
305
|
// Grid cell Up/Down navigation when focus is inside the table and not within
|
|
263
306
|
// an editable control (input/textarea/select). Preserves column position.
|
|
264
307
|
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
265
|
-
/** @type {
|
|
266
|
-
const tgt = /** @type {any} */ (ev.target);
|
|
267
|
-
/** @type {HTMLTableElement|null} */
|
|
308
|
+
const tgt = /** @type {HTMLElement} */ (ev.target);
|
|
268
309
|
const table =
|
|
269
310
|
tgt && typeof tgt.closest === 'function'
|
|
270
|
-
?
|
|
311
|
+
? tgt.closest('#list-root table.table')
|
|
271
312
|
: null;
|
|
272
313
|
if (table) {
|
|
273
314
|
// Do not intercept when inside native editable controls
|
|
@@ -279,34 +320,26 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
279
320
|
tgt.closest('select'))
|
|
280
321
|
);
|
|
281
322
|
if (!in_editable) {
|
|
282
|
-
/** @type {HTMLTableCellElement|null} */
|
|
283
323
|
const cell =
|
|
284
|
-
tgt && typeof tgt.closest === 'function'
|
|
285
|
-
? /** @type {any} */ (tgt.closest('td'))
|
|
286
|
-
: null;
|
|
324
|
+
tgt && typeof tgt.closest === 'function' ? tgt.closest('td') : null;
|
|
287
325
|
if (cell && cell.parentElement) {
|
|
288
|
-
/** @type {HTMLTableRowElement} */
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
326
|
+
const row = /** @type {HTMLTableRowElement} */ (cell.parentElement);
|
|
327
|
+
const tbody = /** @type {HTMLTableSectionElement|null} */ (
|
|
328
|
+
row.parentElement
|
|
329
|
+
);
|
|
292
330
|
if (tbody && tbody.querySelectorAll) {
|
|
293
331
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
294
332
|
const row_idx = Math.max(0, rows.indexOf(row));
|
|
295
|
-
const col_idx =
|
|
333
|
+
const col_idx = cell.cellIndex || 0;
|
|
296
334
|
const next_idx =
|
|
297
335
|
ev.key === 'ArrowDown'
|
|
298
336
|
? Math.min(row_idx + 1, rows.length - 1)
|
|
299
337
|
: Math.max(row_idx - 1, 0);
|
|
300
|
-
const next_row =
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
/** @type {HTMLTableCellElement|null} */
|
|
304
|
-
const next_cell = /** @type {any} */ (
|
|
305
|
-
next_row && next_row.cells ? next_row.cells[col_idx] : null
|
|
306
|
-
);
|
|
338
|
+
const next_row = rows[next_idx];
|
|
339
|
+
const next_cell =
|
|
340
|
+
next_row && next_row.cells ? next_row.cells[col_idx] : null;
|
|
307
341
|
if (next_cell) {
|
|
308
|
-
/** @type {HTMLElement|null} */
|
|
309
|
-
const focusable = /** @type {any} */ (
|
|
342
|
+
const focusable = /** @type {HTMLElement|null} */ (
|
|
310
343
|
next_cell.querySelector(
|
|
311
344
|
'button:not([disabled]), [tabindex]:not([tabindex="-1"]), a[href], select:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled])'
|
|
312
345
|
)
|
|
@@ -323,14 +356,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
323
356
|
}
|
|
324
357
|
}
|
|
325
358
|
|
|
326
|
-
/** @type {HTMLTableSectionElement|null} */
|
|
327
|
-
const tbody = /** @type {any} */ (
|
|
359
|
+
const tbody = /** @type {HTMLTableSectionElement|null} */ (
|
|
328
360
|
mount_element.querySelector('#list-root tbody')
|
|
329
361
|
);
|
|
330
|
-
|
|
331
|
-
const items = tbody
|
|
332
|
-
? tbody.querySelectorAll('tr')
|
|
333
|
-
: /** @type {any} */ ([]);
|
|
362
|
+
const items = tbody ? tbody.querySelectorAll('tr') : [];
|
|
334
363
|
if (items.length === 0) {
|
|
335
364
|
return;
|
|
336
365
|
}
|
|
@@ -370,7 +399,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
370
399
|
const current = items[idx];
|
|
371
400
|
const id = current ? current.getAttribute('data-issue-id') : '';
|
|
372
401
|
if (id) {
|
|
373
|
-
const nav =
|
|
402
|
+
const nav = navigateFn || ((h) => (window.location.hash = h));
|
|
374
403
|
/** @type {'issues'|'epics'|'board'} */
|
|
375
404
|
const view = store ? store.getState().view : 'issues';
|
|
376
405
|
nav(issueHashFor(view, id));
|
|
@@ -412,6 +441,20 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
412
441
|
});
|
|
413
442
|
}
|
|
414
443
|
|
|
444
|
+
// Live updates: recompose and re-render when issue stores change
|
|
445
|
+
if (selectors) {
|
|
446
|
+
selectors.subscribe(() => {
|
|
447
|
+
try {
|
|
448
|
+
issues_cache = /** @type {Issue[]} */ (
|
|
449
|
+
selectors.selectIssuesFor('tab:issues')
|
|
450
|
+
);
|
|
451
|
+
doRender();
|
|
452
|
+
} catch {
|
|
453
|
+
// ignore
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
415
458
|
return {
|
|
416
459
|
load,
|
|
417
460
|
destroy() {
|
|
@@ -422,46 +465,4 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
422
465
|
}
|
|
423
466
|
}
|
|
424
467
|
};
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Update minimal fields inline via ws mutations and refresh that row's data.
|
|
428
|
-
* @param {string} id
|
|
429
|
-
* @param {{ [k: string]: any }} patch
|
|
430
|
-
*/
|
|
431
|
-
async function updateInline(id, patch) {
|
|
432
|
-
try {
|
|
433
|
-
// Dispatch specific mutations based on provided keys
|
|
434
|
-
if (typeof patch.title === 'string') {
|
|
435
|
-
await sendFn('edit-text', { id, field: 'title', value: patch.title });
|
|
436
|
-
}
|
|
437
|
-
if (typeof patch.assignee === 'string') {
|
|
438
|
-
await sendFn('update-assignee', { id, assignee: patch.assignee });
|
|
439
|
-
}
|
|
440
|
-
if (typeof patch.status === 'string') {
|
|
441
|
-
await sendFn('update-status', { id, status: patch.status });
|
|
442
|
-
}
|
|
443
|
-
if (typeof patch.priority === 'number') {
|
|
444
|
-
await sendFn('update-priority', { id, priority: patch.priority });
|
|
445
|
-
}
|
|
446
|
-
// Refresh the item from backend
|
|
447
|
-
/** @type {any} */
|
|
448
|
-
const full = await sendFn('show-issue', { id });
|
|
449
|
-
// Replace in cache
|
|
450
|
-
const idx = issues_cache.findIndex((x) => x.id === id);
|
|
451
|
-
if (idx >= 0 && full && typeof full === 'object') {
|
|
452
|
-
issues_cache[idx] = /** @type {Issue} */ ({
|
|
453
|
-
id: full.id,
|
|
454
|
-
title: full.title,
|
|
455
|
-
status: full.status,
|
|
456
|
-
priority: full.priority,
|
|
457
|
-
issue_type: full.issue_type,
|
|
458
|
-
assignee: full.assignee,
|
|
459
|
-
labels: Array.isArray(full.labels) ? full.labels : []
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
doRender();
|
|
463
|
-
} catch {
|
|
464
|
-
// ignore failures; UI state remains as-is
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
468
|
}
|
package/app/views/nav.js
CHANGED
|
@@ -2,6 +2,7 @@ import { html, render } from 'lit-html';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Render the top navigation with three tabs and handle route changes.
|
|
5
|
+
*
|
|
5
6
|
* @param {HTMLElement} mount_element
|
|
6
7
|
* @param {{ getState: () => any, subscribe: (fn: (s: any) => void) => () => void }} store
|
|
7
8
|
* @param {{ gotoView: (v: 'issues'|'epics'|'board') => void }} router
|
|
@@ -3,6 +3,7 @@ import { priority_levels } from '../utils/priority.js';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Create and manage the New Issue dialog (native <dialog>).
|
|
6
|
+
*
|
|
6
7
|
* @param {HTMLElement} mount_element - Container to attach dialog (e.g., main#app)
|
|
7
8
|
* @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} sendFn - Transport function
|
|
8
9
|
* @param {{ gotoIssue: (id: string) => void }} router - Router for opening details after create
|
|
@@ -10,8 +11,9 @@ import { priority_levels } from '../utils/priority.js';
|
|
|
10
11
|
* @returns {{ open: () => void, close: () => void }}
|
|
11
12
|
*/
|
|
12
13
|
export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
13
|
-
/** @type {HTMLDialogElement} */
|
|
14
|
-
|
|
14
|
+
const dialog = /** @type {HTMLDialogElement} */ (
|
|
15
|
+
document.createElement('dialog')
|
|
16
|
+
);
|
|
15
17
|
dialog.id = 'new-issue-dialog';
|
|
16
18
|
dialog.setAttribute('role', 'dialog');
|
|
17
19
|
dialog.setAttribute('aria-modal', 'true');
|
|
@@ -52,32 +54,34 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
52
54
|
|
|
53
55
|
mount_element.appendChild(dialog);
|
|
54
56
|
|
|
55
|
-
/** @type {HTMLFormElement} */
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const input_title = /** @type {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
/** @type {HTMLSelectElement} */
|
|
62
|
-
|
|
57
|
+
const form = /** @type {HTMLFormElement} */ (
|
|
58
|
+
dialog.querySelector('#new-issue-form')
|
|
59
|
+
);
|
|
60
|
+
const input_title = /** @type {HTMLInputElement} */ (
|
|
61
|
+
dialog.querySelector('#new-title')
|
|
62
|
+
);
|
|
63
|
+
const sel_type = /** @type {HTMLSelectElement} */ (
|
|
64
|
+
dialog.querySelector('#new-type')
|
|
65
|
+
);
|
|
66
|
+
const sel_priority = /** @type {HTMLSelectElement} */ (
|
|
63
67
|
dialog.querySelector('#new-priority')
|
|
64
68
|
);
|
|
65
|
-
/** @type {HTMLInputElement} */
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const input_description = /** @type {
|
|
69
|
+
const input_labels = /** @type {HTMLInputElement} */ (
|
|
70
|
+
dialog.querySelector('#new-labels')
|
|
71
|
+
);
|
|
72
|
+
const input_description = /** @type {HTMLTextAreaElement} */ (
|
|
69
73
|
dialog.querySelector('#new-description')
|
|
70
74
|
);
|
|
71
|
-
/** @type {HTMLDivElement} */
|
|
72
|
-
const error_box = /** @type {any} */ (
|
|
75
|
+
const error_box = /** @type {HTMLDivElement} */ (
|
|
73
76
|
dialog.querySelector('#new-issue-error')
|
|
74
77
|
);
|
|
75
|
-
/** @type {HTMLButtonElement} */
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const btn_create = /** @type {
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
const btn_cancel = /** @type {HTMLButtonElement} */ (
|
|
79
|
+
dialog.querySelector('#btn-cancel')
|
|
80
|
+
);
|
|
81
|
+
const btn_create = /** @type {HTMLButtonElement} */ (
|
|
82
|
+
dialog.querySelector('#btn-create')
|
|
83
|
+
);
|
|
84
|
+
const btn_close = /** @type {HTMLButtonElement} */ (
|
|
81
85
|
dialog.querySelector('.new-issue__close')
|
|
82
86
|
);
|
|
83
87
|
|
|
@@ -181,6 +185,7 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
181
185
|
|
|
182
186
|
/**
|
|
183
187
|
* Extract numeric suffix from an id like "UI-123"; return -1 when absent.
|
|
188
|
+
*
|
|
184
189
|
* @param {string} id
|
|
185
190
|
*/
|
|
186
191
|
function idNumeric(id) {
|
|
@@ -190,29 +195,25 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
190
195
|
|
|
191
196
|
/**
|
|
192
197
|
* Submit handler: validate, create, then open the created issue details.
|
|
198
|
+
*
|
|
193
199
|
* @returns {Promise<void>}
|
|
194
200
|
*/
|
|
195
201
|
async function createNow() {
|
|
196
202
|
clearError();
|
|
197
|
-
/** @type {string} */
|
|
198
203
|
const title = String(input_title.value || '').trim();
|
|
199
204
|
if (title.length === 0) {
|
|
200
205
|
setError('Title is required');
|
|
201
206
|
input_title.focus();
|
|
202
207
|
return;
|
|
203
208
|
}
|
|
204
|
-
/** @type {number} */
|
|
205
209
|
const prio = Number(sel_priority.value || '2');
|
|
206
210
|
if (!(prio >= 0 && prio <= 4)) {
|
|
207
211
|
setError('Priority must be 0..4');
|
|
208
212
|
sel_priority.focus();
|
|
209
213
|
return;
|
|
210
214
|
}
|
|
211
|
-
/** @type {string} */
|
|
212
215
|
const type = String(sel_type.value || '');
|
|
213
|
-
/** @type {string} */
|
|
214
216
|
const desc = String(input_description.value || '');
|
|
215
|
-
/** @type {string[]} */
|
|
216
217
|
const labels = String(input_labels.value || '')
|
|
217
218
|
.split(',')
|
|
218
219
|
.map((s) => s.trim())
|
|
@@ -251,10 +252,8 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
251
252
|
} catch {
|
|
252
253
|
list = null;
|
|
253
254
|
}
|
|
254
|
-
/** @type {string} */
|
|
255
255
|
let created_id = '';
|
|
256
256
|
if (Array.isArray(list)) {
|
|
257
|
-
/** @type {any[]} */
|
|
258
257
|
const matches = list.filter((it) => String(it.title || '') === title);
|
|
259
258
|
if (matches.length > 0) {
|
|
260
259
|
/** @type {any} */
|
|
@@ -326,11 +325,8 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
326
325
|
clearError();
|
|
327
326
|
loadDefaults();
|
|
328
327
|
try {
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
typeof (/** @type {any} */ (dialog).showModal) === 'function'
|
|
332
|
-
) {
|
|
333
|
-
/** @type {any} */ (dialog).showModal();
|
|
328
|
+
if ('showModal' in dialog && typeof dialog.showModal === 'function') {
|
|
329
|
+
dialog.showModal();
|
|
334
330
|
} else {
|
|
335
331
|
dialog.setAttribute('open', '');
|
|
336
332
|
}
|
package/app/ws.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* global Console */
|
|
2
1
|
/**
|
|
3
2
|
* @import { MessageType } from './protocol.js'
|
|
4
3
|
*/
|
|
@@ -9,7 +8,7 @@
|
|
|
9
8
|
* Usage:
|
|
10
9
|
* const ws = createWsClient();
|
|
11
10
|
* const data = await ws.send('list-issues', { filters: {} });
|
|
12
|
-
* const off = ws.on('
|
|
11
|
+
* const off = ws.on('snapshot', (payload) => { <push event> });
|
|
13
12
|
*/
|
|
14
13
|
import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
|
|
15
14
|
|
|
@@ -27,6 +26,7 @@ import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
|
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* Create a WebSocket client with auto-reconnect and message correlation.
|
|
29
|
+
*
|
|
30
30
|
* @param {ClientOptions} [options]
|
|
31
31
|
*/
|
|
32
32
|
export function createWsClient(options = {}) {
|
|
@@ -123,8 +123,6 @@ export function createWsClient(options = {}) {
|
|
|
123
123
|
state = 'open';
|
|
124
124
|
notifyConnection(state);
|
|
125
125
|
attempts = 0;
|
|
126
|
-
// subscribe first
|
|
127
|
-
sendRaw(makeRequest('subscribe-updates', {}));
|
|
128
126
|
// flush queue
|
|
129
127
|
while (queue.length) {
|
|
130
128
|
const req = queue.shift();
|
|
@@ -193,16 +191,15 @@ export function createWsClient(options = {}) {
|
|
|
193
191
|
}
|
|
194
192
|
const url = resolveUrl();
|
|
195
193
|
try {
|
|
196
|
-
ws =
|
|
194
|
+
ws = new WebSocket(url);
|
|
197
195
|
state = 'connecting';
|
|
198
196
|
notifyConnection(state);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
s.addEventListener('error', () => {
|
|
197
|
+
ws.addEventListener('open', onOpen);
|
|
198
|
+
ws.addEventListener('message', onMessage);
|
|
199
|
+
ws.addEventListener('error', () => {
|
|
203
200
|
// let close handler handle reconnect
|
|
204
201
|
});
|
|
205
|
-
|
|
202
|
+
ws.addEventListener('close', onClose);
|
|
206
203
|
} catch (err) {
|
|
207
204
|
logger.error('ws connect failed', err);
|
|
208
205
|
scheduleReconnect();
|
|
@@ -214,6 +211,7 @@ export function createWsClient(options = {}) {
|
|
|
214
211
|
return {
|
|
215
212
|
/**
|
|
216
213
|
* Send a request and await its correlated reply payload.
|
|
214
|
+
*
|
|
217
215
|
* @param {MessageType} type
|
|
218
216
|
* @param {unknown} [payload]
|
|
219
217
|
* @returns {Promise<any>}
|
|
@@ -236,6 +234,7 @@ export function createWsClient(options = {}) {
|
|
|
236
234
|
/**
|
|
237
235
|
* Register a handler for a server-initiated event type.
|
|
238
236
|
* Returns an unsubscribe function.
|
|
237
|
+
*
|
|
239
238
|
* @param {MessageType} type
|
|
240
239
|
* @param {(payload: any) => void} handler
|
|
241
240
|
* @returns {() => void}
|
|
@@ -252,6 +251,7 @@ export function createWsClient(options = {}) {
|
|
|
252
251
|
},
|
|
253
252
|
/**
|
|
254
253
|
* Subscribe to connection state changes.
|
|
254
|
+
*
|
|
255
255
|
* @param {(state: ConnectionState) => void} handler
|
|
256
256
|
* @returns {() => void}
|
|
257
257
|
*/
|
package/bin/bdui.js
CHANGED
|
@@ -10,7 +10,7 @@ const argv = process.argv.slice(2);
|
|
|
10
10
|
try {
|
|
11
11
|
const code = await main(argv);
|
|
12
12
|
if (Number.isFinite(code)) {
|
|
13
|
-
process.exitCode =
|
|
13
|
+
process.exitCode = code;
|
|
14
14
|
}
|
|
15
15
|
} catch (err) {
|
|
16
16
|
console.error(String(/** @type {any} */ (err)?.message || err));
|