beads-ui 0.1.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/.beads/issues.jsonl +107 -0
- package/.editorconfig +10 -0
- package/.eslintrc.json +36 -0
- package/.github/workflows/ci.yml +38 -0
- package/.prettierignore +5 -0
- package/AGENTS.md +85 -0
- package/CHANGES.md +5 -0
- package/LICENSE +22 -0
- package/README.md +75 -0
- package/app/data/providers.js +178 -0
- package/app/data/providers.test.js +126 -0
- package/app/index.html +29 -0
- package/app/main.board-switch.test.js +94 -0
- package/app/main.deep-link.test.js +64 -0
- package/app/main.js +280 -0
- package/app/main.live-updates.test.js +229 -0
- package/app/main.test.js +17 -0
- package/app/main.theme.test.js +41 -0
- package/app/main.view-sync.test.js +54 -0
- package/app/protocol.js +200 -0
- package/app/protocol.md +64 -0
- package/app/protocol.test.js +57 -0
- package/app/router.js +78 -0
- package/app/router.test.js +34 -0
- package/app/state.js +87 -0
- package/app/state.test.js +21 -0
- package/app/styles.css +1343 -0
- package/app/utils/issue-id.js +10 -0
- package/app/utils/issue-type.js +27 -0
- package/app/utils/markdown.js +201 -0
- package/app/utils/markdown.test.js +103 -0
- package/app/utils/priority-badge.js +49 -0
- package/app/utils/priority.js +1 -0
- package/app/utils/status-badge.js +33 -0
- package/app/utils/status.js +23 -0
- package/app/utils/type-badge.js +36 -0
- package/app/utils/type-badge.test.js +30 -0
- package/app/views/board.js +183 -0
- package/app/views/board.test.js +184 -0
- package/app/views/detail.acceptance-notes.test.js +67 -0
- package/app/views/detail.assignee.test.js +161 -0
- package/app/views/detail.deps.test.js +97 -0
- package/app/views/detail.edits.test.js +146 -0
- package/app/views/detail.js +1039 -0
- package/app/views/detail.labels.test.js +73 -0
- package/app/views/detail.priority.test.js +86 -0
- package/app/views/detail.test.js +188 -0
- package/app/views/detail.ui47.test.js +78 -0
- package/app/views/epics.js +228 -0
- package/app/views/epics.test.js +283 -0
- package/app/views/issue-row.js +191 -0
- package/app/views/list.inline-edits.test.js +84 -0
- package/app/views/list.js +393 -0
- package/app/views/list.test.js +479 -0
- package/app/views/nav.js +67 -0
- package/app/views/nav.test.js +43 -0
- package/app/ws.js +252 -0
- package/app/ws.test.js +168 -0
- package/bin/bdui.js +18 -0
- package/docs/architecture.md +244 -0
- package/docs/db-watching.md +29 -0
- package/docs/quickstart.md +142 -0
- package/eslint.config.js +59 -0
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/package.json +48 -0
- package/prettier.config.js +13 -0
- package/server/app.js +80 -0
- package/server/app.test.js +29 -0
- package/server/bd.js +125 -0
- package/server/bd.test.js +93 -0
- package/server/cli/cli.test.js +109 -0
- package/server/cli/commands.integration.test.js +155 -0
- package/server/cli/commands.js +91 -0
- package/server/cli/commands.unit.test.js +94 -0
- package/server/cli/daemon.js +239 -0
- package/server/cli/index.js +74 -0
- package/server/cli/open.js +96 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +22 -0
- package/server/config.js +29 -0
- package/server/db.js +100 -0
- package/server/db.test.js +70 -0
- package/server/index.js +29 -0
- package/server/protocol.js +3 -0
- package/server/protocol.test.js +87 -0
- package/server/watcher.js +107 -0
- package/server/watcher.test.js +100 -0
- package/server/ws.handlers.test.js +174 -0
- package/server/ws.js +784 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.mutations.test.js +261 -0
- package/server/ws.subscriptions.test.js +116 -0
- package/server/ws.test.js +52 -0
- package/test/setup-vitest.js +12 -0
- package/tsconfig.json +23 -0
- package/vitest.config.mjs +14 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Agents
|
|
2
|
+
|
|
3
|
+
## Beads (bd) — Work Tracking
|
|
4
|
+
|
|
5
|
+
Use MCP `beads` (bd) as our dependency‑aware issue tracker. Run
|
|
6
|
+
`beads/quickstart` to learn how to use it.
|
|
7
|
+
|
|
8
|
+
### Issue Types
|
|
9
|
+
|
|
10
|
+
- `bug` - Something broken that needs fixing
|
|
11
|
+
- `feature` - New functionality
|
|
12
|
+
- `task` - Work item (tests, docs, refactoring)
|
|
13
|
+
- `epic` - Large feature composed of multiple issues
|
|
14
|
+
- `chore` - Maintenance work (dependencies, tooling)
|
|
15
|
+
|
|
16
|
+
### Priorities
|
|
17
|
+
|
|
18
|
+
- `0` - Critical (security, data loss, broken builds)
|
|
19
|
+
- `1` - High (major features, important bugs)
|
|
20
|
+
- `2` - Medium (nice-to-have features, minor bugs)
|
|
21
|
+
- `3` - Low (polish, optimization)
|
|
22
|
+
- `4` - Backlog (future ideas)
|
|
23
|
+
|
|
24
|
+
### Dependency Types
|
|
25
|
+
|
|
26
|
+
- `blocks` - Hard dependency (issue X blocks issue Y)
|
|
27
|
+
- `related` - Soft relationship (issues are connected)
|
|
28
|
+
- `parent-child` - Epic/subtask relationship
|
|
29
|
+
- `discovered-from` - Track issues discovered during work
|
|
30
|
+
|
|
31
|
+
Only `blocks` dependencies affect the ready work queue.
|
|
32
|
+
|
|
33
|
+
### Structured Fields and Labels
|
|
34
|
+
|
|
35
|
+
- Use issue `type` and `priority` fields.
|
|
36
|
+
- Use issue type "epic" and `parent-child` dependencies.
|
|
37
|
+
- Use `related` or `discovered-from` dependencies.
|
|
38
|
+
- Area pointers are labels, e.g.: `frontend`, `backend`
|
|
39
|
+
|
|
40
|
+
### Agent Workflow
|
|
41
|
+
|
|
42
|
+
If no issue is specified, run `bd ready` and claim an unblocked issue.
|
|
43
|
+
|
|
44
|
+
1. Open issue with `bd show <id>` and read all linked docs.
|
|
45
|
+
2. Assign to `agent`, update status as you work (`in_progress` → `closed`);
|
|
46
|
+
maintain dependencies, and attach notes/links for traceability.
|
|
47
|
+
3. Discover new work? Create linked issue with dependency
|
|
48
|
+
`discovered-from:<parent-id>`.
|
|
49
|
+
4. Land the change; run tests/lint; update any referenced docs.
|
|
50
|
+
5. Close the issue with `bd close <id>`.
|
|
51
|
+
|
|
52
|
+
## Coding Standards
|
|
53
|
+
|
|
54
|
+
- Use ECMAScript modules.
|
|
55
|
+
- Classes, interfaces, and factory types use `PascalCase`.
|
|
56
|
+
- Functions and methods use `camelCase`.
|
|
57
|
+
- Variables and parameters use `lower_snake_case`, unless they refer to a
|
|
58
|
+
function or class.
|
|
59
|
+
- Constants are `UPPER_SNAKE_CASE`.
|
|
60
|
+
- File and directory names are `kebab-case`.
|
|
61
|
+
- Use `.js` files with JSDoc type annotations (TypeScript mode).
|
|
62
|
+
- Use `.ts` files only for interface definitions.
|
|
63
|
+
- Type only imports: `@import { X, Y, Z } from './file.js` in top-of-file JSDoc.
|
|
64
|
+
- Add JSDoc to all functions and methods with `@param` (and `@returns` for non
|
|
65
|
+
trivial return types).
|
|
66
|
+
- Annotate local variables with `@type` blocks if their type is not obvious from
|
|
67
|
+
the initializer.
|
|
68
|
+
- Use blocks for all control flow statements, even single-line bodies.
|
|
69
|
+
- Avoid runtime type checks, undefined/null checks and optional chaining
|
|
70
|
+
operators (`?.`, `??`) unless strictly necessary.
|
|
71
|
+
|
|
72
|
+
## Unit Testing Standards
|
|
73
|
+
|
|
74
|
+
- Write short, focused test-case functions asserting one behavior each.
|
|
75
|
+
- Do not use "should" in test names; use verbs like "returns", "throws",
|
|
76
|
+
"emits", or "calls"
|
|
77
|
+
- Structure: setup → execution → assertion (separate with blank lines).
|
|
78
|
+
- Never change implementation code to make tests pass.
|
|
79
|
+
|
|
80
|
+
## Pre‑Handoff Validation
|
|
81
|
+
|
|
82
|
+
- Run type checks: `npm run typecheck`
|
|
83
|
+
- Run tests: `npm test`
|
|
84
|
+
- Run eslint: `npm run lint`
|
|
85
|
+
- Run prettier: `npm run format`
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2025 Maximilian Antoni
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person
|
|
4
|
+
obtaining a copy of this software and associated documentation
|
|
5
|
+
files (the "Software"), to deal in the Software without
|
|
6
|
+
restriction, including without limitation the rights to use,
|
|
7
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the
|
|
9
|
+
Software is furnished to do so, subject to the following
|
|
10
|
+
conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be
|
|
13
|
+
included in all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
16
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
17
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
18
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
19
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
20
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
21
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# beads-ui
|
|
2
|
+
|
|
3
|
+
Local‑first UI for the `bd` CLI (beads) — a fast, dependency‑aware issue
|
|
4
|
+
tracker.
|
|
5
|
+
|
|
6
|
+
beads-ui complements the upstream beads project by providing a single‑page web
|
|
7
|
+
app served from a local Node.js server. It talks to `bd` over a local WebSocket
|
|
8
|
+
to list issues, show details, and apply edits. All changes happen by executing
|
|
9
|
+
`bd` commands, and live updates flow in as the database changes on disk.
|
|
10
|
+
|
|
11
|
+
Upstream beads (CLI and docs): https://github.com/steveyegge/beads
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- Issues list with inline edits, quick filters, and keyboard navigation
|
|
16
|
+
- Epics view grouped by epic (from `bd epic status --json`) with expandable rows
|
|
17
|
+
- Board view with Ready / In progress / Closed columns
|
|
18
|
+
- Deep links for navigation; state persists across reloads
|
|
19
|
+
- Live updates via FS watch + WebSocket; optimistic UI with rollbacks on error
|
|
20
|
+
- Dark theme toggle, saved per user
|
|
21
|
+
- Local CLI helper `bdui` to daemonize the server and open your browser
|
|
22
|
+
|
|
23
|
+
## Screenshots
|
|
24
|
+
|
|
25
|
+
Issues
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
Epics
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
Board
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
Prerequisites:
|
|
40
|
+
|
|
41
|
+
- Node.js >= 22
|
|
42
|
+
- `bd` CLI on your PATH (or set `BD_BIN=/path/to/bd`)
|
|
43
|
+
|
|
44
|
+
Install and start:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npm install -g beads-ui
|
|
48
|
+
bdui start
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
See `bdui --help` for options.
|
|
52
|
+
|
|
53
|
+
Environment variables:
|
|
54
|
+
|
|
55
|
+
- `BDUI_RUNTIME_DIR`: override runtime directory for PID/logs. Defaults to
|
|
56
|
+
`$XDG_RUNTIME_DIR/beads-ui` or the system temp dir.
|
|
57
|
+
- `BDUI_NO_OPEN=1`: disable opening the default browser on `start`.
|
|
58
|
+
- `PORT`: overrides the listen port (default `3000`). The server binds to
|
|
59
|
+
`127.0.0.1`.
|
|
60
|
+
|
|
61
|
+
Platform notes:
|
|
62
|
+
|
|
63
|
+
- macOS/Linux are fully supported. On Windows, the CLI uses `cmd /c start` to
|
|
64
|
+
open URLs and relies on Node’s `process.kill` semantics for stopping the
|
|
65
|
+
daemon.
|
|
66
|
+
|
|
67
|
+
## Developer Workflow
|
|
68
|
+
|
|
69
|
+
- Type check: `npm run typecheck`
|
|
70
|
+
- Tests: `npm test`
|
|
71
|
+
- Lint: `npm run lint`
|
|
72
|
+
- Format: `npm run format`
|
|
73
|
+
|
|
74
|
+
See `docs/quickstart.md` for details and `docs/architecture.md` for the protocol
|
|
75
|
+
and component overview.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data layer: typed wrappers around the ws transport for bd-backed queries.
|
|
3
|
+
* @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
|
|
4
|
+
* @param {(type: import('../protocol.js').MessageType, handler: (payload: unknown) => void) => void} [on_event] - Optional event subscription (used to invalidate caches on push updates).
|
|
5
|
+
* @returns {{ getEpicStatus: () => Promise<unknown[]>, getReady: () => Promise<unknown[]>, getOpen: () => Promise<unknown[]>, getInProgress: () => Promise<unknown[]>, getClosed: (limit?: number) => Promise<unknown[]>, getIssue: (id: string) => Promise<unknown>, updateIssue: (input: { id: string, title?: string, acceptance?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
|
|
6
|
+
*/
|
|
7
|
+
export function createDataLayer(transport, on_event) {
|
|
8
|
+
/** @type {{ list_ready?: unknown, list_open?: unknown, list_in_progress?: unknown, list_closed_10?: unknown, epic_status?: unknown }} */
|
|
9
|
+
const cache = {};
|
|
10
|
+
|
|
11
|
+
// Invalidate caches on server push updates when available
|
|
12
|
+
if (on_event) {
|
|
13
|
+
try {
|
|
14
|
+
on_event('issues-changed', () => {
|
|
15
|
+
cache.list_ready = undefined;
|
|
16
|
+
cache.list_open = undefined;
|
|
17
|
+
cache.list_in_progress = undefined;
|
|
18
|
+
cache.list_closed_10 = undefined;
|
|
19
|
+
cache.epic_status = undefined;
|
|
20
|
+
});
|
|
21
|
+
} catch {
|
|
22
|
+
// noop
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get epic status groups via `bd epic status --json`.
|
|
28
|
+
* @returns {Promise<unknown[]>}
|
|
29
|
+
*/
|
|
30
|
+
async function getEpicStatus() {
|
|
31
|
+
if (Array.isArray(cache.epic_status)) {
|
|
32
|
+
return /** @type {unknown[]} */ (cache.epic_status);
|
|
33
|
+
}
|
|
34
|
+
/** @type {unknown} */
|
|
35
|
+
const res = await transport('epic-status');
|
|
36
|
+
const arr = Array.isArray(res) ? res : [];
|
|
37
|
+
cache.epic_status = arr;
|
|
38
|
+
return arr;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ready issues: `bd ready --json`.
|
|
43
|
+
* Sort by priority then updated_at on the UI; transport returns raw list.
|
|
44
|
+
* @returns {Promise<unknown[]>}
|
|
45
|
+
*/
|
|
46
|
+
async function getReady() {
|
|
47
|
+
if (Array.isArray(cache.list_ready)) {
|
|
48
|
+
return /** @type {unknown[]} */ (cache.list_ready);
|
|
49
|
+
}
|
|
50
|
+
/** @type {unknown} */
|
|
51
|
+
const res = await transport('list-issues', { filters: { ready: true } });
|
|
52
|
+
const arr = Array.isArray(res) ? res : [];
|
|
53
|
+
cache.list_ready = arr;
|
|
54
|
+
return arr;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Open issues: `bd list -s open --json`.
|
|
59
|
+
* @returns {Promise<unknown[]>}
|
|
60
|
+
*/
|
|
61
|
+
async function getOpen() {
|
|
62
|
+
if (Array.isArray(cache.list_open)) {
|
|
63
|
+
return /** @type {unknown[]} */ (cache.list_open);
|
|
64
|
+
}
|
|
65
|
+
/** @type {unknown} */
|
|
66
|
+
const res = await transport('list-issues', {
|
|
67
|
+
filters: { status: 'open' }
|
|
68
|
+
});
|
|
69
|
+
const arr = Array.isArray(res) ? res : [];
|
|
70
|
+
cache.list_open = arr;
|
|
71
|
+
return arr;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* In progress issues: `bd list -s in_progress --json`.
|
|
76
|
+
* @returns {Promise<unknown[]>}
|
|
77
|
+
*/
|
|
78
|
+
async function getInProgress() {
|
|
79
|
+
if (Array.isArray(cache.list_in_progress)) {
|
|
80
|
+
return /** @type {unknown[]} */ (cache.list_in_progress);
|
|
81
|
+
}
|
|
82
|
+
/** @type {unknown} */
|
|
83
|
+
const res = await transport('list-issues', {
|
|
84
|
+
filters: { status: 'in_progress' }
|
|
85
|
+
});
|
|
86
|
+
const arr = Array.isArray(res) ? res : [];
|
|
87
|
+
cache.list_in_progress = arr;
|
|
88
|
+
return arr;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Closed issues: `bd list -s closed -l 10 --json`.
|
|
93
|
+
* @param {number} [limit] - Optional limit (defaults to 10).
|
|
94
|
+
* @returns {Promise<unknown[]>}
|
|
95
|
+
*/
|
|
96
|
+
async function getClosed(limit = 10) {
|
|
97
|
+
if (limit === 10 && Array.isArray(cache.list_closed_10)) {
|
|
98
|
+
return /** @type {unknown[]} */ (cache.list_closed_10);
|
|
99
|
+
}
|
|
100
|
+
/** @type {unknown} */
|
|
101
|
+
const res = await transport('list-issues', {
|
|
102
|
+
filters: { status: 'closed', limit }
|
|
103
|
+
});
|
|
104
|
+
const arr = Array.isArray(res) ? res : [];
|
|
105
|
+
if (limit === 10) {
|
|
106
|
+
cache.list_closed_10 = arr;
|
|
107
|
+
}
|
|
108
|
+
return arr;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Show a single issue via `bd show <id> --json`.
|
|
113
|
+
* @param {string} id
|
|
114
|
+
* @returns {Promise<unknown>}
|
|
115
|
+
*/
|
|
116
|
+
async function getIssue(id) {
|
|
117
|
+
/** @type {unknown} */
|
|
118
|
+
const res = await transport('show-issue', { id });
|
|
119
|
+
return res;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Update issue fields by dispatching specific mutations.
|
|
124
|
+
* Supported fields: title, acceptance, status, priority, assignee.
|
|
125
|
+
* Returns the updated issue on success.
|
|
126
|
+
* @param {{ id: string, title?: string, acceptance?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
|
|
127
|
+
* @returns {Promise<unknown>}
|
|
128
|
+
*/
|
|
129
|
+
async function updateIssue(input) {
|
|
130
|
+
const { id } = input;
|
|
131
|
+
/** @type {unknown} */
|
|
132
|
+
let last = null;
|
|
133
|
+
if (typeof input.title === 'string') {
|
|
134
|
+
last = await transport('edit-text', {
|
|
135
|
+
id,
|
|
136
|
+
field: 'title',
|
|
137
|
+
value: input.title
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (typeof input.acceptance === 'string') {
|
|
141
|
+
last = await transport('edit-text', {
|
|
142
|
+
id,
|
|
143
|
+
field: 'acceptance',
|
|
144
|
+
value: input.acceptance
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (typeof input.status === 'string') {
|
|
148
|
+
last = await transport('update-status', {
|
|
149
|
+
id,
|
|
150
|
+
status: input.status
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (typeof input.priority === 'number') {
|
|
154
|
+
last = await transport('update-priority', {
|
|
155
|
+
id,
|
|
156
|
+
priority: input.priority
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// type updates are not supported via UI
|
|
160
|
+
if (typeof input.assignee === 'string') {
|
|
161
|
+
last = await transport('update-assignee', {
|
|
162
|
+
id,
|
|
163
|
+
assignee: input.assignee
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return last;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
getEpicStatus,
|
|
171
|
+
getReady,
|
|
172
|
+
getOpen,
|
|
173
|
+
getInProgress,
|
|
174
|
+
getClosed,
|
|
175
|
+
getIssue,
|
|
176
|
+
updateIssue
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { createDataLayer } from './providers.js';
|
|
3
|
+
|
|
4
|
+
// Using a minimal fixture shaped like epic-status-example.json
|
|
5
|
+
const epicFixture = [
|
|
6
|
+
{
|
|
7
|
+
epic: {
|
|
8
|
+
id: 'WK-1',
|
|
9
|
+
title: 'Example Epic',
|
|
10
|
+
description: 'Example',
|
|
11
|
+
acceptance_criteria: 'Demo',
|
|
12
|
+
notes: '',
|
|
13
|
+
status: 'open',
|
|
14
|
+
priority: 1,
|
|
15
|
+
issue_type: 'epic',
|
|
16
|
+
created_at: '2025-10-21T00:00:00.000Z',
|
|
17
|
+
updated_at: '2025-10-21T00:00:00.000Z'
|
|
18
|
+
},
|
|
19
|
+
total_children: 2,
|
|
20
|
+
closed_children: 1,
|
|
21
|
+
eligible_for_close: false
|
|
22
|
+
}
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @returns {{ calls: { type: string, payload: any }[], send: (type: string, payload?: any) => Promise<any> }}
|
|
27
|
+
*/
|
|
28
|
+
function makeTransportRecorder() {
|
|
29
|
+
/** @type {{ type: string, payload: any }[]} */
|
|
30
|
+
const calls = [];
|
|
31
|
+
return {
|
|
32
|
+
calls,
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} type
|
|
35
|
+
* @param {any} [payload]
|
|
36
|
+
*/
|
|
37
|
+
async send(type, payload) {
|
|
38
|
+
calls.push({ type, payload });
|
|
39
|
+
// default fake payloads
|
|
40
|
+
if (type === 'epic-status') {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
if (type === 'list-issues') {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
if (type === 'show-issue') {
|
|
47
|
+
return { id: payload?.id || 'X' };
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
type === 'update-status' ||
|
|
51
|
+
type === 'update-priority' ||
|
|
52
|
+
type === 'edit-text' ||
|
|
53
|
+
type === 'update-assignee'
|
|
54
|
+
) {
|
|
55
|
+
return { id: payload?.id || 'X' };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('data/providers', () => {
|
|
63
|
+
test('getClosed requests list-issues with status and limit=10 by default', async () => {
|
|
64
|
+
const rec = makeTransportRecorder();
|
|
65
|
+
const data = createDataLayer((t, p) => rec.send(t, p));
|
|
66
|
+
await data.getClosed();
|
|
67
|
+
const last = rec.calls[rec.calls.length - 1];
|
|
68
|
+
expect(last.type).toBe('list-issues');
|
|
69
|
+
expect(last.payload.filters.status).toBe('closed');
|
|
70
|
+
expect(last.payload.filters.limit).toBe(10);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('getInProgress requests list-issues with status=in_progress', async () => {
|
|
74
|
+
const rec = makeTransportRecorder();
|
|
75
|
+
const data = createDataLayer((t, p) => rec.send(t, p));
|
|
76
|
+
await data.getInProgress();
|
|
77
|
+
const last = rec.calls[rec.calls.length - 1];
|
|
78
|
+
expect(last.type).toBe('list-issues');
|
|
79
|
+
expect(last.payload.filters.status).toBe('in_progress');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('getReady uses list-issues with ready:true', async () => {
|
|
83
|
+
const rec = makeTransportRecorder();
|
|
84
|
+
const data = createDataLayer((t, p) => rec.send(t, p));
|
|
85
|
+
await data.getReady();
|
|
86
|
+
const last = rec.calls[rec.calls.length - 1];
|
|
87
|
+
expect(last.type).toBe('list-issues');
|
|
88
|
+
expect(last.payload.filters.ready).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('getEpicStatus calls epic-status and returns fixture-shaped data', async () => {
|
|
92
|
+
const rec = makeTransportRecorder();
|
|
93
|
+
const data = createDataLayer(async (t, p) => {
|
|
94
|
+
if (t === 'epic-status') {
|
|
95
|
+
rec.calls.push({ type: t, payload: p });
|
|
96
|
+
return epicFixture;
|
|
97
|
+
}
|
|
98
|
+
return rec.send(t, p);
|
|
99
|
+
});
|
|
100
|
+
const res = await data.getEpicStatus();
|
|
101
|
+
const last = rec.calls[rec.calls.length - 1];
|
|
102
|
+
expect(last.type).toBe('epic-status');
|
|
103
|
+
expect(Array.isArray(res)).toBe(true);
|
|
104
|
+
// basic shape check from fixture
|
|
105
|
+
// @ts-ignore
|
|
106
|
+
expect(res[0].epic?.id).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('updateIssue dispatches field-specific mutations', async () => {
|
|
110
|
+
const rec = makeTransportRecorder();
|
|
111
|
+
const data = createDataLayer((t, p) => rec.send(t, p));
|
|
112
|
+
await data.updateIssue({
|
|
113
|
+
id: 'UI-1',
|
|
114
|
+
title: 'X',
|
|
115
|
+
acceptance: 'Y',
|
|
116
|
+
status: 'in_progress',
|
|
117
|
+
priority: 2,
|
|
118
|
+
assignee: 'max'
|
|
119
|
+
});
|
|
120
|
+
const types = rec.calls.map((c) => c.type);
|
|
121
|
+
expect(types).toContain('edit-text');
|
|
122
|
+
expect(types).toContain('update-status');
|
|
123
|
+
expect(types).toContain('update-priority');
|
|
124
|
+
expect(types).toContain('update-assignee');
|
|
125
|
+
});
|
|
126
|
+
});
|
package/app/index.html
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Beads</title>
|
|
7
|
+
<link rel="stylesheet" href="./styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="app-header">
|
|
11
|
+
<div class="header-left">
|
|
12
|
+
<h1 class="app-title">Beads</h1>
|
|
13
|
+
<nav id="top-nav" class="header-nav" aria-label="Primary"></nav>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="header-actions">
|
|
16
|
+
<label class="theme-toggle" title="Toggle dark mode">
|
|
17
|
+
<span>Dark</span>
|
|
18
|
+
<input
|
|
19
|
+
id="theme-switch"
|
|
20
|
+
type="checkbox"
|
|
21
|
+
aria-label="Toggle dark mode"
|
|
22
|
+
/>
|
|
23
|
+
</label>
|
|
24
|
+
</div>
|
|
25
|
+
</header>
|
|
26
|
+
<main id="app" class="app-shell" aria-live="polite"></main>
|
|
27
|
+
<script type="module" src="/main.bundle.js"></script>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { bootstrap } from './main.js';
|
|
3
|
+
|
|
4
|
+
// Mock the Board view to manipulate DOM content deterministically
|
|
5
|
+
vi.mock('./views/board.js', () => ({
|
|
6
|
+
/**
|
|
7
|
+
* @param {HTMLElement} mount
|
|
8
|
+
*/
|
|
9
|
+
createBoardView: (mount) => ({
|
|
10
|
+
async load() {
|
|
11
|
+
// Simulate a rendered board shell
|
|
12
|
+
mount.innerHTML = '<div class="panel__body board-root"></div>';
|
|
13
|
+
},
|
|
14
|
+
clear() {
|
|
15
|
+
// No-op in this test; we no longer depend on clearing when switching views
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mock WS client to avoid network and provide minimal data
|
|
21
|
+
vi.mock('./ws.js', () => ({
|
|
22
|
+
createWsClient: () => ({
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} type
|
|
25
|
+
*/
|
|
26
|
+
async send(type) {
|
|
27
|
+
if (type === 'list-issues') {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
if (type === 'show-issue') {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (type === 'epic-status') {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
},
|
|
38
|
+
on() {
|
|
39
|
+
return () => {};
|
|
40
|
+
},
|
|
41
|
+
close() {},
|
|
42
|
+
getState() {
|
|
43
|
+
return 'open';
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
describe('board visibility on view change', () => {
|
|
49
|
+
test('hides board when leaving and shows again when returning', async () => {
|
|
50
|
+
// Start on issues, then go to board so subscribers are active
|
|
51
|
+
window.location.hash = '#/issues';
|
|
52
|
+
document.body.innerHTML = '<main id="app"></main>';
|
|
53
|
+
const root = /** @type {HTMLElement} */ (document.getElementById('app'));
|
|
54
|
+
|
|
55
|
+
bootstrap(root);
|
|
56
|
+
|
|
57
|
+
// Allow initial render to flush
|
|
58
|
+
await Promise.resolve();
|
|
59
|
+
await Promise.resolve();
|
|
60
|
+
|
|
61
|
+
const boardRoot = /** @type {HTMLElement} */ (
|
|
62
|
+
document.getElementById('board-root')
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Navigate to board
|
|
66
|
+
window.location.hash = '#/board';
|
|
67
|
+
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
|
|
71
|
+
// Board is visible and rendered with its internal shell
|
|
72
|
+
expect(boardRoot.hidden).toBe(false);
|
|
73
|
+
expect(boardRoot.querySelector('.board-root')).not.toBeNull();
|
|
74
|
+
|
|
75
|
+
// Navigate away to issues
|
|
76
|
+
window.location.hash = '#/issues';
|
|
77
|
+
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
78
|
+
|
|
79
|
+
await Promise.resolve();
|
|
80
|
+
await Promise.resolve();
|
|
81
|
+
|
|
82
|
+
// Board route gets hidden but DOM may remain; CSS [hidden] must hide it
|
|
83
|
+
expect(boardRoot.hidden).toBe(true);
|
|
84
|
+
expect(boardRoot.querySelector('.board-root')).not.toBeNull();
|
|
85
|
+
|
|
86
|
+
// Go back to Board, content is still there (or re-rendered by load)
|
|
87
|
+
window.location.hash = '#/board';
|
|
88
|
+
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
89
|
+
await Promise.resolve();
|
|
90
|
+
await Promise.resolve();
|
|
91
|
+
expect(boardRoot.hidden).toBe(false);
|
|
92
|
+
expect(boardRoot.querySelector('.board-root')).not.toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
// Import after mocking
|
|
3
|
+
import { bootstrap } from './main.js';
|
|
4
|
+
|
|
5
|
+
// Mock WS client before importing the app
|
|
6
|
+
const calls = [];
|
|
7
|
+
const issues = [
|
|
8
|
+
{ id: 'UI-1', title: 'One', status: 'open', priority: 1 },
|
|
9
|
+
{ id: 'UI-2', title: 'Two', status: 'open', priority: 2 }
|
|
10
|
+
];
|
|
11
|
+
vi.mock('./ws.js', () => ({
|
|
12
|
+
createWsClient: () => ({
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} type
|
|
15
|
+
* @param {any} payload
|
|
16
|
+
*/
|
|
17
|
+
async send(type, payload) {
|
|
18
|
+
calls.push({ type, payload });
|
|
19
|
+
if (type === 'list-issues') {
|
|
20
|
+
return issues;
|
|
21
|
+
}
|
|
22
|
+
if (type === 'show-issue') {
|
|
23
|
+
const id = /** @type {any} */ (payload).id;
|
|
24
|
+
const it = issues.find((i) => i.id === id);
|
|
25
|
+
return it || null;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
},
|
|
29
|
+
on() {
|
|
30
|
+
return () => {};
|
|
31
|
+
},
|
|
32
|
+
close() {},
|
|
33
|
+
getState() {
|
|
34
|
+
return 'open';
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
describe('deep link on initial load (UI-44)', () => {
|
|
40
|
+
test('loads detail and highlights list item when hash includes issue id', async () => {
|
|
41
|
+
window.location.hash = '#/issue/UI-2';
|
|
42
|
+
document.body.innerHTML = '<main id="app"></main>';
|
|
43
|
+
const root = /** @type {HTMLElement} */ (document.getElementById('app'));
|
|
44
|
+
|
|
45
|
+
bootstrap(root);
|
|
46
|
+
|
|
47
|
+
// Allow async loads to complete
|
|
48
|
+
await Promise.resolve();
|
|
49
|
+
await Promise.resolve();
|
|
50
|
+
|
|
51
|
+
const detailId = /** @type {HTMLElement} */ (
|
|
52
|
+
document.querySelector('#detail-panel .detail-title .detail-id')
|
|
53
|
+
);
|
|
54
|
+
expect(detailId && detailId.textContent).toBe('#2');
|
|
55
|
+
|
|
56
|
+
const list = /** @type {HTMLElement} */ (
|
|
57
|
+
document.getElementById('list-root')
|
|
58
|
+
);
|
|
59
|
+
const selected = /** @type {HTMLElement|null} */ (
|
|
60
|
+
list.querySelector('tr.issue-row.selected')
|
|
61
|
+
);
|
|
62
|
+
expect(selected && selected.getAttribute('data-issue-id')).toBe('UI-2');
|
|
63
|
+
});
|
|
64
|
+
});
|