@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,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
+ }