@mdxui/issues 6.0.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.
@@ -0,0 +1,180 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Button } from '@mdxui/primitives/button'
5
+ import { Input } from '@mdxui/primitives/input'
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from '@mdxui/primitives/select'
13
+ import { cn } from '@mdxui/primitives/lib/utils'
14
+ import { Search, X } from 'lucide-react'
15
+ import { useIssuesStore } from '../hooks/use-issues-store'
16
+ import type { IssueStatus, IssueType, IssueFilters as IssueFiltersType } from '../types'
17
+
18
+ export interface IssueFiltersProps {
19
+ className?: string
20
+ }
21
+
22
+ export function IssueFilters({ className }: IssueFiltersProps) {
23
+ const filters = useIssuesStore((s) => s.filters)
24
+ const setFilters = useIssuesStore((s) => s.setFilters)
25
+ const [searchText, setSearchText] = React.useState(filters.search ?? '')
26
+
27
+ // Debounce search
28
+ React.useEffect(() => {
29
+ const timer = setTimeout(() => {
30
+ setFilters({ ...filters, search: searchText || undefined })
31
+ }, 300)
32
+ return () => clearTimeout(timer)
33
+ }, [searchText])
34
+
35
+ const updateFilter = <K extends keyof IssueFiltersType>(
36
+ key: K,
37
+ value: IssueFiltersType[K]
38
+ ) => {
39
+ setFilters({ ...filters, [key]: value })
40
+ }
41
+
42
+ const clearFilters = () => {
43
+ setSearchText('')
44
+ setFilters({})
45
+ }
46
+
47
+ const hasActiveFilters =
48
+ searchText ||
49
+ filters.status?.length ||
50
+ filters.type?.length ||
51
+ filters.priority?.length
52
+
53
+ return (
54
+ <div className={cn('flex flex-wrap items-center gap-2', className)}>
55
+ {/* Search */}
56
+ <div className="relative flex-1 min-w-48">
57
+ <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
58
+ <Input
59
+ type="search"
60
+ placeholder="Search issues..."
61
+ value={searchText}
62
+ onChange={(e) => setSearchText(e.target.value)}
63
+ className="pl-8"
64
+ />
65
+ </div>
66
+
67
+ {/* Status Filter */}
68
+ <Select
69
+ value={filters.status?.[0] ?? 'all'}
70
+ onValueChange={(value) =>
71
+ updateFilter('status', value === 'all' ? undefined : [value as IssueStatus])
72
+ }
73
+ >
74
+ <SelectTrigger className="w-32">
75
+ <SelectValue placeholder="Status" />
76
+ </SelectTrigger>
77
+ <SelectContent>
78
+ <SelectItem value="all">All Status</SelectItem>
79
+ <SelectItem value="open">Open</SelectItem>
80
+ <SelectItem value="in_progress">In Progress</SelectItem>
81
+ <SelectItem value="blocked">Blocked</SelectItem>
82
+ <SelectItem value="closed">Closed</SelectItem>
83
+ </SelectContent>
84
+ </Select>
85
+
86
+ {/* Type Filter */}
87
+ <Select
88
+ value={filters.type?.[0] ?? 'all'}
89
+ onValueChange={(value) =>
90
+ updateFilter('type', value === 'all' ? undefined : [value as IssueType])
91
+ }
92
+ >
93
+ <SelectTrigger className="w-28">
94
+ <SelectValue placeholder="Type" />
95
+ </SelectTrigger>
96
+ <SelectContent>
97
+ <SelectItem value="all">All Types</SelectItem>
98
+ <SelectItem value="task">Task</SelectItem>
99
+ <SelectItem value="bug">Bug</SelectItem>
100
+ <SelectItem value="feature">Feature</SelectItem>
101
+ <SelectItem value="epic">Epic</SelectItem>
102
+ <SelectItem value="story">Story</SelectItem>
103
+ <SelectItem value="chore">Chore</SelectItem>
104
+ </SelectContent>
105
+ </Select>
106
+
107
+ {/* Clear Filters */}
108
+ {hasActiveFilters && (
109
+ <Button variant="ghost" size="sm" onClick={clearFilters}>
110
+ <X className="h-4 w-4 mr-1" />
111
+ Clear
112
+ </Button>
113
+ )}
114
+ </div>
115
+ )
116
+ }
117
+
118
+ // Quick filter buttons for common filters
119
+ export interface QuickFiltersProps {
120
+ className?: string
121
+ }
122
+
123
+ export function QuickFilters({ className }: QuickFiltersProps) {
124
+ const filters = useIssuesStore((s) => s.filters)
125
+ const setFilters = useIssuesStore((s) => s.setFilters)
126
+
127
+ const isActive = (check: Partial<IssueFiltersType>) => {
128
+ if (check.ready && filters.ready) return true
129
+ if (check.blocked && filters.blocked) return true
130
+ if (check.status && JSON.stringify(check.status) === JSON.stringify(filters.status))
131
+ return true
132
+ return false
133
+ }
134
+
135
+ const toggleFilter = (filter: Partial<IssueFiltersType>) => {
136
+ if (isActive(filter)) {
137
+ // Clear the filter
138
+ const newFilters = { ...filters }
139
+ if (filter.ready) delete newFilters.ready
140
+ if (filter.blocked) delete newFilters.blocked
141
+ if (filter.status) delete newFilters.status
142
+ setFilters(newFilters)
143
+ } else {
144
+ setFilters({ ...filters, ...filter, ready: undefined, blocked: undefined })
145
+ }
146
+ }
147
+
148
+ return (
149
+ <div className={cn('flex items-center gap-1', className)}>
150
+ <Button
151
+ variant={filters.ready ? 'default' : 'outline'}
152
+ size="sm"
153
+ onClick={() => toggleFilter({ ready: true })}
154
+ >
155
+ Ready
156
+ </Button>
157
+ <Button
158
+ variant={filters.blocked ? 'default' : 'outline'}
159
+ size="sm"
160
+ onClick={() => toggleFilter({ blocked: true })}
161
+ >
162
+ Blocked
163
+ </Button>
164
+ <Button
165
+ variant={isActive({ status: ['in_progress'] }) ? 'default' : 'outline'}
166
+ size="sm"
167
+ onClick={() => toggleFilter({ status: ['in_progress'] })}
168
+ >
169
+ In Progress
170
+ </Button>
171
+ <Button
172
+ variant={isActive({ status: ['closed'] }) ? 'default' : 'outline'}
173
+ size="sm"
174
+ onClick={() => toggleFilter({ status: ['closed'] })}
175
+ >
176
+ Closed
177
+ </Button>
178
+ </div>
179
+ )
180
+ }
@@ -0,0 +1,142 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableHead,
8
+ TableHeader,
9
+ TableRow,
10
+ } from '@mdxui/primitives/table'
11
+ import { cn } from '@mdxui/primitives/lib/utils'
12
+ import { IssueRow } from './issue-row'
13
+ import { IssueFilters } from './issue-filters'
14
+ import { useIssuesStore, useFilteredIssues } from '../hooks/use-issues-store'
15
+ import type { Issue, IssueSort } from '../types'
16
+
17
+ export interface IssueListProps {
18
+ /** Issues to display (overrides store) */
19
+ issues?: Issue[]
20
+ /** Currently selected issue ID */
21
+ selectedId?: string
22
+ /** Callback when an issue is selected */
23
+ onSelect?: (issue: Issue) => void
24
+ /** Show filters */
25
+ showFilters?: boolean
26
+ /** Custom class name */
27
+ className?: string
28
+ }
29
+
30
+ export function IssueList({
31
+ issues: propIssues,
32
+ selectedId: propSelectedId,
33
+ onSelect,
34
+ showFilters = true,
35
+ className,
36
+ }: IssueListProps) {
37
+ const storeIssues = useFilteredIssues()
38
+ const storeSelectedId = useIssuesStore((s) => s.selectedId)
39
+ const selectIssue = useIssuesStore((s) => s.selectIssue)
40
+ const sort = useIssuesStore((s) => s.sort)
41
+ const setSort = useIssuesStore((s) => s.setSort)
42
+
43
+ const issues = propIssues ?? storeIssues
44
+ const selectedId = propSelectedId ?? storeSelectedId
45
+
46
+ const handleSelect = (issue: Issue) => {
47
+ selectIssue(issue.id)
48
+ onSelect?.(issue)
49
+ }
50
+
51
+ const handleSort = (field: IssueSort['field']) => {
52
+ if (sort.field === field) {
53
+ setSort({ field, direction: sort.direction === 'asc' ? 'desc' : 'asc' })
54
+ } else {
55
+ setSort({ field, direction: 'asc' })
56
+ }
57
+ }
58
+
59
+ const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
60
+ if (e.key === 'ArrowDown' && index < issues.length - 1) {
61
+ e.preventDefault()
62
+ handleSelect(issues[index + 1])
63
+ } else if (e.key === 'ArrowUp' && index > 0) {
64
+ e.preventDefault()
65
+ handleSelect(issues[index - 1])
66
+ }
67
+ }
68
+
69
+ const SortHeader = ({
70
+ field,
71
+ children,
72
+ className: headerClassName,
73
+ }: {
74
+ field: IssueSort['field']
75
+ children: React.ReactNode
76
+ className?: string
77
+ }) => (
78
+ <TableHead
79
+ className={cn('cursor-pointer select-none hover:bg-muted/50', headerClassName)}
80
+ onClick={() => handleSort(field)}
81
+ >
82
+ <div className="flex items-center gap-1">
83
+ {children}
84
+ {sort.field === field && (
85
+ <span className="text-xs">{sort.direction === 'asc' ? '↑' : '↓'}</span>
86
+ )}
87
+ </div>
88
+ </TableHead>
89
+ )
90
+
91
+ return (
92
+ <div className={cn('flex flex-col gap-4', className)}>
93
+ {showFilters && <IssueFilters />}
94
+
95
+ <div className="rounded-md border">
96
+ <Table>
97
+ <TableHeader>
98
+ <TableRow>
99
+ <TableHead className="w-28">ID</TableHead>
100
+ <TableHead className="w-20">Type</TableHead>
101
+ <SortHeader field="title" className="max-w-md">
102
+ Title
103
+ </SortHeader>
104
+ <SortHeader field="status" className="w-28">
105
+ Status
106
+ </SortHeader>
107
+ <TableHead className="w-24">Assignee</TableHead>
108
+ <SortHeader field="priority" className="w-16">
109
+ Priority
110
+ </SortHeader>
111
+ </TableRow>
112
+ </TableHeader>
113
+ <TableBody>
114
+ {issues.length === 0 ? (
115
+ <TableRow>
116
+ <TableHead colSpan={6} className="text-center text-muted-foreground py-8">
117
+ No issues found
118
+ </TableHead>
119
+ </TableRow>
120
+ ) : (
121
+ issues.map((issue, index) => (
122
+ <IssueRow
123
+ key={issue.id}
124
+ issue={issue}
125
+ isSelected={issue.id === selectedId}
126
+ onClick={() => handleSelect(issue)}
127
+ onKeyDown={(e) => handleKeyDown(e, index)}
128
+ />
129
+ ))
130
+ )}
131
+ </TableBody>
132
+ </Table>
133
+ </div>
134
+
135
+ {issues.length > 0 && (
136
+ <div className="text-sm text-muted-foreground">
137
+ {issues.length} issue{issues.length !== 1 ? 's' : ''}
138
+ </div>
139
+ )}
140
+ </div>
141
+ )
142
+ }
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { TableRow, TableCell } from '@mdxui/primitives/table'
5
+ import { cn } from '@mdxui/primitives/lib/utils'
6
+ import { TypeBadge, StatusBadge, PriorityBadge } from './issue-badges'
7
+ import type { Issue } from '../types'
8
+
9
+ export interface IssueRowProps {
10
+ issue: Issue
11
+ isSelected?: boolean
12
+ onClick?: () => void
13
+ onKeyDown?: (e: React.KeyboardEvent) => void
14
+ className?: string
15
+ }
16
+
17
+ export function IssueRow({ issue, isSelected, onClick, onKeyDown, className }: IssueRowProps) {
18
+ return (
19
+ <TableRow
20
+ role="button"
21
+ tabIndex={0}
22
+ onClick={onClick}
23
+ onKeyDown={(e) => {
24
+ if (e.key === 'Enter' || e.key === ' ') {
25
+ e.preventDefault()
26
+ onClick?.()
27
+ }
28
+ onKeyDown?.(e)
29
+ }}
30
+ className={cn(
31
+ 'cursor-pointer transition-colors',
32
+ isSelected && 'bg-muted',
33
+ className
34
+ )}
35
+ >
36
+ <TableCell className="font-mono text-xs text-muted-foreground w-28">
37
+ {issue.id}
38
+ </TableCell>
39
+ <TableCell className="w-20">
40
+ <TypeBadge type={issue.type} />
41
+ </TableCell>
42
+ <TableCell className="max-w-md">
43
+ <span className="line-clamp-1">{issue.title}</span>
44
+ </TableCell>
45
+ <TableCell className="w-28">
46
+ <StatusBadge status={issue.status} />
47
+ </TableCell>
48
+ <TableCell className="w-24 text-sm text-muted-foreground">
49
+ {issue.assignee ?? '-'}
50
+ </TableCell>
51
+ <TableCell className="w-16">
52
+ <PriorityBadge priority={issue.priority} />
53
+ </TableCell>
54
+ </TableRow>
55
+ )
56
+ }
@@ -0,0 +1,3 @@
1
+ export * from './use-issues-store'
2
+ export * from './use-issue-mutations'
3
+ export * from './use-beads'
@@ -0,0 +1,224 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { useIssuesStore } from './use-issues-store'
3
+ import type { Issue } from '../types'
4
+
5
+ export interface BeadsConfig {
6
+ /** Base URL for the beads API server */
7
+ apiUrl?: string
8
+ /** WebSocket URL for live updates */
9
+ wsUrl?: string
10
+ /** Enable live updates via WebSocket */
11
+ liveUpdates?: boolean
12
+ /** Polling interval in ms (fallback if WS not available) */
13
+ pollInterval?: number
14
+ }
15
+
16
+ interface BeadsTransport {
17
+ /** Send an RPC request to the beads server */
18
+ send: (method: string, params: Record<string, unknown>) => Promise<unknown>
19
+ /** Connect to WebSocket for live updates */
20
+ connect: () => void
21
+ /** Disconnect WebSocket */
22
+ disconnect: () => void
23
+ /** Connection status */
24
+ isConnected: boolean
25
+ }
26
+
27
+ /**
28
+ * Hook for connecting to the beads CLI/server.
29
+ * Provides real-time updates and RPC transport.
30
+ */
31
+ export function useBeads(config: BeadsConfig = {}): BeadsTransport {
32
+ const {
33
+ apiUrl = 'http://localhost:5000/api',
34
+ wsUrl = 'ws://localhost:5000/ws',
35
+ liveUpdates = true,
36
+ pollInterval = 30000,
37
+ } = config
38
+
39
+ const wsRef = useRef<WebSocket | null>(null)
40
+ const [isConnected, setIsConnected] = useState(false)
41
+ const setIssues = useIssuesStore((s) => s.setIssues)
42
+ const updateIssue = useIssuesStore((s) => s.updateIssue)
43
+ const addIssue = useIssuesStore((s) => s.addIssue)
44
+ const removeIssue = useIssuesStore((s) => s.removeIssue)
45
+
46
+ // Fetch all issues from the server
47
+ const fetchIssues = useCallback(async () => {
48
+ try {
49
+ const response = await fetch(`${apiUrl}/issues`)
50
+ if (!response.ok) throw new Error('Failed to fetch issues')
51
+ const data = await response.json()
52
+ setIssues(mapBeadsIssuesToIssues(data.issues ?? []))
53
+ } catch (error) {
54
+ console.error('Failed to fetch issues:', error)
55
+ }
56
+ }, [apiUrl, setIssues])
57
+
58
+ // Send RPC request
59
+ const send = useCallback(
60
+ async (method: string, params: Record<string, unknown>): Promise<unknown> => {
61
+ const response = await fetch(`${apiUrl}/rpc`, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ method, params }),
65
+ })
66
+ if (!response.ok) {
67
+ const error = await response.json().catch(() => ({ message: 'Unknown error' }))
68
+ throw new Error(error.message ?? 'RPC request failed')
69
+ }
70
+ return response.json()
71
+ },
72
+ [apiUrl]
73
+ )
74
+
75
+ // WebSocket connection
76
+ const connect = useCallback(() => {
77
+ if (wsRef.current?.readyState === WebSocket.OPEN) return
78
+
79
+ try {
80
+ const ws = new WebSocket(wsUrl)
81
+
82
+ ws.onopen = () => {
83
+ console.log('WebSocket connected')
84
+ setIsConnected(true)
85
+ }
86
+
87
+ ws.onclose = () => {
88
+ console.log('WebSocket disconnected')
89
+ setIsConnected(false)
90
+ // Reconnect after delay
91
+ setTimeout(connect, 5000)
92
+ }
93
+
94
+ ws.onerror = (error) => {
95
+ console.error('WebSocket error:', error)
96
+ }
97
+
98
+ ws.onmessage = (event) => {
99
+ try {
100
+ const message = JSON.parse(event.data)
101
+ handleLiveUpdate(message)
102
+ } catch (error) {
103
+ console.error('Failed to parse WebSocket message:', error)
104
+ }
105
+ }
106
+
107
+ wsRef.current = ws
108
+ } catch (error) {
109
+ console.error('Failed to connect WebSocket:', error)
110
+ }
111
+ }, [wsUrl])
112
+
113
+ const disconnect = useCallback(() => {
114
+ if (wsRef.current) {
115
+ wsRef.current.close()
116
+ wsRef.current = null
117
+ }
118
+ }, [])
119
+
120
+ // Handle live updates from WebSocket
121
+ const handleLiveUpdate = (message: { type: string; payload: unknown }) => {
122
+ switch (message.type) {
123
+ case 'issue:created':
124
+ addIssue(mapBeadsIssueToIssue(message.payload as BeadsIssue))
125
+ break
126
+ case 'issue:updated':
127
+ const updated = message.payload as BeadsIssue
128
+ updateIssue(updated.id, mapBeadsIssueToIssue(updated))
129
+ break
130
+ case 'issue:deleted':
131
+ removeIssue((message.payload as { id: string }).id)
132
+ break
133
+ case 'issues:refresh':
134
+ fetchIssues()
135
+ break
136
+ }
137
+ }
138
+
139
+ // Initial fetch and setup
140
+ useEffect(() => {
141
+ fetchIssues()
142
+
143
+ if (liveUpdates) {
144
+ connect()
145
+ } else if (pollInterval > 0) {
146
+ const interval = setInterval(fetchIssues, pollInterval)
147
+ return () => clearInterval(interval)
148
+ }
149
+
150
+ return disconnect
151
+ }, [fetchIssues, connect, disconnect, liveUpdates, pollInterval])
152
+
153
+ return { send, connect, disconnect, isConnected }
154
+ }
155
+
156
+ // Beads-specific issue format (from beads CLI)
157
+ interface BeadsIssue {
158
+ id: string
159
+ title: string
160
+ description?: string
161
+ type: string
162
+ status: string
163
+ priority: number
164
+ assignee?: string
165
+ labels?: string[]
166
+ epic?: string
167
+ deps?: string[]
168
+ dependents?: string[]
169
+ design?: string
170
+ acceptance?: string
171
+ notes?: string
172
+ created: string
173
+ updated: string
174
+ closed?: string
175
+ close_reason?: string
176
+ }
177
+
178
+ // Map beads format to our Issue type
179
+ function mapBeadsIssueToIssue(beads: BeadsIssue): Issue {
180
+ return {
181
+ id: beads.id,
182
+ title: beads.title,
183
+ description: beads.description,
184
+ type: beads.type as Issue['type'],
185
+ status: beads.status as Issue['status'],
186
+ priority: beads.priority as Issue['priority'],
187
+ assignee: beads.assignee,
188
+ labels: beads.labels ?? [],
189
+ epic: beads.epic,
190
+ dependencies: beads.deps ?? [],
191
+ dependents: beads.dependents ?? [],
192
+ design: beads.design,
193
+ acceptance: beads.acceptance,
194
+ notes: beads.notes,
195
+ createdAt: beads.created,
196
+ updatedAt: beads.updated,
197
+ closedAt: beads.closed,
198
+ closeReason: beads.close_reason,
199
+ }
200
+ }
201
+
202
+ function mapBeadsIssuesToIssues(beads: BeadsIssue[]): Issue[] {
203
+ return beads.map(mapBeadsIssueToIssue)
204
+ }
205
+
206
+ // Map our Issue type to beads format for updates
207
+ export function mapIssueToBeads(issue: Partial<Issue>): Partial<BeadsIssue> {
208
+ const beads: Partial<BeadsIssue> = {}
209
+
210
+ if (issue.title !== undefined) beads.title = issue.title
211
+ if (issue.description !== undefined) beads.description = issue.description
212
+ if (issue.type !== undefined) beads.type = issue.type
213
+ if (issue.status !== undefined) beads.status = issue.status
214
+ if (issue.priority !== undefined) beads.priority = issue.priority
215
+ if (issue.assignee !== undefined) beads.assignee = issue.assignee
216
+ if (issue.labels !== undefined) beads.labels = issue.labels
217
+ if (issue.epic !== undefined) beads.epic = issue.epic
218
+ if (issue.dependencies !== undefined) beads.deps = issue.dependencies
219
+ if (issue.design !== undefined) beads.design = issue.design
220
+ if (issue.acceptance !== undefined) beads.acceptance = issue.acceptance
221
+ if (issue.notes !== undefined) beads.notes = issue.notes
222
+
223
+ return beads
224
+ }