beads-ui 0.1.0 → 0.1.2
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 +8 -0
- package/README.md +7 -3
- package/package.json +12 -2
- package/.beads/issues.jsonl +0 -107
- package/.editorconfig +0 -10
- package/.eslintrc.json +0 -36
- package/.github/workflows/ci.yml +0 -38
- package/.prettierignore +0 -5
- package/AGENTS.md +0 -85
- package/app/data/providers.test.js +0 -126
- package/app/main.board-switch.test.js +0 -94
- package/app/main.deep-link.test.js +0 -64
- package/app/main.live-updates.test.js +0 -229
- package/app/main.test.js +0 -17
- package/app/main.theme.test.js +0 -41
- package/app/main.view-sync.test.js +0 -54
- package/app/protocol.test.js +0 -57
- package/app/router.test.js +0 -34
- package/app/state.test.js +0 -21
- package/app/utils/markdown.test.js +0 -103
- package/app/utils/type-badge.test.js +0 -30
- package/app/views/board.test.js +0 -184
- package/app/views/detail.acceptance-notes.test.js +0 -67
- package/app/views/detail.assignee.test.js +0 -161
- package/app/views/detail.deps.test.js +0 -97
- package/app/views/detail.edits.test.js +0 -146
- package/app/views/detail.labels.test.js +0 -73
- package/app/views/detail.priority.test.js +0 -86
- package/app/views/detail.test.js +0 -188
- package/app/views/detail.ui47.test.js +0 -78
- package/app/views/epics.test.js +0 -283
- package/app/views/list.inline-edits.test.js +0 -84
- package/app/views/list.test.js +0 -479
- package/app/views/nav.test.js +0 -43
- package/app/ws.test.js +0 -168
- package/eslint.config.js +0 -59
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/prettier.config.js +0 -13
- package/server/app.test.js +0 -29
- package/server/bd.test.js +0 -93
- package/server/cli/cli.test.js +0 -109
- package/server/cli/commands.integration.test.js +0 -155
- package/server/cli/commands.unit.test.js +0 -94
- package/server/cli/open.test.js +0 -26
- package/server/db.test.js +0 -70
- package/server/protocol.test.js +0 -87
- package/server/watcher.test.js +0 -100
- package/server/ws.handlers.test.js +0 -174
- package/server/ws.labels.test.js +0 -95
- package/server/ws.mutations.test.js +0 -261
- package/server/ws.subscriptions.test.js +0 -116
- package/server/ws.test.js +0 -52
- package/test/setup-vitest.js +0 -12
- package/tsconfig.json +0 -23
- package/vitest.config.mjs +0 -14
package/app/views/detail.test.js
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
/* global NodeListOf */
|
|
2
|
-
import { describe, expect, test } from 'vitest';
|
|
3
|
-
import { createDetailView } from './detail.js';
|
|
4
|
-
|
|
5
|
-
/** @type {(map: Record<string, any>) => (type: string, payload?: unknown) => Promise<any>} */
|
|
6
|
-
const stubSend = (map) => async (type, payload) => {
|
|
7
|
-
if (type !== 'show-issue') {
|
|
8
|
-
throw new Error('Unexpected type');
|
|
9
|
-
}
|
|
10
|
-
const id = /** @type {any} */ (payload).id;
|
|
11
|
-
return map[id] || null;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
describe('views/detail', () => {
|
|
15
|
-
test('renders fields, markdown description, and dependency links', async () => {
|
|
16
|
-
document.body.innerHTML =
|
|
17
|
-
'<section class="panel"><div id="mount"></div></section>';
|
|
18
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
19
|
-
|
|
20
|
-
/** @type {any} */
|
|
21
|
-
const issue = {
|
|
22
|
-
id: 'UI-29',
|
|
23
|
-
title: 'Issue detail view',
|
|
24
|
-
description:
|
|
25
|
-
'# Heading\n\nImplement detail view with a [link](https://example.com) and `code`.',
|
|
26
|
-
status: 'open',
|
|
27
|
-
priority: 2,
|
|
28
|
-
dependencies: [{ id: 'UI-25' }, { id: 'UI-27' }],
|
|
29
|
-
dependents: [{ id: 'UI-34' }]
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/** @type {string[]} */
|
|
33
|
-
const navigations = [];
|
|
34
|
-
const view = createDetailView(
|
|
35
|
-
mount,
|
|
36
|
-
stubSend({ 'UI-29': issue }),
|
|
37
|
-
(hash) => {
|
|
38
|
-
navigations.push(hash);
|
|
39
|
-
}
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
await view.load('UI-29');
|
|
43
|
-
|
|
44
|
-
const idMono = /** @type {HTMLElement|null} */ (
|
|
45
|
-
mount.querySelector('.detail-title .detail-id')
|
|
46
|
-
);
|
|
47
|
-
expect(idMono && idMono.textContent).toBe('#29');
|
|
48
|
-
const titleSpan = /** @type {HTMLSpanElement} */ (
|
|
49
|
-
mount.querySelector('h2 .editable')
|
|
50
|
-
);
|
|
51
|
-
expect(titleSpan.textContent).toBe('Issue detail view');
|
|
52
|
-
// status select + priority select exist
|
|
53
|
-
const selects = mount.querySelectorAll('select');
|
|
54
|
-
expect(selects.length).toBeGreaterThanOrEqual(2);
|
|
55
|
-
// description rendered as markdown in read mode
|
|
56
|
-
const md = /** @type {HTMLDivElement} */ (mount.querySelector('.md'));
|
|
57
|
-
expect(md).toBeTruthy();
|
|
58
|
-
const a = /** @type {HTMLAnchorElement|null} */ (md.querySelector('a'));
|
|
59
|
-
expect(a && a.getAttribute('href')).toBe('https://example.com');
|
|
60
|
-
const code = md.querySelector('code');
|
|
61
|
-
expect(code && code.textContent).toBe('code');
|
|
62
|
-
|
|
63
|
-
const links = /** @type {NodeListOf<HTMLAnchorElement>} */ (
|
|
64
|
-
mount.querySelectorAll('a')
|
|
65
|
-
);
|
|
66
|
-
const hrefs = Array.from(links)
|
|
67
|
-
.map((a) => a.getAttribute('href') || '')
|
|
68
|
-
.filter((h) => h.startsWith('#/issue/'));
|
|
69
|
-
expect(hrefs).toEqual(['#/issue/UI-25', '#/issue/UI-27', '#/issue/UI-34']);
|
|
70
|
-
|
|
71
|
-
// No textarea in read mode
|
|
72
|
-
const descInput0 = /** @type {HTMLTextAreaElement|null} */ (
|
|
73
|
-
mount.querySelector('textarea')
|
|
74
|
-
);
|
|
75
|
-
expect(descInput0).toBeNull();
|
|
76
|
-
|
|
77
|
-
// Simulate clicking the first internal link, ensure navigate_fn is used
|
|
78
|
-
const firstInternal = Array.from(links).find((a) =>
|
|
79
|
-
(a.getAttribute('href') || '').startsWith('#/issue/')
|
|
80
|
-
);
|
|
81
|
-
if (!firstInternal) {
|
|
82
|
-
throw new Error('No internal link found');
|
|
83
|
-
}
|
|
84
|
-
firstInternal.click();
|
|
85
|
-
expect(navigations[navigations.length - 1]).toBe('#/issue/UI-25');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('renders type in Properties sidebar', async () => {
|
|
89
|
-
document.body.innerHTML =
|
|
90
|
-
'<section class="panel"><div id="mount"></div></section>';
|
|
91
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
92
|
-
/** @type {any} */
|
|
93
|
-
const issue = {
|
|
94
|
-
id: 'UI-50',
|
|
95
|
-
title: 'With type',
|
|
96
|
-
issue_type: 'feature',
|
|
97
|
-
dependencies: [],
|
|
98
|
-
dependents: []
|
|
99
|
-
};
|
|
100
|
-
const view = createDetailView(mount, async (type) => {
|
|
101
|
-
if (type === 'show-issue') {
|
|
102
|
-
return issue;
|
|
103
|
-
}
|
|
104
|
-
throw new Error('Unexpected');
|
|
105
|
-
});
|
|
106
|
-
await view.load('UI-50');
|
|
107
|
-
const badge = mount.querySelector('.props-card .type-badge');
|
|
108
|
-
expect(badge).toBeTruthy();
|
|
109
|
-
expect(badge && badge.textContent).toBe('Feature');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test('inline editing toggles for title and description', async () => {
|
|
113
|
-
document.body.innerHTML =
|
|
114
|
-
'<section class="panel"><div id="mount"></div></section>';
|
|
115
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
116
|
-
|
|
117
|
-
/** @type {any} */
|
|
118
|
-
const issue = {
|
|
119
|
-
id: 'UI-29',
|
|
120
|
-
title: 'Issue detail view',
|
|
121
|
-
description: 'Some text',
|
|
122
|
-
status: 'open',
|
|
123
|
-
priority: 2,
|
|
124
|
-
dependencies: [],
|
|
125
|
-
dependents: []
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const view = createDetailView(mount, async (type, payload) => {
|
|
129
|
-
if (type === 'show-issue') {
|
|
130
|
-
return issue;
|
|
131
|
-
}
|
|
132
|
-
if (type === 'edit-text') {
|
|
133
|
-
const f = /** @type {any} */ (payload).field;
|
|
134
|
-
const v = /** @type {any} */ (payload).value;
|
|
135
|
-
issue[f] = v;
|
|
136
|
-
return issue;
|
|
137
|
-
}
|
|
138
|
-
throw new Error('Unexpected type');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
await view.load('UI-29');
|
|
142
|
-
|
|
143
|
-
// Title: click to edit -> input appears, Esc cancels
|
|
144
|
-
const titleSpan = /** @type {HTMLSpanElement} */ (
|
|
145
|
-
mount.querySelector('h2 .editable')
|
|
146
|
-
);
|
|
147
|
-
titleSpan.click();
|
|
148
|
-
let titleInput = /** @type {HTMLInputElement} */ (
|
|
149
|
-
mount.querySelector('h2 input')
|
|
150
|
-
);
|
|
151
|
-
expect(titleInput).toBeTruthy();
|
|
152
|
-
const esc = new KeyboardEvent('keydown', { key: 'Escape' });
|
|
153
|
-
titleInput.dispatchEvent(esc);
|
|
154
|
-
expect(
|
|
155
|
-
/** @type {HTMLInputElement|null} */ (mount.querySelector('h2 input'))
|
|
156
|
-
).toBeNull();
|
|
157
|
-
|
|
158
|
-
// Description: click to edit -> textarea appears, Ctrl+Enter saves
|
|
159
|
-
const md = /** @type {HTMLDivElement} */ (mount.querySelector('.md'));
|
|
160
|
-
md.click();
|
|
161
|
-
const area = /** @type {HTMLTextAreaElement} */ (
|
|
162
|
-
mount.querySelector('textarea')
|
|
163
|
-
);
|
|
164
|
-
area.value = 'Changed';
|
|
165
|
-
const key = new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true });
|
|
166
|
-
area.dispatchEvent(key);
|
|
167
|
-
// After save, returns to read mode (allow microtask flush)
|
|
168
|
-
await Promise.resolve();
|
|
169
|
-
expect(
|
|
170
|
-
/** @type {HTMLTextAreaElement|null} */ (mount.querySelector('textarea'))
|
|
171
|
-
).toBeNull();
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test('shows placeholder when not found or bad payload', async () => {
|
|
175
|
-
document.body.innerHTML =
|
|
176
|
-
'<section class="panel"><div id="mount"></div></section>';
|
|
177
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
178
|
-
const view = createDetailView(mount, stubSend({}));
|
|
179
|
-
|
|
180
|
-
await view.load('UI-404');
|
|
181
|
-
expect((mount.textContent || '').toLowerCase()).toContain('not found');
|
|
182
|
-
|
|
183
|
-
view.clear();
|
|
184
|
-
expect((mount.textContent || '').toLowerCase()).toContain(
|
|
185
|
-
'select an issue'
|
|
186
|
-
);
|
|
187
|
-
});
|
|
188
|
-
});
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
-
import { createDetailView } from './detail.js';
|
|
3
|
-
|
|
4
|
-
describe('detail deps UI (UI-47)', () => {
|
|
5
|
-
test('renders id, type and title for dependency items', async () => {
|
|
6
|
-
document.body.innerHTML =
|
|
7
|
-
'<section class="panel"><div id="mount"></div></section>';
|
|
8
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
9
|
-
|
|
10
|
-
/** @type {any} */
|
|
11
|
-
const issue = {
|
|
12
|
-
id: 'UI-100',
|
|
13
|
-
title: 'Parent',
|
|
14
|
-
dependencies: [
|
|
15
|
-
{ id: 'UI-1', issue_type: 'feature', title: 'Alpha' },
|
|
16
|
-
{ id: 'UI-2', issue_type: 'bug', title: 'Beta' }
|
|
17
|
-
],
|
|
18
|
-
dependents: [{ id: 'UI-3', issue_type: 'task', title: 'Gamma' }]
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const view = createDetailView(mount, async (type) => {
|
|
22
|
-
if (type === 'show-issue') {
|
|
23
|
-
return issue;
|
|
24
|
-
}
|
|
25
|
-
throw new Error('Unexpected');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
await view.load('UI-100');
|
|
29
|
-
|
|
30
|
-
const text = mount.textContent || '';
|
|
31
|
-
expect(text).toContain('#1');
|
|
32
|
-
expect(text).toContain('Alpha');
|
|
33
|
-
expect(text).toContain('#3');
|
|
34
|
-
expect(text).toContain('Gamma');
|
|
35
|
-
const badges = mount.querySelectorAll('ul .type-badge');
|
|
36
|
-
expect(badges.length).toBeGreaterThanOrEqual(2);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('clicking a dependency row triggers navigation', async () => {
|
|
40
|
-
document.body.innerHTML =
|
|
41
|
-
'<section class="panel"><div id="mount"></div></section>';
|
|
42
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
43
|
-
const navs = /** @type {string[]} */ ([]);
|
|
44
|
-
const send = vi.fn().mockResolvedValue({
|
|
45
|
-
id: 'UI-200',
|
|
46
|
-
dependencies: [{ id: 'UI-9', issue_type: 'feature', title: 'Z' }],
|
|
47
|
-
dependents: []
|
|
48
|
-
});
|
|
49
|
-
const view = createDetailView(mount, /** @type {any} */ (send), (hash) =>
|
|
50
|
-
navs.push(hash)
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
await view.load('UI-200');
|
|
54
|
-
|
|
55
|
-
const row = /** @type {HTMLLIElement} */ (mount.querySelector('ul li'));
|
|
56
|
-
row.click();
|
|
57
|
-
expect(navs[navs.length - 1]).toBe('#/issue/UI-9');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('add input is placed at the bottom of the section', async () => {
|
|
61
|
-
document.body.innerHTML =
|
|
62
|
-
'<section class="panel"><div id="mount"></div></section>';
|
|
63
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
64
|
-
const send = vi
|
|
65
|
-
.fn()
|
|
66
|
-
.mockResolvedValue({ id: 'UI-300', dependencies: [], dependents: [] });
|
|
67
|
-
const view = createDetailView(mount, /** @type {any} */ (send));
|
|
68
|
-
await view.load('UI-300');
|
|
69
|
-
|
|
70
|
-
const input = /** @type {HTMLInputElement} */ (
|
|
71
|
-
mount.querySelector('[data-testid="add-dependency"]')
|
|
72
|
-
);
|
|
73
|
-
expect(input).toBeTruthy();
|
|
74
|
-
const prev = input.parentElement?.previousElementSibling;
|
|
75
|
-
// Expect the add controls to follow the list (ul)
|
|
76
|
-
expect(prev && prev.tagName).toBe('UL');
|
|
77
|
-
});
|
|
78
|
-
});
|
package/app/views/epics.test.js
DELETED
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
-
import { createEpicsView } from './epics.js';
|
|
3
|
-
|
|
4
|
-
describe('views/epics', () => {
|
|
5
|
-
test('loads groups and expands to show non-closed children, navigates on click', async () => {
|
|
6
|
-
document.body.innerHTML = '<div id="m"></div>';
|
|
7
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
|
|
8
|
-
const data = {
|
|
9
|
-
async getEpicStatus() {
|
|
10
|
-
return [
|
|
11
|
-
{
|
|
12
|
-
epic: { id: 'UI-1', title: 'Epic One' },
|
|
13
|
-
total_children: 2,
|
|
14
|
-
closed_children: 1,
|
|
15
|
-
eligible_for_close: false
|
|
16
|
-
}
|
|
17
|
-
];
|
|
18
|
-
},
|
|
19
|
-
/** @param {string} id */
|
|
20
|
-
async getIssue(id) {
|
|
21
|
-
if (id === 'UI-1') {
|
|
22
|
-
return { id: 'UI-1', dependents: [{ id: 'UI-2' }, { id: 'UI-3' }] };
|
|
23
|
-
}
|
|
24
|
-
if (id === 'UI-2') {
|
|
25
|
-
return {
|
|
26
|
-
id: 'UI-2',
|
|
27
|
-
title: 'Alpha',
|
|
28
|
-
status: 'open',
|
|
29
|
-
priority: 1,
|
|
30
|
-
issue_type: 'task'
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
return {
|
|
34
|
-
id: 'UI-3',
|
|
35
|
-
title: 'Beta',
|
|
36
|
-
status: 'closed',
|
|
37
|
-
priority: 2,
|
|
38
|
-
issue_type: 'task'
|
|
39
|
-
};
|
|
40
|
-
},
|
|
41
|
-
updateIssue: vi.fn()
|
|
42
|
-
};
|
|
43
|
-
/** @type {string[]} */
|
|
44
|
-
const navCalls = [];
|
|
45
|
-
const view = createEpicsView(mount, /** @type {any} */ (data), (id) =>
|
|
46
|
-
navCalls.push(id)
|
|
47
|
-
);
|
|
48
|
-
await view.load();
|
|
49
|
-
const header = mount.querySelector('.epic-header');
|
|
50
|
-
expect(header).not.toBeNull();
|
|
51
|
-
// After expansion, only non-closed child should be present
|
|
52
|
-
const rows = mount.querySelectorAll('tr.epic-row');
|
|
53
|
-
expect(rows.length).toBe(1);
|
|
54
|
-
rows[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
55
|
-
expect(navCalls[0]).toBe('UI-2');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('sorts children by priority then updated_at', async () => {
|
|
59
|
-
document.body.innerHTML = '<div id="m"></div>';
|
|
60
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
|
|
61
|
-
const data = {
|
|
62
|
-
async getEpicStatus() {
|
|
63
|
-
return [
|
|
64
|
-
{
|
|
65
|
-
epic: { id: 'UI-10', title: 'Epic Sort' },
|
|
66
|
-
total_children: 3,
|
|
67
|
-
closed_children: 0,
|
|
68
|
-
eligible_for_close: false
|
|
69
|
-
}
|
|
70
|
-
];
|
|
71
|
-
},
|
|
72
|
-
/** @param {string} id */
|
|
73
|
-
async getIssue(id) {
|
|
74
|
-
if (id === 'UI-10') {
|
|
75
|
-
return {
|
|
76
|
-
id: 'UI-10',
|
|
77
|
-
dependents: [{ id: 'UI-11' }, { id: 'UI-12' }, { id: 'UI-13' }]
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
if (id === 'UI-11') {
|
|
81
|
-
return {
|
|
82
|
-
id: 'UI-11',
|
|
83
|
-
title: 'Low priority, newest within p1',
|
|
84
|
-
status: 'open',
|
|
85
|
-
priority: 1,
|
|
86
|
-
issue_type: 'task',
|
|
87
|
-
updated_at: '2025-10-22T10:00:00.000Z'
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
if (id === 'UI-12') {
|
|
91
|
-
return {
|
|
92
|
-
id: 'UI-12',
|
|
93
|
-
title: 'Low priority, older',
|
|
94
|
-
status: 'open',
|
|
95
|
-
priority: 1,
|
|
96
|
-
issue_type: 'task',
|
|
97
|
-
updated_at: '2025-10-20T10:00:00.000Z'
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
return {
|
|
101
|
-
id: 'UI-13',
|
|
102
|
-
title: 'Higher priority number (lower precedence)',
|
|
103
|
-
status: 'open',
|
|
104
|
-
priority: 2,
|
|
105
|
-
issue_type: 'task',
|
|
106
|
-
updated_at: '2025-10-23T10:00:00.000Z'
|
|
107
|
-
};
|
|
108
|
-
},
|
|
109
|
-
updateIssue: vi.fn()
|
|
110
|
-
};
|
|
111
|
-
const view = createEpicsView(mount, /** @type {any} */ (data), () => {});
|
|
112
|
-
await view.load();
|
|
113
|
-
const rows = Array.from(mount.querySelectorAll('tr.epic-row'));
|
|
114
|
-
const ids = rows.map((r) =>
|
|
115
|
-
/** @type {HTMLElement} */ (
|
|
116
|
-
r.querySelector('td.mono')
|
|
117
|
-
)?.textContent?.trim()
|
|
118
|
-
);
|
|
119
|
-
expect(ids).toEqual(['#11', '#12', '#13']);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test('clicking inputs/selects inside a row does not navigate', async () => {
|
|
123
|
-
document.body.innerHTML = '<div id="m"></div>';
|
|
124
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
|
|
125
|
-
const data = {
|
|
126
|
-
async getEpicStatus() {
|
|
127
|
-
return [
|
|
128
|
-
{
|
|
129
|
-
epic: { id: 'UI-20', title: 'Epic Click Guard' },
|
|
130
|
-
total_children: 1,
|
|
131
|
-
closed_children: 0,
|
|
132
|
-
eligible_for_close: false
|
|
133
|
-
}
|
|
134
|
-
];
|
|
135
|
-
},
|
|
136
|
-
/** @param {string} id */
|
|
137
|
-
async getIssue(id) {
|
|
138
|
-
if (id === 'UI-20') {
|
|
139
|
-
return { id: 'UI-20', dependents: [{ id: 'UI-21' }] };
|
|
140
|
-
}
|
|
141
|
-
return {
|
|
142
|
-
id: 'UI-21',
|
|
143
|
-
title: 'Editable',
|
|
144
|
-
status: 'open',
|
|
145
|
-
priority: 2,
|
|
146
|
-
issue_type: 'task',
|
|
147
|
-
updated_at: '2025-10-21T10:00:00.000Z'
|
|
148
|
-
};
|
|
149
|
-
},
|
|
150
|
-
updateIssue: vi.fn()
|
|
151
|
-
};
|
|
152
|
-
/** @type {string[]} */
|
|
153
|
-
const navCalls = [];
|
|
154
|
-
const view = createEpicsView(mount, /** @type {any} */ (data), (id) =>
|
|
155
|
-
navCalls.push(id)
|
|
156
|
-
);
|
|
157
|
-
await view.load();
|
|
158
|
-
// Click a select inside the row; should not navigate
|
|
159
|
-
const sel = /** @type {HTMLSelectElement|null} */ (
|
|
160
|
-
mount.querySelector('tr.epic-row select')
|
|
161
|
-
);
|
|
162
|
-
sel?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
163
|
-
expect(navCalls.length).toBe(0);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test('shows Loading… while fetching children on manual expansion (no flicker)', async () => {
|
|
167
|
-
document.body.innerHTML = '<div id="m"></div>';
|
|
168
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
|
|
169
|
-
let resolveEpic;
|
|
170
|
-
const epicPromise = new Promise((r) => {
|
|
171
|
-
resolveEpic = r;
|
|
172
|
-
});
|
|
173
|
-
const data = {
|
|
174
|
-
async getEpicStatus() {
|
|
175
|
-
return [
|
|
176
|
-
{
|
|
177
|
-
epic: { id: 'UI-40', title: 'Auto Expanded' },
|
|
178
|
-
total_children: 0,
|
|
179
|
-
closed_children: 0,
|
|
180
|
-
eligible_for_close: false
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
epic: { id: 'UI-41', title: 'Manual Expand' },
|
|
184
|
-
total_children: 1,
|
|
185
|
-
closed_children: 0,
|
|
186
|
-
eligible_for_close: false
|
|
187
|
-
}
|
|
188
|
-
];
|
|
189
|
-
},
|
|
190
|
-
/** @param {string} id */
|
|
191
|
-
async getIssue(id) {
|
|
192
|
-
if (id === 'UI-40') {
|
|
193
|
-
return { id: 'UI-40', dependents: [] };
|
|
194
|
-
}
|
|
195
|
-
if (id === 'UI-41') {
|
|
196
|
-
// Delay to simulate loading
|
|
197
|
-
await epicPromise;
|
|
198
|
-
return { id: 'UI-41', dependents: [{ id: 'UI-42' }] };
|
|
199
|
-
}
|
|
200
|
-
return {
|
|
201
|
-
id: 'UI-42',
|
|
202
|
-
title: 'Child',
|
|
203
|
-
status: 'open',
|
|
204
|
-
priority: 2,
|
|
205
|
-
issue_type: 'task'
|
|
206
|
-
};
|
|
207
|
-
},
|
|
208
|
-
updateIssue: vi.fn()
|
|
209
|
-
};
|
|
210
|
-
const view = createEpicsView(mount, /** @type {any} */ (data), () => {});
|
|
211
|
-
await view.load();
|
|
212
|
-
// Expand the second group manually
|
|
213
|
-
const groups = Array.from(mount.querySelectorAll('.epic-group'));
|
|
214
|
-
const manual = groups.find(
|
|
215
|
-
(g) => g.getAttribute('data-epic-id') === 'UI-41'
|
|
216
|
-
);
|
|
217
|
-
expect(manual).toBeDefined();
|
|
218
|
-
manual
|
|
219
|
-
?.querySelector('.epic-header')
|
|
220
|
-
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
221
|
-
|
|
222
|
-
// Immediately after click, expect Loading…
|
|
223
|
-
const text = manual?.querySelector('.epic-children')?.textContent || '';
|
|
224
|
-
expect(text.includes('Loading…')).toBe(true);
|
|
225
|
-
|
|
226
|
-
// Resolve and ensure a row appears
|
|
227
|
-
// @ts-ignore
|
|
228
|
-
resolveEpic();
|
|
229
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
230
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
231
|
-
const rows = manual?.querySelectorAll('tr.epic-row') || [];
|
|
232
|
-
expect(rows.length).toBe(1);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
test('clicking the editable title does not navigate and enters edit mode', async () => {
|
|
236
|
-
document.body.innerHTML = '<div id="m"></div>';
|
|
237
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
|
|
238
|
-
const data = {
|
|
239
|
-
async getEpicStatus() {
|
|
240
|
-
return [
|
|
241
|
-
{
|
|
242
|
-
epic: { id: 'UI-30', title: 'Epic Title Click' },
|
|
243
|
-
total_children: 1,
|
|
244
|
-
closed_children: 0,
|
|
245
|
-
eligible_for_close: false
|
|
246
|
-
}
|
|
247
|
-
];
|
|
248
|
-
},
|
|
249
|
-
/** @param {string} id */
|
|
250
|
-
async getIssue(id) {
|
|
251
|
-
if (id === 'UI-30') {
|
|
252
|
-
return { id: 'UI-30', dependents: [{ id: 'UI-31' }] };
|
|
253
|
-
}
|
|
254
|
-
return {
|
|
255
|
-
id: 'UI-31',
|
|
256
|
-
title: 'Clickable Title',
|
|
257
|
-
status: 'open',
|
|
258
|
-
priority: 2,
|
|
259
|
-
issue_type: 'task'
|
|
260
|
-
};
|
|
261
|
-
},
|
|
262
|
-
updateIssue: vi.fn()
|
|
263
|
-
};
|
|
264
|
-
/** @type {string[]} */
|
|
265
|
-
const navCalls = [];
|
|
266
|
-
const view = createEpicsView(mount, /** @type {any} */ (data), (id) =>
|
|
267
|
-
navCalls.push(id)
|
|
268
|
-
);
|
|
269
|
-
await view.load();
|
|
270
|
-
const titleSpan = /** @type {HTMLElement|null} */ (
|
|
271
|
-
mount.querySelector('tr.epic-row td:nth-child(3) .editable')
|
|
272
|
-
);
|
|
273
|
-
expect(titleSpan).not.toBeNull();
|
|
274
|
-
titleSpan?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
275
|
-
// Should not have navigated
|
|
276
|
-
expect(navCalls.length).toBe(0);
|
|
277
|
-
// Should render an input for title now
|
|
278
|
-
const input = /** @type {HTMLInputElement|null} */ (
|
|
279
|
-
mount.querySelector('tr.epic-row td:nth-child(3) input[type="text"]')
|
|
280
|
-
);
|
|
281
|
-
expect(input).not.toBeNull();
|
|
282
|
-
});
|
|
283
|
-
});
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
-
import { createListView } from './list.js';
|
|
3
|
-
|
|
4
|
-
describe('views/list inline edits', () => {
|
|
5
|
-
test('priority select dispatches update and refreshes row', async () => {
|
|
6
|
-
document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
|
|
7
|
-
const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
|
|
8
|
-
|
|
9
|
-
const initial = [
|
|
10
|
-
{
|
|
11
|
-
id: 'UI-1',
|
|
12
|
-
title: 'One',
|
|
13
|
-
status: 'open',
|
|
14
|
-
priority: 1,
|
|
15
|
-
issue_type: 'task'
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
id: 'UI-2',
|
|
19
|
-
title: 'Two',
|
|
20
|
-
status: 'open',
|
|
21
|
-
priority: 2,
|
|
22
|
-
issue_type: 'bug'
|
|
23
|
-
}
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
/** @type {{ calls: Array<{ type: string, payload: any }> }} */
|
|
27
|
-
const spy = { calls: [] };
|
|
28
|
-
let current = [...initial];
|
|
29
|
-
|
|
30
|
-
/** @type {(type: string, payload?: any) => Promise<any>} */
|
|
31
|
-
const send = vi.fn(async (type, payload) => {
|
|
32
|
-
spy.calls.push({ type, payload });
|
|
33
|
-
if (type === 'list-issues') {
|
|
34
|
-
return current;
|
|
35
|
-
}
|
|
36
|
-
if (type === 'update-priority') {
|
|
37
|
-
// no-op; list refresh happens via show-issue below
|
|
38
|
-
return {};
|
|
39
|
-
}
|
|
40
|
-
if (type === 'show-issue') {
|
|
41
|
-
const id = payload.id;
|
|
42
|
-
const idx = current.findIndex((x) => x.id === id);
|
|
43
|
-
if (idx >= 0) {
|
|
44
|
-
// Return an updated item with a different priority to simulate backend
|
|
45
|
-
const updated = { ...current[idx], priority: 4 };
|
|
46
|
-
// and reflect it into the list that will be rendered after refresh
|
|
47
|
-
current[idx] = updated;
|
|
48
|
-
return updated;
|
|
49
|
-
}
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
throw new Error('Unexpected');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const view = createListView(mount, send);
|
|
56
|
-
await view.load();
|
|
57
|
-
|
|
58
|
-
const firstRow = /** @type {HTMLElement} */ (
|
|
59
|
-
mount.querySelector('tr.issue-row[data-issue-id="UI-1"]')
|
|
60
|
-
);
|
|
61
|
-
expect(firstRow).toBeTruthy();
|
|
62
|
-
const prio = /** @type {HTMLSelectElement} */ (
|
|
63
|
-
firstRow.querySelector('select.badge--priority')
|
|
64
|
-
);
|
|
65
|
-
expect(prio.value).toBe('1');
|
|
66
|
-
|
|
67
|
-
// Change to a different priority; handler should call update-priority then show-issue
|
|
68
|
-
prio.value = '4';
|
|
69
|
-
prio.dispatchEvent(new Event('change'));
|
|
70
|
-
|
|
71
|
-
await Promise.resolve();
|
|
72
|
-
|
|
73
|
-
const types = spy.calls.map((c) => c.type);
|
|
74
|
-
expect(types).toContain('update-priority');
|
|
75
|
-
expect(types).toContain('show-issue');
|
|
76
|
-
|
|
77
|
-
const prio2 = /** @type {HTMLSelectElement} */ (
|
|
78
|
-
mount.querySelector(
|
|
79
|
-
'tr.issue-row[data-issue-id="UI-1"] select.badge--priority'
|
|
80
|
-
)
|
|
81
|
-
);
|
|
82
|
-
expect(prio2.value).toBe('4');
|
|
83
|
-
});
|
|
84
|
-
});
|