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.
- package/AGENTS.md +218 -0
- package/README.md +64 -0
- package/backend/.env.example +1 -0
- package/backend/__tests__/getJiraClient.test.js +57 -0
- package/backend/__tests__/issues.test.js +565 -0
- package/backend/__tests__/jiraService.test.js +1127 -0
- package/backend/__tests__/projects.test.js +256 -0
- package/backend/coverage/clover.xml +426 -0
- package/backend/coverage/coverage-final.json +4 -0
- package/backend/coverage/lcov-report/base.css +224 -0
- package/backend/coverage/lcov-report/block-navigation.js +87 -0
- package/backend/coverage/lcov-report/favicon.png +0 -0
- package/backend/coverage/lcov-report/index.html +131 -0
- package/backend/coverage/lcov-report/prettify.css +1 -0
- package/backend/coverage/lcov-report/prettify.js +2 -0
- package/backend/coverage/lcov-report/routes/index.html +131 -0
- package/backend/coverage/lcov-report/routes/issues.js.html +823 -0
- package/backend/coverage/lcov-report/routes/projects.js.html +190 -0
- package/backend/coverage/lcov-report/service/index.html +116 -0
- package/backend/coverage/lcov-report/service/jiraService.js.html +1663 -0
- package/backend/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/backend/coverage/lcov-report/sorter.js +210 -0
- package/backend/coverage/lcov.info +707 -0
- package/backend/index.js +38 -0
- package/backend/jest.config.js +11 -0
- package/backend/package-lock.json +5636 -0
- package/backend/package.json +28 -0
- package/backend/routes/issues.js +246 -0
- package/backend/routes/projects.js +35 -0
- package/backend/service/jiraService.js +526 -0
- package/bin/jira.js +92 -0
- package/frontend/.env.example +1 -0
- package/frontend/coverage/base.css +224 -0
- package/frontend/coverage/block-navigation.js +87 -0
- package/frontend/coverage/clover.xml +559 -0
- package/frontend/coverage/components/CreateIssueModal.jsx.html +592 -0
- package/frontend/coverage/components/IssueDetailPanel.jsx.html +1633 -0
- package/frontend/coverage/components/IssueDrawer.jsx.html +550 -0
- package/frontend/coverage/components/IssueTable.jsx.html +571 -0
- package/frontend/coverage/components/SkeletonComponents.jsx.html +223 -0
- package/frontend/coverage/components/ToastContainer.jsx.html +142 -0
- package/frontend/coverage/components/index.html +191 -0
- package/frontend/coverage/coverage-final.json +14 -0
- package/frontend/coverage/favicon.png +0 -0
- package/frontend/coverage/hooks/index.html +161 -0
- package/frontend/coverage/hooks/useFocusTrap.js.html +262 -0
- package/frontend/coverage/hooks/useIssueDrawer.js.html +1000 -0
- package/frontend/coverage/hooks/useIssuesList.js.html +175 -0
- package/frontend/coverage/hooks/useToasts.js.html +142 -0
- package/frontend/coverage/index.html +161 -0
- package/frontend/coverage/prettify.css +1 -0
- package/frontend/coverage/prettify.js +2 -0
- package/frontend/coverage/services/api.js.html +547 -0
- package/frontend/coverage/services/index.html +116 -0
- package/frontend/coverage/sort-arrow-sprite.png +0 -0
- package/frontend/coverage/sorter.js +210 -0
- package/frontend/coverage/utils/index.html +131 -0
- package/frontend/coverage/utils/issueHelpers.jsx.html +334 -0
- package/frontend/coverage/utils/sanitize.js.html +166 -0
- package/frontend/index.html +13 -0
- package/frontend/package-lock.json +3436 -0
- package/frontend/package.json +30 -0
- package/frontend/src/App.jsx +447 -0
- package/frontend/src/__tests__/components/CreateIssueModal.test.jsx +375 -0
- package/frontend/src/__tests__/components/IssueDetailPanel.test.jsx +962 -0
- package/frontend/src/__tests__/components/IssueDrawer.test.jsx +240 -0
- package/frontend/src/__tests__/components/IssueTable.test.jsx +423 -0
- package/frontend/src/__tests__/components/ToastContainer.test.jsx +196 -0
- package/frontend/src/__tests__/hooks/useFocusTrap.test.js +197 -0
- package/frontend/src/__tests__/hooks/useIssueDrawer.test.js +1053 -0
- package/frontend/src/__tests__/hooks/useIssuesList.test.js +175 -0
- package/frontend/src/__tests__/hooks/useToasts.test.js +110 -0
- package/frontend/src/__tests__/services/api.test.js +568 -0
- package/frontend/src/__tests__/setup.js +54 -0
- package/frontend/src/__tests__/utils/issueHelpers.test.jsx +336 -0
- package/frontend/src/__tests__/utils/sanitize.test.js +238 -0
- package/frontend/src/components/CreateIssueModal.jsx +169 -0
- package/frontend/src/components/ErrorBoundary.jsx +52 -0
- package/frontend/src/components/IssueDetailPanel.jsx +517 -0
- package/frontend/src/components/IssueDrawer.jsx +155 -0
- package/frontend/src/components/IssueTable.jsx +162 -0
- package/frontend/src/components/SkeletonComponents.jsx +46 -0
- package/frontend/src/components/StandaloneIssuePage.jsx +176 -0
- package/frontend/src/components/ToastContainer.jsx +19 -0
- package/frontend/src/hooks/useFocusTrap.js +59 -0
- package/frontend/src/hooks/useIssueDrawer.js +305 -0
- package/frontend/src/hooks/useIssuesList.js +30 -0
- package/frontend/src/hooks/useToasts.js +19 -0
- package/frontend/src/index.css +2070 -0
- package/frontend/src/main.jsx +13 -0
- package/frontend/src/services/api.js +154 -0
- package/frontend/src/utils/issueHelpers.jsx +84 -0
- package/frontend/src/utils/sanitize.js +27 -0
- package/frontend/vite.config.js +15 -0
- package/package.json +19 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import React, { Inbox } from 'react';
|
|
2
|
+
import { getIssueTypeIcon, getStatusClass, getSeverityIcon, formatSeverity, getInitials, SEVERITY_FIELD } from '../utils/issueHelpers.jsx';
|
|
3
|
+
|
|
4
|
+
function LoadingSkeleton() {
|
|
5
|
+
return (
|
|
6
|
+
<div className="table-container">
|
|
7
|
+
<table className="issue-table">
|
|
8
|
+
<thead>
|
|
9
|
+
<tr>
|
|
10
|
+
<th>Key</th>
|
|
11
|
+
<th>Summary</th>
|
|
12
|
+
<th>Status</th>
|
|
13
|
+
<th>Assignee</th>
|
|
14
|
+
<th>Severity</th>
|
|
15
|
+
</tr>
|
|
16
|
+
</thead>
|
|
17
|
+
<tbody>
|
|
18
|
+
{[...Array(5)].map((_, i) => (
|
|
19
|
+
<tr key={i} className="skeleton-row">
|
|
20
|
+
<td><div className="skeleton skeleton-cell" style={{ maxWidth: '100px' }}></div></td>
|
|
21
|
+
<td><div className="skeleton skeleton-cell"></div></td>
|
|
22
|
+
<td><div className="skeleton skeleton-cell" style={{ maxWidth: '100px' }}></div></td>
|
|
23
|
+
<td><div className="skeleton skeleton-avatar"></div></td>
|
|
24
|
+
<td><div className="skeleton skeleton-cell" style={{ maxWidth: '80px' }}></div></td>
|
|
25
|
+
</tr>
|
|
26
|
+
))}
|
|
27
|
+
</tbody>
|
|
28
|
+
</table>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function EmptyState() {
|
|
34
|
+
return (
|
|
35
|
+
<div className="empty-state">
|
|
36
|
+
<Inbox size={48} className="empty-state-icon" />
|
|
37
|
+
<h3>No issues found</h3>
|
|
38
|
+
<p>Try adjusting your filters or create a new issue</p>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default React.memo(function IssueTable({ issues, loading, onRowClick, currentPage = 0, pageSize = 25, total, onPageChange }) {
|
|
44
|
+
const startItem = total > 0 ? currentPage * pageSize + 1 : 0;
|
|
45
|
+
const endItem = Math.min((currentPage + 1) * pageSize, total);
|
|
46
|
+
|
|
47
|
+
if (loading) {
|
|
48
|
+
return <LoadingSkeleton />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!loading && issues.length === 0) {
|
|
52
|
+
return <EmptyState />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="table-container">
|
|
57
|
+
<table className="issue-table" role="grid">
|
|
58
|
+
<thead>
|
|
59
|
+
<tr>
|
|
60
|
+
<th>Key</th>
|
|
61
|
+
<th>Summary</th>
|
|
62
|
+
<th>Status</th>
|
|
63
|
+
<th>Assignee</th>
|
|
64
|
+
<th>Severity</th>
|
|
65
|
+
</tr>
|
|
66
|
+
</thead>
|
|
67
|
+
<tbody>
|
|
68
|
+
{[...issues]
|
|
69
|
+
.sort((a, b) => {
|
|
70
|
+
const aStatus = a.fields.status?.name?.toLowerCase() || '';
|
|
71
|
+
const bStatus = b.fields.status?.name?.toLowerCase() || '';
|
|
72
|
+
if (aStatus === 'backlog' && bStatus !== 'backlog') return 1;
|
|
73
|
+
if (aStatus !== 'backlog' && bStatus === 'backlog') return -1;
|
|
74
|
+
return 0;
|
|
75
|
+
})
|
|
76
|
+
.map((issue) => (
|
|
77
|
+
<tr
|
|
78
|
+
key={issue.id}
|
|
79
|
+
className="clickable-row"
|
|
80
|
+
onClick={() => onRowClick(issue.key)}
|
|
81
|
+
role="row"
|
|
82
|
+
tabIndex={0}
|
|
83
|
+
onKeyDown={(e) => e.key === 'Enter' && onRowClick(issue.key)}
|
|
84
|
+
>
|
|
85
|
+
<td>
|
|
86
|
+
<span className="issue-key">
|
|
87
|
+
{getIssueTypeIcon(issue.fields.issuetype?.name)}
|
|
88
|
+
{issue.key}
|
|
89
|
+
</span>
|
|
90
|
+
</td>
|
|
91
|
+
<td>
|
|
92
|
+
<span className="issue-summary" title={issue.fields.summary}>
|
|
93
|
+
{issue.fields.summary}
|
|
94
|
+
</span>
|
|
95
|
+
</td>
|
|
96
|
+
<td>
|
|
97
|
+
<span className={`status-badge ${getStatusClass(issue.fields.status?.name)}`}>
|
|
98
|
+
{issue.fields.status?.name || 'Unknown'}
|
|
99
|
+
</span>
|
|
100
|
+
</td>
|
|
101
|
+
<td>
|
|
102
|
+
<div className="assignee-cell">
|
|
103
|
+
{issue.fields.assignee ? (
|
|
104
|
+
<>
|
|
105
|
+
<span className="avatar">
|
|
106
|
+
{getInitials(issue.fields.assignee.displayName)}
|
|
107
|
+
</span>
|
|
108
|
+
<span>{issue.fields.assignee.displayName}</span>
|
|
109
|
+
</>
|
|
110
|
+
) : (
|
|
111
|
+
<>
|
|
112
|
+
<span className="avatar placeholder">
|
|
113
|
+
{getInitials('Unassigned')}
|
|
114
|
+
</span>
|
|
115
|
+
<span>Unassigned</span>
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
</td>
|
|
120
|
+
<td>
|
|
121
|
+
{issue.fields[SEVERITY_FIELD] ? (
|
|
122
|
+
<span className={`severity-badge ${(issue.fields[SEVERITY_FIELD].value || issue.fields[SEVERITY_FIELD].name || '').toLowerCase().replace(/\s+/g, '-')}`}>
|
|
123
|
+
{getSeverityIcon(issue.fields[SEVERITY_FIELD].value || issue.fields[SEVERITY_FIELD].name)}
|
|
124
|
+
{formatSeverity(issue.fields[SEVERITY_FIELD].value || issue.fields[SEVERITY_FIELD].name)}
|
|
125
|
+
</span>
|
|
126
|
+
) : (
|
|
127
|
+
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>—</span>
|
|
128
|
+
)}
|
|
129
|
+
</td>
|
|
130
|
+
</tr>
|
|
131
|
+
))}
|
|
132
|
+
</tbody>
|
|
133
|
+
</table>
|
|
134
|
+
{total !== undefined && total > 0 && (
|
|
135
|
+
<div className="pagination-bar">
|
|
136
|
+
<span className="pagination-info">
|
|
137
|
+
Showing {startItem}–{endItem} of {total}
|
|
138
|
+
</span>
|
|
139
|
+
<div className="pagination-controls">
|
|
140
|
+
<button
|
|
141
|
+
className="pagination-btn"
|
|
142
|
+
onClick={() => onPageChange(currentPage - 1)}
|
|
143
|
+
disabled={currentPage === 0}
|
|
144
|
+
>
|
|
145
|
+
Previous
|
|
146
|
+
</button>
|
|
147
|
+
<span className="pagination-page">Page {currentPage + 1}</span>
|
|
148
|
+
<button
|
|
149
|
+
className="pagination-btn"
|
|
150
|
+
onClick={() => onPageChange(currentPage + 1)}
|
|
151
|
+
disabled={endItem >= total}
|
|
152
|
+
>
|
|
153
|
+
Next
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
export { getIssueTypeIcon, getStatusClass, getSeverityIcon, formatSeverity, getInitials, SEVERITY_FIELD } from '../utils/issueHelpers.jsx';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export function SkeletonTitle({ width = '60%' }) {
|
|
4
|
+
return <div className="skeleton skeleton-title" style={{ width }} />;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function SkeletonText({ lines = 3, lastLineWidth = '60%' }) {
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
11
|
+
<div
|
|
12
|
+
key={i}
|
|
13
|
+
className="skeleton skeleton-line"
|
|
14
|
+
style={{ width: i === lines - 1 ? lastLineWidth : '100%' }}
|
|
15
|
+
/>
|
|
16
|
+
))}
|
|
17
|
+
</>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SkeletonAvatar({ size = 32 }) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className="skeleton skeleton-avatar"
|
|
25
|
+
style={{ width: size, height: size }}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function SkeletonChip({ width = 80 }) {
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className="skeleton skeleton-chip"
|
|
34
|
+
style={{ width }}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function SkeletonSidebarItem() {
|
|
40
|
+
return (
|
|
41
|
+
<div className="skeleton-sidebar-item">
|
|
42
|
+
<div className="skeleton" style={{ height: 14, width: '30%', marginBottom: 4 }} />
|
|
43
|
+
<div className="skeleton" style={{ height: 20, width: '50%' }} />
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useIssueDrawer } from '../hooks/useIssueDrawer';
|
|
3
|
+
import IssueDetailPanel from './IssueDetailPanel';
|
|
4
|
+
import { useToasts } from '../hooks/useToasts';
|
|
5
|
+
import {
|
|
6
|
+
X, Loader2, AlertCircle, Link2, Calendar, Package,
|
|
7
|
+
PlusCircle, ArrowLeft
|
|
8
|
+
} from 'lucide-react';
|
|
9
|
+
|
|
10
|
+
export default function StandaloneIssuePage() {
|
|
11
|
+
const [issueKey, setIssueKey] = useState(null);
|
|
12
|
+
const { toasts, addToast, removeToast } = useToasts();
|
|
13
|
+
const {
|
|
14
|
+
issueDetail,
|
|
15
|
+
comments,
|
|
16
|
+
transitions,
|
|
17
|
+
assignableUsers,
|
|
18
|
+
drawerLoading,
|
|
19
|
+
drawerError,
|
|
20
|
+
newCommentText,
|
|
21
|
+
setNewCommentText,
|
|
22
|
+
isPostingComment,
|
|
23
|
+
targetTransitionId,
|
|
24
|
+
setTargetTransitionId,
|
|
25
|
+
isTransitioning,
|
|
26
|
+
assigneeSearch,
|
|
27
|
+
setAssigneeSearch,
|
|
28
|
+
targetAccountId,
|
|
29
|
+
setTargetAccountId,
|
|
30
|
+
isAssigning,
|
|
31
|
+
projectVersions,
|
|
32
|
+
isUpdatingVersions,
|
|
33
|
+
newLabelText,
|
|
34
|
+
setNewLabelText,
|
|
35
|
+
labelSuggestions,
|
|
36
|
+
showLabelSuggestions,
|
|
37
|
+
setShowLabelSuggestions,
|
|
38
|
+
isUpdatingLabels,
|
|
39
|
+
isUploading,
|
|
40
|
+
uploadMessage,
|
|
41
|
+
handlePostComment,
|
|
42
|
+
handleAssignUser,
|
|
43
|
+
handleFileUpload,
|
|
44
|
+
handleUpdateVersion,
|
|
45
|
+
handleAddLabel,
|
|
46
|
+
handleRemoveLabel,
|
|
47
|
+
handleUpdateStatus
|
|
48
|
+
} = useIssueDrawer(issueKey, addToast);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const loadFromHash = () => {
|
|
52
|
+
const hash = window.location.hash;
|
|
53
|
+
if (hash.startsWith('#/view/')) {
|
|
54
|
+
const key = hash.replace('#/view/', '');
|
|
55
|
+
if (key && key !== issueKey) {
|
|
56
|
+
setIssueKey(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
loadFromHash();
|
|
62
|
+
window.addEventListener('hashchange', loadFromHash);
|
|
63
|
+
window.addEventListener('popstate', loadFromHash);
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
window.removeEventListener('hashchange', loadFromHash);
|
|
67
|
+
window.removeEventListener('popstate', loadFromHash);
|
|
68
|
+
};
|
|
69
|
+
}, [issueKey]);
|
|
70
|
+
|
|
71
|
+
if (drawerLoading) {
|
|
72
|
+
return (
|
|
73
|
+
<div className="standalone-page">
|
|
74
|
+
<div className="standalone-issue-container">
|
|
75
|
+
<header className="standalone-header">
|
|
76
|
+
<a href="/" className="standalone-back-btn">
|
|
77
|
+
<ArrowLeft size={18} />
|
|
78
|
+
Back to Dashboard
|
|
79
|
+
</a>
|
|
80
|
+
</header>
|
|
81
|
+
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
82
|
+
<Loader2 size={32} style={{ animation: 'spin 1s linear infinite' }} />
|
|
83
|
+
<span style={{ marginLeft: '12px', fontSize: '16px', color: '#666' }}>Loading issue...</span>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (drawerError || !issueDetail) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="standalone-page">
|
|
93
|
+
<div className="standalone-issue-container">
|
|
94
|
+
<header className="standalone-header">
|
|
95
|
+
<a href="/" className="standalone-back-btn">
|
|
96
|
+
<ArrowLeft size={18} />
|
|
97
|
+
Back to Dashboard
|
|
98
|
+
</a>
|
|
99
|
+
</header>
|
|
100
|
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '16px' }}>
|
|
101
|
+
<AlertCircle size={48} style={{ color: '#de350b' }} />
|
|
102
|
+
<h2 style={{ margin: 0, color: 'var(--text-main)' }}>{drawerError || 'Issue not found'}</h2>
|
|
103
|
+
<a href="/" className="btn-primary" style={{ textDecoration: 'none', padding: '12px 24px' }}>
|
|
104
|
+
Back to Dashboard
|
|
105
|
+
</a>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="standalone-page">
|
|
114
|
+
<div className="standalone-issue-container">
|
|
115
|
+
<header className="standalone-header">
|
|
116
|
+
<a href="/" className="standalone-back-btn">
|
|
117
|
+
<ArrowLeft size={18} />
|
|
118
|
+
Back to Dashboard
|
|
119
|
+
</a>
|
|
120
|
+
<div className="drawer-header-actions">
|
|
121
|
+
<button
|
|
122
|
+
className="action-btn"
|
|
123
|
+
onClick={() => {
|
|
124
|
+
navigator.clipboard.writeText(window.location.href);
|
|
125
|
+
addToast('Link copied to clipboard', 'success');
|
|
126
|
+
}}
|
|
127
|
+
aria-label="Copy link"
|
|
128
|
+
>
|
|
129
|
+
<Link2 size={18} />
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</header>
|
|
133
|
+
|
|
134
|
+
<div className="drawer-body">
|
|
135
|
+
<IssueDetailPanel
|
|
136
|
+
mode="standalone"
|
|
137
|
+
issueDetail={issueDetail}
|
|
138
|
+
comments={comments}
|
|
139
|
+
transitions={transitions}
|
|
140
|
+
assignableUsers={assignableUsers}
|
|
141
|
+
projectVersions={projectVersions}
|
|
142
|
+
drawerLoading={drawerLoading}
|
|
143
|
+
drawerError={drawerError}
|
|
144
|
+
newCommentText={newCommentText}
|
|
145
|
+
setNewCommentText={setNewCommentText}
|
|
146
|
+
isPostingComment={isPostingComment}
|
|
147
|
+
targetTransitionId={targetTransitionId}
|
|
148
|
+
setTargetTransitionId={setTargetTransitionId}
|
|
149
|
+
isTransitioning={isTransitioning}
|
|
150
|
+
assigneeSearch={assigneeSearch}
|
|
151
|
+
setAssigneeSearch={setAssigneeSearch}
|
|
152
|
+
targetAccountId={targetAccountId}
|
|
153
|
+
setTargetAccountId={setTargetAccountId}
|
|
154
|
+
isAssigning={isAssigning}
|
|
155
|
+
isUpdatingVersions={isUpdatingVersions}
|
|
156
|
+
newLabelText={newLabelText}
|
|
157
|
+
setNewLabelText={setNewLabelText}
|
|
158
|
+
labelSuggestions={labelSuggestions}
|
|
159
|
+
showLabelSuggestions={showLabelSuggestions}
|
|
160
|
+
setShowLabelSuggestions={setShowLabelSuggestions}
|
|
161
|
+
isUpdatingLabels={isUpdatingLabels}
|
|
162
|
+
isUploading={isUploading}
|
|
163
|
+
uploadMessage={uploadMessage}
|
|
164
|
+
handlePostComment={handlePostComment}
|
|
165
|
+
handleAssignUser={handleAssignUser}
|
|
166
|
+
handleFileUpload={handleFileUpload}
|
|
167
|
+
handleUpdateVersion={handleUpdateVersion}
|
|
168
|
+
handleAddLabel={handleAddLabel}
|
|
169
|
+
handleRemoveLabel={handleRemoveLabel}
|
|
170
|
+
handleUpdateStatus={handleUpdateStatus}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CheckCircle, AlertCircle, Info, X } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
export default function ToastContainer({ toasts, onRemove }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="toast-container" aria-live="polite">
|
|
6
|
+
{toasts.map(toast => (
|
|
7
|
+
<div key={toast.id} className={`toast ${toast.type}`} role="alert">
|
|
8
|
+
{toast.type === 'success' && <CheckCircle size={18} />}
|
|
9
|
+
{toast.type === 'error' && <AlertCircle size={18} />}
|
|
10
|
+
{toast.type === 'info' && <Info size={18} />}
|
|
11
|
+
<span>{toast.message}</span>
|
|
12
|
+
<button className="toast-close" onClick={() => onRemove(toast.id)} aria-label="Dismiss">
|
|
13
|
+
<X size={16} />
|
|
14
|
+
</button>
|
|
15
|
+
</div>
|
|
16
|
+
))}
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const FOCUSABLE_SELECTORS = [
|
|
4
|
+
'a[href]',
|
|
5
|
+
'button:not([disabled])',
|
|
6
|
+
'textarea:not([disabled])',
|
|
7
|
+
'input:not([disabled])',
|
|
8
|
+
'select:not([disabled])',
|
|
9
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
10
|
+
].join(', ');
|
|
11
|
+
|
|
12
|
+
export function useFocusTrap(containerRef, isActive) {
|
|
13
|
+
const previousActiveElement = useRef(null);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!isActive || !containerRef.current) return;
|
|
17
|
+
|
|
18
|
+
const container = containerRef.current;
|
|
19
|
+
previousActiveElement.current = document.activeElement;
|
|
20
|
+
|
|
21
|
+
const focusableElements = container.querySelectorAll(FOCUSABLE_SELECTORS);
|
|
22
|
+
const firstElement = focusableElements[0];
|
|
23
|
+
|
|
24
|
+
if (firstElement) {
|
|
25
|
+
firstElement.focus();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const handleKeyDown = (e) => {
|
|
29
|
+
if (e.key !== 'Tab') return;
|
|
30
|
+
|
|
31
|
+
const focusableInTrap = container.querySelectorAll(FOCUSABLE_SELECTORS);
|
|
32
|
+
if (focusableInTrap.length === 0) return;
|
|
33
|
+
|
|
34
|
+
const first = focusableInTrap[0];
|
|
35
|
+
const last = focusableInTrap[focusableInTrap.length - 1];
|
|
36
|
+
|
|
37
|
+
if (e.shiftKey) {
|
|
38
|
+
if (document.activeElement === first) {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
last.focus();
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
if (document.activeElement === last) {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
first.focus();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
54
|
+
if (previousActiveElement.current && previousActiveElement.current.focus) {
|
|
55
|
+
previousActiveElement.current.focus();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}, [isActive, containerRef]);
|
|
59
|
+
}
|