@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.
- package/.turbo/turbo-typecheck.log +5 -0
- package/CHANGELOG.md +80 -0
- package/package.json +35 -0
- package/src/components/epic-list.tsx +210 -0
- package/src/components/index.ts +9 -0
- package/src/components/issue-badges.tsx +135 -0
- package/src/components/issue-board.tsx +211 -0
- package/src/components/issue-card.tsx +57 -0
- package/src/components/issue-detail.tsx +390 -0
- package/src/components/issue-dialog.tsx +184 -0
- package/src/components/issue-filters.tsx +180 -0
- package/src/components/issue-list.tsx +142 -0
- package/src/components/issue-row.tsx +56 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-beads.ts +224 -0
- package/src/hooks/use-issue-mutations.ts +260 -0
- package/src/hooks/use-issues-store.ts +210 -0
- package/src/index.ts +20 -0
- package/src/types/index.ts +194 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Card, CardContent } from '@mdxui/primitives/card'
|
|
5
|
+
import { cn } from '@mdxui/primitives/lib/utils'
|
|
6
|
+
import { TypeBadge, PriorityBadge } from './issue-badges'
|
|
7
|
+
import type { Issue } from '../types'
|
|
8
|
+
|
|
9
|
+
export interface IssueCardProps {
|
|
10
|
+
issue: Issue
|
|
11
|
+
isSelected?: boolean
|
|
12
|
+
onClick?: () => void
|
|
13
|
+
onKeyDown?: (e: React.KeyboardEvent) => void
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function IssueCard({ issue, isSelected, onClick, onKeyDown, className }: IssueCardProps) {
|
|
18
|
+
return (
|
|
19
|
+
<Card
|
|
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 hover:bg-muted/50',
|
|
32
|
+
isSelected && 'ring-2 ring-primary',
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<CardContent className="p-3 space-y-2">
|
|
37
|
+
<div className="flex items-start justify-between gap-2">
|
|
38
|
+
<h3 className="font-medium text-sm leading-tight line-clamp-2">
|
|
39
|
+
{issue.title}
|
|
40
|
+
</h3>
|
|
41
|
+
</div>
|
|
42
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
43
|
+
<TypeBadge type={issue.type} />
|
|
44
|
+
<PriorityBadge priority={issue.priority} />
|
|
45
|
+
<span className="text-xs text-muted-foreground ml-auto">
|
|
46
|
+
{issue.id}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
{issue.assignee && (
|
|
50
|
+
<div className="text-xs text-muted-foreground">
|
|
51
|
+
{issue.assignee}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</CardContent>
|
|
55
|
+
</Card>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Button } from '@mdxui/primitives/button'
|
|
5
|
+
import { Card, CardContent, CardHeader } from '@mdxui/primitives/card'
|
|
6
|
+
import { Input } from '@mdxui/primitives/input'
|
|
7
|
+
import { Textarea } from '@mdxui/primitives/textarea'
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from '@mdxui/primitives/select'
|
|
15
|
+
import { Separator } from '@mdxui/primitives/separator'
|
|
16
|
+
import { cn } from '@mdxui/primitives/lib/utils'
|
|
17
|
+
import { Edit2, Save, X, Plus, Link } from 'lucide-react'
|
|
18
|
+
import { StatusBadge, TypeBadge, LabelBadge } from './issue-badges'
|
|
19
|
+
import { useSelectedIssue, useIssuesStore } from '../hooks/use-issues-store'
|
|
20
|
+
import { useIssueMutations, type UseIssueMutationsOptions } from '../hooks/use-issue-mutations'
|
|
21
|
+
import type { Issue, IssueStatus, IssuePriority } from '../types'
|
|
22
|
+
import { formatDistanceToNow } from 'date-fns'
|
|
23
|
+
|
|
24
|
+
export interface IssueDetailProps {
|
|
25
|
+
/** Issue to display (overrides store selection) */
|
|
26
|
+
issue?: Issue
|
|
27
|
+
/** Mutation options (transport, callbacks) */
|
|
28
|
+
mutationOptions?: UseIssueMutationsOptions
|
|
29
|
+
/** Custom class name */
|
|
30
|
+
className?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface EditableFieldProps {
|
|
34
|
+
label: string
|
|
35
|
+
value: string
|
|
36
|
+
onSave: (value: string) => void
|
|
37
|
+
multiline?: boolean
|
|
38
|
+
placeholder?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function EditableField({ label, value, onSave, multiline, placeholder }: EditableFieldProps) {
|
|
42
|
+
const [isEditing, setIsEditing] = React.useState(false)
|
|
43
|
+
const [editValue, setEditValue] = React.useState(value)
|
|
44
|
+
|
|
45
|
+
const handleSave = () => {
|
|
46
|
+
onSave(editValue)
|
|
47
|
+
setIsEditing(false)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const handleCancel = () => {
|
|
51
|
+
setEditValue(value)
|
|
52
|
+
setIsEditing(false)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isEditing) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-2">
|
|
58
|
+
<div className="flex items-center justify-between">
|
|
59
|
+
<label className="text-sm font-medium text-muted-foreground">{label}</label>
|
|
60
|
+
<div className="flex gap-1">
|
|
61
|
+
<Button variant="ghost" size="sm" onClick={handleSave}>
|
|
62
|
+
<Save className="h-4 w-4" />
|
|
63
|
+
</Button>
|
|
64
|
+
<Button variant="ghost" size="sm" onClick={handleCancel}>
|
|
65
|
+
<X className="h-4 w-4" />
|
|
66
|
+
</Button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
{multiline ? (
|
|
70
|
+
<Textarea
|
|
71
|
+
value={editValue}
|
|
72
|
+
onChange={(e) => setEditValue(e.target.value)}
|
|
73
|
+
placeholder={placeholder}
|
|
74
|
+
className="min-h-24"
|
|
75
|
+
autoFocus
|
|
76
|
+
/>
|
|
77
|
+
) : (
|
|
78
|
+
<Input
|
|
79
|
+
value={editValue}
|
|
80
|
+
onChange={(e) => setEditValue(e.target.value)}
|
|
81
|
+
placeholder={placeholder}
|
|
82
|
+
autoFocus
|
|
83
|
+
onKeyDown={(e) => {
|
|
84
|
+
if (e.key === 'Enter') handleSave()
|
|
85
|
+
if (e.key === 'Escape') handleCancel()
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="space-y-1 group">
|
|
95
|
+
<div className="flex items-center justify-between">
|
|
96
|
+
<label className="text-sm font-medium text-muted-foreground">{label}</label>
|
|
97
|
+
<Button
|
|
98
|
+
variant="ghost"
|
|
99
|
+
size="sm"
|
|
100
|
+
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
|
101
|
+
onClick={() => setIsEditing(true)}
|
|
102
|
+
>
|
|
103
|
+
<Edit2 className="h-4 w-4" />
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
{value ? (
|
|
107
|
+
<div className="text-sm whitespace-pre-wrap">{value}</div>
|
|
108
|
+
) : (
|
|
109
|
+
<div className="text-sm text-muted-foreground italic">{placeholder ?? 'Not set'}</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function IssueDetail({
|
|
116
|
+
issue: propIssue,
|
|
117
|
+
mutationOptions,
|
|
118
|
+
className,
|
|
119
|
+
}: IssueDetailProps) {
|
|
120
|
+
const storeIssue = useSelectedIssue()
|
|
121
|
+
const issues = useIssuesStore((s) => s.issues)
|
|
122
|
+
const issue = propIssue ?? storeIssue
|
|
123
|
+
const mutations = useIssueMutations(mutationOptions)
|
|
124
|
+
const [newLabel, setNewLabel] = React.useState('')
|
|
125
|
+
|
|
126
|
+
if (!issue) {
|
|
127
|
+
return (
|
|
128
|
+
<Card className={cn('h-full', className)}>
|
|
129
|
+
<CardContent className="flex items-center justify-center h-full text-muted-foreground">
|
|
130
|
+
Select an issue to view details
|
|
131
|
+
</CardContent>
|
|
132
|
+
</Card>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const handleAddLabel = () => {
|
|
137
|
+
if (newLabel.trim()) {
|
|
138
|
+
mutations.addLabel(issue.id, newLabel.trim())
|
|
139
|
+
setNewLabel('')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const getDependencyIssue = (id: string) => issues.find((i) => i.id === id)
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Card className={cn('h-full overflow-auto', className)}>
|
|
147
|
+
<CardHeader className="pb-4">
|
|
148
|
+
<div className="flex items-start justify-between gap-4">
|
|
149
|
+
<div className="space-y-1">
|
|
150
|
+
<div className="flex items-center gap-2">
|
|
151
|
+
<span className="font-mono text-sm text-muted-foreground">{issue.id}</span>
|
|
152
|
+
<TypeBadge type={issue.type} />
|
|
153
|
+
</div>
|
|
154
|
+
<EditableField
|
|
155
|
+
label=""
|
|
156
|
+
value={issue.title}
|
|
157
|
+
onSave={(title) => mutations.updateTitle(issue.id, title)}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</CardHeader>
|
|
162
|
+
|
|
163
|
+
<CardContent className="space-y-6">
|
|
164
|
+
{/* Status, Priority, Assignee */}
|
|
165
|
+
<div className="grid grid-cols-3 gap-4">
|
|
166
|
+
<div className="space-y-1">
|
|
167
|
+
<label className="text-sm font-medium text-muted-foreground">Status</label>
|
|
168
|
+
<Select
|
|
169
|
+
value={issue.status}
|
|
170
|
+
onValueChange={(value) => mutations.updateStatus(issue.id, value as IssueStatus)}
|
|
171
|
+
>
|
|
172
|
+
<SelectTrigger>
|
|
173
|
+
<SelectValue />
|
|
174
|
+
</SelectTrigger>
|
|
175
|
+
<SelectContent>
|
|
176
|
+
<SelectItem value="open">Open</SelectItem>
|
|
177
|
+
<SelectItem value="in_progress">In Progress</SelectItem>
|
|
178
|
+
<SelectItem value="blocked">Blocked</SelectItem>
|
|
179
|
+
<SelectItem value="closed">Closed</SelectItem>
|
|
180
|
+
</SelectContent>
|
|
181
|
+
</Select>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div className="space-y-1">
|
|
185
|
+
<label className="text-sm font-medium text-muted-foreground">Priority</label>
|
|
186
|
+
<Select
|
|
187
|
+
value={String(issue.priority)}
|
|
188
|
+
onValueChange={(value) =>
|
|
189
|
+
mutations.updatePriority(issue.id, Number(value) as IssuePriority)
|
|
190
|
+
}
|
|
191
|
+
>
|
|
192
|
+
<SelectTrigger>
|
|
193
|
+
<SelectValue />
|
|
194
|
+
</SelectTrigger>
|
|
195
|
+
<SelectContent>
|
|
196
|
+
<SelectItem value="0">P0 - Critical</SelectItem>
|
|
197
|
+
<SelectItem value="1">P1 - High</SelectItem>
|
|
198
|
+
<SelectItem value="2">P2 - Medium</SelectItem>
|
|
199
|
+
<SelectItem value="3">P3 - Low</SelectItem>
|
|
200
|
+
<SelectItem value="4">P4 - Backlog</SelectItem>
|
|
201
|
+
</SelectContent>
|
|
202
|
+
</Select>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div className="space-y-1">
|
|
206
|
+
<label className="text-sm font-medium text-muted-foreground">Assignee</label>
|
|
207
|
+
<Input
|
|
208
|
+
value={issue.assignee ?? ''}
|
|
209
|
+
onChange={(e) => mutations.updateAssignee(issue.id, e.target.value || undefined)}
|
|
210
|
+
placeholder="Unassigned"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<Separator />
|
|
216
|
+
|
|
217
|
+
{/* Labels */}
|
|
218
|
+
<div className="space-y-2">
|
|
219
|
+
<label className="text-sm font-medium text-muted-foreground">Labels</label>
|
|
220
|
+
<div className="flex flex-wrap gap-1">
|
|
221
|
+
{issue.labels.map((label: string) => (
|
|
222
|
+
<LabelBadge
|
|
223
|
+
key={label}
|
|
224
|
+
label={label}
|
|
225
|
+
onRemove={() => mutations.removeLabel(issue.id, label)}
|
|
226
|
+
/>
|
|
227
|
+
))}
|
|
228
|
+
<div className="flex items-center gap-1">
|
|
229
|
+
<Input
|
|
230
|
+
value={newLabel}
|
|
231
|
+
onChange={(e) => setNewLabel(e.target.value)}
|
|
232
|
+
placeholder="Add label"
|
|
233
|
+
className="h-6 w-24 text-xs"
|
|
234
|
+
onKeyDown={(e) => {
|
|
235
|
+
if (e.key === 'Enter') handleAddLabel()
|
|
236
|
+
}}
|
|
237
|
+
/>
|
|
238
|
+
<Button variant="ghost" size="sm" onClick={handleAddLabel}>
|
|
239
|
+
<Plus className="h-3 w-3" />
|
|
240
|
+
</Button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<Separator />
|
|
246
|
+
|
|
247
|
+
{/* Description */}
|
|
248
|
+
<EditableField
|
|
249
|
+
label="Description"
|
|
250
|
+
value={issue.description ?? ''}
|
|
251
|
+
onSave={(description) => mutations.updateDescription(issue.id, description)}
|
|
252
|
+
multiline
|
|
253
|
+
placeholder="Add a description..."
|
|
254
|
+
/>
|
|
255
|
+
|
|
256
|
+
{/* Design Notes */}
|
|
257
|
+
<EditableField
|
|
258
|
+
label="Design Notes"
|
|
259
|
+
value={issue.design ?? ''}
|
|
260
|
+
onSave={(design) => mutations.updateDesign(issue.id, design)}
|
|
261
|
+
multiline
|
|
262
|
+
placeholder="Add design notes..."
|
|
263
|
+
/>
|
|
264
|
+
|
|
265
|
+
{/* Acceptance Criteria */}
|
|
266
|
+
<EditableField
|
|
267
|
+
label="Acceptance Criteria"
|
|
268
|
+
value={issue.acceptance ?? ''}
|
|
269
|
+
onSave={(acceptance) => mutations.updateAcceptance(issue.id, acceptance)}
|
|
270
|
+
multiline
|
|
271
|
+
placeholder="Add acceptance criteria..."
|
|
272
|
+
/>
|
|
273
|
+
|
|
274
|
+
{/* Notes */}
|
|
275
|
+
<EditableField
|
|
276
|
+
label="Notes"
|
|
277
|
+
value={issue.notes ?? ''}
|
|
278
|
+
onSave={(notes) => mutations.updateNotes(issue.id, notes)}
|
|
279
|
+
multiline
|
|
280
|
+
placeholder="Add notes..."
|
|
281
|
+
/>
|
|
282
|
+
|
|
283
|
+
<Separator />
|
|
284
|
+
|
|
285
|
+
{/* Dependencies */}
|
|
286
|
+
<div className="space-y-2">
|
|
287
|
+
<label className="text-sm font-medium text-muted-foreground flex items-center gap-1">
|
|
288
|
+
<Link className="h-4 w-4" />
|
|
289
|
+
Dependencies
|
|
290
|
+
</label>
|
|
291
|
+
{issue.dependencies.length === 0 ? (
|
|
292
|
+
<div className="text-sm text-muted-foreground italic">No dependencies</div>
|
|
293
|
+
) : (
|
|
294
|
+
<div className="space-y-1">
|
|
295
|
+
{issue.dependencies.map((depId: string) => {
|
|
296
|
+
const dep = getDependencyIssue(depId)
|
|
297
|
+
return (
|
|
298
|
+
<div
|
|
299
|
+
key={depId}
|
|
300
|
+
className="flex items-center gap-2 text-sm p-2 rounded border"
|
|
301
|
+
>
|
|
302
|
+
<span className="font-mono text-muted-foreground">{depId}</span>
|
|
303
|
+
{dep && (
|
|
304
|
+
<>
|
|
305
|
+
<span className="flex-1 truncate">{dep.title}</span>
|
|
306
|
+
<StatusBadge status={dep.status} />
|
|
307
|
+
</>
|
|
308
|
+
)}
|
|
309
|
+
<Button
|
|
310
|
+
variant="ghost"
|
|
311
|
+
size="sm"
|
|
312
|
+
onClick={() => mutations.removeDependency(issue.id, depId)}
|
|
313
|
+
>
|
|
314
|
+
<X className="h-3 w-3" />
|
|
315
|
+
</Button>
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
})}
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{/* Dependents */}
|
|
324
|
+
{issue.dependents.length > 0 && (
|
|
325
|
+
<div className="space-y-2">
|
|
326
|
+
<label className="text-sm font-medium text-muted-foreground">
|
|
327
|
+
Blocked by this issue
|
|
328
|
+
</label>
|
|
329
|
+
<div className="space-y-1">
|
|
330
|
+
{issue.dependents.map((depId: string) => {
|
|
331
|
+
const dep = getDependencyIssue(depId)
|
|
332
|
+
return (
|
|
333
|
+
<div
|
|
334
|
+
key={depId}
|
|
335
|
+
className="flex items-center gap-2 text-sm p-2 rounded border"
|
|
336
|
+
>
|
|
337
|
+
<span className="font-mono text-muted-foreground">{depId}</span>
|
|
338
|
+
{dep && (
|
|
339
|
+
<>
|
|
340
|
+
<span className="flex-1 truncate">{dep.title}</span>
|
|
341
|
+
<StatusBadge status={dep.status} />
|
|
342
|
+
</>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
)
|
|
346
|
+
})}
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
<Separator />
|
|
352
|
+
|
|
353
|
+
{/* Timestamps */}
|
|
354
|
+
<div className="text-xs text-muted-foreground space-y-1">
|
|
355
|
+
<div>
|
|
356
|
+
Created {formatDistanceToNow(new Date(issue.createdAt), { addSuffix: true })}
|
|
357
|
+
</div>
|
|
358
|
+
<div>
|
|
359
|
+
Updated {formatDistanceToNow(new Date(issue.updatedAt), { addSuffix: true })}
|
|
360
|
+
</div>
|
|
361
|
+
{issue.closedAt && (
|
|
362
|
+
<div>
|
|
363
|
+
Closed {formatDistanceToNow(new Date(issue.closedAt), { addSuffix: true })}
|
|
364
|
+
{issue.closeReason && ` - ${issue.closeReason}`}
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
{/* Actions */}
|
|
370
|
+
<div className="flex gap-2 pt-4">
|
|
371
|
+
{issue.status === 'closed' ? (
|
|
372
|
+
<Button onClick={() => mutations.reopenIssue(issue.id)}>Reopen Issue</Button>
|
|
373
|
+
) : (
|
|
374
|
+
<Button onClick={() => mutations.closeIssue(issue.id)}>Close Issue</Button>
|
|
375
|
+
)}
|
|
376
|
+
<Button
|
|
377
|
+
variant="destructive"
|
|
378
|
+
onClick={() => {
|
|
379
|
+
if (confirm('Are you sure you want to delete this issue?')) {
|
|
380
|
+
mutations.deleteIssue(issue.id)
|
|
381
|
+
}
|
|
382
|
+
}}
|
|
383
|
+
>
|
|
384
|
+
Delete
|
|
385
|
+
</Button>
|
|
386
|
+
</div>
|
|
387
|
+
</CardContent>
|
|
388
|
+
</Card>
|
|
389
|
+
)
|
|
390
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogTrigger,
|
|
12
|
+
} from '@mdxui/primitives/dialog'
|
|
13
|
+
import { Button } from '@mdxui/primitives/button'
|
|
14
|
+
import { Input } from '@mdxui/primitives/input'
|
|
15
|
+
import { Textarea } from '@mdxui/primitives/textarea'
|
|
16
|
+
import {
|
|
17
|
+
Select,
|
|
18
|
+
SelectContent,
|
|
19
|
+
SelectItem,
|
|
20
|
+
SelectTrigger,
|
|
21
|
+
SelectValue,
|
|
22
|
+
} from '@mdxui/primitives/select'
|
|
23
|
+
import { Label } from '@mdxui/primitives/label'
|
|
24
|
+
import { Plus } from 'lucide-react'
|
|
25
|
+
import { useIssueMutations, type UseIssueMutationsOptions } from '../hooks/use-issue-mutations'
|
|
26
|
+
import type { IssueType, IssuePriority } from '../types'
|
|
27
|
+
|
|
28
|
+
export interface NewIssueDialogProps {
|
|
29
|
+
/** Trigger element (defaults to + button) */
|
|
30
|
+
trigger?: React.ReactNode
|
|
31
|
+
/** Default values */
|
|
32
|
+
defaults?: {
|
|
33
|
+
type?: IssueType
|
|
34
|
+
priority?: IssuePriority
|
|
35
|
+
epic?: string
|
|
36
|
+
labels?: string[]
|
|
37
|
+
}
|
|
38
|
+
/** Mutation options */
|
|
39
|
+
mutationOptions?: UseIssueMutationsOptions
|
|
40
|
+
/** Callback after issue is created */
|
|
41
|
+
onCreated?: (issue: { id: string; title: string }) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function NewIssueDialog({
|
|
45
|
+
trigger,
|
|
46
|
+
defaults,
|
|
47
|
+
mutationOptions,
|
|
48
|
+
onCreated,
|
|
49
|
+
}: NewIssueDialogProps) {
|
|
50
|
+
const [open, setOpen] = React.useState(false)
|
|
51
|
+
const [title, setTitle] = React.useState('')
|
|
52
|
+
const [description, setDescription] = React.useState('')
|
|
53
|
+
const [type, setType] = React.useState<IssueType>(defaults?.type ?? 'task')
|
|
54
|
+
const [priority, setPriority] = React.useState<IssuePriority>(defaults?.priority ?? 2)
|
|
55
|
+
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
56
|
+
|
|
57
|
+
const mutations = useIssueMutations(mutationOptions)
|
|
58
|
+
|
|
59
|
+
const resetForm = () => {
|
|
60
|
+
setTitle('')
|
|
61
|
+
setDescription('')
|
|
62
|
+
setType(defaults?.type ?? 'task')
|
|
63
|
+
setPriority(defaults?.priority ?? 2)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
67
|
+
e.preventDefault()
|
|
68
|
+
if (!title.trim()) return
|
|
69
|
+
|
|
70
|
+
setIsSubmitting(true)
|
|
71
|
+
try {
|
|
72
|
+
const issue = await mutations.createIssue({
|
|
73
|
+
title: title.trim(),
|
|
74
|
+
description: description.trim() || undefined,
|
|
75
|
+
type,
|
|
76
|
+
priority,
|
|
77
|
+
status: 'open',
|
|
78
|
+
labels: defaults?.labels ?? [],
|
|
79
|
+
epic: defaults?.epic,
|
|
80
|
+
dependencies: [],
|
|
81
|
+
dependents: [],
|
|
82
|
+
})
|
|
83
|
+
onCreated?.(issue)
|
|
84
|
+
resetForm()
|
|
85
|
+
setOpen(false)
|
|
86
|
+
} finally {
|
|
87
|
+
setIsSubmitting(false)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
93
|
+
<DialogTrigger asChild>
|
|
94
|
+
{trigger ?? (
|
|
95
|
+
<Button>
|
|
96
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
97
|
+
New Issue
|
|
98
|
+
</Button>
|
|
99
|
+
)}
|
|
100
|
+
</DialogTrigger>
|
|
101
|
+
<DialogContent className="sm:max-w-[500px]">
|
|
102
|
+
<form onSubmit={handleSubmit}>
|
|
103
|
+
<DialogHeader>
|
|
104
|
+
<DialogTitle>Create New Issue</DialogTitle>
|
|
105
|
+
<DialogDescription>
|
|
106
|
+
Add a new issue to track work or report a bug.
|
|
107
|
+
</DialogDescription>
|
|
108
|
+
</DialogHeader>
|
|
109
|
+
|
|
110
|
+
<div className="grid gap-4 py-4">
|
|
111
|
+
<div className="space-y-2">
|
|
112
|
+
<Label htmlFor="title">Title</Label>
|
|
113
|
+
<Input
|
|
114
|
+
id="title"
|
|
115
|
+
value={title}
|
|
116
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
117
|
+
placeholder="Issue title..."
|
|
118
|
+
autoFocus
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div className="grid grid-cols-2 gap-4">
|
|
123
|
+
<div className="space-y-2">
|
|
124
|
+
<Label htmlFor="type">Type</Label>
|
|
125
|
+
<Select value={type} onValueChange={(v) => setType(v as IssueType)}>
|
|
126
|
+
<SelectTrigger id="type">
|
|
127
|
+
<SelectValue />
|
|
128
|
+
</SelectTrigger>
|
|
129
|
+
<SelectContent>
|
|
130
|
+
<SelectItem value="task">Task</SelectItem>
|
|
131
|
+
<SelectItem value="bug">Bug</SelectItem>
|
|
132
|
+
<SelectItem value="feature">Feature</SelectItem>
|
|
133
|
+
<SelectItem value="epic">Epic</SelectItem>
|
|
134
|
+
<SelectItem value="story">Story</SelectItem>
|
|
135
|
+
<SelectItem value="chore">Chore</SelectItem>
|
|
136
|
+
</SelectContent>
|
|
137
|
+
</Select>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div className="space-y-2">
|
|
141
|
+
<Label htmlFor="priority">Priority</Label>
|
|
142
|
+
<Select
|
|
143
|
+
value={String(priority)}
|
|
144
|
+
onValueChange={(v) => setPriority(Number(v) as IssuePriority)}
|
|
145
|
+
>
|
|
146
|
+
<SelectTrigger id="priority">
|
|
147
|
+
<SelectValue />
|
|
148
|
+
</SelectTrigger>
|
|
149
|
+
<SelectContent>
|
|
150
|
+
<SelectItem value="0">P0 - Critical</SelectItem>
|
|
151
|
+
<SelectItem value="1">P1 - High</SelectItem>
|
|
152
|
+
<SelectItem value="2">P2 - Medium</SelectItem>
|
|
153
|
+
<SelectItem value="3">P3 - Low</SelectItem>
|
|
154
|
+
<SelectItem value="4">P4 - Backlog</SelectItem>
|
|
155
|
+
</SelectContent>
|
|
156
|
+
</Select>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div className="space-y-2">
|
|
161
|
+
<Label htmlFor="description">Description (optional)</Label>
|
|
162
|
+
<Textarea
|
|
163
|
+
id="description"
|
|
164
|
+
value={description}
|
|
165
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
166
|
+
placeholder="Describe the issue..."
|
|
167
|
+
className="min-h-24"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<DialogFooter>
|
|
173
|
+
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
|
174
|
+
Cancel
|
|
175
|
+
</Button>
|
|
176
|
+
<Button type="submit" disabled={!title.trim() || isSubmitting}>
|
|
177
|
+
{isSubmitting ? 'Creating...' : 'Create Issue'}
|
|
178
|
+
</Button>
|
|
179
|
+
</DialogFooter>
|
|
180
|
+
</form>
|
|
181
|
+
</DialogContent>
|
|
182
|
+
</Dialog>
|
|
183
|
+
)
|
|
184
|
+
}
|