beads-ui 0.1.1 → 0.2.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.
Files changed (82) hide show
  1. package/CHANGES.md +27 -1
  2. package/README.md +39 -45
  3. package/app/data/providers.js +57 -26
  4. package/app/index.html +8 -0
  5. package/app/main.js +179 -33
  6. package/app/protocol.md +3 -4
  7. package/app/router.js +45 -9
  8. package/app/state.js +27 -11
  9. package/app/styles.css +170 -6
  10. package/app/utils/issue-id-renderer.js +71 -0
  11. package/app/utils/issue-url.js +9 -0
  12. package/app/utils/toast.js +35 -0
  13. package/app/views/board.js +347 -17
  14. package/app/views/detail.js +292 -92
  15. package/app/views/epics.js +2 -2
  16. package/app/views/issue-dialog.js +170 -0
  17. package/app/views/issue-row.js +9 -8
  18. package/app/views/list.js +85 -11
  19. package/app/views/new-issue-dialog.js +352 -0
  20. package/app/ws.js +30 -0
  21. package/docs/architecture.md +1 -1
  22. package/package.json +17 -1
  23. package/server/cli/commands.js +11 -3
  24. package/server/cli/index.js +35 -4
  25. package/server/cli/usage.js +1 -1
  26. package/server/watcher.js +3 -3
  27. package/server/ws.js +39 -19
  28. package/.beads/issues.jsonl +0 -107
  29. package/.editorconfig +0 -10
  30. package/.eslintrc.json +0 -36
  31. package/.github/workflows/ci.yml +0 -38
  32. package/.prettierignore +0 -5
  33. package/AGENTS.md +0 -85
  34. package/app/data/providers.test.js +0 -126
  35. package/app/main.board-switch.test.js +0 -94
  36. package/app/main.deep-link.test.js +0 -64
  37. package/app/main.live-updates.test.js +0 -229
  38. package/app/main.test.js +0 -17
  39. package/app/main.theme.test.js +0 -41
  40. package/app/main.view-sync.test.js +0 -54
  41. package/app/protocol.test.js +0 -57
  42. package/app/router.test.js +0 -34
  43. package/app/state.test.js +0 -21
  44. package/app/utils/markdown.test.js +0 -103
  45. package/app/utils/type-badge.test.js +0 -30
  46. package/app/views/board.test.js +0 -184
  47. package/app/views/detail.acceptance-notes.test.js +0 -67
  48. package/app/views/detail.assignee.test.js +0 -161
  49. package/app/views/detail.deps.test.js +0 -97
  50. package/app/views/detail.edits.test.js +0 -146
  51. package/app/views/detail.labels.test.js +0 -73
  52. package/app/views/detail.priority.test.js +0 -86
  53. package/app/views/detail.test.js +0 -188
  54. package/app/views/detail.ui47.test.js +0 -78
  55. package/app/views/epics.test.js +0 -283
  56. package/app/views/list.inline-edits.test.js +0 -84
  57. package/app/views/list.test.js +0 -479
  58. package/app/views/nav.test.js +0 -43
  59. package/app/ws.test.js +0 -168
  60. package/docs/quickstart.md +0 -142
  61. package/eslint.config.js +0 -59
  62. package/media/bdui-board.png +0 -0
  63. package/media/bdui-epics.png +0 -0
  64. package/media/bdui-issues.png +0 -0
  65. package/prettier.config.js +0 -13
  66. package/server/app.test.js +0 -29
  67. package/server/bd.test.js +0 -93
  68. package/server/cli/cli.test.js +0 -109
  69. package/server/cli/commands.integration.test.js +0 -155
  70. package/server/cli/commands.unit.test.js +0 -94
  71. package/server/cli/open.test.js +0 -26
  72. package/server/db.test.js +0 -70
  73. package/server/protocol.test.js +0 -87
  74. package/server/watcher.test.js +0 -100
  75. package/server/ws.handlers.test.js +0 -174
  76. package/server/ws.labels.test.js +0 -95
  77. package/server/ws.mutations.test.js +0 -261
  78. package/server/ws.subscriptions.test.js +0 -116
  79. package/server/ws.test.js +0 -52
  80. package/test/setup-vitest.js +0 -12
  81. package/tsconfig.json +0 -23
  82. package/vitest.config.mjs +0 -14
