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/app/protocol.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol definitions for beads-ui WebSocket communication.
|
|
3
|
+
*
|
|
4
|
+
* Conventions
|
|
5
|
+
* - All messages are JSON objects.
|
|
6
|
+
* - Client → Server uses RequestEnvelope.
|
|
7
|
+
* - Server → Client uses ReplyEnvelope.
|
|
8
|
+
* - Every request is correlated by `id` in replies.
|
|
9
|
+
* - Server can also send unsolicited events (e.g., `issues-changed`).
|
|
10
|
+
*
|
|
11
|
+
* Versioning
|
|
12
|
+
* - Increment `PROTOCOL_VERSION` on breaking changes.
|
|
13
|
+
* - Add new message types without breaking existing ones when possible.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @constant {string} */
|
|
17
|
+
export const PROTOCOL_VERSION = '1.0.0';
|
|
18
|
+
|
|
19
|
+
/** @typedef {'list-issues'|'show-issue'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'subscribe-updates'|'issues-changed'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'} MessageType */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} RequestEnvelope
|
|
23
|
+
* @property {string} id - Unique id to correlate request/response.
|
|
24
|
+
* @property {MessageType} type - Message type.
|
|
25
|
+
* @property {unknown} [payload] - Message payload.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} ErrorObject
|
|
30
|
+
* @property {string} code - Stable error code.
|
|
31
|
+
* @property {string} message - Human-readable message.
|
|
32
|
+
* @property {unknown} [details] - Optional extra info for debugging.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {Object} ReplyEnvelope
|
|
37
|
+
* @property {string} id - Correlates to the originating request.
|
|
38
|
+
* @property {boolean} ok - True when request succeeded; false on error.
|
|
39
|
+
* @property {MessageType} type - Echoes request type (or event type).
|
|
40
|
+
* @property {unknown} [payload] - Response payload.
|
|
41
|
+
* @property {ErrorObject} [error] - Present when ok=false.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** @type {MessageType[]} */
|
|
45
|
+
export const MESSAGE_TYPES = /** @type {const} */ ([
|
|
46
|
+
'list-issues',
|
|
47
|
+
'show-issue',
|
|
48
|
+
'update-status',
|
|
49
|
+
'edit-text',
|
|
50
|
+
'update-priority',
|
|
51
|
+
'create-issue',
|
|
52
|
+
'list-ready',
|
|
53
|
+
'subscribe-updates',
|
|
54
|
+
'issues-changed',
|
|
55
|
+
'dep-add',
|
|
56
|
+
'dep-remove',
|
|
57
|
+
'epic-status',
|
|
58
|
+
'update-assignee',
|
|
59
|
+
'label-add',
|
|
60
|
+
'label-remove'
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generate a lexically sortable request id.
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function nextId() {
|
|
68
|
+
const now = Date.now().toString(36);
|
|
69
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
70
|
+
return `${now}-${rand}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a request envelope.
|
|
75
|
+
* @param {MessageType} type - Message type.
|
|
76
|
+
* @param {unknown} [payload] - Message payload.
|
|
77
|
+
* @param {string} [id] - Optional id; generated if omitted.
|
|
78
|
+
* @returns {RequestEnvelope}
|
|
79
|
+
*/
|
|
80
|
+
export function makeRequest(type, payload, id = nextId()) {
|
|
81
|
+
return { id, type, payload };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a successful reply envelope for a given request.
|
|
86
|
+
* @param {RequestEnvelope} req - Original request.
|
|
87
|
+
* @param {unknown} [payload] - Reply payload.
|
|
88
|
+
* @returns {ReplyEnvelope}
|
|
89
|
+
*/
|
|
90
|
+
export function makeOk(req, payload) {
|
|
91
|
+
return { id: req.id, ok: true, type: req.type, payload };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create an error reply envelope for a given request.
|
|
96
|
+
* @param {RequestEnvelope} req - Original request.
|
|
97
|
+
* @param {string} code - Error code.
|
|
98
|
+
* @param {string} message - Error message.
|
|
99
|
+
* @param {unknown} [details] - Extra details.
|
|
100
|
+
* @returns {ReplyEnvelope}
|
|
101
|
+
*/
|
|
102
|
+
export function makeError(req, code, message, details) {
|
|
103
|
+
return {
|
|
104
|
+
id: req.id,
|
|
105
|
+
ok: false,
|
|
106
|
+
type: req.type,
|
|
107
|
+
error: { code, message, details }
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if a value is a plain object.
|
|
113
|
+
* @param {unknown} value
|
|
114
|
+
* @returns {value is Record<string, unknown>}
|
|
115
|
+
*/
|
|
116
|
+
function isRecord(value) {
|
|
117
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Type guard for MessageType values.
|
|
122
|
+
* @param {unknown} value
|
|
123
|
+
* @returns {value is MessageType}
|
|
124
|
+
*/
|
|
125
|
+
export function isMessageType(value) {
|
|
126
|
+
return (
|
|
127
|
+
typeof value === 'string' &&
|
|
128
|
+
MESSAGE_TYPES.includes(/** @type {MessageType} */ (value))
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Type guard for RequestEnvelope.
|
|
134
|
+
* @param {unknown} value
|
|
135
|
+
* @returns {value is RequestEnvelope}
|
|
136
|
+
*/
|
|
137
|
+
export function isRequest(value) {
|
|
138
|
+
if (!isRecord(value)) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
typeof value.id === 'string' &&
|
|
143
|
+
typeof value.type === 'string' &&
|
|
144
|
+
(value.payload === undefined || 'payload' in value)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Type guard for ReplyEnvelope.
|
|
150
|
+
* @param {unknown} value
|
|
151
|
+
* @returns {value is ReplyEnvelope}
|
|
152
|
+
*/
|
|
153
|
+
export function isReply(value) {
|
|
154
|
+
if (!isRecord(value)) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (
|
|
158
|
+
typeof value.id !== 'string' ||
|
|
159
|
+
typeof value.ok !== 'boolean' ||
|
|
160
|
+
!isMessageType(value.type)
|
|
161
|
+
) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
if (value.ok === false) {
|
|
165
|
+
const err = /** @type {any} */ (value).error;
|
|
166
|
+
if (
|
|
167
|
+
!isRecord(err) ||
|
|
168
|
+
typeof err.code !== 'string' ||
|
|
169
|
+
typeof err.message !== 'string'
|
|
170
|
+
) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Normalize and validate an incoming JSON value as a RequestEnvelope.
|
|
179
|
+
* Throws a user-friendly error if invalid.
|
|
180
|
+
* @param {unknown} json
|
|
181
|
+
* @returns {RequestEnvelope}
|
|
182
|
+
*/
|
|
183
|
+
export function decodeRequest(json) {
|
|
184
|
+
if (!isRequest(json)) {
|
|
185
|
+
throw new Error('Invalid request envelope');
|
|
186
|
+
}
|
|
187
|
+
return json;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Normalize and validate an incoming JSON value as a ReplyEnvelope.
|
|
192
|
+
* @param {unknown} json
|
|
193
|
+
* @returns {ReplyEnvelope}
|
|
194
|
+
*/
|
|
195
|
+
export function decodeReply(json) {
|
|
196
|
+
if (!isReply(json)) {
|
|
197
|
+
throw new Error('Invalid reply envelope');
|
|
198
|
+
}
|
|
199
|
+
return json;
|
|
200
|
+
}
|
package/app/protocol.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# beads-ui WebSocket Protocol (v1.0.0)
|
|
2
|
+
|
|
3
|
+
This document defines the JSON messages exchanged between the browser client and
|
|
4
|
+
the local server.
|
|
5
|
+
|
|
6
|
+
- Transport: single WebSocket connection
|
|
7
|
+
- Encoding: JSON text frames
|
|
8
|
+
- Correlation: all request/response pairs share the same `id`
|
|
9
|
+
|
|
10
|
+
## Envelope Shapes
|
|
11
|
+
|
|
12
|
+
- RequestEnvelope: `{ id: string, type: string, payload?: any }`
|
|
13
|
+
- ReplyEnvelope:
|
|
14
|
+
`{ id: string, ok: boolean, type: string, payload?: any, error?: { code: string, message: string, details?: any } }`
|
|
15
|
+
|
|
16
|
+
Server may send unsolicited events (e.g., `issues-changed`) using the
|
|
17
|
+
ReplyEnvelope shape with `ok: true` and a generated `id`.
|
|
18
|
+
|
|
19
|
+
## Message Types
|
|
20
|
+
|
|
21
|
+
- `list-issues` payload: `{ filters?: { status?: string, priority?: number } }`
|
|
22
|
+
- `show-issue` payload: `{ id: string }`
|
|
23
|
+
- `update-status` payload:
|
|
24
|
+
`{ id: string, status: 'open'|'in_progress'|'closed' }`
|
|
25
|
+
- `edit-text` payload:
|
|
26
|
+
`{ id: string, field: 'title'|'description'|'acceptance', value: string }`
|
|
27
|
+
- Note: `description` edits are rejected by the server (unsupported by `bd`).
|
|
28
|
+
- `update-priority` payload: `{ id: string, priority: 0|1|2|3|4 }`
|
|
29
|
+
- `create-issue` payload:
|
|
30
|
+
`{ title: string, type?: 'bug'|'feature'|'task'|'epic'|'chore', priority?: 0|1|2|3|4, description?: string }`
|
|
31
|
+
- `list-ready` payload: `{}`
|
|
32
|
+
- `subscribe-updates` payload: `{}` (server responds with `ok` and begins
|
|
33
|
+
emitting events)
|
|
34
|
+
- `issues-changed` payload: `{ ts: number, hint?: { ids?: string[] } }`
|
|
35
|
+
- `dep-add` payload: `{ a: string, b: string, view_id?: string }` where `a`
|
|
36
|
+
depends on `b` (i.e., `a` is blocked by `b`). Reply payload is the updated
|
|
37
|
+
issue for `view_id` (or `a` when omitted).
|
|
38
|
+
- `dep-remove` payload: `{ a: string, b: string, view_id?: string }` removing
|
|
39
|
+
the `a` depends on `b` link. Reply payload is the updated issue for `view_id`
|
|
40
|
+
(or `a`).
|
|
41
|
+
|
|
42
|
+
## Mapping to `bd` CLI
|
|
43
|
+
|
|
44
|
+
- `list-issues` → `bd list --json [--status <s>] [--priority <n>]`
|
|
45
|
+
- `show-issue` → `bd show <id> --json`
|
|
46
|
+
- `update-status` → `bd update <id> --status <status>`
|
|
47
|
+
- `edit-text` → `bd update <id> --title <t>` or `--acceptance-criteria <a>`
|
|
48
|
+
- `description` has no CLI flag; server responds with an error
|
|
49
|
+
- `update-priority` → `bd update <id> --priority <n>`
|
|
50
|
+
- `create-issue` → `bd create "title" -t <type> -p <prio> -d "desc"`
|
|
51
|
+
- `list-ready` → `bd ready --json`
|
|
52
|
+
|
|
53
|
+
## Errors
|
|
54
|
+
|
|
55
|
+
Errors follow the shape `{ code, message, details? }`. Common codes:
|
|
56
|
+
|
|
57
|
+
- `bad_request` – malformed payload or unknown type
|
|
58
|
+
- `not_found` – entity not found (e.g., issue id)
|
|
59
|
+
- `bd_error` – underlying `bd` command failed
|
|
60
|
+
|
|
61
|
+
## Versioning
|
|
62
|
+
|
|
63
|
+
Breaking changes to shapes or semantics increment `PROTOCOL_VERSION` in
|
|
64
|
+
`app/protocol.js`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
MESSAGE_TYPES,
|
|
4
|
+
PROTOCOL_VERSION,
|
|
5
|
+
decodeReply,
|
|
6
|
+
decodeRequest,
|
|
7
|
+
isMessageType,
|
|
8
|
+
isReply,
|
|
9
|
+
isRequest,
|
|
10
|
+
makeError,
|
|
11
|
+
makeOk,
|
|
12
|
+
makeRequest
|
|
13
|
+
} from './protocol.js';
|
|
14
|
+
|
|
15
|
+
describe('protocol', () => {
|
|
16
|
+
test('version and message types', () => {
|
|
17
|
+
expect(typeof PROTOCOL_VERSION).toBe('string');
|
|
18
|
+
expect(Array.isArray(MESSAGE_TYPES)).toBe(true);
|
|
19
|
+
expect(MESSAGE_TYPES.length).toBeGreaterThan(3);
|
|
20
|
+
expect(isMessageType('list-issues')).toBe(true);
|
|
21
|
+
expect(isMessageType('unknown-type')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('makeRequest / isRequest / decodeRequest', () => {
|
|
25
|
+
const req = makeRequest(
|
|
26
|
+
'list-issues',
|
|
27
|
+
{ filters: { status: 'open' } },
|
|
28
|
+
'r-1'
|
|
29
|
+
);
|
|
30
|
+
expect(isRequest(req)).toBe(true);
|
|
31
|
+
const round = decodeRequest(JSON.parse(JSON.stringify(req)));
|
|
32
|
+
expect(round.id).toBe('r-1');
|
|
33
|
+
expect(round.type).toBe('list-issues');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('makeOk / makeError / isReply / decodeReply', () => {
|
|
37
|
+
const req = makeRequest('show-issue', { id: 'UI-1' }, 'r-2');
|
|
38
|
+
const ok = makeOk(req, { id: 'UI-1', title: 'T' });
|
|
39
|
+
expect(isReply(ok)).toBe(true);
|
|
40
|
+
const ok2 = decodeReply(JSON.parse(JSON.stringify(ok)));
|
|
41
|
+
expect(ok2.ok).toBe(true);
|
|
42
|
+
|
|
43
|
+
const err = makeError(req, 'not_found', 'Issue not found');
|
|
44
|
+
expect(isReply(err)).toBe(true);
|
|
45
|
+
const err2 = decodeReply(JSON.parse(JSON.stringify(err)));
|
|
46
|
+
expect(err2.ok).toBe(false);
|
|
47
|
+
if (!('error' in err2) || !err2.error) {
|
|
48
|
+
throw new Error('Expected error to be present when ok=false');
|
|
49
|
+
}
|
|
50
|
+
expect(err2.error.code).toBe('not_found');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('invalid envelopes are rejected', () => {
|
|
54
|
+
expect(() => decodeRequest({})).toThrow();
|
|
55
|
+
expect(() => decodeReply({ ok: true })).toThrow();
|
|
56
|
+
});
|
|
57
|
+
});
|
package/app/router.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash-based router for tabs (issues/epics/board) and deep-linked issue ids.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse an application hash and extract the selected issue id.
|
|
7
|
+
* @param {string} hash
|
|
8
|
+
* @returns {string | null}
|
|
9
|
+
*/
|
|
10
|
+
export function parseHash(hash) {
|
|
11
|
+
const m = /^#\/issue\/([^\s?#]+)/.exec(hash || '');
|
|
12
|
+
return m && m[1] ? decodeURIComponent(m[1]) : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse the current view from hash.
|
|
17
|
+
* @param {string} hash
|
|
18
|
+
* @returns {'issues'|'epics'|'board'}
|
|
19
|
+
*/
|
|
20
|
+
export function parseView(hash) {
|
|
21
|
+
const h = String(hash || '');
|
|
22
|
+
if (/^#\/epics(\b|\/|$)/.test(h)) {
|
|
23
|
+
return 'epics';
|
|
24
|
+
}
|
|
25
|
+
if (/^#\/board(\b|\/|$)/.test(h)) {
|
|
26
|
+
return 'board';
|
|
27
|
+
}
|
|
28
|
+
// Default to issues (also covers #/issues and unknown/empty)
|
|
29
|
+
return 'issues';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create and start the hash router.
|
|
34
|
+
* @param {{ getState: () => any, setState: (patch: any) => void }} store
|
|
35
|
+
* @returns {{ start: () => void, stop: () => void, gotoIssue: (id: string) => void, gotoView: (v: 'issues'|'epics'|'board') => void }}
|
|
36
|
+
*/
|
|
37
|
+
export function createHashRouter(store) {
|
|
38
|
+
/** @type {(ev?: HashChangeEvent) => any} */
|
|
39
|
+
const onHashChange = () => {
|
|
40
|
+
const hash = window.location.hash || '';
|
|
41
|
+
const id = parseHash(hash);
|
|
42
|
+
// Preserve current view when navigating to a detail route so tabs remain stable
|
|
43
|
+
const current = store.getState ? store.getState() : { view: 'issues' };
|
|
44
|
+
const view = id ? current.view || 'issues' : parseView(hash);
|
|
45
|
+
store.setState({ selected_id: id, view });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
start() {
|
|
50
|
+
window.addEventListener('hashchange', onHashChange);
|
|
51
|
+
onHashChange();
|
|
52
|
+
},
|
|
53
|
+
stop() {
|
|
54
|
+
window.removeEventListener('hashchange', onHashChange);
|
|
55
|
+
},
|
|
56
|
+
gotoIssue(id) {
|
|
57
|
+
const next = `#/issue/${encodeURIComponent(id)}`;
|
|
58
|
+
if (window.location.hash !== next) {
|
|
59
|
+
window.location.hash = next;
|
|
60
|
+
} else {
|
|
61
|
+
// Force state update even if hash is the same
|
|
62
|
+
store.setState({ selected_id: id, view: 'issues' });
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
/**
|
|
66
|
+
* Navigate to a top-level view.
|
|
67
|
+
* @param {'issues'|'epics'|'board'} view
|
|
68
|
+
*/
|
|
69
|
+
gotoView(view) {
|
|
70
|
+
const next = `#/${view}`;
|
|
71
|
+
if (window.location.hash !== next) {
|
|
72
|
+
window.location.hash = next;
|
|
73
|
+
} else {
|
|
74
|
+
store.setState({ view, selected_id: null });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { createHashRouter, parseHash, parseView } from './router.js';
|
|
3
|
+
import { createStore } from './state.js';
|
|
4
|
+
|
|
5
|
+
describe('router', () => {
|
|
6
|
+
test('parseHash extracts id', () => {
|
|
7
|
+
expect(parseHash('#/issue/UI-5')).toBe('UI-5');
|
|
8
|
+
expect(parseHash('#/anything')).toBeNull();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('router updates store and gotoIssue updates hash', () => {
|
|
12
|
+
document.body.innerHTML = '<div></div>';
|
|
13
|
+
const store = createStore();
|
|
14
|
+
const router = createHashRouter(store);
|
|
15
|
+
router.start();
|
|
16
|
+
|
|
17
|
+
window.location.hash = '#/issue/UI-10';
|
|
18
|
+
// Trigger handler synchronously
|
|
19
|
+
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
20
|
+
expect(store.getState().selected_id).toBe('UI-10');
|
|
21
|
+
|
|
22
|
+
router.gotoIssue('UI-11');
|
|
23
|
+
expect(window.location.hash).toBe('#/issue/UI-11');
|
|
24
|
+
router.stop();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('parseView resolves from hash and defaults to issues', () => {
|
|
28
|
+
expect(parseView('#/issues')).toBe('issues');
|
|
29
|
+
expect(parseView('#/epics')).toBe('epics');
|
|
30
|
+
expect(parseView('#/board')).toBe('board');
|
|
31
|
+
expect(parseView('')).toBe('issues');
|
|
32
|
+
expect(parseView('#/unknown')).toBe('issues');
|
|
33
|
+
});
|
|
34
|
+
});
|
package/app/state.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal app state store with subscription.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {'all'|'open'|'in_progress'|'closed'|'ready'} StatusFilter
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{ status: StatusFilter, search: string, type: string }} Filters
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {'issues'|'epics'|'board'} ViewName
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {{ selected_id: string | null, view: ViewName, filters: Filters }} AppState
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a simple store for application state.
|
|
23
|
+
* @param {Partial<AppState>} [initial]
|
|
24
|
+
* @returns {{ getState: () => AppState, setState: (patch: { selected_id?: string | null, filters?: Partial<Filters> }) => void, subscribe: (fn: (s: AppState) => void) => () => void }}
|
|
25
|
+
*/
|
|
26
|
+
export function createStore(initial = {}) {
|
|
27
|
+
/** @type {AppState} */
|
|
28
|
+
let state = {
|
|
29
|
+
selected_id: /** @type {any} */ (initial).selected_id ?? null,
|
|
30
|
+
view: /** @type {any} */ (initial).view ?? 'issues',
|
|
31
|
+
filters: {
|
|
32
|
+
status: /** @type {any} */ (initial).filters?.status ?? 'all',
|
|
33
|
+
search: /** @type {any} */ (initial).filters?.search ?? '',
|
|
34
|
+
type:
|
|
35
|
+
typeof (/** @type {any} */ (initial).filters?.type) === 'string'
|
|
36
|
+
? /** @type {any} */ (initial).filters?.type
|
|
37
|
+
: ''
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** @type {Set<(s: AppState) => void>} */
|
|
42
|
+
const subs = new Set();
|
|
43
|
+
|
|
44
|
+
function emit() {
|
|
45
|
+
for (const fn of Array.from(subs)) {
|
|
46
|
+
try {
|
|
47
|
+
fn(state);
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
getState() {
|
|
56
|
+
return state;
|
|
57
|
+
},
|
|
58
|
+
/**
|
|
59
|
+
* Update state. Nested filters can be partial.
|
|
60
|
+
* @param {{ selected_id?: string | null, filters?: Partial<Filters> }} patch
|
|
61
|
+
*/
|
|
62
|
+
setState(patch) {
|
|
63
|
+
/** @type {AppState} */
|
|
64
|
+
const next = {
|
|
65
|
+
...state,
|
|
66
|
+
...patch,
|
|
67
|
+
filters: { ...state.filters, ...(patch.filters || {}) }
|
|
68
|
+
};
|
|
69
|
+
// Avoid emitting if nothing changed (shallow compare)
|
|
70
|
+
if (
|
|
71
|
+
next.selected_id === state.selected_id &&
|
|
72
|
+
next.view === state.view &&
|
|
73
|
+
next.filters.status === state.filters.status &&
|
|
74
|
+
next.filters.search === state.filters.search &&
|
|
75
|
+
next.filters.type === state.filters.type
|
|
76
|
+
) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
state = next;
|
|
80
|
+
emit();
|
|
81
|
+
},
|
|
82
|
+
subscribe(fn) {
|
|
83
|
+
subs.add(fn);
|
|
84
|
+
return () => subs.delete(fn);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { createStore } from './state.js';
|
|
3
|
+
|
|
4
|
+
describe('state store', () => {
|
|
5
|
+
test('get/set/subscribe works and dedupes unchanged', () => {
|
|
6
|
+
const store = createStore();
|
|
7
|
+
const seen = [];
|
|
8
|
+
const off = store.subscribe((s) => seen.push(s));
|
|
9
|
+
|
|
10
|
+
store.setState({ selected_id: 'UI-1' });
|
|
11
|
+
store.setState({ filters: { status: 'open' } });
|
|
12
|
+
// no-op (unchanged)
|
|
13
|
+
store.setState({ filters: { status: 'open' } });
|
|
14
|
+
off();
|
|
15
|
+
|
|
16
|
+
expect(seen.length).toBe(2);
|
|
17
|
+
const state = store.getState();
|
|
18
|
+
expect(state.selected_id).toBe('UI-1');
|
|
19
|
+
expect(state.filters.status).toBe('open');
|
|
20
|
+
});
|
|
21
|
+
});
|