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/epics.js
DELETED
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
import { html, render } from 'lit-html';
|
|
2
|
-
import { createListSelectors } from '../data/list-selectors.js';
|
|
3
|
-
import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
4
|
-
import { createIssueRowRenderer } from './issue-row.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, created_at?: number, updated_at?: number }} IssueLite
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/**
|
|
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
|
|
17
|
-
* @param {HTMLElement} mount_element
|
|
18
|
-
* @param {{ updateIssue: (input: any) => Promise<any> }} data
|
|
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]
|
|
22
|
-
*/
|
|
23
|
-
export function createEpicsView(
|
|
24
|
-
mount_element,
|
|
25
|
-
data,
|
|
26
|
-
goto_issue,
|
|
27
|
-
subscriptions = undefined,
|
|
28
|
-
issue_stores = undefined
|
|
29
|
-
) {
|
|
30
|
-
/** @type {any[]} */
|
|
31
|
-
let groups = [];
|
|
32
|
-
/** @type {Set<string>} */
|
|
33
|
-
const expanded = new Set();
|
|
34
|
-
/** @type {Set<string>} */
|
|
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
|
-
}
|
|
55
|
-
|
|
56
|
-
// Shared row renderer used for children rows
|
|
57
|
-
const renderRow = createIssueRowRenderer({
|
|
58
|
-
navigate: (id) => goto_issue(id),
|
|
59
|
-
onUpdate: updateInline,
|
|
60
|
-
requestRender: doRender,
|
|
61
|
-
getSelectedId: () => null,
|
|
62
|
-
row_class: 'epic-row'
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
function doRender() {
|
|
66
|
-
render(template(), mount_element);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function template() {
|
|
70
|
-
if (!groups.length) {
|
|
71
|
-
return html`<div class="panel__header muted">No epics found.</div>`;
|
|
72
|
-
}
|
|
73
|
-
return html`${groups.map((g) => groupTemplate(g))}`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* @param {any} g
|
|
78
|
-
*/
|
|
79
|
-
function groupTemplate(g) {
|
|
80
|
-
const epic = g.epic || {};
|
|
81
|
-
const id = String(epic.id || '');
|
|
82
|
-
const is_open = expanded.has(id);
|
|
83
|
-
// Compose children via selectors
|
|
84
|
-
const list = selectors ? selectors.selectEpicChildren(id) : [];
|
|
85
|
-
const is_loading = loading.has(id);
|
|
86
|
-
return html`
|
|
87
|
-
<div class="epic-group" data-epic-id=${id}>
|
|
88
|
-
<div
|
|
89
|
-
class="epic-header"
|
|
90
|
-
@click=${() => toggle(id)}
|
|
91
|
-
role="button"
|
|
92
|
-
tabindex="0"
|
|
93
|
-
aria-expanded=${is_open}
|
|
94
|
-
>
|
|
95
|
-
${createIssueIdRenderer(id, { class_name: 'mono' })}
|
|
96
|
-
<span class="text-truncate" style="margin-left:8px"
|
|
97
|
-
>${epic.title || '(no title)'}</span
|
|
98
|
-
>
|
|
99
|
-
<span
|
|
100
|
-
class="epic-progress"
|
|
101
|
-
style="margin-left:auto; display:flex; align-items:center; gap:8px;"
|
|
102
|
-
>
|
|
103
|
-
<progress
|
|
104
|
-
value=${Number(g.closed_children || 0)}
|
|
105
|
-
max=${Math.max(1, Number(g.total_children || 0))}
|
|
106
|
-
></progress>
|
|
107
|
-
<span class="muted mono"
|
|
108
|
-
>${g.closed_children}/${g.total_children}</span
|
|
109
|
-
>
|
|
110
|
-
</span>
|
|
111
|
-
</div>
|
|
112
|
-
${is_open
|
|
113
|
-
? html`<div class="epic-children">
|
|
114
|
-
${is_loading
|
|
115
|
-
? html`<div class="muted">Loading…</div>`
|
|
116
|
-
: list.length === 0
|
|
117
|
-
? html`<div class="muted">No issues found</div>`
|
|
118
|
-
: html`<table class="table">
|
|
119
|
-
<colgroup>
|
|
120
|
-
<col style="width: 100px" />
|
|
121
|
-
<col style="width: 120px" />
|
|
122
|
-
<col />
|
|
123
|
-
<col style="width: 120px" />
|
|
124
|
-
<col style="width: 160px" />
|
|
125
|
-
<col style="width: 130px" />
|
|
126
|
-
</colgroup>
|
|
127
|
-
<thead>
|
|
128
|
-
<tr>
|
|
129
|
-
<th>ID</th>
|
|
130
|
-
<th>Type</th>
|
|
131
|
-
<th>Title</th>
|
|
132
|
-
<th>Status</th>
|
|
133
|
-
<th>Assignee</th>
|
|
134
|
-
<th>Priority</th>
|
|
135
|
-
</tr>
|
|
136
|
-
</thead>
|
|
137
|
-
<tbody>
|
|
138
|
-
${list.map((it) => renderRow(it))}
|
|
139
|
-
</tbody>
|
|
140
|
-
</table>`}
|
|
141
|
-
</div>`
|
|
142
|
-
: null}
|
|
143
|
-
</div>
|
|
144
|
-
`;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* @param {string} id
|
|
149
|
-
* @param {{ [k: string]: any }} patch
|
|
150
|
-
*/
|
|
151
|
-
async function updateInline(id, patch) {
|
|
152
|
-
try {
|
|
153
|
-
await data.updateIssue({ id, ...patch });
|
|
154
|
-
// Re-render; view will update on subsequent push
|
|
155
|
-
doRender();
|
|
156
|
-
} catch {
|
|
157
|
-
// swallow; UI remains
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* @param {string} epic_id
|
|
163
|
-
*/
|
|
164
|
-
async function toggle(epic_id) {
|
|
165
|
-
if (!expanded.has(epic_id)) {
|
|
166
|
-
expanded.add(epic_id);
|
|
167
|
-
loading.add(epic_id);
|
|
168
|
-
doRender();
|
|
169
|
-
// Subscribe to epic detail; children are rendered from `dependents`
|
|
170
|
-
if (subscriptions && typeof subscriptions.subscribeList === 'function') {
|
|
171
|
-
try {
|
|
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
|
-
});
|
|
179
|
-
}
|
|
180
|
-
} catch {
|
|
181
|
-
// ignore
|
|
182
|
-
}
|
|
183
|
-
const u = await subscriptions.subscribeList(`detail:${epic_id}`, {
|
|
184
|
-
type: 'issue-detail',
|
|
185
|
-
params: { id: epic_id }
|
|
186
|
-
});
|
|
187
|
-
epic_unsubs.set(epic_id, u);
|
|
188
|
-
} catch {
|
|
189
|
-
// ignore subscription failures
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
// Mark as not loading after subscribe attempt; membership will stream in
|
|
193
|
-
loading.delete(epic_id);
|
|
194
|
-
} else {
|
|
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
|
-
}
|
|
215
|
-
}
|
|
216
|
-
doRender();
|
|
217
|
-
}
|
|
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
|
-
|
|
262
|
-
return {
|
|
263
|
-
async load() {
|
|
264
|
-
groups = buildGroupsFromSnapshot();
|
|
265
|
-
doRender();
|
|
266
|
-
// Auto-expand first epic on screen
|
|
267
|
-
try {
|
|
268
|
-
if (groups.length > 0) {
|
|
269
|
-
const first_id = String(groups[0].epic?.id || '');
|
|
270
|
-
if (first_id && !expanded.has(first_id)) {
|
|
271
|
-
// This will render and load children lazily
|
|
272
|
-
await toggle(first_id);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
} catch {
|
|
276
|
-
// ignore auto-expand failures
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import { html } from 'lit-html';
|
|
2
|
-
import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
3
|
-
import { emojiForPriority } from '../utils/priority-badge.js';
|
|
4
|
-
import { priority_levels } from '../utils/priority.js';
|
|
5
|
-
import { statusLabel } from '../utils/status.js';
|
|
6
|
-
import { createTypeBadge } from '../utils/type-badge.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string }} IssueRowData
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Create a reusable issue row renderer used by list and epics views.
|
|
14
|
-
* Handles inline editing for title/assignee and selects for status/priority.
|
|
15
|
-
* @param {{
|
|
16
|
-
* navigate: (id: string) => void,
|
|
17
|
-
* onUpdate: (id: string, patch: { title?: string, assignee?: string, status?: 'open'|'in_progress'|'closed', priority?: number }) => Promise<void>,
|
|
18
|
-
* requestRender: () => void,
|
|
19
|
-
* getSelectedId?: () => string | null,
|
|
20
|
-
* row_class?: string
|
|
21
|
-
* }} options
|
|
22
|
-
* @returns {(it: IssueRowData) => import('lit-html').TemplateResult<1>}
|
|
23
|
-
*/
|
|
24
|
-
export function createIssueRowRenderer(options) {
|
|
25
|
-
const navigate = options.navigate;
|
|
26
|
-
const on_update = options.onUpdate;
|
|
27
|
-
const request_render = options.requestRender;
|
|
28
|
-
const get_selected_id = options.getSelectedId || (() => null);
|
|
29
|
-
const row_class = options.row_class || 'issue-row';
|
|
30
|
-
|
|
31
|
-
/** @type {Set<string>} */
|
|
32
|
-
const editing = new Set();
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* @param {string} id
|
|
36
|
-
* @param {'title'|'assignee'} key
|
|
37
|
-
* @param {string} value
|
|
38
|
-
* @param {string} [placeholder]
|
|
39
|
-
*/
|
|
40
|
-
function editableText(id, key, value, placeholder = '') {
|
|
41
|
-
const k = `${id}:${key}`;
|
|
42
|
-
const is_edit = editing.has(k);
|
|
43
|
-
if (is_edit) {
|
|
44
|
-
return html`<span>
|
|
45
|
-
<input
|
|
46
|
-
type="text"
|
|
47
|
-
.value=${value}
|
|
48
|
-
class="inline-edit"
|
|
49
|
-
@keydown=${
|
|
50
|
-
/** @param {KeyboardEvent} e */ async (e) => {
|
|
51
|
-
if (e.key === 'Escape') {
|
|
52
|
-
editing.delete(k);
|
|
53
|
-
request_render();
|
|
54
|
-
} else if (e.key === 'Enter') {
|
|
55
|
-
const el = /** @type {HTMLInputElement} */ (e.currentTarget);
|
|
56
|
-
const next = el.value || '';
|
|
57
|
-
if (next !== value) {
|
|
58
|
-
await on_update(id, { [key]: next });
|
|
59
|
-
}
|
|
60
|
-
editing.delete(k);
|
|
61
|
-
request_render();
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
@blur=${
|
|
66
|
-
/** @param {Event} ev */ async (ev) => {
|
|
67
|
-
const el = /** @type {HTMLInputElement} */ (ev.currentTarget);
|
|
68
|
-
const next = el.value || '';
|
|
69
|
-
if (next !== value) {
|
|
70
|
-
await on_update(id, { [key]: next });
|
|
71
|
-
}
|
|
72
|
-
editing.delete(k);
|
|
73
|
-
request_render();
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
autofocus
|
|
77
|
-
/>
|
|
78
|
-
</span>`;
|
|
79
|
-
}
|
|
80
|
-
return html`<span
|
|
81
|
-
class="editable text-truncate ${value ? '' : 'muted'}"
|
|
82
|
-
tabindex="0"
|
|
83
|
-
role="button"
|
|
84
|
-
@click=${
|
|
85
|
-
/** @param {MouseEvent} e */ (e) => {
|
|
86
|
-
e.stopPropagation();
|
|
87
|
-
e.preventDefault();
|
|
88
|
-
editing.add(k);
|
|
89
|
-
request_render();
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
@keydown=${
|
|
93
|
-
/** @param {KeyboardEvent} e */ (e) => {
|
|
94
|
-
if (e.key === 'Enter') {
|
|
95
|
-
e.preventDefault();
|
|
96
|
-
e.stopPropagation();
|
|
97
|
-
editing.add(k);
|
|
98
|
-
request_render();
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
>${value || placeholder}</span
|
|
103
|
-
>`;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* @param {string} id
|
|
108
|
-
* @param {'priority'|'status'} key
|
|
109
|
-
* @returns {(ev: Event) => Promise<void>}
|
|
110
|
-
*/
|
|
111
|
-
function makeSelectChange(id, key) {
|
|
112
|
-
return async (ev) => {
|
|
113
|
-
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
114
|
-
const val = sel.value || '';
|
|
115
|
-
/** @type {{ [k:string]: any }} */
|
|
116
|
-
const patch = {};
|
|
117
|
-
patch[key] = key === 'priority' ? Number(val) : val;
|
|
118
|
-
await on_update(id, patch);
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* @param {string} id
|
|
124
|
-
* @returns {(ev: Event) => void}
|
|
125
|
-
*/
|
|
126
|
-
function makeRowClick(id) {
|
|
127
|
-
return (ev) => {
|
|
128
|
-
const el = /** @type {HTMLElement|null} */ (ev.target);
|
|
129
|
-
if (el && (el.tagName === 'INPUT' || el.tagName === 'SELECT')) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
navigate(id);
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* @param {IssueRowData} it
|
|
138
|
-
*/
|
|
139
|
-
function rowTemplate(it) {
|
|
140
|
-
const cur_status = String(it.status || 'open');
|
|
141
|
-
const cur_prio = String(it.priority ?? 2);
|
|
142
|
-
const is_selected = get_selected_id() === it.id;
|
|
143
|
-
return html`<tr
|
|
144
|
-
role="row"
|
|
145
|
-
class="${row_class} ${is_selected ? 'selected' : ''}"
|
|
146
|
-
data-issue-id=${it.id}
|
|
147
|
-
@click=${makeRowClick(it.id)}
|
|
148
|
-
>
|
|
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">
|
|
153
|
-
<select
|
|
154
|
-
class="badge-select badge--status is-${cur_status}"
|
|
155
|
-
.value=${cur_status}
|
|
156
|
-
@change=${makeSelectChange(it.id, 'status')}
|
|
157
|
-
>
|
|
158
|
-
${['open', 'in_progress', 'closed'].map(
|
|
159
|
-
(s) =>
|
|
160
|
-
html`<option value=${s} ?selected=${cur_status === s}>
|
|
161
|
-
${statusLabel(s)}
|
|
162
|
-
</option>`
|
|
163
|
-
)}
|
|
164
|
-
</select>
|
|
165
|
-
</td>
|
|
166
|
-
<td role="gridcell">
|
|
167
|
-
${editableText(it.id, 'assignee', it.assignee || '', 'Unassigned')}
|
|
168
|
-
</td>
|
|
169
|
-
<td role="gridcell">
|
|
170
|
-
<select
|
|
171
|
-
class="badge-select badge--priority ${'is-p' + cur_prio}"
|
|
172
|
-
.value=${cur_prio}
|
|
173
|
-
@change=${makeSelectChange(it.id, 'priority')}
|
|
174
|
-
>
|
|
175
|
-
${priority_levels.map(
|
|
176
|
-
(p, i) =>
|
|
177
|
-
html`<option
|
|
178
|
-
value=${String(i)}
|
|
179
|
-
?selected=${cur_prio === String(i)}
|
|
180
|
-
>
|
|
181
|
-
${emojiForPriority(i)} ${p}
|
|
182
|
-
</option>`
|
|
183
|
-
)}
|
|
184
|
-
</select>
|
|
185
|
-
</td>
|
|
186
|
-
</tr>`;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return rowTemplate;
|
|
190
|
-
}
|