beads-ui 0.1.2 → 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 +29 -2
- package/README.md +39 -45
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +25 -127
- 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/index.html +8 -0
- package/app/main.js +483 -61
- package/app/protocol.js +10 -14
- package/app/protocol.md +21 -19
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +373 -184
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- 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 +34 -0
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +439 -87
- package/app/views/detail.js +364 -154
- package/app/views/epics.js +128 -76
- package/app/views/issue-dialog.js +163 -0
- package/app/views/issue-row.js +10 -11
- package/app/views/list.js +164 -93
- package/app/views/new-issue-dialog.js +345 -0
- package/app/ws.js +36 -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 +35 -85
- 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 +11 -3
- package/server/bd.js +0 -2
- package/server/cli/commands.js +12 -5
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +34 -5
- package/server/cli/usage.js +2 -2
- 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 +6 -9
- package/server/ws.js +466 -227
- package/docs/quickstart.md +0 -142
package/app/views/epics.js
CHANGED
|
@@ -1,28 +1,57 @@
|
|
|
1
1
|
import { html, render } from 'lit-html';
|
|
2
|
-
import {
|
|
2
|
+
import { createListSelectors } from '../data/list-selectors.js';
|
|
3
|
+
import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
3
4
|
import { createIssueRowRenderer } from './issue-row.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, updated_at?:
|
|
7
|
+
* @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, created_at?: number, updated_at?: number }} IssueLite
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
* Epics view:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Epics view (push-only):
|
|
12
|
+
* - Derives epic groups from the local issues store (no RPC reads)
|
|
13
|
+
* - Subscribes to `tab:epics` for top-level membership
|
|
14
|
+
* - On expand, subscribes to `detail:{id}` (issue-detail) for the epic
|
|
15
|
+
* - Renders children from the epic detail's `dependents` list
|
|
16
|
+
* - Provides inline edits via mutations; UI re-renders on push
|
|
13
17
|
* @param {HTMLElement} mount_element
|
|
14
|
-
* @param {{
|
|
18
|
+
* @param {{ updateIssue: (input: any) => Promise<any> }} data
|
|
15
19
|
* @param {(id: string) => void} goto_issue - Navigate to issue detail.
|
|
20
|
+
* @param {{ subscribeList: (client_id: string, spec: { type: string, params?: Record<string, string|number|boolean> }) => Promise<() => Promise<void>>, selectors: { getIds: (client_id: string) => string[], count?: (client_id: string) => number } }} [subscriptions]
|
|
21
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
|
|
16
22
|
*/
|
|
17
|
-
export function createEpicsView(
|
|
23
|
+
export function createEpicsView(
|
|
24
|
+
mount_element,
|
|
25
|
+
data,
|
|
26
|
+
goto_issue,
|
|
27
|
+
subscriptions = undefined,
|
|
28
|
+
issue_stores = undefined
|
|
29
|
+
) {
|
|
18
30
|
/** @type {any[]} */
|
|
19
31
|
let groups = [];
|
|
20
32
|
/** @type {Set<string>} */
|
|
21
33
|
const expanded = new Set();
|
|
22
|
-
/** @type {Map<string, IssueLite[]>} */
|
|
23
|
-
const children = new Map();
|
|
24
34
|
/** @type {Set<string>} */
|
|
25
35
|
const loading = new Set();
|
|
36
|
+
/** @type {Map<string, () => Promise<void>>} */
|
|
37
|
+
const epic_unsubs = new Map();
|
|
38
|
+
// Centralized selection helpers
|
|
39
|
+
const selectors = issue_stores ? createListSelectors(issue_stores) : null;
|
|
40
|
+
// Live re-render on pushes: recompute groups when stores change
|
|
41
|
+
if (selectors) {
|
|
42
|
+
selectors.subscribe(() => {
|
|
43
|
+
const had_none = groups.length === 0;
|
|
44
|
+
groups = buildGroupsFromSnapshot();
|
|
45
|
+
doRender();
|
|
46
|
+
// Auto-expand first epic when transitioning from empty to non-empty
|
|
47
|
+
if (had_none && groups.length > 0) {
|
|
48
|
+
const first_id = String(groups[0].epic?.id || '');
|
|
49
|
+
if (first_id && !expanded.has(first_id)) {
|
|
50
|
+
void toggle(first_id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
26
55
|
|
|
27
56
|
// Shared row renderer used for children rows
|
|
28
57
|
const renderRow = createIssueRowRenderer({
|
|
@@ -51,7 +80,8 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
51
80
|
const epic = g.epic || {};
|
|
52
81
|
const id = String(epic.id || '');
|
|
53
82
|
const is_open = expanded.has(id);
|
|
54
|
-
|
|
83
|
+
// Compose children via selectors
|
|
84
|
+
const list = selectors ? selectors.selectEpicChildren(id) : [];
|
|
55
85
|
const is_loading = loading.has(id);
|
|
56
86
|
return html`
|
|
57
87
|
<div class="epic-group" data-epic-id=${id}>
|
|
@@ -62,7 +92,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
62
92
|
tabindex="0"
|
|
63
93
|
aria-expanded=${is_open}
|
|
64
94
|
>
|
|
65
|
-
|
|
95
|
+
${createIssueIdRenderer(id, { class_name: 'mono' })}
|
|
66
96
|
<span class="text-truncate" style="margin-left:8px"
|
|
67
97
|
>${epic.title || '(no title)'}</span
|
|
68
98
|
>
|
|
@@ -84,7 +114,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
84
114
|
${is_loading
|
|
85
115
|
? html`<div class="muted">Loading…</div>`
|
|
86
116
|
: list.length === 0
|
|
87
|
-
? html`<div class="muted">No
|
|
117
|
+
? html`<div class="muted">No issues found</div>`
|
|
88
118
|
: html`<table class="table">
|
|
89
119
|
<colgroup>
|
|
90
120
|
<col style="width: 100px" />
|
|
@@ -121,24 +151,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
121
151
|
async function updateInline(id, patch) {
|
|
122
152
|
try {
|
|
123
153
|
await data.updateIssue({ id, ...patch });
|
|
124
|
-
//
|
|
125
|
-
const full = await data.getIssue(id);
|
|
126
|
-
/** @type {IssueLite} */
|
|
127
|
-
const lite = {
|
|
128
|
-
id: full.id,
|
|
129
|
-
title: full.title,
|
|
130
|
-
status: full.status,
|
|
131
|
-
priority: full.priority,
|
|
132
|
-
issue_type: full.issue_type,
|
|
133
|
-
assignee: full.assignee
|
|
134
|
-
};
|
|
135
|
-
// Replace in children map
|
|
136
|
-
for (const arr of children.values()) {
|
|
137
|
-
const idx = arr.findIndex((x) => x.id === id);
|
|
138
|
-
if (idx >= 0) {
|
|
139
|
-
arr[idx] = lite;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
154
|
+
// Re-render; view will update on subsequent push
|
|
142
155
|
doRender();
|
|
143
156
|
} catch {
|
|
144
157
|
// swallow; UI remains
|
|
@@ -151,70 +164,109 @@ export function createEpicsView(mount_element, data, goto_issue) {
|
|
|
151
164
|
async function toggle(epic_id) {
|
|
152
165
|
if (!expanded.has(epic_id)) {
|
|
153
166
|
expanded.add(epic_id);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
loading.add(epic_id);
|
|
168
|
+
doRender();
|
|
169
|
+
// Subscribe to epic detail; children are rendered from `dependents`
|
|
170
|
+
if (subscriptions && typeof subscriptions.subscribeList === 'function') {
|
|
158
171
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
for (const d of deps) {
|
|
167
|
-
try {
|
|
168
|
-
const full = await data.getIssue(d.id);
|
|
169
|
-
if (full.status !== 'closed') {
|
|
170
|
-
list.push({
|
|
171
|
-
id: full.id,
|
|
172
|
-
title: full.title,
|
|
173
|
-
status: full.status,
|
|
174
|
-
priority: full.priority,
|
|
175
|
-
issue_type: full.issue_type,
|
|
176
|
-
assignee: full.assignee,
|
|
177
|
-
// include updated_at for secondary sort within same priority
|
|
178
|
-
updated_at: /** @type {any} */ (full).updated_at
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
} catch {
|
|
182
|
-
// ignore individual failures
|
|
172
|
+
// Register store first to avoid dropping the initial snapshot
|
|
173
|
+
try {
|
|
174
|
+
if (issue_stores && /** @type {any} */ (issue_stores).register) {
|
|
175
|
+
/** @type {any} */ (issue_stores).register(`detail:${epic_id}`, {
|
|
176
|
+
type: 'issue-detail',
|
|
177
|
+
params: { id: epic_id }
|
|
178
|
+
});
|
|
183
179
|
}
|
|
180
|
+
} catch {
|
|
181
|
+
// ignore
|
|
184
182
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const pb = b.priority ?? 2;
|
|
189
|
-
if (pa !== pb) {
|
|
190
|
-
return pa - pb;
|
|
191
|
-
}
|
|
192
|
-
// @ts-ignore optional updated_at if present
|
|
193
|
-
const ua = a.updated_at || '';
|
|
194
|
-
// @ts-ignore
|
|
195
|
-
const ub = b.updated_at || '';
|
|
196
|
-
return ua < ub ? 1 : ua > ub ? -1 : 0;
|
|
183
|
+
const u = await subscriptions.subscribeList(`detail:${epic_id}`, {
|
|
184
|
+
type: 'issue-detail',
|
|
185
|
+
params: { id: epic_id }
|
|
197
186
|
});
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
187
|
+
epic_unsubs.set(epic_id, u);
|
|
188
|
+
} catch {
|
|
189
|
+
// ignore subscription failures
|
|
201
190
|
}
|
|
202
191
|
}
|
|
192
|
+
// Mark as not loading after subscribe attempt; membership will stream in
|
|
193
|
+
loading.delete(epic_id);
|
|
203
194
|
} else {
|
|
204
195
|
expanded.delete(epic_id);
|
|
196
|
+
// Unsubscribe when collapsing
|
|
197
|
+
if (epic_unsubs.has(epic_id)) {
|
|
198
|
+
try {
|
|
199
|
+
const u = epic_unsubs.get(epic_id);
|
|
200
|
+
if (u) {
|
|
201
|
+
await u();
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// ignore
|
|
205
|
+
}
|
|
206
|
+
epic_unsubs.delete(epic_id);
|
|
207
|
+
try {
|
|
208
|
+
if (issue_stores && /** @type {any} */ (issue_stores).unregister) {
|
|
209
|
+
/** @type {any} */ (issue_stores).unregister(`detail:${epic_id}`);
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// ignore
|
|
213
|
+
}
|
|
214
|
+
}
|
|
205
215
|
}
|
|
206
216
|
doRender();
|
|
207
217
|
}
|
|
208
218
|
|
|
219
|
+
/** Build groups from the current `tab:epics` snapshot. */
|
|
220
|
+
function buildGroupsFromSnapshot() {
|
|
221
|
+
/** @type {IssueLite[]} */
|
|
222
|
+
const epic_entities =
|
|
223
|
+
issue_stores && issue_stores.snapshotFor
|
|
224
|
+
? /** @type {IssueLite[]} */ (
|
|
225
|
+
issue_stores.snapshotFor('tab:epics') || []
|
|
226
|
+
)
|
|
227
|
+
: [];
|
|
228
|
+
const next_groups = [];
|
|
229
|
+
for (const epic of epic_entities) {
|
|
230
|
+
const dependents = Array.isArray(/** @type {any} */ (epic).dependents)
|
|
231
|
+
? /** @type {any[]} */ (/** @type {any} */ (epic).dependents)
|
|
232
|
+
: [];
|
|
233
|
+
// Prefer explicit counters when provided by server; otherwise derive
|
|
234
|
+
const has_total = Number.isFinite(
|
|
235
|
+
/** @type {any} */ (epic).total_children
|
|
236
|
+
);
|
|
237
|
+
const has_closed = Number.isFinite(
|
|
238
|
+
/** @type {any} */ (epic).closed_children
|
|
239
|
+
);
|
|
240
|
+
const total = has_total
|
|
241
|
+
? Number(/** @type {any} */ (epic).total_children) || 0
|
|
242
|
+
: dependents.length;
|
|
243
|
+
let closed = has_closed
|
|
244
|
+
? Number(/** @type {any} */ (epic).closed_children) || 0
|
|
245
|
+
: 0;
|
|
246
|
+
if (!has_closed) {
|
|
247
|
+
for (const d of dependents) {
|
|
248
|
+
if (String(d.status || '') === 'closed') {
|
|
249
|
+
closed++;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
next_groups.push({
|
|
254
|
+
epic,
|
|
255
|
+
total_children: total,
|
|
256
|
+
closed_children: closed
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return next_groups;
|
|
260
|
+
}
|
|
261
|
+
|
|
209
262
|
return {
|
|
210
263
|
async load() {
|
|
211
|
-
|
|
212
|
-
groups = Array.isArray(res) ? res : [];
|
|
264
|
+
groups = buildGroupsFromSnapshot();
|
|
213
265
|
doRender();
|
|
214
266
|
// Auto-expand first epic on screen
|
|
215
267
|
try {
|
|
216
268
|
if (groups.length > 0) {
|
|
217
|
-
const first_id = String(
|
|
269
|
+
const first_id = String(groups[0].epic?.id || '');
|
|
218
270
|
if (first_id && !expanded.has(first_id)) {
|
|
219
271
|
// This will render and load children lazily
|
|
220
272
|
await toggle(first_id);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Lightweight wrapper around the native <dialog> for issue details
|
|
2
|
+
import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
3
|
+
|
|
4
|
+
// Provides: open(id), close(), getMount()
|
|
5
|
+
// Ensures accessibility, backdrop click to close, and Esc handling.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ getState: () => { selected_id: string|null } }} Store
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create and manage the Issue Details dialog.
|
|
13
|
+
* @param {HTMLElement} mount_element - Container to attach the <dialog> to (e.g., #detail-panel)
|
|
14
|
+
* @param {Store} store - Read-only access to app state
|
|
15
|
+
* @param {() => void} onClose - Called when dialog requests close (backdrop/esc/button)
|
|
16
|
+
* @returns {{ open: (id: string) => void, close: () => void, getMount: () => HTMLElement }}
|
|
17
|
+
*/
|
|
18
|
+
export function createIssueDialog(mount_element, store, onClose) {
|
|
19
|
+
const dialog = document.createElement('dialog');
|
|
20
|
+
dialog.id = 'issue-dialog';
|
|
21
|
+
dialog.setAttribute('role', 'dialog');
|
|
22
|
+
dialog.setAttribute('aria-modal', 'true');
|
|
23
|
+
|
|
24
|
+
// Shell: header (id + close) + body mount
|
|
25
|
+
dialog.innerHTML = `
|
|
26
|
+
<div class="issue-dialog__container" part="container">
|
|
27
|
+
<header class="issue-dialog__header">
|
|
28
|
+
<div class="issue-dialog__title">
|
|
29
|
+
<span class="mono" id="issue-dialog-title"></span>
|
|
30
|
+
</div>
|
|
31
|
+
<button type="button" class="issue-dialog__close" aria-label="Close">×</button>
|
|
32
|
+
</header>
|
|
33
|
+
<div class="issue-dialog__body" id="issue-dialog-body"></div>
|
|
34
|
+
</div>
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
mount_element.appendChild(dialog);
|
|
38
|
+
|
|
39
|
+
const body_mount = /** @type {HTMLElement} */ (
|
|
40
|
+
dialog.querySelector('#issue-dialog-body')
|
|
41
|
+
);
|
|
42
|
+
const title_el = /** @type {HTMLElement} */ (
|
|
43
|
+
dialog.querySelector('#issue-dialog-title')
|
|
44
|
+
);
|
|
45
|
+
const btn_close = /** @type {HTMLButtonElement} */ (
|
|
46
|
+
dialog.querySelector('.issue-dialog__close')
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} id
|
|
51
|
+
*/
|
|
52
|
+
function setTitle(id) {
|
|
53
|
+
// Use copyable ID renderer but keep visible text as raw id for tests/clarity
|
|
54
|
+
title_el.replaceChildren();
|
|
55
|
+
title_el.appendChild(createIssueIdRenderer(id));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Backdrop click: when clicking the dialog itself (outside container), close
|
|
59
|
+
dialog.addEventListener('mousedown', (ev) => {
|
|
60
|
+
if (ev.target === dialog) {
|
|
61
|
+
ev.preventDefault();
|
|
62
|
+
requestClose();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// Esc key produces a cancel event on <dialog>
|
|
66
|
+
dialog.addEventListener('cancel', (ev) => {
|
|
67
|
+
ev.preventDefault();
|
|
68
|
+
requestClose();
|
|
69
|
+
});
|
|
70
|
+
// Close button
|
|
71
|
+
btn_close.addEventListener('click', () => requestClose());
|
|
72
|
+
|
|
73
|
+
/** @type {HTMLElement | null} */
|
|
74
|
+
let last_focus = null;
|
|
75
|
+
|
|
76
|
+
function requestClose() {
|
|
77
|
+
try {
|
|
78
|
+
if (typeof dialog.close === 'function') {
|
|
79
|
+
dialog.close();
|
|
80
|
+
} else {
|
|
81
|
+
dialog.removeAttribute('open');
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
dialog.removeAttribute('open');
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
onClose();
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore consumer errors
|
|
90
|
+
}
|
|
91
|
+
// Restore focus to the element that had focus before opening
|
|
92
|
+
restoreFocus();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} id
|
|
97
|
+
*/
|
|
98
|
+
function open(id) {
|
|
99
|
+
// Capture currently focused element to restore after closing
|
|
100
|
+
try {
|
|
101
|
+
const ae = document.activeElement;
|
|
102
|
+
if (ae && ae instanceof HTMLElement) {
|
|
103
|
+
last_focus = ae;
|
|
104
|
+
} else {
|
|
105
|
+
last_focus = null;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
last_focus = null;
|
|
109
|
+
}
|
|
110
|
+
setTitle(id);
|
|
111
|
+
try {
|
|
112
|
+
if ('showModal' in dialog && typeof dialog.showModal === 'function') {
|
|
113
|
+
dialog.showModal();
|
|
114
|
+
} else {
|
|
115
|
+
dialog.setAttribute('open', '');
|
|
116
|
+
}
|
|
117
|
+
// Focus the dialog container for keyboard users
|
|
118
|
+
setTimeout(() => {
|
|
119
|
+
try {
|
|
120
|
+
btn_close.focus();
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore
|
|
123
|
+
}
|
|
124
|
+
}, 0);
|
|
125
|
+
} catch {
|
|
126
|
+
// Fallback for environments without <dialog>
|
|
127
|
+
dialog.setAttribute('open', '');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function close() {
|
|
132
|
+
try {
|
|
133
|
+
if (typeof dialog.close === 'function') {
|
|
134
|
+
dialog.close();
|
|
135
|
+
} else {
|
|
136
|
+
dialog.removeAttribute('open');
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
dialog.removeAttribute('open');
|
|
140
|
+
}
|
|
141
|
+
restoreFocus();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function restoreFocus() {
|
|
145
|
+
try {
|
|
146
|
+
if (last_focus && document.contains(last_focus)) {
|
|
147
|
+
last_focus.focus();
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// ignore focus errors
|
|
151
|
+
} finally {
|
|
152
|
+
last_focus = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
open,
|
|
158
|
+
close,
|
|
159
|
+
getMount() {
|
|
160
|
+
return body_mount;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
package/app/views/issue-row.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { html } from 'lit-html';
|
|
2
|
-
import {
|
|
2
|
+
import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
3
3
|
import { emojiForPriority } from '../utils/priority-badge.js';
|
|
4
4
|
import { priority_levels } from '../utils/priority.js';
|
|
5
5
|
import { statusLabel } from '../utils/status.js';
|
|
@@ -38,7 +38,6 @@ export function createIssueRowRenderer(options) {
|
|
|
38
38
|
* @param {string} [placeholder]
|
|
39
39
|
*/
|
|
40
40
|
function editableText(id, key, value, placeholder = '') {
|
|
41
|
-
/** @type {string} */
|
|
42
41
|
const k = `${id}:${key}`;
|
|
43
42
|
const is_edit = editing.has(k);
|
|
44
43
|
if (is_edit) {
|
|
@@ -92,9 +91,9 @@ export function createIssueRowRenderer(options) {
|
|
|
92
91
|
}
|
|
93
92
|
@keydown=${
|
|
94
93
|
/** @param {KeyboardEvent} e */ (e) => {
|
|
95
|
-
e.stopPropagation();
|
|
96
94
|
if (e.key === 'Enter') {
|
|
97
95
|
e.preventDefault();
|
|
96
|
+
e.stopPropagation();
|
|
98
97
|
editing.add(k);
|
|
99
98
|
request_render();
|
|
100
99
|
}
|
|
@@ -111,8 +110,7 @@ export function createIssueRowRenderer(options) {
|
|
|
111
110
|
*/
|
|
112
111
|
function makeSelectChange(id, key) {
|
|
113
112
|
return async (ev) => {
|
|
114
|
-
/** @type {HTMLSelectElement} */
|
|
115
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
113
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
116
114
|
const val = sel.value || '';
|
|
117
115
|
/** @type {{ [k:string]: any }} */
|
|
118
116
|
const patch = {};
|
|
@@ -143,14 +141,15 @@ export function createIssueRowRenderer(options) {
|
|
|
143
141
|
const cur_prio = String(it.priority ?? 2);
|
|
144
142
|
const is_selected = get_selected_id() === it.id;
|
|
145
143
|
return html`<tr
|
|
144
|
+
role="row"
|
|
146
145
|
class="${row_class} ${is_selected ? 'selected' : ''}"
|
|
147
146
|
data-issue-id=${it.id}
|
|
148
147
|
@click=${makeRowClick(it.id)}
|
|
149
148
|
>
|
|
150
|
-
<td class="mono">${
|
|
151
|
-
<td>${createTypeBadge(it.issue_type)}</td>
|
|
152
|
-
<td>${editableText(it.id, 'title', it.title || '')}</td>
|
|
153
|
-
<td>
|
|
149
|
+
<td role="gridcell" class="mono">${createIssueIdRenderer(it.id)}</td>
|
|
150
|
+
<td role="gridcell">${createTypeBadge(it.issue_type)}</td>
|
|
151
|
+
<td role="gridcell">${editableText(it.id, 'title', it.title || '')}</td>
|
|
152
|
+
<td role="gridcell">
|
|
154
153
|
<select
|
|
155
154
|
class="badge-select badge--status is-${cur_status}"
|
|
156
155
|
.value=${cur_status}
|
|
@@ -164,10 +163,10 @@ export function createIssueRowRenderer(options) {
|
|
|
164
163
|
)}
|
|
165
164
|
</select>
|
|
166
165
|
</td>
|
|
167
|
-
<td>
|
|
166
|
+
<td role="gridcell">
|
|
168
167
|
${editableText(it.id, 'assignee', it.assignee || '', 'Unassigned')}
|
|
169
168
|
</td>
|
|
170
|
-
<td>
|
|
169
|
+
<td role="gridcell">
|
|
171
170
|
<select
|
|
172
171
|
class="badge-select badge--priority ${'is-p' + cur_prio}"
|
|
173
172
|
.value=${cur_prio}
|