beads-ui 0.2.0 → 0.3.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/CHANGES.md +5 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +5 -138
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/main.js +342 -66
- package/app/protocol.js +10 -14
- package/app/protocol.md +18 -15
- package/app/styles.css +222 -197
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +0 -1
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +166 -144
- package/app/views/detail.js +76 -66
- package/app/views/epics.js +126 -74
- package/app/views/issue-dialog.js +8 -15
- package/app/views/issue-row.js +1 -3
- package/app/views/list.js +101 -104
- package/app/views/new-issue-dialog.js +27 -34
- package/app/ws.js +6 -9
- 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 +4 -2
- package/server/bd.js +0 -2
- package/server/cli/commands.js +1 -2
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +0 -2
- package/server/cli/usage.js +1 -1
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +5 -8
- package/server/ws.js +449 -230
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,7 +10,7 @@ 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
|
/**
|
|
@@ -18,9 +19,30 @@ import { createIssueRowRenderer } from './issue-row.js';
|
|
|
18
19
|
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
19
20
|
* @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
|
|
20
21
|
* @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store] - Optional state store.
|
|
22
|
+
* @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
|
|
23
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issueStores]
|
|
21
24
|
* @returns {{ load: () => Promise<void>, destroy: () => void }} View API.
|
|
22
25
|
*/
|
|
23
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Create the Issues List view.
|
|
28
|
+
* @param {HTMLElement} mount_element
|
|
29
|
+
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn
|
|
30
|
+
* @param {(hash: string) => void} [navigateFn]
|
|
31
|
+
* @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store]
|
|
32
|
+
* @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
|
|
33
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
|
|
34
|
+
* @returns {{ load: () => Promise<void>, destroy: () => void }}
|
|
35
|
+
*/
|
|
36
|
+
export function createListView(
|
|
37
|
+
mount_element,
|
|
38
|
+
sendFn,
|
|
39
|
+
navigateFn,
|
|
40
|
+
store,
|
|
41
|
+
_subscriptions = undefined,
|
|
42
|
+
issue_stores = undefined
|
|
43
|
+
) {
|
|
44
|
+
// Touch unused param to satisfy lint rules without impacting behavior
|
|
45
|
+
/** @type {any} */ (void _subscriptions);
|
|
24
46
|
/** @type {string} */
|
|
25
47
|
let status_filter = 'all';
|
|
26
48
|
/** @type {string} */
|
|
@@ -36,7 +58,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
36
58
|
// Shared row renderer (used in template below)
|
|
37
59
|
const row_renderer = createIssueRowRenderer({
|
|
38
60
|
navigate: (id) => {
|
|
39
|
-
const nav =
|
|
61
|
+
const nav = navigateFn || ((h) => (window.location.hash = h));
|
|
40
62
|
/** @type {'issues'|'epics'|'board'} */
|
|
41
63
|
const view = store ? store.getState().view : 'issues';
|
|
42
64
|
nav(issueHashFor(view, id));
|
|
@@ -54,12 +76,11 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
54
76
|
* @param {Event} ev
|
|
55
77
|
*/
|
|
56
78
|
const onStatusChange = async (ev) => {
|
|
57
|
-
/** @type {HTMLSelectElement} */
|
|
58
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
79
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
59
80
|
status_filter = sel.value;
|
|
60
81
|
if (store) {
|
|
61
82
|
store.setState({
|
|
62
|
-
filters: { status:
|
|
83
|
+
filters: { status: status_filter }
|
|
63
84
|
});
|
|
64
85
|
}
|
|
65
86
|
// Always reload on status changes
|
|
@@ -73,8 +94,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
73
94
|
* @param {Event} ev
|
|
74
95
|
*/
|
|
75
96
|
const onSearchInput = (ev) => {
|
|
76
|
-
/** @type {HTMLInputElement} */
|
|
77
|
-
const input = /** @type {any} */ (ev.currentTarget);
|
|
97
|
+
const input = /** @type {HTMLInputElement} */ (ev.currentTarget);
|
|
78
98
|
search_text = input.value;
|
|
79
99
|
if (store) {
|
|
80
100
|
store.setState({ filters: { search: search_text } });
|
|
@@ -87,8 +107,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
87
107
|
* @param {Event} ev
|
|
88
108
|
*/
|
|
89
109
|
const onTypeChange = (ev) => {
|
|
90
|
-
/** @type {HTMLSelectElement} */
|
|
91
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
110
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
92
111
|
type_filter = sel.value || '';
|
|
93
112
|
if (store) {
|
|
94
113
|
store.setState({ filters: { type: type_filter } });
|
|
@@ -106,12 +125,13 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
106
125
|
}
|
|
107
126
|
}
|
|
108
127
|
// Initial values are reflected via bound `.value` in the template
|
|
128
|
+
// Compose helpers: centralize membership + entity selection + sorting
|
|
129
|
+
const selectors = issue_stores ? createListSelectors(issue_stores) : null;
|
|
109
130
|
|
|
110
131
|
/**
|
|
111
132
|
* Build lit-html template for the list view.
|
|
112
133
|
*/
|
|
113
134
|
function template() {
|
|
114
|
-
/** @type {Issue[]} */
|
|
115
135
|
let filtered = issues_cache;
|
|
116
136
|
if (status_filter !== 'all' && status_filter !== 'ready') {
|
|
117
137
|
filtered = filtered.filter(
|
|
@@ -131,6 +151,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
131
151
|
(it) => String(it.issue_type || '') === String(type_filter)
|
|
132
152
|
);
|
|
133
153
|
}
|
|
154
|
+
// Sorting: closed list is a special case → sort by closed_at desc only
|
|
155
|
+
if (status_filter === 'closed') {
|
|
156
|
+
filtered = filtered.slice().sort(cmpClosedDesc);
|
|
157
|
+
}
|
|
134
158
|
|
|
135
159
|
return html`
|
|
136
160
|
<div class="panel__header">
|
|
@@ -212,40 +236,55 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
212
236
|
// no separate ready checkbox when using select option
|
|
213
237
|
|
|
214
238
|
/**
|
|
215
|
-
*
|
|
239
|
+
* Update minimal fields inline via ws mutations and refresh that row's data.
|
|
240
|
+
* @param {string} id
|
|
241
|
+
* @param {{ [k: string]: any }} patch
|
|
242
|
+
*/
|
|
243
|
+
async function updateInline(id, patch) {
|
|
244
|
+
try {
|
|
245
|
+
// Dispatch specific mutations based on provided keys
|
|
246
|
+
if (typeof patch.title === 'string') {
|
|
247
|
+
await sendFn('edit-text', { id, field: 'title', value: patch.title });
|
|
248
|
+
}
|
|
249
|
+
if (typeof patch.assignee === 'string') {
|
|
250
|
+
await sendFn('update-assignee', { id, assignee: patch.assignee });
|
|
251
|
+
}
|
|
252
|
+
if (typeof patch.status === 'string') {
|
|
253
|
+
await sendFn('update-status', { id, status: patch.status });
|
|
254
|
+
}
|
|
255
|
+
if (typeof patch.priority === 'number') {
|
|
256
|
+
await sendFn('update-priority', { id, priority: patch.priority });
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
// ignore failures; UI state remains as-is
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Load issues from local push stores and re-render.
|
|
216
265
|
*/
|
|
217
266
|
async function load() {
|
|
218
267
|
// Preserve scroll position to avoid jarring jumps on live refresh
|
|
219
|
-
/** @type {HTMLElement|null} */
|
|
220
|
-
const beforeEl = /** @type {any} */ (
|
|
268
|
+
const beforeEl = /** @type {HTMLElement|null} */ (
|
|
221
269
|
mount_element.querySelector('#list-root')
|
|
222
270
|
);
|
|
223
271
|
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;
|
|
272
|
+
// Compose items from subscriptions membership and issues store entities
|
|
234
273
|
try {
|
|
235
|
-
|
|
274
|
+
if (selectors) {
|
|
275
|
+
issues_cache = /** @type {Issue[]} */ (
|
|
276
|
+
selectors.selectIssuesFor('tab:issues')
|
|
277
|
+
);
|
|
278
|
+
} else {
|
|
279
|
+
issues_cache = [];
|
|
280
|
+
}
|
|
236
281
|
} catch {
|
|
237
|
-
result = [];
|
|
238
|
-
}
|
|
239
|
-
if (!Array.isArray(result)) {
|
|
240
282
|
issues_cache = [];
|
|
241
|
-
} else {
|
|
242
|
-
issues_cache = /** @type {Issue[]} */ (result);
|
|
243
283
|
}
|
|
244
284
|
doRender();
|
|
245
285
|
// Restore scroll position if possible
|
|
246
286
|
try {
|
|
247
|
-
/** @type {HTMLElement|null} */
|
|
248
|
-
const afterEl = /** @type {any} */ (
|
|
287
|
+
const afterEl = /** @type {HTMLElement|null} */ (
|
|
249
288
|
mount_element.querySelector('#list-root')
|
|
250
289
|
);
|
|
251
290
|
if (afterEl && prevScroll > 0) {
|
|
@@ -262,12 +301,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
262
301
|
// Grid cell Up/Down navigation when focus is inside the table and not within
|
|
263
302
|
// an editable control (input/textarea/select). Preserves column position.
|
|
264
303
|
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
265
|
-
/** @type {
|
|
266
|
-
const tgt = /** @type {any} */ (ev.target);
|
|
267
|
-
/** @type {HTMLTableElement|null} */
|
|
304
|
+
const tgt = /** @type {HTMLElement} */ (ev.target);
|
|
268
305
|
const table =
|
|
269
306
|
tgt && typeof tgt.closest === 'function'
|
|
270
|
-
?
|
|
307
|
+
? tgt.closest('#list-root table.table')
|
|
271
308
|
: null;
|
|
272
309
|
if (table) {
|
|
273
310
|
// Do not intercept when inside native editable controls
|
|
@@ -279,34 +316,26 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
279
316
|
tgt.closest('select'))
|
|
280
317
|
);
|
|
281
318
|
if (!in_editable) {
|
|
282
|
-
/** @type {HTMLTableCellElement|null} */
|
|
283
319
|
const cell =
|
|
284
|
-
tgt && typeof tgt.closest === 'function'
|
|
285
|
-
? /** @type {any} */ (tgt.closest('td'))
|
|
286
|
-
: null;
|
|
320
|
+
tgt && typeof tgt.closest === 'function' ? tgt.closest('td') : null;
|
|
287
321
|
if (cell && cell.parentElement) {
|
|
288
|
-
/** @type {HTMLTableRowElement} */
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
322
|
+
const row = /** @type {HTMLTableRowElement} */ (cell.parentElement);
|
|
323
|
+
const tbody = /** @type {HTMLTableSectionElement|null} */ (
|
|
324
|
+
row.parentElement
|
|
325
|
+
);
|
|
292
326
|
if (tbody && tbody.querySelectorAll) {
|
|
293
327
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
294
328
|
const row_idx = Math.max(0, rows.indexOf(row));
|
|
295
|
-
const col_idx =
|
|
329
|
+
const col_idx = cell.cellIndex || 0;
|
|
296
330
|
const next_idx =
|
|
297
331
|
ev.key === 'ArrowDown'
|
|
298
332
|
? Math.min(row_idx + 1, rows.length - 1)
|
|
299
333
|
: 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
|
-
);
|
|
334
|
+
const next_row = rows[next_idx];
|
|
335
|
+
const next_cell =
|
|
336
|
+
next_row && next_row.cells ? next_row.cells[col_idx] : null;
|
|
307
337
|
if (next_cell) {
|
|
308
|
-
/** @type {HTMLElement|null} */
|
|
309
|
-
const focusable = /** @type {any} */ (
|
|
338
|
+
const focusable = /** @type {HTMLElement|null} */ (
|
|
310
339
|
next_cell.querySelector(
|
|
311
340
|
'button:not([disabled]), [tabindex]:not([tabindex="-1"]), a[href], select:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled])'
|
|
312
341
|
)
|
|
@@ -323,14 +352,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
323
352
|
}
|
|
324
353
|
}
|
|
325
354
|
|
|
326
|
-
/** @type {HTMLTableSectionElement|null} */
|
|
327
|
-
const tbody = /** @type {any} */ (
|
|
355
|
+
const tbody = /** @type {HTMLTableSectionElement|null} */ (
|
|
328
356
|
mount_element.querySelector('#list-root tbody')
|
|
329
357
|
);
|
|
330
|
-
|
|
331
|
-
const items = tbody
|
|
332
|
-
? tbody.querySelectorAll('tr')
|
|
333
|
-
: /** @type {any} */ ([]);
|
|
358
|
+
const items = tbody ? tbody.querySelectorAll('tr') : [];
|
|
334
359
|
if (items.length === 0) {
|
|
335
360
|
return;
|
|
336
361
|
}
|
|
@@ -370,7 +395,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
370
395
|
const current = items[idx];
|
|
371
396
|
const id = current ? current.getAttribute('data-issue-id') : '';
|
|
372
397
|
if (id) {
|
|
373
|
-
const nav =
|
|
398
|
+
const nav = navigateFn || ((h) => (window.location.hash = h));
|
|
374
399
|
/** @type {'issues'|'epics'|'board'} */
|
|
375
400
|
const view = store ? store.getState().view : 'issues';
|
|
376
401
|
nav(issueHashFor(view, id));
|
|
@@ -412,6 +437,20 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
412
437
|
});
|
|
413
438
|
}
|
|
414
439
|
|
|
440
|
+
// Live updates: recompose and re-render when issue stores change
|
|
441
|
+
if (selectors) {
|
|
442
|
+
selectors.subscribe(() => {
|
|
443
|
+
try {
|
|
444
|
+
issues_cache = /** @type {Issue[]} */ (
|
|
445
|
+
selectors.selectIssuesFor('tab:issues')
|
|
446
|
+
);
|
|
447
|
+
doRender();
|
|
448
|
+
} catch {
|
|
449
|
+
// ignore
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
415
454
|
return {
|
|
416
455
|
load,
|
|
417
456
|
destroy() {
|
|
@@ -422,46 +461,4 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
422
461
|
}
|
|
423
462
|
}
|
|
424
463
|
};
|
|
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
464
|
}
|
|
@@ -10,8 +10,9 @@ import { priority_levels } from '../utils/priority.js';
|
|
|
10
10
|
* @returns {{ open: () => void, close: () => void }}
|
|
11
11
|
*/
|
|
12
12
|
export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
13
|
-
/** @type {HTMLDialogElement} */
|
|
14
|
-
|
|
13
|
+
const dialog = /** @type {HTMLDialogElement} */ (
|
|
14
|
+
document.createElement('dialog')
|
|
15
|
+
);
|
|
15
16
|
dialog.id = 'new-issue-dialog';
|
|
16
17
|
dialog.setAttribute('role', 'dialog');
|
|
17
18
|
dialog.setAttribute('aria-modal', 'true');
|
|
@@ -52,32 +53,34 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
52
53
|
|
|
53
54
|
mount_element.appendChild(dialog);
|
|
54
55
|
|
|
55
|
-
/** @type {HTMLFormElement} */
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const input_title = /** @type {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
/** @type {HTMLSelectElement} */
|
|
62
|
-
|
|
56
|
+
const form = /** @type {HTMLFormElement} */ (
|
|
57
|
+
dialog.querySelector('#new-issue-form')
|
|
58
|
+
);
|
|
59
|
+
const input_title = /** @type {HTMLInputElement} */ (
|
|
60
|
+
dialog.querySelector('#new-title')
|
|
61
|
+
);
|
|
62
|
+
const sel_type = /** @type {HTMLSelectElement} */ (
|
|
63
|
+
dialog.querySelector('#new-type')
|
|
64
|
+
);
|
|
65
|
+
const sel_priority = /** @type {HTMLSelectElement} */ (
|
|
63
66
|
dialog.querySelector('#new-priority')
|
|
64
67
|
);
|
|
65
|
-
/** @type {HTMLInputElement} */
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const input_description = /** @type {
|
|
68
|
+
const input_labels = /** @type {HTMLInputElement} */ (
|
|
69
|
+
dialog.querySelector('#new-labels')
|
|
70
|
+
);
|
|
71
|
+
const input_description = /** @type {HTMLTextAreaElement} */ (
|
|
69
72
|
dialog.querySelector('#new-description')
|
|
70
73
|
);
|
|
71
|
-
/** @type {HTMLDivElement} */
|
|
72
|
-
const error_box = /** @type {any} */ (
|
|
74
|
+
const error_box = /** @type {HTMLDivElement} */ (
|
|
73
75
|
dialog.querySelector('#new-issue-error')
|
|
74
76
|
);
|
|
75
|
-
/** @type {HTMLButtonElement} */
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const btn_create = /** @type {
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
const btn_cancel = /** @type {HTMLButtonElement} */ (
|
|
78
|
+
dialog.querySelector('#btn-cancel')
|
|
79
|
+
);
|
|
80
|
+
const btn_create = /** @type {HTMLButtonElement} */ (
|
|
81
|
+
dialog.querySelector('#btn-create')
|
|
82
|
+
);
|
|
83
|
+
const btn_close = /** @type {HTMLButtonElement} */ (
|
|
81
84
|
dialog.querySelector('.new-issue__close')
|
|
82
85
|
);
|
|
83
86
|
|
|
@@ -194,25 +197,20 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
194
197
|
*/
|
|
195
198
|
async function createNow() {
|
|
196
199
|
clearError();
|
|
197
|
-
/** @type {string} */
|
|
198
200
|
const title = String(input_title.value || '').trim();
|
|
199
201
|
if (title.length === 0) {
|
|
200
202
|
setError('Title is required');
|
|
201
203
|
input_title.focus();
|
|
202
204
|
return;
|
|
203
205
|
}
|
|
204
|
-
/** @type {number} */
|
|
205
206
|
const prio = Number(sel_priority.value || '2');
|
|
206
207
|
if (!(prio >= 0 && prio <= 4)) {
|
|
207
208
|
setError('Priority must be 0..4');
|
|
208
209
|
sel_priority.focus();
|
|
209
210
|
return;
|
|
210
211
|
}
|
|
211
|
-
/** @type {string} */
|
|
212
212
|
const type = String(sel_type.value || '');
|
|
213
|
-
/** @type {string} */
|
|
214
213
|
const desc = String(input_description.value || '');
|
|
215
|
-
/** @type {string[]} */
|
|
216
214
|
const labels = String(input_labels.value || '')
|
|
217
215
|
.split(',')
|
|
218
216
|
.map((s) => s.trim())
|
|
@@ -251,10 +249,8 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
251
249
|
} catch {
|
|
252
250
|
list = null;
|
|
253
251
|
}
|
|
254
|
-
/** @type {string} */
|
|
255
252
|
let created_id = '';
|
|
256
253
|
if (Array.isArray(list)) {
|
|
257
|
-
/** @type {any[]} */
|
|
258
254
|
const matches = list.filter((it) => String(it.title || '') === title);
|
|
259
255
|
if (matches.length > 0) {
|
|
260
256
|
/** @type {any} */
|
|
@@ -326,11 +322,8 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
326
322
|
clearError();
|
|
327
323
|
loadDefaults();
|
|
328
324
|
try {
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
typeof (/** @type {any} */ (dialog).showModal) === 'function'
|
|
332
|
-
) {
|
|
333
|
-
/** @type {any} */ (dialog).showModal();
|
|
325
|
+
if ('showModal' in dialog && typeof dialog.showModal === 'function') {
|
|
326
|
+
dialog.showModal();
|
|
334
327
|
} else {
|
|
335
328
|
dialog.setAttribute('open', '');
|
|
336
329
|
}
|
package/app/ws.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Usage:
|
|
10
10
|
* const ws = createWsClient();
|
|
11
11
|
* const data = await ws.send('list-issues', { filters: {} });
|
|
12
|
-
* const off = ws.on('
|
|
12
|
+
* const off = ws.on('snapshot', (payload) => { <push event> });
|
|
13
13
|
*/
|
|
14
14
|
import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
|
|
15
15
|
|
|
@@ -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();
|
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));
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# ADR 001 — Push‑Only Lists (v2)
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Date: 2025-10-26
|
|
5
|
+
Status: Accepted (data‑flow details superseded by ADR 002)
|
|
6
|
+
Owner: agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
The UI currently mixes push updates with read RPCs like `list-issues` and
|
|
12
|
+
`epic-status`. This ADR establishes the push‑only direction for list data and
|
|
13
|
+
removing read RPCs in list views. It predated ADR 002 which later simplified the
|
|
14
|
+
data flow further (per‑subscription stores + full‑issue payloads).
|
|
15
|
+
|
|
16
|
+
- Push streams provide everything lists need to render. See
|
|
17
|
+
`docs/protocol/issues-push-v2.md` and ADR 002. Earlier iterations used a
|
|
18
|
+
central `issues` entity cache plus `list-delta` membership; this has been
|
|
19
|
+
replaced by per‑subscription stores receiving full issue payloads.
|
|
20
|
+
|
|
21
|
+
We want every list‑shaped view (Issues, Board, Epics → children) to render
|
|
22
|
+
exclusively from local push data. Reads remain only for mutations that return a
|
|
23
|
+
single updated entity (e.g. detail view refresh).
|
|
24
|
+
|
|
25
|
+
Related docs:
|
|
26
|
+
|
|
27
|
+
- Protocol: `docs/protocol/issues-push-v2.md`
|
|
28
|
+
- Server plan: `docs/data-exchange-subscription-plan.md`
|
|
29
|
+
|
|
30
|
+
## Decision
|
|
31
|
+
|
|
32
|
+
- One active subscription per visible list. Examples (client ids):
|
|
33
|
+
- Issues tab: `tab:issues` with spec from filters via `computeIssuesSpec()`
|
|
34
|
+
- Board: `tab:board:ready|in-progress|closed|blocked`
|
|
35
|
+
- Epics list: `tab:epics` (for epic entities); children subscribe on expand as
|
|
36
|
+
`detail:{id}` with `{ type: 'issue-detail', params: { id } }`
|
|
37
|
+
- Rendering reads from two local stores only:
|
|
38
|
+
- `per‑subscription stores`: one store per active client subscription id.
|
|
39
|
+
Stores receive versioned `snapshot`/`upsert`/`delete` push envelopes with
|
|
40
|
+
full issue payloads and expose deterministic, sorted snapshots for the
|
|
41
|
+
owning view.
|
|
42
|
+
- `subscriptions`: manages subscription lifecycle and keys. Rendering reads
|
|
43
|
+
from per‑subscription stores, not from membership ids.
|
|
44
|
+
- Introduce a small selectors utility to apply view‑specific sort rules on store
|
|
45
|
+
snapshots (no composition from a central cache).
|
|
46
|
+
- Remove read RPCs used for lists: `list-issues`, `epic-status`. Keep mutation
|
|
47
|
+
RPCs and `show-issue` until detail view also reads from push cache.
|
|
48
|
+
- Tests drive views with push envelopes and `list-delta`; no RPC stubs for
|
|
49
|
+
reads.
|
|
50
|
+
|
|
51
|
+
## API Shape (Client)
|
|
52
|
+
|
|
53
|
+
Subscriptions store (already implemented):
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
// app/data/subscriptions-store.js
|
|
57
|
+
createSubscriptionStore(send) -> {
|
|
58
|
+
wireEvents(on), subscribeList(client_id, spec) -> unsubscribe,
|
|
59
|
+
selectors: { getIds(client_id), has(client_id), count(client_id) }
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Selectors utility (implemented):
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
// app/data/list-selectors.js
|
|
67
|
+
/** Compose from per‑subscription store snapshots and apply stable sort. */
|
|
68
|
+
export function createListSelectors(issueStores) {
|
|
69
|
+
return {
|
|
70
|
+
selectIssuesFor(client_id) {},
|
|
71
|
+
selectBoardColumn(client_id, mode) {},
|
|
72
|
+
selectEpicChildren(epic_id) {},
|
|
73
|
+
subscribe(fn) {}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Sorting rules:
|
|
79
|
+
|
|
80
|
+
- Issues list: priority asc (0..4), then `created_at` desc, then id asc.
|
|
81
|
+
- Board columns: preserve existing view rules (ready → priority asc, then
|
|
82
|
+
`updated_at` desc; in‑progress → `updated_at` desc; closed → `closed_at`
|
|
83
|
+
desc).
|
|
84
|
+
- Epics children: same as Issues list unless view specifies otherwise.
|
|
85
|
+
|
|
86
|
+
## Consequences
|
|
87
|
+
|
|
88
|
+
Pros:
|
|
89
|
+
|
|
90
|
+
- Consistent, snappy UI with minimal fetch logic; views are pure derives.
|
|
91
|
+
- Server can batch and coalesce; client renders at most once per envelope.
|
|
92
|
+
- Clear separation: mutations via RPC, reads via push caches.
|
|
93
|
+
|
|
94
|
+
Cons / Risks:
|
|
95
|
+
|
|
96
|
+
- Initial implementation work in views and tests.
|
|
97
|
+
- Need disciplined subscription lifecycle on route/tab changes.
|
|
98
|
+
- Requires follow‑up to migrate detail view fully to the push cache.
|
|
99
|
+
|
|
100
|
+
## Migration Checklist
|
|
101
|
+
|
|
102
|
+
Views
|
|
103
|
+
|
|
104
|
+
- [x] Issues view renders from per‑subscription stores; no `list-issues`.
|
|
105
|
+
- [x] Board renders from per‑subscription stores; no `get*` list reads.
|
|
106
|
+
- [x] Epics list/children render from per‑subscription stores; children use
|
|
107
|
+
`issue-detail` for the epic id; children come from `dependents`.
|
|
108
|
+
|
|
109
|
+
Client Data Layer
|
|
110
|
+
|
|
111
|
+
- [x] Add `app/data/list-selectors.js` with helpers listed above (UI-156).
|
|
112
|
+
- [x] Remove list read functions from `app/data/providers.js` (UI-159).
|
|
113
|
+
- [ ] Keep `getIssue` and all mutation helpers until detail view push migration
|
|
114
|
+
happens (follow‑up).
|
|
115
|
+
|
|
116
|
+
Tests
|
|
117
|
+
|
|
118
|
+
- [x] Update list/board/epics tests to use per‑subscription push envelopes
|
|
119
|
+
(UI-158).
|
|
120
|
+
- [x] Remove RPC read stubs from tests.
|
|
121
|
+
|
|
122
|
+
Docs
|
|
123
|
+
|
|
124
|
+
- [x] This ADR committed (UI-152).
|
|
125
|
+
- [x] Update protocol and architecture docs for push‑only model (UI-160).
|
|
126
|
+
|
|
127
|
+
## Notes
|
|
128
|
+
|
|
129
|
+
- Client ids used in this repo today:
|
|
130
|
+
- `tab:issues` for the Issues view
|
|
131
|
+
- `tab:board:ready|in-progress|closed|blocked` for Board columns
|
|
132
|
+
- `tab:epics` for the Epics tab; `epic:${id}` for expanded children
|
|
133
|
+
- See `app/main.js` for current subscription wiring, filter → spec mapping, and
|
|
134
|
+
per‑subscription push routing.
|