beads-ui 0.3.0 → 0.4.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 +26 -0
- package/README.md +15 -6
- package/app/main.bundle.js +617 -0
- package/app/main.bundle.js.map +7 -0
- package/bin/bdui.js +2 -1
- package/package.json +27 -16
- package/server/app.js +39 -35
- package/server/bd.js +6 -2
- package/server/cli/commands.js +12 -8
- package/server/cli/daemon.js +20 -5
- package/server/cli/index.js +19 -31
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +4 -2
- package/server/config.js +3 -2
- package/server/db.js +9 -6
- package/server/index.js +10 -4
- package/server/list-adapters.js +9 -3
- package/server/logging.js +23 -0
- package/server/subscriptions.js +12 -0
- package/server/validators.js +2 -0
- package/server/watcher.js +10 -5
- package/server/ws.js +31 -10
- package/app/data/list-selectors.js +0 -98
- package/app/data/providers.js +0 -76
- package/app/data/sort.js +0 -45
- package/app/data/subscription-issue-store.js +0 -161
- package/app/data/subscription-issue-stores.js +0 -102
- package/app/data/subscriptions-store.js +0 -219
- package/app/main.js +0 -702
- package/app/protocol.js +0 -196
- package/app/protocol.md +0 -66
- package/app/router.js +0 -114
- package/app/state.js +0 -103
- package/app/utils/issue-id-renderer.js +0 -71
- package/app/utils/issue-id.js +0 -10
- package/app/utils/issue-type.js +0 -27
- package/app/utils/issue-url.js +0 -9
- package/app/utils/markdown.js +0 -22
- package/app/utils/priority-badge.js +0 -47
- package/app/utils/priority.js +0 -1
- package/app/utils/status-badge.js +0 -32
- package/app/utils/status.js +0 -23
- package/app/utils/toast.js +0 -34
- package/app/utils/type-badge.js +0 -33
- package/app/views/board.js +0 -535
- package/app/views/detail.js +0 -1249
- package/app/views/epics.js +0 -280
- package/app/views/issue-dialog.js +0 -163
- package/app/views/issue-row.js +0 -190
- package/app/views/list.js +0 -464
- package/app/views/nav.js +0 -67
- package/app/views/new-issue-dialog.js +0 -345
- package/app/ws.js +0 -279
- package/docs/adr/001-push-only-lists.md +0 -134
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
- package/docs/architecture.md +0 -194
- package/docs/data-exchange-subscription-plan.md +0 -198
- package/docs/db-watching.md +0 -30
- package/docs/migration-v2.md +0 -54
- package/docs/protocol/issues-push-v2.md +0 -179
- package/docs/subscription-issue-store.md +0 -112
package/app/views/list.js
DELETED
|
@@ -1,464 +0,0 @@
|
|
|
1
|
-
import { html, render } from 'lit-html';
|
|
2
|
-
import { createListSelectors } from '../data/list-selectors.js';
|
|
3
|
-
import { cmpClosedDesc } from '../data/sort.js';
|
|
4
|
-
import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
|
|
5
|
-
import { issueHashFor } from '../utils/issue-url.js';
|
|
6
|
-
// issueDisplayId not used directly in this file; rendered in shared row
|
|
7
|
-
import { statusLabel } from '../utils/status.js';
|
|
8
|
-
import { createIssueRowRenderer } from './issue-row.js';
|
|
9
|
-
|
|
10
|
-
// List view implementation; requires a transport send function.
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* @typedef {{ id: string, title?: string, status?: 'closed'|'open'|'in_progress', priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Create the Issues List view.
|
|
18
|
-
* @param {HTMLElement} mount_element - Element to render into.
|
|
19
|
-
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
20
|
-
* @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
|
|
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]
|
|
24
|
-
* @returns {{ load: () => Promise<void>, destroy: () => void }} View API.
|
|
25
|
-
*/
|
|
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);
|
|
46
|
-
/** @type {string} */
|
|
47
|
-
let status_filter = 'all';
|
|
48
|
-
/** @type {string} */
|
|
49
|
-
let search_text = '';
|
|
50
|
-
/** @type {Issue[]} */
|
|
51
|
-
let issues_cache = [];
|
|
52
|
-
/** @type {string} */
|
|
53
|
-
let type_filter = '';
|
|
54
|
-
/** @type {string | null} */
|
|
55
|
-
let selected_id = store ? store.getState().selected_id : null;
|
|
56
|
-
/** @type {null | (() => void)} */
|
|
57
|
-
let unsubscribe = null;
|
|
58
|
-
// Shared row renderer (used in template below)
|
|
59
|
-
const row_renderer = createIssueRowRenderer({
|
|
60
|
-
navigate: (id) => {
|
|
61
|
-
const nav = navigateFn || ((h) => (window.location.hash = h));
|
|
62
|
-
/** @type {'issues'|'epics'|'board'} */
|
|
63
|
-
const view = store ? store.getState().view : 'issues';
|
|
64
|
-
nav(issueHashFor(view, id));
|
|
65
|
-
},
|
|
66
|
-
onUpdate: updateInline,
|
|
67
|
-
requestRender: doRender,
|
|
68
|
-
getSelectedId: () => selected_id,
|
|
69
|
-
row_class: 'issue-row'
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Event: select status change.
|
|
74
|
-
*/
|
|
75
|
-
/**
|
|
76
|
-
* @param {Event} ev
|
|
77
|
-
*/
|
|
78
|
-
const onStatusChange = async (ev) => {
|
|
79
|
-
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
80
|
-
status_filter = sel.value;
|
|
81
|
-
if (store) {
|
|
82
|
-
store.setState({
|
|
83
|
-
filters: { status: status_filter }
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
// Always reload on status changes
|
|
87
|
-
await load();
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Event: search input.
|
|
92
|
-
*/
|
|
93
|
-
/**
|
|
94
|
-
* @param {Event} ev
|
|
95
|
-
*/
|
|
96
|
-
const onSearchInput = (ev) => {
|
|
97
|
-
const input = /** @type {HTMLInputElement} */ (ev.currentTarget);
|
|
98
|
-
search_text = input.value;
|
|
99
|
-
if (store) {
|
|
100
|
-
store.setState({ filters: { search: search_text } });
|
|
101
|
-
}
|
|
102
|
-
doRender();
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Event: type select change.
|
|
107
|
-
* @param {Event} ev
|
|
108
|
-
*/
|
|
109
|
-
const onTypeChange = (ev) => {
|
|
110
|
-
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
111
|
-
type_filter = sel.value || '';
|
|
112
|
-
if (store) {
|
|
113
|
-
store.setState({ filters: { type: type_filter } });
|
|
114
|
-
}
|
|
115
|
-
doRender();
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
// Initialize filters from store on first render so reload applies persisted state
|
|
119
|
-
if (store) {
|
|
120
|
-
const s = store.getState();
|
|
121
|
-
if (s && s.filters && typeof s.filters === 'object') {
|
|
122
|
-
status_filter = s.filters.status || 'all';
|
|
123
|
-
search_text = s.filters.search || '';
|
|
124
|
-
type_filter = typeof s.filters.type === 'string' ? s.filters.type : '';
|
|
125
|
-
}
|
|
126
|
-
}
|
|
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;
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Build lit-html template for the list view.
|
|
133
|
-
*/
|
|
134
|
-
function template() {
|
|
135
|
-
let filtered = issues_cache;
|
|
136
|
-
if (status_filter !== 'all' && status_filter !== 'ready') {
|
|
137
|
-
filtered = filtered.filter(
|
|
138
|
-
(it) => String(it.status || '') === status_filter
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
if (search_text) {
|
|
142
|
-
const needle = search_text.toLowerCase();
|
|
143
|
-
filtered = filtered.filter((it) => {
|
|
144
|
-
const a = String(it.id).toLowerCase();
|
|
145
|
-
const b = String(it.title || '').toLowerCase();
|
|
146
|
-
return a.includes(needle) || b.includes(needle);
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
if (type_filter) {
|
|
150
|
-
filtered = filtered.filter(
|
|
151
|
-
(it) => String(it.issue_type || '') === String(type_filter)
|
|
152
|
-
);
|
|
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
|
-
}
|
|
158
|
-
|
|
159
|
-
return html`
|
|
160
|
-
<div class="panel__header">
|
|
161
|
-
<select @change=${onStatusChange} .value=${status_filter}>
|
|
162
|
-
<option value="all">All</option>
|
|
163
|
-
<option value="ready">Ready</option>
|
|
164
|
-
<option value="open">${statusLabel('open')}</option>
|
|
165
|
-
<option value="in_progress">${statusLabel('in_progress')}</option>
|
|
166
|
-
<option value="closed">${statusLabel('closed')}</option>
|
|
167
|
-
</select>
|
|
168
|
-
<select
|
|
169
|
-
@change=${onTypeChange}
|
|
170
|
-
.value=${type_filter}
|
|
171
|
-
aria-label="Filter by type"
|
|
172
|
-
>
|
|
173
|
-
<option value="">All types</option>
|
|
174
|
-
${ISSUE_TYPES.map(
|
|
175
|
-
(t) =>
|
|
176
|
-
html`<option value=${t} ?selected=${type_filter === t}>
|
|
177
|
-
${typeLabel(t)}
|
|
178
|
-
</option>`
|
|
179
|
-
)}
|
|
180
|
-
</select>
|
|
181
|
-
<input
|
|
182
|
-
type="search"
|
|
183
|
-
placeholder="Search…"
|
|
184
|
-
@input=${onSearchInput}
|
|
185
|
-
.value=${search_text}
|
|
186
|
-
/>
|
|
187
|
-
</div>
|
|
188
|
-
<div class="panel__body" id="list-root">
|
|
189
|
-
${filtered.length === 0
|
|
190
|
-
? html`<div class="issues-block">
|
|
191
|
-
<div class="muted" style="padding:10px 12px;">No issues</div>
|
|
192
|
-
</div>`
|
|
193
|
-
: html`<div class="issues-block">
|
|
194
|
-
<table
|
|
195
|
-
class="table"
|
|
196
|
-
role="grid"
|
|
197
|
-
aria-rowcount=${String(filtered.length)}
|
|
198
|
-
aria-colcount="6"
|
|
199
|
-
>
|
|
200
|
-
<colgroup>
|
|
201
|
-
<col style="width: 100px" />
|
|
202
|
-
<col style="width: 120px" />
|
|
203
|
-
<col />
|
|
204
|
-
<col style="width: 120px" />
|
|
205
|
-
<col style="width: 160px" />
|
|
206
|
-
<col style="width: 130px" />
|
|
207
|
-
</colgroup>
|
|
208
|
-
<thead>
|
|
209
|
-
<tr role="row">
|
|
210
|
-
<th role="columnheader">ID</th>
|
|
211
|
-
<th role="columnheader">Type</th>
|
|
212
|
-
<th role="columnheader">Title</th>
|
|
213
|
-
<th role="columnheader">Status</th>
|
|
214
|
-
<th role="columnheader">Assignee</th>
|
|
215
|
-
<th role="columnheader">Priority</th>
|
|
216
|
-
</tr>
|
|
217
|
-
</thead>
|
|
218
|
-
<tbody role="rowgroup">
|
|
219
|
-
${filtered.map((it) => row_renderer(it))}
|
|
220
|
-
</tbody>
|
|
221
|
-
</table>
|
|
222
|
-
</div>`}
|
|
223
|
-
</div>
|
|
224
|
-
`;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Render the current issues_cache with filters applied.
|
|
229
|
-
*/
|
|
230
|
-
function doRender() {
|
|
231
|
-
render(template(), mount_element);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Initial render (header + body shell with current state)
|
|
235
|
-
doRender();
|
|
236
|
-
// no separate ready checkbox when using select option
|
|
237
|
-
|
|
238
|
-
/**
|
|
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.
|
|
265
|
-
*/
|
|
266
|
-
async function load() {
|
|
267
|
-
// Preserve scroll position to avoid jarring jumps on live refresh
|
|
268
|
-
const beforeEl = /** @type {HTMLElement|null} */ (
|
|
269
|
-
mount_element.querySelector('#list-root')
|
|
270
|
-
);
|
|
271
|
-
const prevScroll = beforeEl ? beforeEl.scrollTop : 0;
|
|
272
|
-
// Compose items from subscriptions membership and issues store entities
|
|
273
|
-
try {
|
|
274
|
-
if (selectors) {
|
|
275
|
-
issues_cache = /** @type {Issue[]} */ (
|
|
276
|
-
selectors.selectIssuesFor('tab:issues')
|
|
277
|
-
);
|
|
278
|
-
} else {
|
|
279
|
-
issues_cache = [];
|
|
280
|
-
}
|
|
281
|
-
} catch {
|
|
282
|
-
issues_cache = [];
|
|
283
|
-
}
|
|
284
|
-
doRender();
|
|
285
|
-
// Restore scroll position if possible
|
|
286
|
-
try {
|
|
287
|
-
const afterEl = /** @type {HTMLElement|null} */ (
|
|
288
|
-
mount_element.querySelector('#list-root')
|
|
289
|
-
);
|
|
290
|
-
if (afterEl && prevScroll > 0) {
|
|
291
|
-
afterEl.scrollTop = prevScroll;
|
|
292
|
-
}
|
|
293
|
-
} catch {
|
|
294
|
-
// ignore
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Keyboard navigation
|
|
299
|
-
mount_element.tabIndex = 0;
|
|
300
|
-
mount_element.addEventListener('keydown', (ev) => {
|
|
301
|
-
// Grid cell Up/Down navigation when focus is inside the table and not within
|
|
302
|
-
// an editable control (input/textarea/select). Preserves column position.
|
|
303
|
-
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
304
|
-
const tgt = /** @type {HTMLElement} */ (ev.target);
|
|
305
|
-
const table =
|
|
306
|
-
tgt && typeof tgt.closest === 'function'
|
|
307
|
-
? tgt.closest('#list-root table.table')
|
|
308
|
-
: null;
|
|
309
|
-
if (table) {
|
|
310
|
-
// Do not intercept when inside native editable controls
|
|
311
|
-
const in_editable = Boolean(
|
|
312
|
-
tgt &&
|
|
313
|
-
typeof tgt.closest === 'function' &&
|
|
314
|
-
(tgt.closest('input') ||
|
|
315
|
-
tgt.closest('textarea') ||
|
|
316
|
-
tgt.closest('select'))
|
|
317
|
-
);
|
|
318
|
-
if (!in_editable) {
|
|
319
|
-
const cell =
|
|
320
|
-
tgt && typeof tgt.closest === 'function' ? tgt.closest('td') : null;
|
|
321
|
-
if (cell && cell.parentElement) {
|
|
322
|
-
const row = /** @type {HTMLTableRowElement} */ (cell.parentElement);
|
|
323
|
-
const tbody = /** @type {HTMLTableSectionElement|null} */ (
|
|
324
|
-
row.parentElement
|
|
325
|
-
);
|
|
326
|
-
if (tbody && tbody.querySelectorAll) {
|
|
327
|
-
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
328
|
-
const row_idx = Math.max(0, rows.indexOf(row));
|
|
329
|
-
const col_idx = cell.cellIndex || 0;
|
|
330
|
-
const next_idx =
|
|
331
|
-
ev.key === 'ArrowDown'
|
|
332
|
-
? Math.min(row_idx + 1, rows.length - 1)
|
|
333
|
-
: Math.max(row_idx - 1, 0);
|
|
334
|
-
const next_row = rows[next_idx];
|
|
335
|
-
const next_cell =
|
|
336
|
-
next_row && next_row.cells ? next_row.cells[col_idx] : null;
|
|
337
|
-
if (next_cell) {
|
|
338
|
-
const focusable = /** @type {HTMLElement|null} */ (
|
|
339
|
-
next_cell.querySelector(
|
|
340
|
-
'button:not([disabled]), [tabindex]:not([tabindex="-1"]), a[href], select:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled])'
|
|
341
|
-
)
|
|
342
|
-
);
|
|
343
|
-
if (focusable && typeof focusable.focus === 'function') {
|
|
344
|
-
ev.preventDefault();
|
|
345
|
-
focusable.focus();
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const tbody = /** @type {HTMLTableSectionElement|null} */ (
|
|
356
|
-
mount_element.querySelector('#list-root tbody')
|
|
357
|
-
);
|
|
358
|
-
const items = tbody ? tbody.querySelectorAll('tr') : [];
|
|
359
|
-
if (items.length === 0) {
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
let idx = 0;
|
|
363
|
-
if (selected_id) {
|
|
364
|
-
const arr = Array.from(items);
|
|
365
|
-
idx = arr.findIndex((el) => {
|
|
366
|
-
const did = el.getAttribute('data-issue-id') || '';
|
|
367
|
-
return did === selected_id;
|
|
368
|
-
});
|
|
369
|
-
if (idx < 0) {
|
|
370
|
-
idx = 0;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
if (ev.key === 'ArrowDown') {
|
|
374
|
-
ev.preventDefault();
|
|
375
|
-
const next = items[Math.min(idx + 1, items.length - 1)];
|
|
376
|
-
const next_id = next ? next.getAttribute('data-issue-id') : '';
|
|
377
|
-
const set = next_id ? next_id : null;
|
|
378
|
-
if (store && set) {
|
|
379
|
-
store.setState({ selected_id: set });
|
|
380
|
-
}
|
|
381
|
-
selected_id = set;
|
|
382
|
-
doRender();
|
|
383
|
-
} else if (ev.key === 'ArrowUp') {
|
|
384
|
-
ev.preventDefault();
|
|
385
|
-
const prev = items[Math.max(idx - 1, 0)];
|
|
386
|
-
const prev_id = prev ? prev.getAttribute('data-issue-id') : '';
|
|
387
|
-
const set = prev_id ? prev_id : null;
|
|
388
|
-
if (store && set) {
|
|
389
|
-
store.setState({ selected_id: set });
|
|
390
|
-
}
|
|
391
|
-
selected_id = set;
|
|
392
|
-
doRender();
|
|
393
|
-
} else if (ev.key === 'Enter') {
|
|
394
|
-
ev.preventDefault();
|
|
395
|
-
const current = items[idx];
|
|
396
|
-
const id = current ? current.getAttribute('data-issue-id') : '';
|
|
397
|
-
if (id) {
|
|
398
|
-
const nav = navigateFn || ((h) => (window.location.hash = h));
|
|
399
|
-
/** @type {'issues'|'epics'|'board'} */
|
|
400
|
-
const view = store ? store.getState().view : 'issues';
|
|
401
|
-
nav(issueHashFor(view, id));
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
// Keep selection in sync with store
|
|
407
|
-
if (store) {
|
|
408
|
-
unsubscribe = store.subscribe((s) => {
|
|
409
|
-
if (s.selected_id !== selected_id) {
|
|
410
|
-
selected_id = s.selected_id;
|
|
411
|
-
doRender();
|
|
412
|
-
}
|
|
413
|
-
if (s.filters && typeof s.filters === 'object') {
|
|
414
|
-
const next_status = s.filters.status;
|
|
415
|
-
const next_search = s.filters.search || '';
|
|
416
|
-
const next_type =
|
|
417
|
-
typeof s.filters.type === 'string' ? s.filters.type : '';
|
|
418
|
-
let needs_render = false;
|
|
419
|
-
if (next_status !== status_filter) {
|
|
420
|
-
status_filter = next_status;
|
|
421
|
-
// Reload on any status scope change to keep cache correct
|
|
422
|
-
void load();
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
if (next_search !== search_text) {
|
|
426
|
-
search_text = next_search;
|
|
427
|
-
needs_render = true;
|
|
428
|
-
}
|
|
429
|
-
if (next_type !== type_filter) {
|
|
430
|
-
type_filter = next_type;
|
|
431
|
-
needs_render = true;
|
|
432
|
-
}
|
|
433
|
-
if (needs_render) {
|
|
434
|
-
doRender();
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
});
|
|
438
|
-
}
|
|
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
|
-
|
|
454
|
-
return {
|
|
455
|
-
load,
|
|
456
|
-
destroy() {
|
|
457
|
-
mount_element.replaceChildren();
|
|
458
|
-
if (unsubscribe) {
|
|
459
|
-
unsubscribe();
|
|
460
|
-
unsubscribe = null;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
};
|
|
464
|
-
}
|
package/app/views/nav.js
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { html, render } from 'lit-html';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Render the top navigation with three tabs and handle route changes.
|
|
5
|
-
* @param {HTMLElement} mount_element
|
|
6
|
-
* @param {{ getState: () => any, subscribe: (fn: (s: any) => void) => () => void }} store
|
|
7
|
-
* @param {{ gotoView: (v: 'issues'|'epics'|'board') => void }} router
|
|
8
|
-
*/
|
|
9
|
-
export function createTopNav(mount_element, store, router) {
|
|
10
|
-
/** @type {(() => void) | null} */
|
|
11
|
-
let unsubscribe = null;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* @param {'issues'|'epics'|'board'} view
|
|
15
|
-
* @returns {(ev: MouseEvent) => void}
|
|
16
|
-
*/
|
|
17
|
-
function onClick(view) {
|
|
18
|
-
return (ev) => {
|
|
19
|
-
ev.preventDefault();
|
|
20
|
-
router.gotoView(view);
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function template() {
|
|
25
|
-
const s = store.getState();
|
|
26
|
-
const active = s.view || 'issues';
|
|
27
|
-
return html`
|
|
28
|
-
<nav class="header-nav" aria-label="Primary">
|
|
29
|
-
<a
|
|
30
|
-
href="#/issues"
|
|
31
|
-
class="tab ${active === 'issues' ? 'active' : ''}"
|
|
32
|
-
@click=${onClick('issues')}
|
|
33
|
-
>Issues</a
|
|
34
|
-
>
|
|
35
|
-
<a
|
|
36
|
-
href="#/epics"
|
|
37
|
-
class="tab ${active === 'epics' ? 'active' : ''}"
|
|
38
|
-
@click=${onClick('epics')}
|
|
39
|
-
>Epics</a
|
|
40
|
-
>
|
|
41
|
-
<a
|
|
42
|
-
href="#/board"
|
|
43
|
-
class="tab ${active === 'board' ? 'active' : ''}"
|
|
44
|
-
@click=${onClick('board')}
|
|
45
|
-
>Board</a
|
|
46
|
-
>
|
|
47
|
-
</nav>
|
|
48
|
-
`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function doRender() {
|
|
52
|
-
render(template(), mount_element);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
doRender();
|
|
56
|
-
unsubscribe = store.subscribe(() => doRender());
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
destroy() {
|
|
60
|
-
if (unsubscribe) {
|
|
61
|
-
unsubscribe();
|
|
62
|
-
unsubscribe = null;
|
|
63
|
-
}
|
|
64
|
-
render(html``, mount_element);
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
}
|