jira-pat 1.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.
Files changed (95) hide show
  1. package/AGENTS.md +218 -0
  2. package/README.md +64 -0
  3. package/backend/.env.example +1 -0
  4. package/backend/__tests__/getJiraClient.test.js +57 -0
  5. package/backend/__tests__/issues.test.js +565 -0
  6. package/backend/__tests__/jiraService.test.js +1127 -0
  7. package/backend/__tests__/projects.test.js +256 -0
  8. package/backend/coverage/clover.xml +426 -0
  9. package/backend/coverage/coverage-final.json +4 -0
  10. package/backend/coverage/lcov-report/base.css +224 -0
  11. package/backend/coverage/lcov-report/block-navigation.js +87 -0
  12. package/backend/coverage/lcov-report/favicon.png +0 -0
  13. package/backend/coverage/lcov-report/index.html +131 -0
  14. package/backend/coverage/lcov-report/prettify.css +1 -0
  15. package/backend/coverage/lcov-report/prettify.js +2 -0
  16. package/backend/coverage/lcov-report/routes/index.html +131 -0
  17. package/backend/coverage/lcov-report/routes/issues.js.html +823 -0
  18. package/backend/coverage/lcov-report/routes/projects.js.html +190 -0
  19. package/backend/coverage/lcov-report/service/index.html +116 -0
  20. package/backend/coverage/lcov-report/service/jiraService.js.html +1663 -0
  21. package/backend/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  22. package/backend/coverage/lcov-report/sorter.js +210 -0
  23. package/backend/coverage/lcov.info +707 -0
  24. package/backend/index.js +38 -0
  25. package/backend/jest.config.js +11 -0
  26. package/backend/package-lock.json +5636 -0
  27. package/backend/package.json +28 -0
  28. package/backend/routes/issues.js +246 -0
  29. package/backend/routes/projects.js +35 -0
  30. package/backend/service/jiraService.js +526 -0
  31. package/bin/jira.js +92 -0
  32. package/frontend/.env.example +1 -0
  33. package/frontend/coverage/base.css +224 -0
  34. package/frontend/coverage/block-navigation.js +87 -0
  35. package/frontend/coverage/clover.xml +559 -0
  36. package/frontend/coverage/components/CreateIssueModal.jsx.html +592 -0
  37. package/frontend/coverage/components/IssueDetailPanel.jsx.html +1633 -0
  38. package/frontend/coverage/components/IssueDrawer.jsx.html +550 -0
  39. package/frontend/coverage/components/IssueTable.jsx.html +571 -0
  40. package/frontend/coverage/components/SkeletonComponents.jsx.html +223 -0
  41. package/frontend/coverage/components/ToastContainer.jsx.html +142 -0
  42. package/frontend/coverage/components/index.html +191 -0
  43. package/frontend/coverage/coverage-final.json +14 -0
  44. package/frontend/coverage/favicon.png +0 -0
  45. package/frontend/coverage/hooks/index.html +161 -0
  46. package/frontend/coverage/hooks/useFocusTrap.js.html +262 -0
  47. package/frontend/coverage/hooks/useIssueDrawer.js.html +1000 -0
  48. package/frontend/coverage/hooks/useIssuesList.js.html +175 -0
  49. package/frontend/coverage/hooks/useToasts.js.html +142 -0
  50. package/frontend/coverage/index.html +161 -0
  51. package/frontend/coverage/prettify.css +1 -0
  52. package/frontend/coverage/prettify.js +2 -0
  53. package/frontend/coverage/services/api.js.html +547 -0
  54. package/frontend/coverage/services/index.html +116 -0
  55. package/frontend/coverage/sort-arrow-sprite.png +0 -0
  56. package/frontend/coverage/sorter.js +210 -0
  57. package/frontend/coverage/utils/index.html +131 -0
  58. package/frontend/coverage/utils/issueHelpers.jsx.html +334 -0
  59. package/frontend/coverage/utils/sanitize.js.html +166 -0
  60. package/frontend/index.html +13 -0
  61. package/frontend/package-lock.json +3436 -0
  62. package/frontend/package.json +30 -0
  63. package/frontend/src/App.jsx +447 -0
  64. package/frontend/src/__tests__/components/CreateIssueModal.test.jsx +375 -0
  65. package/frontend/src/__tests__/components/IssueDetailPanel.test.jsx +962 -0
  66. package/frontend/src/__tests__/components/IssueDrawer.test.jsx +240 -0
  67. package/frontend/src/__tests__/components/IssueTable.test.jsx +423 -0
  68. package/frontend/src/__tests__/components/ToastContainer.test.jsx +196 -0
  69. package/frontend/src/__tests__/hooks/useFocusTrap.test.js +197 -0
  70. package/frontend/src/__tests__/hooks/useIssueDrawer.test.js +1053 -0
  71. package/frontend/src/__tests__/hooks/useIssuesList.test.js +175 -0
  72. package/frontend/src/__tests__/hooks/useToasts.test.js +110 -0
  73. package/frontend/src/__tests__/services/api.test.js +568 -0
  74. package/frontend/src/__tests__/setup.js +54 -0
  75. package/frontend/src/__tests__/utils/issueHelpers.test.jsx +336 -0
  76. package/frontend/src/__tests__/utils/sanitize.test.js +238 -0
  77. package/frontend/src/components/CreateIssueModal.jsx +169 -0
  78. package/frontend/src/components/ErrorBoundary.jsx +52 -0
  79. package/frontend/src/components/IssueDetailPanel.jsx +517 -0
  80. package/frontend/src/components/IssueDrawer.jsx +155 -0
  81. package/frontend/src/components/IssueTable.jsx +162 -0
  82. package/frontend/src/components/SkeletonComponents.jsx +46 -0
  83. package/frontend/src/components/StandaloneIssuePage.jsx +176 -0
  84. package/frontend/src/components/ToastContainer.jsx +19 -0
  85. package/frontend/src/hooks/useFocusTrap.js +59 -0
  86. package/frontend/src/hooks/useIssueDrawer.js +305 -0
  87. package/frontend/src/hooks/useIssuesList.js +30 -0
  88. package/frontend/src/hooks/useToasts.js +19 -0
  89. package/frontend/src/index.css +2070 -0
  90. package/frontend/src/main.jsx +13 -0
  91. package/frontend/src/services/api.js +154 -0
  92. package/frontend/src/utils/issueHelpers.jsx +84 -0
  93. package/frontend/src/utils/sanitize.js +27 -0
  94. package/frontend/vite.config.js +15 -0
  95. package/package.json +19 -0
