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,962 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import React from 'react';
5
+ import IssueDetailPanel from '../../components/IssueDetailPanel.jsx';
6
+
7
+ vi.mock('../../utils/sanitize.js', () => ({
8
+ sanitizeHtml: vi.fn((html) => html),
9
+ }));
10
+
11
+ const mockIssueDetail = {
12
+ key: 'PROJ-123',
13
+ fields: {
14
+ summary: 'Test Issue',
15
+ descriptionHtml: '<p>Test description</p>',
16
+ descriptionText: 'Test description',
17
+ status: { name: 'In Progress' },
18
+ assignee: { displayName: 'John Doe' },
19
+ reporter: { displayName: 'Jane Smith' },
20
+ issuetype: { name: 'Bug' },
21
+ customfield_10260: { value: 'Critical' },
22
+ created: '2024-01-01T00:00:00.000Z',
23
+ updated: '2024-01-02T00:00:00.000Z',
24
+ labels: ['bug', 'urgent'],
25
+ fixVersions: [{ id: '10001', name: 'v1.0' }],
26
+ attachment: [
27
+ { id: 'att1', filename: 'test.txt', content: 'http://example.com/file', size: 1024 },
28
+ ],
29
+ subtasksMapped: [
30
+ { key: 'PROJ-124', summary: 'Subtask 1', status: 'To Do' },
31
+ ],
32
+ issuelinksMapped: [
33
+ { key: 'PROJ-125', summary: 'Linked Issue', status: 'Done', label: 'blocks' },
34
+ ],
35
+ },
36
+ };
37
+
38
+ const mockComments = [
39
+ {
40
+ id: '10001',
41
+ author: { displayName: 'John Doe' },
42
+ created: '2024-01-01T12:00:00.000Z',
43
+ bodyText: 'Test comment',
44
+ renderedHtml: '<p>Test comment</p>',
45
+ },
46
+ ];
47
+
48
+ const mockTransitions = [
49
+ { id: '1', name: 'To Do', to: { name: 'To Do' } },
50
+ { id: '2', name: 'In Progress', to: { name: 'In Progress' } },
51
+ { id: '3', name: 'Done', to: { name: 'Done' } },
52
+ ];
53
+
54
+ const mockAssignableUsers = [
55
+ { accountId: 'acc1', displayName: 'User One' },
56
+ { accountId: 'acc2', displayName: 'User Two' },
57
+ ];
58
+
59
+ const mockProjectVersions = [
60
+ { id: '10001', name: 'v1.0', released: false },
61
+ { id: '10002', name: 'v0.9', released: true },
62
+ ];
63
+
64
+ describe('IssueDetailPanel', () => {
65
+ const defaultProps = {
66
+ mode: 'drawer',
67
+ issueDetail: mockIssueDetail,
68
+ comments: mockComments,
69
+ transitions: mockTransitions,
70
+ assignableUsers: mockAssignableUsers,
71
+ projectVersions: mockProjectVersions,
72
+ drawerLoading: false,
73
+ drawerError: null,
74
+ newCommentText: '',
75
+ setNewCommentText: vi.fn(),
76
+ isPostingComment: false,
77
+ targetTransitionId: '',
78
+ setTargetTransitionId: vi.fn(),
79
+ isTransitioning: false,
80
+ isTransitioningOptimistic: false,
81
+ optimisticStatus: null,
82
+ assigneeSearch: '',
83
+ setAssigneeSearch: vi.fn(),
84
+ targetAccountId: '',
85
+ setTargetAccountId: vi.fn(),
86
+ isAssigning: false,
87
+ isAssigningOptimistic: false,
88
+ optimisticAssignee: null,
89
+ isUpdatingVersions: false,
90
+ newLabelText: '',
91
+ setNewLabelText: vi.fn(),
92
+ labelSuggestions: ['suggestion1', 'suggestion2'],
93
+ showLabelSuggestions: false,
94
+ setShowLabelSuggestions: vi.fn(),
95
+ isUpdatingLabels: false,
96
+ isUploading: false,
97
+ uploadMessage: '',
98
+ handlePostComment: vi.fn(),
99
+ handleAssignUser: vi.fn(),
100
+ handleFileUpload: vi.fn(),
101
+ handleUpdateVersion: vi.fn(),
102
+ handleAddLabel: vi.fn(),
103
+ handleRemoveLabel: vi.fn(),
104
+ handleUpdateStatus: vi.fn(),
105
+ onSubtaskClick: vi.fn(),
106
+ };
107
+
108
+ beforeEach(() => {
109
+ vi.clearAllMocks();
110
+ });
111
+
112
+ describe('Rendering', () => {
113
+ it('renders issue description', () => {
114
+ render(<IssueDetailPanel {...defaultProps} />);
115
+
116
+ expect(screen.getByText('Description')).toBeInTheDocument();
117
+ expect(screen.getByText('Test description')).toBeInTheDocument();
118
+ });
119
+
120
+ it('renders HTML description with dangerouslySetInnerHTML', () => {
121
+ render(<IssueDetailPanel {...defaultProps} />);
122
+
123
+ const descContent = document.querySelector('.description-content');
124
+ expect(descContent).toBeInTheDocument();
125
+ });
126
+
127
+ it('renders placeholder when no description', () => {
128
+ const noDescIssue = {
129
+ ...mockIssueDetail,
130
+ fields: { ...mockIssueDetail.fields, descriptionHtml: null, descriptionText: null },
131
+ };
132
+ render(<IssueDetailPanel {...defaultProps} issueDetail={noDescIssue} />);
133
+
134
+ expect(screen.getByText('No description provided.')).toBeInTheDocument();
135
+ });
136
+
137
+ it('renders subtasks section', () => {
138
+ render(<IssueDetailPanel {...defaultProps} />);
139
+
140
+ expect(screen.getByText('Subtasks (1)')).toBeInTheDocument();
141
+ expect(screen.getByText('Subtask 1')).toBeInTheDocument();
142
+ });
143
+
144
+ it('renders linked issues section', () => {
145
+ render(<IssueDetailPanel {...defaultProps} />);
146
+
147
+ expect(screen.getByText('Linked Issues (1)')).toBeInTheDocument();
148
+ expect(screen.getByText('Linked Issue')).toBeInTheDocument();
149
+ });
150
+
151
+ it('renders attachments section', () => {
152
+ render(<IssueDetailPanel {...defaultProps} />);
153
+
154
+ expect(screen.getByText('Attachments (1)')).toBeInTheDocument();
155
+ expect(screen.getByText('test.txt')).toBeInTheDocument();
156
+ });
157
+
158
+ it('renders comments section', () => {
159
+ render(<IssueDetailPanel {...defaultProps} />);
160
+
161
+ expect(screen.getByText('Comments (1)')).toBeInTheDocument();
162
+ expect(screen.getByText('Test comment')).toBeInTheDocument();
163
+ });
164
+
165
+ it('renders empty comments state', () => {
166
+ render(<IssueDetailPanel {...defaultProps} comments={[]} />);
167
+
168
+ expect(screen.getByText('Comments (0)')).toBeInTheDocument();
169
+ expect(screen.getByText('No comments yet. Be the first to comment!')).toBeInTheDocument();
170
+ });
171
+
172
+ it('renders sidebar sections', () => {
173
+ render(<IssueDetailPanel {...defaultProps} />);
174
+
175
+ expect(screen.getByText('Status')).toBeInTheDocument();
176
+ expect(screen.getByText('Assignee')).toBeInTheDocument();
177
+ expect(screen.getByText('Severity')).toBeInTheDocument();
178
+ expect(screen.getByText('Labels')).toBeInTheDocument();
179
+ expect(screen.getByText('Fix Version')).toBeInTheDocument();
180
+ expect(screen.getByText('Reporter')).toBeInTheDocument();
181
+ expect(screen.getByText('Created')).toBeInTheDocument();
182
+ expect(screen.getByText('Updated')).toBeInTheDocument();
183
+ });
184
+ });
185
+
186
+ describe('Loading State', () => {
187
+ it('renders skeleton when drawerLoading is true', () => {
188
+ render(<IssueDetailPanel {...defaultProps} drawerLoading={true} />);
189
+
190
+ expect(document.querySelector('.drawer-skeleton')).toBeInTheDocument();
191
+ });
192
+
193
+ it('does not render content when loading', () => {
194
+ render(<IssueDetailPanel {...defaultProps} drawerLoading={true} />);
195
+
196
+ expect(screen.queryByText('Description')).not.toBeInTheDocument();
197
+ });
198
+ });
199
+
200
+ describe('Error State', () => {
201
+ it('renders error message when drawerError is present', () => {
202
+ render(<IssueDetailPanel {...defaultProps} drawerError="Failed to load issue" />);
203
+
204
+ expect(screen.getByText('Failed to load issue')).toBeInTheDocument();
205
+ });
206
+
207
+ it('error has role alert', () => {
208
+ render(<IssueDetailPanel {...defaultProps} drawerError="Error occurred" />);
209
+
210
+ expect(screen.getByRole('alert')).toBeInTheDocument();
211
+ });
212
+ });
213
+
214
+ describe('Comments', () => {
215
+ it('renders comment author name', () => {
216
+ render(<IssueDetailPanel {...defaultProps} />);
217
+
218
+ const authors = screen.getAllByText('John Doe');
219
+ expect(authors.length).toBeGreaterThan(0);
220
+ });
221
+
222
+ it('renders comment date', () => {
223
+ render(<IssueDetailPanel {...defaultProps} />);
224
+
225
+ const commentDates = document.querySelectorAll('.comment-date');
226
+ expect(commentDates.length).toBeGreaterThan(0);
227
+ });
228
+
229
+ it('renders comment textarea', () => {
230
+ render(<IssueDetailPanel {...defaultProps} />);
231
+
232
+ const textarea = screen.getByLabelText('Add comment');
233
+ expect(textarea).toBeInTheDocument();
234
+ });
235
+
236
+ it('disables textarea when posting comment', () => {
237
+ render(<IssueDetailPanel {...defaultProps} isPostingComment={true} />);
238
+
239
+ const textarea = screen.getByLabelText('Add comment');
240
+ expect(textarea).toBeDisabled();
241
+ });
242
+
243
+ it('calls setNewCommentText when typing', async () => {
244
+ render(<IssueDetailPanel {...defaultProps} />);
245
+
246
+ const textarea = screen.getByLabelText('Add comment');
247
+ await userEvent.type(textarea, 'New comment');
248
+
249
+ expect(defaultProps.setNewCommentText).toHaveBeenCalled();
250
+ });
251
+
252
+ it('disables post button when comment is empty', () => {
253
+ render(<IssueDetailPanel {...defaultProps} />);
254
+
255
+ const postButton = screen.getByLabelText('Post comment');
256
+ expect(postButton).toBeDisabled();
257
+ });
258
+
259
+ it('enables post button when comment has text', async () => {
260
+ render(<IssueDetailPanel {...defaultProps} newCommentText="Test" />);
261
+
262
+ const postButton = screen.getByLabelText('Post comment');
263
+ expect(postButton).not.toBeDisabled();
264
+ });
265
+
266
+ it('calls handlePostComment when post button is clicked', async () => {
267
+ render(<IssueDetailPanel {...defaultProps} newCommentText="Test comment" />);
268
+
269
+ const postButton = screen.getByLabelText('Post comment');
270
+ await userEvent.click(postButton);
271
+
272
+ expect(defaultProps.handlePostComment).toHaveBeenCalled();
273
+ });
274
+
275
+ it('shows loading text when posting comment', () => {
276
+ render(<IssueDetailPanel {...defaultProps} newCommentText="Test" isPostingComment={true} />);
277
+
278
+ expect(screen.getByText('Posting...')).toBeInTheDocument();
279
+ });
280
+ });
281
+
282
+ describe('Transitions', () => {
283
+ it('renders transition select dropdown', () => {
284
+ render(<IssueDetailPanel {...defaultProps} />);
285
+
286
+ const select = screen.getByLabelText('Select transition');
287
+ expect(select).toBeInTheDocument();
288
+
289
+ const options = select.querySelectorAll('option');
290
+ const optionTexts = Array.from(options).map(o => o.textContent);
291
+ expect(optionTexts).toContain('To Do');
292
+ expect(optionTexts).toContain('In Progress');
293
+ expect(optionTexts).toContain('Done');
294
+ });
295
+
296
+ it('handles transitions without to.name by falling back to name', () => {
297
+ const transitionsWithNameOnly = [
298
+ { id: '1', name: 'To Do' },
299
+ { id: '2', name: 'In Progress' },
300
+ ];
301
+ render(<IssueDetailPanel {...defaultProps} transitions={transitionsWithNameOnly} />);
302
+
303
+ const select = screen.getByLabelText('Select transition');
304
+ const options = select.querySelectorAll('option');
305
+ const optionTexts = Array.from(options).map(o => o.textContent);
306
+ expect(optionTexts).toContain('To Do');
307
+ expect(optionTexts).toContain('In Progress');
308
+ });
309
+
310
+ it('sorts Backlog transitions to the end', () => {
311
+ const transitionsWithBacklog = [
312
+ { id: '1', name: 'Backlog', to: { name: 'Backlog' } },
313
+ { id: '2', name: 'In Progress', to: { name: 'In Progress' } },
314
+ ];
315
+ render(<IssueDetailPanel {...defaultProps} transitions={transitionsWithBacklog} />);
316
+
317
+ const select = screen.getByLabelText('Select transition');
318
+ const options = Array.from(select.querySelectorAll('option')).filter(o => o.value);
319
+ expect(options[0].textContent).toBe('In Progress');
320
+ expect(options[1].textContent).toBe('Backlog');
321
+ });
322
+
323
+ it('calls setTargetTransitionId when transition is selected', async () => {
324
+ render(<IssueDetailPanel {...defaultProps} />);
325
+
326
+ const select = screen.getByLabelText('Select transition');
327
+ await userEvent.selectOptions(select, '2');
328
+
329
+ expect(defaultProps.setTargetTransitionId).toHaveBeenCalledWith('2');
330
+ });
331
+
332
+ it('disables Go button when no transition selected', () => {
333
+ render(<IssueDetailPanel {...defaultProps} />);
334
+
335
+ const goButton = screen.getByLabelText('Update status');
336
+ expect(goButton).toBeDisabled();
337
+ });
338
+
339
+ it('enables Go button when transition is selected', async () => {
340
+ render(<IssueDetailPanel {...defaultProps} targetTransitionId="2" />);
341
+
342
+ const goButton = screen.getByLabelText('Update status');
343
+ expect(goButton).not.toBeDisabled();
344
+ });
345
+
346
+ it('calls handleUpdateStatus when Go button is clicked', async () => {
347
+ render(<IssueDetailPanel {...defaultProps} targetTransitionId="2" />);
348
+
349
+ const goButton = screen.getByLabelText('Update status');
350
+ await userEvent.click(goButton);
351
+
352
+ expect(defaultProps.handleUpdateStatus).toHaveBeenCalled();
353
+ });
354
+
355
+ it('uses name fallback when to.name is not available in handleUpdateStatus', async () => {
356
+ const transitionsWithNameOnly = [
357
+ { id: '1', name: 'To Do' },
358
+ { id: '2', name: 'In Progress' },
359
+ ];
360
+ render(<IssueDetailPanel {...defaultProps} transitions={transitionsWithNameOnly} targetTransitionId="2" />);
361
+
362
+ const goButton = screen.getByLabelText('Update status');
363
+ await userEvent.click(goButton);
364
+
365
+ expect(defaultProps.handleUpdateStatus).toHaveBeenCalledWith('2', 'In Progress');
366
+ });
367
+
368
+ it('shows inline spinner when transitioning optimistically', () => {
369
+ render(<IssueDetailPanel {...defaultProps} targetTransitionId="2" isTransitioningOptimistic={true} />);
370
+
371
+ const spinner = document.querySelector('.inline-spinner');
372
+ expect(spinner).toBeInTheDocument();
373
+ });
374
+
375
+ it('disables Go button when transitioning', () => {
376
+ render(<IssueDetailPanel {...defaultProps} targetTransitionId="2" isTransitioning={true} />);
377
+
378
+ const goButton = screen.getByLabelText('Update status');
379
+ expect(goButton).toBeDisabled();
380
+ });
381
+ });
382
+
383
+ describe('Assignee', () => {
384
+ it('renders current assignee', () => {
385
+ render(<IssueDetailPanel {...defaultProps} />);
386
+
387
+ const authors = screen.getAllByText('John Doe');
388
+ expect(authors.length).toBeGreaterThan(0);
389
+ });
390
+
391
+ it('renders unassigned when no assignee', () => {
392
+ const unassignedIssue = {
393
+ ...mockIssueDetail,
394
+ fields: { ...mockIssueDetail.fields, assignee: null },
395
+ };
396
+ render(<IssueDetailPanel {...defaultProps} issueDetail={unassignedIssue} />);
397
+
398
+ expect(screen.getByText('Unassigned')).toBeInTheDocument();
399
+ });
400
+
401
+ it('renders assignee search input', () => {
402
+ render(<IssueDetailPanel {...defaultProps} />);
403
+
404
+ const input = screen.getByLabelText('Search for assignee');
405
+ expect(input).toBeInTheDocument();
406
+ });
407
+
408
+ it('calls setAssigneeSearch when typing', async () => {
409
+ render(<IssueDetailPanel {...defaultProps} />);
410
+
411
+ const input = screen.getByLabelText('Search for assignee');
412
+ await userEvent.type(input, 'User');
413
+
414
+ expect(defaultProps.setAssigneeSearch).toHaveBeenCalled();
415
+ });
416
+
417
+ it('shows assignee suggestions when search matches', async () => {
418
+ render(<IssueDetailPanel {...defaultProps} assigneeSearch="User" />);
419
+
420
+ const suggestions = document.querySelector('.assignee-suggestions-dropdown');
421
+ expect(suggestions).toBeInTheDocument();
422
+ expect(screen.getByText('User One')).toBeInTheDocument();
423
+ });
424
+
425
+ it('calls setTargetAccountId when suggestion is clicked', async () => {
426
+ render(<IssueDetailPanel {...defaultProps} assigneeSearch="User" />);
427
+
428
+ await userEvent.click(screen.getByText('User One'));
429
+
430
+ expect(defaultProps.setTargetAccountId).toHaveBeenCalledWith('acc1');
431
+ });
432
+
433
+ it('shows spinner when assigning', () => {
434
+ render(<IssueDetailPanel {...defaultProps} targetAccountId="acc1" isAssigning={true} />);
435
+
436
+ const assignButton = screen.getByLabelText('Assign');
437
+ expect(assignButton).toBeDisabled();
438
+ expect(assignButton).toHaveTextContent('...');
439
+ });
440
+
441
+ it('shows optimistic assignee when isAssigningOptimistic is true', () => {
442
+ const optimisticUser = { displayName: 'Optimistic User', accountId: 'opt1' };
443
+ render(
444
+ <IssueDetailPanel
445
+ {...defaultProps}
446
+ isAssigningOptimistic={true}
447
+ optimisticAssignee={optimisticUser}
448
+ />
449
+ );
450
+
451
+ expect(screen.getByText('Optimistic User')).toBeInTheDocument();
452
+ });
453
+
454
+ it('shows saving spinner when isAssigningOptimistic is true', () => {
455
+ const optimisticUser = { displayName: 'Optimistic User', accountId: 'opt1' };
456
+ render(
457
+ <IssueDetailPanel
458
+ {...defaultProps}
459
+ isAssigningOptimistic={true}
460
+ optimisticAssignee={optimisticUser}
461
+ />
462
+ );
463
+
464
+ const spinner = document.querySelector('.inline-spinner');
465
+ expect(spinner).toBeInTheDocument();
466
+ });
467
+
468
+ it('calls handleAssignUser when Assign is clicked', async () => {
469
+ render(<IssueDetailPanel {...defaultProps} targetAccountId="acc1" assigneeSearch="User One" />);
470
+
471
+ const assignButton = screen.getByLabelText('Assign');
472
+ await userEvent.click(assignButton);
473
+
474
+ expect(defaultProps.handleAssignUser).toHaveBeenCalled();
475
+ });
476
+
477
+ it('uses assigneeSearch fallback when user not in assignableUsers', async () => {
478
+ render(<IssueDetailPanel {...defaultProps} targetAccountId="unknown-id" assigneeSearch="Unknown User" />);
479
+
480
+ const assignButton = screen.getByLabelText('Assign');
481
+ await userEvent.click(assignButton);
482
+
483
+ expect(defaultProps.handleAssignUser).toHaveBeenCalledWith('unknown-id', 'Unknown User');
484
+ });
485
+
486
+ it('shows Clear button to reset assignee selection', () => {
487
+ render(<IssueDetailPanel {...defaultProps} targetAccountId="acc1" />);
488
+
489
+ const clearButton = screen.getByLabelText('Clear');
490
+ expect(clearButton).toBeInTheDocument();
491
+ });
492
+
493
+ it('clears assignee selection when Clear is clicked', async () => {
494
+ render(<IssueDetailPanel {...defaultProps} targetAccountId="acc1" assigneeSearch="User One" />);
495
+
496
+ const clearButton = screen.getByLabelText('Clear');
497
+ await userEvent.click(clearButton);
498
+
499
+ expect(defaultProps.setTargetAccountId).toHaveBeenCalledWith('');
500
+ expect(defaultProps.setAssigneeSearch).toHaveBeenCalledWith('');
501
+ });
502
+ });
503
+
504
+ describe('Labels', () => {
505
+ it('renders labels', () => {
506
+ render(<IssueDetailPanel {...defaultProps} />);
507
+
508
+ expect(screen.getByText('bug')).toBeInTheDocument();
509
+ expect(screen.getByText('urgent')).toBeInTheDocument();
510
+ });
511
+
512
+ it('renders label remove buttons', () => {
513
+ render(<IssueDetailPanel {...defaultProps} />);
514
+
515
+ const removeButtons = document.querySelectorAll('.label-remove');
516
+ expect(removeButtons).toHaveLength(2);
517
+ });
518
+
519
+ it('calls handleRemoveLabel when remove is clicked', async () => {
520
+ render(<IssueDetailPanel {...defaultProps} />);
521
+
522
+ const removeButtons = document.querySelectorAll('.label-remove');
523
+ await userEvent.click(removeButtons[0]);
524
+
525
+ expect(defaultProps.handleRemoveLabel).toHaveBeenCalledWith('bug');
526
+ });
527
+
528
+ it('renders add label input', () => {
529
+ render(<IssueDetailPanel {...defaultProps} />);
530
+
531
+ const input = document.querySelector('.add-label-input');
532
+ expect(input).toBeInTheDocument();
533
+ });
534
+
535
+ it('calls handleAddLabel when Enter is pressed', async () => {
536
+ render(<IssueDetailPanel {...defaultProps} newLabelText="newlabel" />);
537
+
538
+ const input = document.querySelector('.add-label-input');
539
+ await userEvent.type(input, '{Enter}');
540
+
541
+ expect(defaultProps.handleAddLabel).toHaveBeenCalled();
542
+ });
543
+ });
544
+
545
+ describe('Labels', () => {
546
+ it('renders labels', () => {
547
+ render(<IssueDetailPanel {...defaultProps} />);
548
+
549
+ expect(screen.getByText('bug')).toBeInTheDocument();
550
+ expect(screen.getByText('urgent')).toBeInTheDocument();
551
+ });
552
+
553
+ it('renders label remove buttons', () => {
554
+ render(<IssueDetailPanel {...defaultProps} />);
555
+
556
+ const removeButtons = document.querySelectorAll('.label-remove');
557
+ expect(removeButtons).toHaveLength(2);
558
+ });
559
+
560
+ it('calls handleRemoveLabel when remove is clicked', async () => {
561
+ render(<IssueDetailPanel {...defaultProps} />);
562
+
563
+ const removeButtons = document.querySelectorAll('.label-remove');
564
+ await userEvent.click(removeButtons[0]);
565
+
566
+ expect(defaultProps.handleRemoveLabel).toHaveBeenCalledWith('bug');
567
+ });
568
+
569
+ it('renders add label input', () => {
570
+ render(<IssueDetailPanel {...defaultProps} />);
571
+
572
+ const input = document.querySelector('.add-label-input');
573
+ expect(input).toBeInTheDocument();
574
+ });
575
+
576
+ it('calls setNewLabelText when typing in label input', async () => {
577
+ render(<IssueDetailPanel {...defaultProps} />);
578
+
579
+ const input = document.querySelector('.add-label-input');
580
+ await userEvent.type(input, 'newlabel');
581
+
582
+ expect(defaultProps.setNewLabelText).toHaveBeenCalled();
583
+ });
584
+
585
+ it('calls handleAddLabel when Enter is pressed', async () => {
586
+ render(<IssueDetailPanel {...defaultProps} newLabelText="newlabel" />);
587
+
588
+ const input = document.querySelector('.add-label-input');
589
+ await userEvent.type(input, '{Enter}');
590
+
591
+ expect(defaultProps.handleAddLabel).toHaveBeenCalled();
592
+ });
593
+
594
+ it('shows label suggestions on focus when suggestions exist', () => {
595
+ render(<IssueDetailPanel {...defaultProps} labelSuggestions={['suggestion1', 'suggestion2']} />);
596
+
597
+ const input = document.querySelector('.add-label-input');
598
+ fireEvent.focus(input);
599
+
600
+ expect(defaultProps.setShowLabelSuggestions).toHaveBeenCalledWith(true);
601
+ });
602
+
603
+ it('renders label suggestions dropdown when showLabelSuggestions is true', () => {
604
+ render(<IssueDetailPanel {...defaultProps} showLabelSuggestions={true} />);
605
+
606
+ const dropdown = document.querySelector('.label-suggestions-dropdown');
607
+ expect(dropdown).toBeInTheDocument();
608
+ expect(screen.getByText('suggestion1')).toBeInTheDocument();
609
+ expect(screen.getByText('suggestion2')).toBeInTheDocument();
610
+ });
611
+
612
+ it('calls handleAddLabel with suggestion text when suggestion is clicked', () => {
613
+ render(<IssueDetailPanel {...defaultProps} showLabelSuggestions={true} />);
614
+
615
+ fireEvent.click(screen.getByText('suggestion1'));
616
+
617
+ expect(defaultProps.handleAddLabel).toHaveBeenCalledWith('suggestion1');
618
+ });
619
+
620
+ it('disables add label input when updating labels', () => {
621
+ render(<IssueDetailPanel {...defaultProps} isUpdatingLabels={true} />);
622
+
623
+ const input = document.querySelector('.add-label-input');
624
+ expect(input).toBeDisabled();
625
+ });
626
+
627
+ it('hides label suggestions on blur after timeout', async () => {
628
+ vi.useFakeTimers();
629
+ render(<IssueDetailPanel {...defaultProps} showLabelSuggestions={true} />);
630
+
631
+ const input = document.querySelector('.add-label-input');
632
+ fireEvent.focus(input);
633
+ fireEvent.blur(input);
634
+
635
+ vi.advanceTimersByTime(200);
636
+
637
+ expect(defaultProps.setShowLabelSuggestions).toHaveBeenCalledWith(false);
638
+ vi.useRealTimers();
639
+ });
640
+ });
641
+
642
+ describe('File Upload', () => {
643
+ it('renders file input', () => {
644
+ render(<IssueDetailPanel {...defaultProps} />);
645
+
646
+ const fileInput = screen.getByLabelText('Upload attachment');
647
+ expect(fileInput).toBeInTheDocument();
648
+ });
649
+
650
+ it('disables file input when uploading', () => {
651
+ render(<IssueDetailPanel {...defaultProps} isUploading={true} />);
652
+
653
+ const fileInput = screen.getByLabelText('Upload attachment');
654
+ expect(fileInput).toBeDisabled();
655
+ });
656
+
657
+ it('shows upload message', () => {
658
+ render(<IssueDetailPanel {...defaultProps} uploadMessage="Upload complete" />);
659
+
660
+ expect(screen.getByText('Upload complete')).toBeInTheDocument();
661
+ });
662
+
663
+ it('shows error upload message with error class', () => {
664
+ render(<IssueDetailPanel {...defaultProps} uploadMessage="Error: Upload failed" />);
665
+
666
+ const message = document.querySelector('.upload-message.error');
667
+ expect(message).toBeInTheDocument();
668
+ });
669
+
670
+ it('shows success upload message with success class', () => {
671
+ render(<IssueDetailPanel {...defaultProps} uploadMessage="Upload complete!" />);
672
+
673
+ const message = document.querySelector('.upload-message.success');
674
+ expect(message).toBeInTheDocument();
675
+ });
676
+
677
+ it('shows uploading indicator when isUploading is true', () => {
678
+ render(<IssueDetailPanel {...defaultProps} isUploading={true} />);
679
+
680
+ expect(screen.getByText('Uploading...')).toBeInTheDocument();
681
+ });
682
+ });
683
+
684
+ describe('Empty States', () => {
685
+ it('renders empty subtasks when no subtasks', () => {
686
+ const noSubtasksIssue = {
687
+ ...mockIssueDetail,
688
+ fields: { ...mockIssueDetail.fields, subtasksMapped: [] },
689
+ };
690
+ render(<IssueDetailPanel {...defaultProps} issueDetail={noSubtasksIssue} />);
691
+
692
+ expect(screen.queryByText(/Subtasks/)).not.toBeInTheDocument();
693
+ });
694
+
695
+ it('renders empty linked issues when no linked issues', () => {
696
+ const noLinksIssue = {
697
+ ...mockIssueDetail,
698
+ fields: { ...mockIssueDetail.fields, issuelinksMapped: [] },
699
+ };
700
+ render(<IssueDetailPanel {...defaultProps} issueDetail={noLinksIssue} />);
701
+
702
+ expect(screen.queryByText(/Linked Issues/)).not.toBeInTheDocument();
703
+ });
704
+
705
+ it('renders empty attachments message', () => {
706
+ const noAttachmentsIssue = {
707
+ ...mockIssueDetail,
708
+ fields: { ...mockIssueDetail.fields, attachment: [] },
709
+ };
710
+ render(<IssueDetailPanel {...defaultProps} issueDetail={noAttachmentsIssue} />);
711
+
712
+ expect(screen.getByText('Attachments (0)')).toBeInTheDocument();
713
+ expect(screen.getByText('No attachments yet.')).toBeInTheDocument();
714
+ });
715
+
716
+ it('renders empty attachments when attachment is null', () => {
717
+ const nullAttachmentsIssue = {
718
+ ...mockIssueDetail,
719
+ fields: { ...mockIssueDetail.fields, attachment: null },
720
+ };
721
+ render(<IssueDetailPanel {...defaultProps} issueDetail={nullAttachmentsIssue} />);
722
+
723
+ expect(screen.getByText('Attachments (0)')).toBeInTheDocument();
724
+ expect(screen.getByText('No attachments yet.')).toBeInTheDocument();
725
+ });
726
+
727
+ it('renders empty labels when no labels', () => {
728
+ const noLabelsIssue = {
729
+ ...mockIssueDetail,
730
+ fields: { ...mockIssueDetail.fields, labels: [] },
731
+ };
732
+ render(<IssueDetailPanel {...defaultProps} issueDetail={noLabelsIssue} />);
733
+
734
+ expect(screen.getByText('Labels')).toBeInTheDocument();
735
+ });
736
+ });
737
+
738
+ describe('Edge Cases', () => {
739
+ it('renders issue without severity', () => {
740
+ const noSeverityIssue = {
741
+ ...mockIssueDetail,
742
+ fields: { ...mockIssueDetail.fields, customfield_10260: null },
743
+ };
744
+ render(<IssueDetailPanel {...defaultProps} issueDetail={noSeverityIssue} />);
745
+
746
+ expect(screen.getAllByText('—').length).toBeGreaterThan(0);
747
+ });
748
+
749
+ it('renders severity with name when value is not present', () => {
750
+ const nameOnlySeverity = {
751
+ ...mockIssueDetail,
752
+ fields: { ...mockIssueDetail.fields, customfield_10260: { name: 'High' } },
753
+ };
754
+ render(<IssueDetailPanel {...defaultProps} issueDetail={nameOnlySeverity} />);
755
+
756
+ expect(screen.getByText('High')).toBeInTheDocument();
757
+ });
758
+
759
+ it('renders status as Unknown when status name is null', () => {
760
+ const nullStatusIssue = {
761
+ ...mockIssueDetail,
762
+ fields: { ...mockIssueDetail.fields, status: null },
763
+ };
764
+ render(<IssueDetailPanel {...defaultProps} issueDetail={nullStatusIssue} />);
765
+
766
+ expect(screen.getByText('Unknown')).toBeInTheDocument();
767
+ });
768
+
769
+ it('renders empty labels array when labels is null', () => {
770
+ const nullLabelsIssue = {
771
+ ...mockIssueDetail,
772
+ fields: { ...mockIssueDetail.fields, labels: null },
773
+ };
774
+ render(<IssueDetailPanel {...defaultProps} issueDetail={nullLabelsIssue} />);
775
+
776
+ expect(screen.getByText('Labels')).toBeInTheDocument();
777
+ });
778
+
779
+ it('renders issue without reporter', () => {
780
+ const noReporterIssue = {
781
+ ...mockIssueDetail,
782
+ fields: { ...mockIssueDetail.fields, reporter: null },
783
+ };
784
+ render(<IssueDetailPanel {...defaultProps} issueDetail={noReporterIssue} />);
785
+
786
+ expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0);
787
+ });
788
+
789
+ it('renders issue without fixVersion', () => {
790
+ const noVersionIssue = {
791
+ ...mockIssueDetail,
792
+ fields: { ...mockIssueDetail.fields, fixVersions: [] },
793
+ };
794
+ render(<IssueDetailPanel {...defaultProps} issueDetail={noVersionIssue} />);
795
+
796
+ const select = document.querySelector('.version-select');
797
+ const selectedOption = select.querySelector('option:checked');
798
+ expect(selectedOption.value).toBe('');
799
+ });
800
+
801
+ it('renders comment with renderedHtml', () => {
802
+ render(<IssueDetailPanel {...defaultProps} />);
803
+
804
+ const commentBody = document.querySelector('.comment-body');
805
+ expect(commentBody).toBeInTheDocument();
806
+ });
807
+
808
+ it('renders comment with bodyText only (no renderedHtml)', () => {
809
+ const textOnlyComment = [
810
+ {
811
+ id: '10002',
812
+ author: { displayName: 'Jane Doe' },
813
+ created: '2024-01-02T12:00:00.000Z',
814
+ bodyText: 'Plain text comment',
815
+ renderedHtml: null,
816
+ },
817
+ ];
818
+ render(<IssueDetailPanel {...defaultProps} comments={textOnlyComment} />);
819
+
820
+ expect(screen.getByText('Plain text comment')).toBeInTheDocument();
821
+ });
822
+
823
+ it('renders comment with empty body', () => {
824
+ const emptyComment = [
825
+ {
826
+ id: '10002',
827
+ author: { displayName: 'Jane Doe' },
828
+ created: '2024-01-02T12:00:00.000Z',
829
+ bodyText: '',
830
+ renderedHtml: null,
831
+ },
832
+ ];
833
+ render(<IssueDetailPanel {...defaultProps} comments={emptyComment} />);
834
+
835
+ expect(screen.getByText('Empty comment')).toBeInTheDocument();
836
+ });
837
+
838
+ it('renders comment author with unknown name when no displayName', () => {
839
+ const unknownAuthorComment = [
840
+ {
841
+ id: '10002',
842
+ author: {},
843
+ created: '2024-01-02T12:00:00.000Z',
844
+ bodyText: 'Test comment',
845
+ renderedHtml: '<p>Test comment</p>',
846
+ },
847
+ ];
848
+ render(<IssueDetailPanel {...defaultProps} comments={unknownAuthorComment} />);
849
+
850
+ expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0);
851
+ });
852
+ });
853
+
854
+ describe('Navigation', () => {
855
+ it('subtask clickable with Enter key', async () => {
856
+ render(<IssueDetailPanel {...defaultProps} />);
857
+
858
+ const subtask = document.querySelector('.subtask-item');
859
+ subtask.focus();
860
+ await userEvent.keyboard('{Enter}');
861
+
862
+ expect(defaultProps.onSubtaskClick).toHaveBeenCalledWith('PROJ-124');
863
+ });
864
+
865
+ it('linked issue clickable with Enter key', async () => {
866
+ render(<IssueDetailPanel {...defaultProps} />);
867
+
868
+ const linkedIssue = document.querySelector('.linked-item');
869
+ linkedIssue.focus();
870
+ await userEvent.keyboard('{Enter}');
871
+
872
+ expect(defaultProps.onSubtaskClick).toHaveBeenCalledWith('PROJ-125');
873
+ });
874
+
875
+ it('subtask clickable by mouse click', async () => {
876
+ render(<IssueDetailPanel {...defaultProps} />);
877
+
878
+ const subtask = document.querySelector('.subtask-item');
879
+ await userEvent.click(subtask);
880
+
881
+ expect(defaultProps.onSubtaskClick).toHaveBeenCalledWith('PROJ-124');
882
+ });
883
+
884
+ it('linked issue clickable by mouse click', async () => {
885
+ render(<IssueDetailPanel {...defaultProps} />);
886
+
887
+ const linkedIssue = document.querySelector('.linked-item');
888
+ await userEvent.click(linkedIssue);
889
+
890
+ expect(defaultProps.onSubtaskClick).toHaveBeenCalledWith('PROJ-125');
891
+ });
892
+
893
+ it('sets window.location.hash in standalone mode when subtask clicked', async () => {
894
+ render(<IssueDetailPanel {...defaultProps} mode="standalone" />);
895
+
896
+ const subtask = document.querySelector('.subtask-item');
897
+ await userEvent.click(subtask);
898
+
899
+ expect(window.location.hash).toBe('#/view/PROJ-124');
900
+ });
901
+
902
+ it('sets window.location.hash in standalone mode when linked issue clicked', async () => {
903
+ render(<IssueDetailPanel {...defaultProps} mode="standalone" />);
904
+
905
+ const linkedIssue = document.querySelector('.linked-item');
906
+ await userEvent.click(linkedIssue);
907
+
908
+ expect(window.location.hash).toBe('#/view/PROJ-125');
909
+ });
910
+
911
+ it('does not call onSubtaskClick in standalone mode when subtask clicked', async () => {
912
+ render(<IssueDetailPanel {...defaultProps} mode="standalone" />);
913
+
914
+ const subtask = document.querySelector('.subtask-item');
915
+ await userEvent.click(subtask);
916
+
917
+ expect(defaultProps.onSubtaskClick).not.toHaveBeenCalled();
918
+ });
919
+
920
+ it('does nothing in drawer mode when onSubtaskClick is not provided', async () => {
921
+ const { onSubtaskClick, ...rest } = defaultProps;
922
+ render(<IssueDetailPanel {...rest} mode="drawer" />);
923
+
924
+ const subtask = document.querySelector('.subtask-item');
925
+ await userEvent.click(subtask);
926
+
927
+ expect(onSubtaskClick).not.toHaveBeenCalled();
928
+ });
929
+ });
930
+
931
+ describe('Version Update', () => {
932
+ it('renders version select', () => {
933
+ render(<IssueDetailPanel {...defaultProps} />);
934
+
935
+ const select = document.querySelector('.version-select');
936
+ expect(select).toBeInTheDocument();
937
+ });
938
+
939
+ it('calls handleUpdateVersion when version changes', async () => {
940
+ render(<IssueDetailPanel {...defaultProps} />);
941
+
942
+ const select = document.querySelector('.version-select');
943
+ await userEvent.selectOptions(select, '10002');
944
+
945
+ expect(defaultProps.handleUpdateVersion).toHaveBeenCalledWith('10002');
946
+ });
947
+
948
+ it('shows spinner when updating versions', () => {
949
+ const issueWithVersion = {
950
+ ...mockIssueDetail,
951
+ fields: {
952
+ ...mockIssueDetail.fields,
953
+ fixVersions: [{ id: '10001', name: 'v1.0' }],
954
+ },
955
+ };
956
+ render(<IssueDetailPanel {...defaultProps} issueDetail={issueWithVersion} isUpdatingVersions={true} />);
957
+
958
+ const spinner = document.querySelector('.spinner');
959
+ expect(spinner).toBeInTheDocument();
960
+ });
961
+ });
962
+ });