@@ -1,161 +0,0 @@
1
- import { describe, expect, test, vi } from 'vitest';
2
- import { createDetailView } from './detail.js';
3
-
4
- /** @type {(impl: (type: string, payload?: unknown) => Promise<any>) => (type: string, payload?: unknown) => Promise<any>} */
5
- const mockSend = (impl) => vi.fn(impl);
6
-
7
- describe('views/detail assignee edit', () => {
8
- test('edits assignee via Properties control', async () => {
9
- document.body.innerHTML =
10
- '<section class="panel"><div id="mount"></div></section>';
11
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
12
-
13
- const issue = {
14
- id: 'UI-57',
15
- title: 'Detail screen',
16
- description: '',
17
- status: 'open',
18
- priority: 2,
19
- assignee: 'alice',
20
- dependencies: [],
21
- dependents: []
22
- };
23
-
24
- const send = mockSend(async (type, payload) => {
25
- if (type === 'show-issue') {
26
- return issue;
27
- }
28
- if (type === 'update-assignee') {
29
- expect(payload).toEqual({ id: 'UI-57', assignee: 'max' });
30
- const next = { ...issue, assignee: 'max' };
31
- return next;
32
- }
33
- throw new Error('Unexpected');
34
- });
35
-
36
- const view = createDetailView(mount, send);
37
- await view.load('UI-57');
38
-
39
- const assigneeSpan = /** @type {HTMLSpanElement} */ (
40
- mount.querySelector('#detail-root .prop.assignee .value .editable')
41
- );
42
- expect(assigneeSpan).toBeTruthy();
43
- expect(assigneeSpan.textContent).toBe('alice');
44
-
45
- assigneeSpan.click();
46
- const input = /** @type {HTMLInputElement} */ (
47
- mount.querySelector('#detail-root .prop.assignee input')
48
- );
49
- const saveBtn = /** @type {HTMLButtonElement} */ (
50
- mount.querySelector('#detail-root .prop.assignee button')
51
- );
52
- input.value = 'max';
53
- saveBtn.click();
54
-
55
- await Promise.resolve();
56
-
57
- const assigneeSpan2 = /** @type {HTMLSpanElement} */ (
58
- mount.querySelector('#detail-root .prop.assignee .value .editable')
59
- );
60
- expect(assigneeSpan2.textContent).toBe('max');
61
- });
62
-
63
- test('shows editable placeholder when unassigned', async () => {
64
- document.body.innerHTML =
65
- '<section class="panel"><div id="mount"></div></section>';
66
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
67
-
68
- const issue = {
69
- id: 'UI-88',
70
- title: 'No assignee yet',
71
- description: '',
72
- status: 'open',
73
- priority: 2,
74
- // no assignee field
75
- dependencies: [],
76
- dependents: []
77
- };
78
-
79
- const send = mockSend(async (type, payload) => {
80
- if (type === 'show-issue') {
81
- return issue;
82
- }
83
- if (type === 'update-assignee') {
84
- const next = {
85
- ...issue,
86
- assignee: /** @type {any} */ (payload).assignee
87
- };
88
- return next;
89
- }
90
- throw new Error('Unexpected');
91
- });
92
-
93
- const view = createDetailView(mount, send);
94
- await view.load('UI-88');
95
-
96
- const ph = /** @type {HTMLSpanElement} */ (
97
- mount.querySelector('#detail-root .prop.assignee .value .editable')
98
- );
99
- expect(ph).toBeTruthy();
100
- expect(ph.className).toContain('muted');
101
- expect(ph.textContent).toBe('Unassigned');
102
-
103
- ph.click();
104
- const input = /** @type {HTMLInputElement} */ (
105
- mount.querySelector('#detail-root .prop.assignee input')
106
- );
107
- expect(input).toBeTruthy();
108
- });
109
-
110
- test('clears assignee to empty string and shows placeholder', async () => {
111
- document.body.innerHTML =
112
- '<section class="panel"><div id="mount"></div></section>';
113
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
114
-
115
- const issue = {
116
- id: 'UI-31',
117
- title: 'Clearable',
118
- status: 'open',
119
- priority: 2,
120
- assignee: 'bob',
121
- dependencies: [],
122
- dependents: []
123
- };
124
-
125
- const send = mockSend(async (type, payload) => {
126
- if (type === 'show-issue') {
127
- return issue;
128
- }
129
- if (type === 'update-assignee') {
130
- const next = {
131
- ...issue,
132
- assignee: /** @type {any} */ (payload).assignee
133
- };
134
- return next;
135
- }
136
- throw new Error('Unexpected');
137
- });
138
-
139
- const view = createDetailView(mount, send);
140
- await view.load('UI-31');
141
-
142
- const span = /** @type {HTMLSpanElement} */ (
143
- mount.querySelector('#detail-root .prop.assignee .value .editable')
144
- );
145
- span.click();
146
- const input = /** @type {HTMLInputElement} */ (
147
- mount.querySelector('#detail-root .prop.assignee input')
148
- );
149
- const save = /** @type {HTMLButtonElement} */ (
150
- mount.querySelector('#detail-root .prop.assignee button')
151
- );
152
- input.value = '';
153
- save.click();
154
- await Promise.resolve();
155
- const span2 = /** @type {HTMLSpanElement} */ (
156
- mount.querySelector('#detail-root .prop.assignee .value .editable')
157
- );
158
- expect(span2.textContent).toBe('Unassigned');
159
- expect(span2.className).toContain('muted');
160
- });
161
- });
@@ -1,97 +0,0 @@
1
- import { describe, expect, test, vi } from 'vitest';
2
- import { createDetailView } from './detail.js';
3
-
4
- function setupDom() {
5
- const root = document.createElement('div');
6
- document.body.appendChild(root);
7
- return root;
8
- }
9
-
10
- describe('views/detail dependencies', () => {
11
- test('adds Dependencies link and re-renders', async () => {
12
- const mount = setupDom();
13
- const send = vi
14
- .fn()
15
- // initial show
16
- .mockResolvedValueOnce({
17
- id: 'UI-10',
18
- title: 'X',
19
- dependencies: [],
20
- dependents: []
21
- })
22
- // dep-add returns updated issue
23
- .mockResolvedValueOnce({
24
- id: 'UI-10',
25
- dependencies: [{ id: 'UI-2' }],
26
- dependents: []
27
- });
28
- const view = createDetailView(mount, /** @type {any} */ (send));
29
- await view.load('UI-10');
30
-
31
- const input = mount.querySelector('[data-testid="add-dependency"]');
32
- expect(input).toBeTruthy();
33
- /** @type {HTMLInputElement} */
34
- const el = /** @type {any} */ (input);
35
- el.value = 'UI-2';
36
- const addBtn = el.nextElementSibling;
37
- addBtn?.dispatchEvent(new window.Event('click'));
38
-
39
- // Next tick
40
- await Promise.resolve();
41
-
42
- // Should have called dep-add
43
- const calls = send.mock.calls.map((c) => c[0]);
44
- expect(calls.includes('dep-add')).toBe(true);
45
- });
46
-
47
- test('removes Blocks link', async () => {
48
- const mount = setupDom();
49
- const send = vi
50
- .fn()
51
- // initial show
52
- .mockResolvedValueOnce({
53
- id: 'UI-20',
54
- title: 'Y',
55
- dependencies: [],
56
- dependents: [{ id: 'UI-5' }]
57
- })
58
- // dep-remove returns updated issue
59
- .mockResolvedValueOnce({ id: 'UI-20', dependencies: [], dependents: [] });
60
- const view = createDetailView(mount, /** @type {any} */ (send));
61
- await view.load('UI-20');
62
-
63
- // Find the remove button next to link #5
64
- const btns = mount.querySelectorAll('button');
65
- const rm = Array.from(btns).find((b) =>
66
- b.getAttribute('aria-label')?.includes('#5')
67
- );
68
- expect(rm).toBeTruthy();
69
- rm?.dispatchEvent(new window.Event('click'));
70
-
71
- await Promise.resolve();
72
- const calls = send.mock.calls.map((c) => c[0]);
73
- expect(calls.includes('dep-remove')).toBe(true);
74
- });
75
-
76
- test('prevents duplicate link add', async () => {
77
- const mount = setupDom();
78
- const send = vi.fn().mockResolvedValueOnce({
79
- id: 'UI-30',
80
- dependencies: [{ id: 'UI-9' }],
81
- dependents: []
82
- });
83
- const view = createDetailView(mount, /** @type {any} */ (send));
84
- await view.load('UI-30');
85
-
86
- const input = mount.querySelector('[data-testid="add-dependency"]');
87
- const el = /** @type {HTMLInputElement} */ (/** @type {any} */ (input));
88
- el.value = 'UI-9';
89
- const addBtn = el.nextElementSibling;
90
- addBtn?.dispatchEvent(new window.Event('click'));
91
-
92
- await Promise.resolve();
93
- // send should not be called with dep-add
94
- const calls = send.mock.calls.map((c) => c[0]);
95
- expect(calls.includes('dep-add')).toBe(false);
96
- });
97
- });
@@ -1,146 +0,0 @@
1
- import { describe, expect, test, vi } from 'vitest';
2
- import { createDetailView } from './detail.js';
3
-
4
- /** @type {(impl: (type: string, payload?: unknown) => Promise<any>) => (type: string, payload?: unknown) => Promise<any>} */
5
- const mockSend = (impl) => vi.fn(impl);
6
-
7
- describe('views/detail edits', () => {
8
- test('updates status via dropdown and disables while pending', async () => {
9
- document.body.innerHTML =
10
- '<section class="panel"><div id="mount"></div></section>';
11
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
12
-
13
- const initial = {
14
- id: 'UI-7',
15
- title: 'T',
16
- description: 'D',
17
- status: 'open',
18
- priority: 2
19
- };
20
- const updated = { ...initial, status: 'in_progress' };
21
-
22
- const send = mockSend(async (type, payload) => {
23
- if (type === 'show-issue') {
24
- return initial;
25
- }
26
- if (type === 'update-status') {
27
- expect(payload).toEqual({ id: 'UI-7', status: 'in_progress' });
28
- // simulate server reconcile payload
29
- return updated;
30
- }
31
- throw new Error('Unexpected');
32
- });
33
-
34
- const view = createDetailView(mount, send);
35
- await view.load('UI-7');
36
-
37
- const select = /** @type {HTMLSelectElement} */ (
38
- mount.querySelector('select')
39
- );
40
- expect(select.value).toBe('open');
41
-
42
- // Trigger change
43
- select.value = 'in_progress';
44
- const beforeDisabled = select.disabled;
45
- select.dispatchEvent(new Event('change'));
46
- // After dispatch, the component sets disabled & will re-render upon reply
47
- expect(beforeDisabled || select.disabled).toBe(true);
48
-
49
- // After async flow, DOM should reflect updated status
50
- await Promise.resolve(); // allow microtasks
51
- const select2 = /** @type {HTMLSelectElement} */ (
52
- mount.querySelector('select')
53
- );
54
- expect(select2.value).toBe('in_progress');
55
- });
56
-
57
- test('saves title and re-renders from reply', async () => {
58
- document.body.innerHTML =
59
- '<section class="panel"><div id="mount"></div></section>';
60
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
61
- const initial = {
62
- id: 'UI-8',
63
- title: 'Old',
64
- description: '',
65
- status: 'open',
66
- priority: 1
67
- };
68
- const send = mockSend(async (type, payload) => {
69
- if (type === 'show-issue') {
70
- return initial;
71
- }
72
- if (type === 'edit-text') {
73
- const next = { ...initial, title: /** @type {any} */ (payload).value };
74
- return next;
75
- }
76
- throw new Error('Unexpected');
77
- });
78
- const view = createDetailView(mount, send);
79
- await view.load('UI-8');
80
- // Enter edit mode by clicking the span
81
- const titleSpan = /** @type {HTMLSpanElement} */ (
82
- mount.querySelector('h2 .editable')
83
- );
84
- titleSpan.click();
85
- const titleInput = /** @type {HTMLInputElement} */ (
86
- mount.querySelector('h2 input')
87
- );
88
- const titleSave = /** @type {HTMLButtonElement} */ (
89
- mount.querySelector('h2 button')
90
- );
91
- titleInput.value = 'New Title';
92
- titleSave.click();
93
- await Promise.resolve();
94
- // After save, returns to read mode with updated text
95
- const titleSpan2 = /** @type {HTMLSpanElement} */ (
96
- mount.querySelector('h2 .editable')
97
- );
98
- expect(titleSpan2.textContent).toBe('New Title');
99
- });
100
-
101
- test('shows toast on description save error and re-enables', async () => {
102
- vi.useFakeTimers();
103
- document.body.innerHTML =
104
- '<section class="panel"><div id="mount"></div></section>';
105
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
106
- const initial = {
107
- id: 'UI-9',
108
- title: 'T',
109
- description: 'D',
110
- status: 'open',
111
- priority: 2
112
- };
113
- const send = mockSend(async (type) => {
114
- if (type === 'show-issue') {
115
- return initial;
116
- }
117
- if (type === 'edit-text') {
118
- throw new Error('boom');
119
- }
120
- throw new Error('Unexpected');
121
- });
122
- const view = createDetailView(mount, send);
123
- await view.load('UI-9');
124
- // Enter edit mode
125
- const md = /** @type {HTMLDivElement} */ (mount.querySelector('.md'));
126
- md.click();
127
- const ta = /** @type {HTMLTextAreaElement} */ (
128
- mount.querySelector('textarea')
129
- );
130
- const btn = /** @type {HTMLButtonElement} */ (
131
- mount.querySelector('.editable-actions button')
132
- );
133
- ta.value = 'New D';
134
- btn.click();
135
- await Promise.resolve();
136
- // Toast appears
137
- const toast = /** @type {HTMLElement} */ (mount.querySelector('.toast'));
138
- expect(toast).not.toBeNull();
139
- expect((toast.textContent || '').toLowerCase()).toContain(
140
- 'failed to save description'
141
- );
142
- // Auto-dismiss after a while
143
- await vi.advanceTimersByTimeAsync(3000);
144
- vi.useRealTimers();
145
- });
146
- });
@@ -1,73 +0,0 @@
1
- import { describe, expect, test, vi } from 'vitest';
2
- import { createDetailView } from './detail.js';
3
-
4
- function mountDiv() {
5
- const div = document.createElement('div');
6
- document.body.appendChild(div);
7
- return div;
8
- }
9
-
10
- describe('detail view labels', () => {
11
- test('shows labels and allows add/remove', async () => {
12
- const mount = mountDiv();
13
- let current = {
14
- id: 'UI-5',
15
- title: 'With labels',
16
- status: 'open',
17
- priority: 2,
18
- labels: ['frontend']
19
- };
20
- const sendFn = vi.fn(async (type, payload) => {
21
- if (type === 'show-issue') {
22
- return current;
23
- }
24
- if (type === 'label-add') {
25
- current = { ...current, labels: [...current.labels, payload.label] };
26
- return current;
27
- }
28
- if (type === 'label-remove') {
29
- current = {
30
- ...current,
31
- labels: current.labels.filter((l) => l !== payload.label)
32
- };
33
- return current;
34
- }
35
- return current;
36
- });
37
-
38
- const view = createDetailView(mount, sendFn);
39
- await view.load('UI-5');
40
-
41
- // Initial chip present
42
- expect(mount.querySelectorAll('.prop.labels .badge').length).toBe(1);
43
-
44
- // Add a label via input + Enter
45
- const input = /** @type {HTMLInputElement} */ (
46
- mount.querySelector('.prop.labels input')
47
- );
48
- input.value = 'backend';
49
- input.dispatchEvent(new Event('input', { bubbles: true }));
50
- input.dispatchEvent(
51
- new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
52
- );
53
- await Promise.resolve();
54
-
55
- expect(sendFn).toHaveBeenCalledWith('label-add', {
56
- id: 'UI-5',
57
- label: 'backend'
58
- });
59
- expect(mount.querySelectorAll('.prop.labels .badge').length).toBe(2);
60
-
61
- // Remove the first label by clicking the × button
62
- const removeBtn = /** @type {HTMLButtonElement} */ (
63
- mount.querySelector('.prop.labels .badge button')
64
- );
65
- removeBtn.click();
66
- await Promise.resolve();
67
- expect(sendFn).toHaveBeenCalledWith('label-remove', {
68
- id: 'UI-5',
69
- label: 'frontend'
70
- });
71
- expect(mount.querySelectorAll('.prop.labels .badge').length).toBe(1);
72
- });
73
- });
@@ -1,86 +0,0 @@
1
- import { describe, expect, test, vi } from 'vitest';
2
- import { createDetailView } from './detail.js';
3
-
4
- /** @type {(impl: (type: string, payload?: unknown) => Promise<any>) => (type: string, payload?: unknown) => Promise<any>} */
5
- const mockSend = (impl) => vi.fn(impl);
6
-
7
- describe('views/detail priority edit', () => {
8
- test('updates priority via dropdown and re-renders from reply', async () => {
9
- document.body.innerHTML =
10
- '<section class="panel"><div id="mount"></div></section>';
11
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
12
-
13
- const initial = {
14
- id: 'UI-70',
15
- title: 'P',
16
- description: '',
17
- status: 'open',
18
- priority: 2
19
- };
20
- const send = mockSend(async (type, payload) => {
21
- if (type === 'show-issue') {
22
- return initial;
23
- }
24
- if (type === 'update-priority') {
25
- expect(payload).toEqual({ id: 'UI-70', priority: 4 });
26
- return { ...initial, priority: 4 };
27
- }
28
- throw new Error('Unexpected');
29
- });
30
-
31
- const view = createDetailView(mount, send);
32
- await view.load('UI-70');
33
-
34
- const selects = mount.querySelectorAll('select.badge--priority');
35
- expect(selects.length).toBe(1);
36
- const prio = /** @type {HTMLSelectElement} */ (selects[0]);
37
- expect(prio.value).toBe('2');
38
-
39
- prio.value = '4';
40
- prio.dispatchEvent(new Event('change'));
41
-
42
- await Promise.resolve();
43
-
44
- const prio2 = /** @type {HTMLSelectElement} */ (
45
- mount.querySelector('select.badge--priority')
46
- );
47
- expect(prio2.value).toBe('4');
48
- });
49
-
50
- test('shows toast on error and restores previous value', async () => {
51
- document.body.innerHTML =
52
- '<section class="panel"><div id="mount"></div></section>';
53
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
54
-
55
- const initial = { id: 'UI-71', title: 'Q', status: 'open', priority: 1 };
56
- const send = mockSend(async (type) => {
57
- if (type === 'show-issue') {
58
- return initial;
59
- }
60
- if (type === 'update-priority') {
61
- throw new Error('oops');
62
- }
63
- throw new Error('Unexpected');
64
- });
65
-
66
- const view = createDetailView(mount, send);
67
- await view.load('UI-71');
68
- const prio = /** @type {HTMLSelectElement} */ (
69
- mount.querySelector('select.badge--priority')
70
- );
71
- expect(prio.value).toBe('1');
72
-
73
- prio.value = '3';
74
- prio.dispatchEvent(new Event('change'));
75
-
76
- await Promise.resolve();
77
-
78
- const toast = mount.querySelector('.toast');
79
- expect(toast).toBeTruthy();
80
- // Should restore previous value
81
- const prio2 = /** @type {HTMLSelectElement} */ (
82
- mount.querySelector('select.badge--priority')
83
- );
84
- expect(prio2.value).toBe('1');
85
- });
86
- });