@@ -0,0 +1,517 @@
1
+ import {
2
+ X, Loader2, AlertCircle, Link2, Paperclip, Calendar, Package,
3
+ PlusCircle
4
+ } from 'lucide-react';
5
+ import { sanitizeHtml } from '../utils/sanitize.js';
6
+ import {
7
+ SEVERITY_FIELD,
8
+ getIssueTypeIcon,
9
+ getStatusClass,
10
+ getSeverityIcon,
11
+ formatSeverity,
12
+ getInitials
13
+ } from '../utils/issueHelpers.jsx';
14
+ import {
15
+ SkeletonTitle,
16
+ SkeletonText,
17
+ SkeletonChip,
18
+ SkeletonSidebarItem
19
+ } from './SkeletonComponents.jsx';
20
+
21
+ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000';
22
+
23
+ const getAttachmentUrl = (content, mode) => {
24
+ const baseUrl = mode === 'standalone' ? API_BASE_URL : 'http://localhost:5000';
25
+ return `${baseUrl}/api/issues/image/proxy?url=${encodeURIComponent(content)}`;
26
+ };
27
+
28
+ const handleNavigation = (key, mode, onSubtaskClick) => {
29
+ if (mode === 'standalone') {
30
+ window.location.hash = `#/view/${key}`;
31
+ } else if (onSubtaskClick) {
32
+ onSubtaskClick(key);
33
+ }
34
+ };
35
+
36
+ export default function IssueDetailPanel({
37
+ mode,
38
+ issueDetail,
39
+ comments,
40
+ transitions,
41
+ assignableUsers,
42
+ projectVersions,
43
+ drawerLoading,
44
+ drawerError,
45
+ newCommentText,
46
+ setNewCommentText,
47
+ isPostingComment,
48
+ targetTransitionId,
49
+ setTargetTransitionId,
50
+ isTransitioning,
51
+ isTransitioningOptimistic,
52
+ optimisticStatus,
53
+ assigneeSearch,
54
+ setAssigneeSearch,
55
+ targetAccountId,
56
+ setTargetAccountId,
57
+ isAssigning,
58
+ isAssigningOptimistic,
59
+ optimisticAssignee,
60
+ isUpdatingVersions,
61
+ newLabelText,
62
+ setNewLabelText,
63
+ labelSuggestions,
64
+ showLabelSuggestions,
65
+ setShowLabelSuggestions,
66
+ isUpdatingLabels,
67
+ isUploading,
68
+ uploadMessage,
69
+ handlePostComment,
70
+ handleAssignUser,
71
+ handleFileUpload,
72
+ handleUpdateVersion,
73
+ handleAddLabel,
74
+ handleRemoveLabel,
75
+ handleUpdateStatus,
76
+ onSubtaskClick
77
+ }) {
78
+ return (
79
+ <>
80
+ {drawerLoading && (
81
+ <>
82
+ <div className="drawer-main">
83
+ <div className="drawer-skeleton">
84
+ <div className="drawer-skeleton-header">
85
+ <SkeletonTitle width="40%" />
86
+ <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
87
+ <SkeletonChip />
88
+ <SkeletonChip width={60} />
89
+ </div>
90
+ </div>
91
+ <div className="drawer-skeleton-description">
92
+ <SkeletonText lines={4} />
93
+ </div>
94
+ </div>
95
+ </div>
96
+ <div className="drawer-sidebar">
97
+ <div className="drawer-skeleton-sidebar">
98
+ <SkeletonSidebarItem />
99
+ <SkeletonSidebarItem />
100
+ <SkeletonSidebarItem />
101
+ <SkeletonSidebarItem />
102
+ </div>
103
+ </div>
104
+ </>
105
+ )}
106
+ {drawerError && (
107
+ <div className="error" role="alert" style={{ gridColumn: '1 / -1' }}>
108
+ <AlertCircle size={16} style={{ marginRight: '0.5rem' }} />
109
+ {drawerError}
110
+ </div>
111
+ )}
112
+
113
+ {!drawerLoading && !drawerError && issueDetail && (
114
+ <>
115
+ <div className="drawer-main">
116
+ <div className="main-content-section">
117
+ <h3>Description</h3>
118
+ <div className="description-content">
119
+ {issueDetail.fields.descriptionHtml ? (
120
+ <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(issueDetail.fields.descriptionHtml) }} />
121
+ ) : (
122
+ issueDetail.fields.descriptionText || <i style={{ color: 'var(--text-muted)' }}>No description provided.</i>
123
+ )}
124
+ </div>
125
+ </div>
126
+
127
+ {issueDetail.fields.subtasksMapped?.length > 0 && (
128
+ <div className="main-content-section">
129
+ <h3>Subtasks ({issueDetail.fields.subtasksMapped.length})</h3>
130
+ <div className="subtask-list">
131
+ {issueDetail.fields.subtasksMapped.map(st => (
132
+ <div
133
+ key={st.key}
134
+ className="subtask-item"
135
+ onClick={() => handleNavigation(st.key, mode, onSubtaskClick)}
136
+ role="button"
137
+ tabIndex={0}
138
+ onKeyDown={(e) => e.key === 'Enter' && handleNavigation(st.key, mode, onSubtaskClick)}
139
+ >
140
+ <span className="issue-key">
141
+ {getIssueTypeIcon('subtask')}
142
+ {st.key}
143
+ </span>
144
+ <span className={`status-badge ${getStatusClass(st.status)}`}>
145
+ {st.status}
146
+ </span>
147
+ <span className="summary">{st.summary}</span>
148
+ </div>
149
+ ))}
150
+ </div>
151
+ </div>
152
+ )}
153
+
154
+ {issueDetail.fields.issuelinksMapped?.length > 0 && (
155
+ <div className="main-content-section">
156
+ <h3>Linked Issues ({issueDetail.fields.issuelinksMapped.length})</h3>
157
+ <div className="linked-list">
158
+ {issueDetail.fields.issuelinksMapped.map(link => (
159
+ <div
160
+ key={link.key}
161
+ className="linked-item"
162
+ onClick={() => handleNavigation(link.key, mode, onSubtaskClick)}
163
+ role="button"
164
+ tabIndex={0}
165
+ onKeyDown={(e) => e.key === 'Enter' && handleNavigation(link.key, mode, onSubtaskClick)}
166
+ >
167
+ <Link2 size={14} className="link-type-icon" style={{ color: 'var(--text-muted)' }} />
168
+ <span className="link-type">{link.label}</span>
169
+ <span className="issue-key">{link.key}</span>
170
+ <span className={`status-badge ${getStatusClass(link.status)}`}>
171
+ {link.status}
172
+ </span>
173
+ <span className="summary">{link.summary}</span>
174
+ </div>
175
+ ))}
176
+ </div>
177
+ </div>
178
+ )}
179
+
180
+ <div className="main-content-section">
181
+ <h3>Attachments ({issueDetail.fields.attachment?.length || 0})</h3>
182
+ <div className="attachments-list">
183
+ {issueDetail.fields.attachment && issueDetail.fields.attachment.length > 0 ? (
184
+ issueDetail.fields.attachment.map(att => (
185
+ <div key={att.id} className="attachment-item">
186
+ <Paperclip size={14} style={{ color: 'var(--text-muted)' }} />
187
+ <a
188
+ href={getAttachmentUrl(att.content, mode)}
189
+ target="_blank"
190
+ rel="noreferrer"
191
+ >
192
+ {att.filename}
193
+ </a>
194
+ <span className="size">({Math.round(att.size / 1024)} KB)</span>
195
+ </div>
196
+ ))
197
+ ) : (
198
+ <div style={{ color: 'var(--text-muted)', fontSize: '13px' }}>
199
+ No attachments yet.
200
+ </div>
201
+ )}
202
+ </div>
203
+ <div className="upload-zone">
204
+ <input
205
+ type="file"
206
+ onChange={handleFileUpload}
207
+ disabled={isUploading}
208
+ aria-label="Upload attachment"
209
+ />
210
+ {isUploading && (
211
+ <div className="upload-message">
212
+ <Loader2 size={14} style={{ animation: 'spin 1s linear infinite', marginRight: '4px' }} />
213
+ Uploading...
214
+ </div>
215
+ )}
216
+ {uploadMessage && (
217
+ <div className={`upload-message ${uploadMessage.startsWith('Error') ? 'error' : 'success'}`}>
218
+ {uploadMessage}
219
+ </div>
220
+ )}
221
+ </div>
222
+ </div>
223
+
224
+ <div className="main-content-section">
225
+ <h3>Comments ({comments.length})</h3>
226
+ <div className="comment-composer">
227
+ <textarea
228
+ className="comment-box"
229
+ placeholder="Add a comment..."
230
+ value={newCommentText}
231
+ onChange={(e) => setNewCommentText(e.target.value)}
232
+ disabled={isPostingComment}
233
+ aria-label="Add comment"
234
+ />
235
+ <button
236
+ className="btn-primary"
237
+ onClick={handlePostComment}
238
+ disabled={!newCommentText.trim() || isPostingComment}
239
+ aria-label="Post comment"
240
+ >
241
+ {isPostingComment ? (
242
+ <>
243
+ <Loader2 size={16} className="spinner" />
244
+ Posting...
245
+ </>
246
+ ) : 'Post Comment'}
247
+ </button>
248
+ </div>
249
+ {comments.length === 0 ? (
250
+ <div style={{ color: 'var(--text-muted)', fontSize: '13px', padding: '16px', textAlign: 'center', background: 'var(--sidebar-bg)', borderRadius: 'var(--radius-md)', marginTop: '16px' }}>
251
+ No comments yet. Be the first to comment!
252
+ </div>
253
+ ) : (
254
+ <div className="comments-list">
255
+ {[...comments].reverse().map(comment => (
256
+ <div key={comment.id} className="comment-item">
257
+ <div className="comment-header">
258
+ <span className="avatar">
259
+ {getInitials(comment.author?.displayName || 'Unknown')}
260
+ </span>
261
+ <span className="comment-author">{comment.author?.displayName || 'Unknown'}</span>
262
+ <span className="comment-date">{new Date(comment.created).toLocaleString()}</span>
263
+ </div>
264
+ <div className="comment-body">
265
+ {comment.renderedHtml ? (
266
+ <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(comment.renderedHtml) }} />
267
+ ) : (
268
+ comment.bodyText || <i>Empty comment</i>
269
+ )}
270
+ </div>
271
+ </div>
272
+ ))}
273
+ </div>
274
+ )}
275
+ </div>
276
+ </div>
277
+
278
+ <div className="drawer-sidebar">
279
+ <div className="sidebar-section">
280
+ <div className="sidebar-label">Status</div>
281
+ <div className="status-sidebar">
282
+ <span className={`status-badge ${getStatusClass(optimisticStatus ?? issueDetail.fields.status?.name)}`}>
283
+ {optimisticStatus ?? issueDetail.fields.status?.name ?? 'Unknown'}
284
+ {isTransitioningOptimistic && <span className="inline-spinner" aria-label="Saving..." />}
285
+ </span>
286
+ <div className="transition-control">
287
+ <select
288
+ className="select-input"
289
+ value={targetTransitionId}
290
+ onChange={(e) => setTargetTransitionId(e.target.value)}
291
+ aria-label="Select transition"
292
+ >
293
+ <option value="">Transition...</option>
294
+ {[...transitions]
295
+ .sort((a, b) => {
296
+ const aName = (a.to?.name || a.name).toLowerCase();
297
+ const bName = (b.to?.name || b.name).toLowerCase();
298
+ if (aName === 'backlog') return 1;
299
+ if (bName === 'backlog') return -1;
300
+ return 0;
301
+ })
302
+ .map(t => (
303
+ <option key={t.id} value={t.id}>{t.to?.name || t.name}</option>
304
+ ))}
305
+ </select>
306
+ <button
307
+ className="btn-primary btn-sm"
308
+ disabled={!targetTransitionId || isTransitioning}
309
+ onClick={() => {
310
+ const selectedTransition = transitions.find(t => t.id === targetTransitionId);
311
+ handleUpdateStatus(targetTransitionId, selectedTransition?.to?.name || selectedTransition?.name);
312
+ }}
313
+ aria-label="Update status"
314
+ >
315
+ {isTransitioning ? '...' : 'Go'}
316
+ </button>
317
+ </div>
318
+ </div>
319
+ </div>
320
+
321
+ <div className="sidebar-section">
322
+ <div className="sidebar-label">Assignee</div>
323
+ <div className="assignee-sidebar">
324
+ {(() => {
325
+ const displayAssignee = isAssigningOptimistic ? optimisticAssignee : issueDetail.fields.assignee;
326
+ return displayAssignee ? (
327
+ <div className="user-info">
328
+ <span className="avatar small">
329
+ {getInitials(displayAssignee.displayName)}
330
+ </span>
331
+ <span style={{ fontSize: '13px' }}>{displayAssignee.displayName}</span>
332
+ {isAssigningOptimistic && <span className="inline-spinner" aria-label="Saving..." />}
333
+ </div>
334
+ ) : (
335
+ <span className="unassigned-text">Unassigned</span>
336
+ );
337
+ })()}
338
+ <input
339
+ type="text"
340
+ className="text-input"
341
+ placeholder="Change assignee..."
342
+ value={assigneeSearch}
343
+ onChange={(e) => setAssigneeSearch(e.target.value)}
344
+ aria-label="Search for assignee"
345
+ />
346
+ {assigneeSearch.trim() && (
347
+ <div className="assignee-suggestions-dropdown">
348
+ {assignableUsers
349
+ .filter(u => u.displayName.toLowerCase().includes(assigneeSearch.toLowerCase()))
350
+ .slice(0, 10)
351
+ .map(u => (
352
+ <div
353
+ key={u.accountId}
354
+ className="assignee-suggestion-item"
355
+ onClick={() => {
356
+ setTargetAccountId(u.accountId);
357
+ setAssigneeSearch(u.displayName);
358
+ }}
359
+ >
360
+ <span className="avatar small">
361
+ {getInitials(u.displayName)}
362
+ </span>
363
+ <span className="user-name">{u.displayName}</span>
364
+ </div>
365
+ ))}
366
+ </div>
367
+ )}
368
+ {targetAccountId && (
369
+ <div className="assignee-actions">
370
+ <button
371
+ className="btn-primary btn-sm"
372
+ disabled={isAssigning}
373
+ onClick={() => {
374
+ const selectedUser = assignableUsers.find(u => u.accountId === targetAccountId);
375
+ handleAssignUser(targetAccountId, selectedUser?.displayName || assigneeSearch);
376
+ }}
377
+ aria-label="Assign"
378
+ >
379
+ {isAssigning ? '...' : 'Assign'}
380
+ </button>
381
+ <button
382
+ className="btn-icon"
383
+ onClick={() => {
384
+ setTargetAccountId('');
385
+ setAssigneeSearch('');
386
+ }}
387
+ aria-label="Clear"
388
+ >
389
+ <X size={14} />
390
+ </button>
391
+ </div>
392
+ )}
393
+ </div>
394
+ </div>
395
+
396
+ <div className="sidebar-section">
397
+ <div className="sidebar-label">Severity</div>
398
+ <div className="sidebar-value">
399
+ {issueDetail.fields[SEVERITY_FIELD] ? (
400
+ <span className={`severity-badge ${(issueDetail.fields[SEVERITY_FIELD].value || issueDetail.fields[SEVERITY_FIELD].name || '').toLowerCase().replace(/\s+/g, '-')}`}>
401
+ {getSeverityIcon(issueDetail.fields[SEVERITY_FIELD].value || issueDetail.fields[SEVERITY_FIELD].name)}
402
+ {formatSeverity(issueDetail.fields[SEVERITY_FIELD].value || issueDetail.fields[SEVERITY_FIELD].name)}
403
+ </span>
404
+ ) : (
405
+ <span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>—</span>
406
+ )}
407
+ </div>
408
+ </div>
409
+
410
+ <div className="sidebar-section">
411
+ <div className="sidebar-label">Labels</div>
412
+ <div className="sidebar-labels">
413
+ {(issueDetail.fields.labels || []).map(label => (
414
+ <span key={label} className="label-tag">
415
+ {label}
416
+ <button
417
+ className="label-remove"
418
+ onClick={() => handleRemoveLabel(label)}
419
+ disabled={isUpdatingLabels}
420
+ title="Remove Label"
421
+ >
422
+ <X size={10} />
423
+ </button>
424
+ </span>
425
+ ))}
426
+ <div className="add-label-wrapper" style={{ position: 'relative' }}>
427
+ {isUpdatingLabels ? (
428
+ <Loader2 size={12} className="spinner" />
429
+ ) : (
430
+ <PlusCircle size={12} style={{ color: 'var(--primary)' }} />
431
+ )}
432
+ <input
433
+ type="text"
434
+ className="add-label-input"
435
+ placeholder="Add..."
436
+ value={newLabelText}
437
+ onChange={(e) => setNewLabelText(e.target.value)}
438
+ onKeyDown={(e) => {
439
+ if (e.key === 'Enter') {
440
+ handleAddLabel();
441
+ }
442
+ }}
443
+ onFocus={() => setShowLabelSuggestions(labelSuggestions.length > 0)}
444
+ onBlur={() => setTimeout(() => setShowLabelSuggestions(false), 200)}
445
+ disabled={isUpdatingLabels}
446
+ />
447
+ {showLabelSuggestions && (
448
+ <div className="label-suggestions-dropdown">
449
+ {labelSuggestions.map(label => (
450
+ <div
451
+ key={label}
452
+ className="label-suggestion-item"
453
+ onClick={() => handleAddLabel(label)}
454
+ >
455
+ {label}
456
+ </div>
457
+ ))}
458
+ </div>
459
+ )}
460
+ </div>
461
+ </div>
462
+ </div>
463
+
464
+ <div className="sidebar-section">
465
+ <div className="sidebar-label">Fix Version</div>
466
+ <div className="sidebar-value">
467
+ <Package size={14} />
468
+ <select
469
+ className="version-select"
470
+ value={issueDetail.fields.fixVersions?.[0]?.id || ''}
471
+ onChange={(e) => handleUpdateVersion(e.target.value)}
472
+ disabled={isUpdatingVersions}
473
+ >
474
+ <option value="">None</option>
475
+ {projectVersions.map(v => (
476
+ <option key={v.id} value={v.id}>
477
+ {v.name} {v.released ? '(Released)' : ''}
478
+ </option>
479
+ ))}
480
+ </select>
481
+ {isUpdatingVersions && (
482
+ <Loader2 size={12} className="spinner" />
483
+ )}
484
+ </div>
485
+ </div>
486
+
487
+ <div className="sidebar-section">
488
+ <div className="sidebar-label">Reporter</div>
489
+ <div className="sidebar-value">
490
+ <span className="avatar small">
491
+ {getInitials(issueDetail.fields.reporter?.displayName || 'Unknown')}
492
+ </span>
493
+ <span>{issueDetail.fields.reporter?.displayName || 'Unknown'}</span>
494
+ </div>
495
+ </div>
496
+
497
+ <div className="sidebar-section">
498
+ <div className="sidebar-label">Created</div>
499
+ <div className="sidebar-date">
500
+ <Calendar size={14} />
501
+ <span>{new Date(issueDetail.fields.created).toLocaleDateString()}</span>
502
+ </div>
503
+ </div>
504
+
505
+ <div className="sidebar-section">
506
+ <div className="sidebar-label">Updated</div>
507
+ <div className="sidebar-date">
508
+ <Calendar size={14} />
509
+ <span>{new Date(issueDetail.fields.updated).toLocaleDateString()}</span>
510
+ </div>
511
+ </div>
512
+ </div>
513
+ </>
514
+ )}
515
+ </>
516
+ );
517
+ }
@@ -0,0 +1,155 @@
1
+ import React, { useRef } from 'react';
2
+ import {
3
+ X, Link2, ExternalLink
4
+ } from 'lucide-react';
5
+ import IssueDetailPanel from './IssueDetailPanel';
6
+ import { getIssueTypeIcon } from '../utils/issueHelpers.jsx';
7
+ import { useFocusTrap } from '../hooks/useFocusTrap.js';
8
+
9
+ export default React.memo(function IssueDrawer({
10
+ issueKey,
11
+ isExpanded,
12
+ onClose,
13
+ onExpand,
14
+ addToast,
15
+ issueDetail,
16
+ comments,
17
+ transitions,
18
+ assignableUsers,
19
+ drawerLoading,
20
+ drawerError,
21
+ newCommentText,
22
+ setNewCommentText,
23
+ isPostingComment,
24
+ targetTransitionId,
25
+ setTargetTransitionId,
26
+ isTransitioning,
27
+ assigneeSearch,
28
+ setAssigneeSearch,
29
+ targetAccountId,
30
+ setTargetAccountId,
31
+ isAssigning,
32
+ projectVersions,
33
+ isUpdatingVersions,
34
+ newLabelText,
35
+ setNewLabelText,
36
+ labelSuggestions,
37
+ showLabelSuggestions,
38
+ setShowLabelSuggestions,
39
+ isUpdatingLabels,
40
+ isUploading,
41
+ uploadMessage,
42
+ handlePostComment,
43
+ handleAssignUser,
44
+ handleFileUpload,
45
+ handleUpdateVersion,
46
+ handleAddLabel,
47
+ handleRemoveLabel,
48
+ handleUpdateStatus,
49
+ onSubtaskClick
50
+ }) {
51
+ const drawerRef = useRef(null);
52
+ useFocusTrap(drawerRef, isExpanded);
53
+
54
+ const copyLink = () => {
55
+ navigator.clipboard.writeText(window.location.href);
56
+ addToast('Link copied to clipboard', 'success');
57
+ };
58
+
59
+ return (
60
+ <div className="drawer-overlay" ref={drawerRef} onClick={onClose}>
61
+ <div className={`drawer ${isExpanded ? 'expanded' : ''}`} onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="drawer-title">
62
+ <div className="drawer-header">
63
+ <div className="drawer-header-content">
64
+ <div className="drawer-breadcrumb">
65
+ <span>{issueKey?.split('-')[0]}</span>
66
+ <span>/</span>
67
+ <span>{issueKey}</span>
68
+ </div>
69
+ {issueDetail && (
70
+ <h2 id="drawer-title">
71
+ {getIssueTypeIcon(issueDetail.fields.issuetype?.name)}
72
+ {issueDetail.fields.summary}
73
+ </h2>
74
+ )}
75
+ </div>
76
+ <div className="drawer-header-actions">
77
+ <button
78
+ className="action-btn"
79
+ onClick={() => onExpand(!isExpanded)}
80
+ aria-label={isExpanded ? "Collapse" : "Expand to full view"}
81
+ >
82
+ {isExpanded ? (
83
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
84
+ <path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
85
+ </svg>
86
+ ) : (
87
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
88
+ <path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
89
+ </svg>
90
+ )}
91
+ </button>
92
+ <button
93
+ className="action-btn"
94
+ onClick={() => window.open(`/#/view/${issueKey}`, '_blank')}
95
+ aria-label="Open in new tab"
96
+ >
97
+ <ExternalLink size={18} />
98
+ </button>
99
+ <button
100
+ className="action-btn"
101
+ onClick={copyLink}
102
+ aria-label="Copy link"
103
+ >
104
+ <Link2 size={18} />
105
+ </button>
106
+ <button className="close-btn" onClick={onClose} aria-label="Close drawer">
107
+ <X size={20} />
108
+ </button>
109
+ </div>
110
+ </div>
111
+
112
+ <div className="drawer-body">
113
+ <IssueDetailPanel
114
+ mode="drawer"
115
+ issueDetail={issueDetail}
116
+ comments={comments}
117
+ transitions={transitions}
118
+ assignableUsers={assignableUsers}
119
+ projectVersions={projectVersions}
120
+ drawerLoading={drawerLoading}
121
+ drawerError={drawerError}
122
+ newCommentText={newCommentText}
123
+ setNewCommentText={setNewCommentText}
124
+ isPostingComment={isPostingComment}
125
+ targetTransitionId={targetTransitionId}
126
+ setTargetTransitionId={setTargetTransitionId}
127
+ isTransitioning={isTransitioning}
128
+ assigneeSearch={assigneeSearch}
129
+ setAssigneeSearch={setAssigneeSearch}
130
+ targetAccountId={targetAccountId}
131
+ setTargetAccountId={setTargetAccountId}
132
+ isAssigning={isAssigning}
133
+ isUpdatingVersions={isUpdatingVersions}
134
+ newLabelText={newLabelText}
135
+ setNewLabelText={setNewLabelText}
136
+ labelSuggestions={labelSuggestions}
137
+ showLabelSuggestions={showLabelSuggestions}
138
+ setShowLabelSuggestions={setShowLabelSuggestions}
139
+ isUpdatingLabels={isUpdatingLabels}
140
+ isUploading={isUploading}
141
+ uploadMessage={uploadMessage}
142
+ handlePostComment={handlePostComment}
143
+ handleAssignUser={handleAssignUser}
144
+ handleFileUpload={handleFileUpload}
145
+ handleUpdateVersion={handleUpdateVersion}
146
+ handleAddLabel={handleAddLabel}
147
+ handleRemoveLabel={handleRemoveLabel}
148
+ handleUpdateStatus={handleUpdateStatus}
149
+ onSubtaskClick={onSubtaskClick}
150
+ />
151
+ </div>
152
+ </div>
153
+ </div>
154
+ );
155
+ });