feat: complete LOW priority code quality improvements
Backend: - LOW-002: Add Query validation with max page size limits (100) - LOW-003: Replace magic strings with TaskStatus.is_done flag - LOW-004: Add 'creation' trigger type validation - Add action_executor.py with UpdateFieldAction and AutoAssignAction Frontend: - LOW-005: Replace TypeScript 'any' with 'unknown' + type guards - LOW-006: Add ConfirmModal component with A11Y support - LOW-007: Add ToastContext for user feedback notifications - LOW-009: Add Skeleton components (17 loading states replaced) - LOW-010: Setup Vitest with 21 tests for ConfirmModal and Skeleton Components updated: - App.tsx, ProtectedRoute.tsx, Spaces.tsx, Projects.tsx, Tasks.tsx - ProjectSettings.tsx, AuditPage.tsx, WorkloadPage.tsx, ProjectHealthPage.tsx - Comments.tsx, AttachmentList.tsx, TriggerList.tsx, TaskDetailModal.tsx - NotificationBell.tsx, BlockerDialog.tsx, CalendarView.tsx, WorkloadUserDetail.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
import { Skeleton } from './components/Skeleton'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Spaces from './pages/Spaces'
|
||||
@@ -16,7 +17,12 @@ function App() {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return <div className="container">Loading...</div>
|
||||
return (
|
||||
<div className="container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<Skeleton variant="rect" width="100%" height={60} style={{ marginBottom: '24px' }} />
|
||||
<Skeleton variant="rect" width="100%" height={400} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { attachmentService, Attachment } from '../services/attachments'
|
||||
import { AttachmentVersionHistory } from './AttachmentVersionHistory'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface AttachmentListProps {
|
||||
taskId: string
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
interface VersionHistoryState {
|
||||
attachmentId: string
|
||||
currentVersion: number
|
||||
filename: string
|
||||
}
|
||||
|
||||
export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
const { showToast } = useToast()
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
const [versionHistory, setVersionHistory] = useState<VersionHistoryState | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Attachment | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAttachments()
|
||||
}, [taskId])
|
||||
|
||||
const loadAttachments = async () => {
|
||||
const loadAttachments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await attachmentService.listAttachments(taskId)
|
||||
@@ -25,37 +34,59 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [taskId])
|
||||
|
||||
useEffect(() => {
|
||||
loadAttachments()
|
||||
}, [loadAttachments])
|
||||
|
||||
const handleDownload = async (attachment: Attachment) => {
|
||||
try {
|
||||
await attachmentService.downloadAttachment(attachment.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to download attachment:', error)
|
||||
alert('Failed to download file')
|
||||
showToast('Failed to download file', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (attachment: Attachment) => {
|
||||
if (!confirm(`Are you sure you want to delete "${attachment.filename}"?`)) {
|
||||
return
|
||||
}
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteConfirm) return
|
||||
|
||||
const attachment = deleteConfirm
|
||||
setDeleteConfirm(null)
|
||||
setDeleting(attachment.id)
|
||||
try {
|
||||
await attachmentService.deleteAttachment(attachment.id)
|
||||
setAttachments(prev => prev.filter(a => a.id !== attachment.id))
|
||||
onRefresh?.()
|
||||
showToast('File deleted successfully', 'success')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete attachment:', error)
|
||||
alert('Failed to delete file')
|
||||
showToast('Failed to delete file', 'error')
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenHistory = (attachment: Attachment) => {
|
||||
setVersionHistory({
|
||||
attachmentId: attachment.id,
|
||||
currentVersion: attachment.current_version,
|
||||
filename: attachment.filename,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseHistory = () => {
|
||||
setVersionHistory(null)
|
||||
}
|
||||
|
||||
const handleVersionRestored = () => {
|
||||
loadAttachments()
|
||||
onRefresh?.()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading attachments...</div>
|
||||
return <SkeletonList count={2} />
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
@@ -84,6 +115,15 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.actions}>
|
||||
{attachment.current_version > 1 && (
|
||||
<button
|
||||
style={styles.historyBtn}
|
||||
onClick={() => handleOpenHistory(attachment)}
|
||||
title="Version History"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={styles.downloadBtn}
|
||||
onClick={() => handleDownload(attachment)}
|
||||
@@ -93,7 +133,7 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
</button>
|
||||
<button
|
||||
style={styles.deleteBtn}
|
||||
onClick={() => handleDelete(attachment)}
|
||||
onClick={() => setDeleteConfirm(attachment)}
|
||||
disabled={deleting === attachment.id}
|
||||
title="Delete"
|
||||
>
|
||||
@@ -102,6 +142,28 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{versionHistory && (
|
||||
<AttachmentVersionHistory
|
||||
attachmentId={versionHistory.attachmentId}
|
||||
currentVersion={versionHistory.currentVersion}
|
||||
filename={versionHistory.filename}
|
||||
isOpen={true}
|
||||
onClose={handleCloseHistory}
|
||||
onRestore={handleVersionRestored}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm !== null}
|
||||
title="Delete File"
|
||||
message={`Are you sure you want to delete "${deleteConfirm?.filename}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmStyle="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -163,6 +225,15 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
},
|
||||
historyBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
downloadBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#007bff',
|
||||
|
||||
437
frontend/src/components/AttachmentVersionHistory.tsx
Normal file
437
frontend/src/components/AttachmentVersionHistory.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { attachmentService, AttachmentVersion, VersionHistoryResponse } from '../services/attachments'
|
||||
|
||||
interface AttachmentVersionHistoryProps {
|
||||
attachmentId: string
|
||||
currentVersion: number
|
||||
filename: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onRestore: () => void
|
||||
}
|
||||
|
||||
export function AttachmentVersionHistory({
|
||||
attachmentId,
|
||||
currentVersion,
|
||||
filename,
|
||||
isOpen,
|
||||
onClose,
|
||||
onRestore,
|
||||
}: AttachmentVersionHistoryProps) {
|
||||
const [versions, setVersions] = useState<AttachmentVersion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [restoring, setRestoring] = useState<number | null>(null)
|
||||
const [confirmRestore, setConfirmRestore] = useState<number | null>(null)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const loadVersionHistory = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response: VersionHistoryResponse = await attachmentService.getVersionHistory(attachmentId)
|
||||
setVersions(response.versions)
|
||||
} catch (err) {
|
||||
console.error('Failed to load version history:', err)
|
||||
setError('Failed to load version history')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [attachmentId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadVersionHistory()
|
||||
}
|
||||
}, [isOpen, loadVersionHistory])
|
||||
|
||||
const handleRestore = async (version: number) => {
|
||||
setRestoring(version)
|
||||
setError(null)
|
||||
try {
|
||||
await attachmentService.restoreVersion(attachmentId, version)
|
||||
setConfirmRestore(null)
|
||||
onRestore()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to restore version:', err)
|
||||
setError('Failed to restore version. Please try again.')
|
||||
} finally {
|
||||
setRestoring(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadVersion = async (version: number) => {
|
||||
try {
|
||||
await attachmentService.downloadAttachment(attachmentId, version)
|
||||
} catch (err) {
|
||||
console.error('Failed to download version:', err)
|
||||
setError('Failed to download version')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="version-history-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerContent}>
|
||||
<h3 id="version-history-title" style={styles.title}>Version History</h3>
|
||||
<p style={styles.subtitle}>{filename}</p>
|
||||
</div>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.content}>
|
||||
{error && (
|
||||
<div style={styles.error}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={styles.loading}>Loading version history...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div style={styles.empty}>No version history available</div>
|
||||
) : (
|
||||
<div style={styles.versionList}>
|
||||
{versions.map((version) => {
|
||||
const isCurrent = version.version === currentVersion
|
||||
const isConfirming = confirmRestore === version.version
|
||||
const isRestoring = restoring === version.version
|
||||
|
||||
return (
|
||||
<div
|
||||
key={version.id}
|
||||
style={{
|
||||
...styles.versionItem,
|
||||
...(isCurrent ? styles.currentVersionItem : {}),
|
||||
}}
|
||||
>
|
||||
<div style={styles.versionInfo}>
|
||||
<div style={styles.versionHeader}>
|
||||
<span style={styles.versionNumber}>
|
||||
Version {version.version}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span style={styles.currentBadge}>Current</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.versionMeta}>
|
||||
<span>{formatDate(version.created_at)}</span>
|
||||
<span style={styles.separator}>|</span>
|
||||
<span>{attachmentService.formatFileSize(version.file_size)}</span>
|
||||
{version.uploader_name && (
|
||||
<>
|
||||
<span style={styles.separator}>|</span>
|
||||
<span>by {version.uploader_name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.versionActions}>
|
||||
<button
|
||||
style={styles.downloadVersionBtn}
|
||||
onClick={() => handleDownloadVersion(version.version)}
|
||||
title="Download this version"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
|
||||
{!isCurrent && (
|
||||
<>
|
||||
{isConfirming ? (
|
||||
<div style={styles.confirmGroup}>
|
||||
<span style={styles.confirmText}>Restore?</span>
|
||||
<button
|
||||
style={styles.confirmYesBtn}
|
||||
onClick={() => handleRestore(version.version)}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
{isRestoring ? '...' : 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
style={styles.confirmNoBtn}
|
||||
onClick={() => setConfirmRestore(null)}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
style={styles.restoreBtn}
|
||||
onClick={() => setConfirmRestore(version.version)}
|
||||
title="Restore to this version"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.footer}>
|
||||
<button onClick={onClose} style={styles.closeFooterBtn}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1100,
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
width: '90%',
|
||||
maxWidth: '600px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
headerContent: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#212529',
|
||||
},
|
||||
subtitle: {
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '14px',
|
||||
color: '#6c757d',
|
||||
},
|
||||
closeButton: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px 24px',
|
||||
},
|
||||
error: {
|
||||
padding: '12px',
|
||||
backgroundColor: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '16px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
loading: {
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
},
|
||||
empty: {
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
},
|
||||
versionList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
},
|
||||
versionItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '14px 16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e9ecef',
|
||||
},
|
||||
currentVersionItem: {
|
||||
backgroundColor: '#e8f4fd',
|
||||
borderColor: '#b8daff',
|
||||
},
|
||||
versionInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
versionHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
versionNumber: {
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#212529',
|
||||
},
|
||||
currentBadge: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
},
|
||||
versionMeta: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
alignItems: 'center',
|
||||
},
|
||||
separator: {
|
||||
color: '#adb5bd',
|
||||
},
|
||||
versionActions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginLeft: '16px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
downloadVersionBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
restoreBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
confirmGroup: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
},
|
||||
confirmText: {
|
||||
fontSize: '12px',
|
||||
color: '#495057',
|
||||
fontWeight: 500,
|
||||
},
|
||||
confirmYesBtn: {
|
||||
padding: '4px 10px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
confirmNoBtn: {
|
||||
padding: '4px 10px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
footer: {
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #eee',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
closeFooterBtn: {
|
||||
padding: '8px 20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
color: '#495057',
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}
|
||||
|
||||
export default AttachmentVersionHistory
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { blockersApi, Blocker } from '../services/collaboration'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface BlockerDialogProps {
|
||||
taskId: string
|
||||
@@ -104,7 +105,9 @@ export function BlockerDialog({
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-4">Loading...</div>
|
||||
<div className="py-2">
|
||||
<SkeletonList count={2} showAvatar={false} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Active blocker */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import { EventClickArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'
|
||||
import api from '../services/api'
|
||||
import { Skeleton } from './Skeleton'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -354,7 +355,7 @@ export function CalendarView({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loading && <span style={styles.loadingIndicator}>Loading...</span>}
|
||||
{loading && <Skeleton variant="text" width={80} height={20} />}
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { commentsApi, usersApi, Comment, UserSearchResult } from '../services/collaboration'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface CommentsProps {
|
||||
taskId: string
|
||||
@@ -18,6 +20,7 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [mentionSuggestions, setMentionSuggestions] = useState<UserSearchResult[]>([])
|
||||
const [showMentions, setShowMentions] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
const fetchComments = useCallback(async () => {
|
||||
try {
|
||||
@@ -71,10 +74,10 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
}
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
if (!confirm('Delete this comment?')) return
|
||||
try {
|
||||
await commentsApi.delete(commentId)
|
||||
await fetchComments()
|
||||
setDeleteConfirm(null)
|
||||
} catch (err) {
|
||||
setError('Failed to delete comment')
|
||||
}
|
||||
@@ -112,7 +115,7 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
setShowMentions(false)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4 text-gray-500">Loading comments...</div>
|
||||
if (loading) return <SkeletonList count={3} showAvatar />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -148,7 +151,7 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
onClick={() => setDeleteConfirm(comment.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Delete
|
||||
@@ -253,6 +256,17 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
{submitting ? 'Posting...' : 'Post Comment'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm !== null}
|
||||
title="Delete Comment"
|
||||
message="Are you sure you want to delete this comment? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmStyle="danger"
|
||||
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
75
frontend/src/components/ConfirmModal.test.tsx
Normal file
75
frontend/src/components/ConfirmModal.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
|
||||
describe('ConfirmModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
title: 'Confirm Action',
|
||||
message: 'Are you sure you want to proceed?',
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
render(<ConfirmModal {...defaultProps} isOpen={false} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders modal when isOpen is true', () => {
|
||||
render(<ConfirmModal {...defaultProps} />)
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument()
|
||||
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm when confirm button is clicked', () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<ConfirmModal {...defaultProps} onConfirm={onConfirm} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Confirm'))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<ConfirmModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when Escape key is pressed', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<ConfirmModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses custom button text', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
{...defaultProps}
|
||||
confirmText="Delete"
|
||||
cancelText="Go Back"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('Go Back')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders confirm button with danger style prop', () => {
|
||||
const { rerender } = render(<ConfirmModal {...defaultProps} confirmStyle="danger" />)
|
||||
|
||||
const dangerButton = screen.getByText('Confirm')
|
||||
// The danger button should be rendered
|
||||
expect(dangerButton).toBeInTheDocument()
|
||||
|
||||
// When rendered with primary style, the button should also be rendered
|
||||
rerender(<ConfirmModal {...defaultProps} confirmStyle="primary" />)
|
||||
const primaryButton = screen.getByText('Confirm')
|
||||
expect(primaryButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
155
frontend/src/components/ConfirmModal.tsx
Normal file
155
frontend/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
confirmStyle?: 'danger' | 'primary'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmStyle = 'danger',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
const confirmButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus confirm button when modal opens
|
||||
confirmButtonRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onCancel])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
aria-describedby="confirm-modal-message"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 id="confirm-modal-title" style={styles.title}>
|
||||
{title}
|
||||
</h2>
|
||||
<p id="confirm-modal-message" style={styles.message}>
|
||||
{message}
|
||||
</p>
|
||||
<div style={styles.actions}>
|
||||
<button onClick={onCancel} style={styles.cancelButton}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmButtonRef}
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
...styles.confirmButton,
|
||||
...(confirmStyle === 'danger' ? styles.dangerButton : styles.primaryButton),
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1200,
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
width: '400px',
|
||||
maxWidth: '90%',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
title: {
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#212529',
|
||||
},
|
||||
message: {
|
||||
margin: '0 0 24px 0',
|
||||
fontSize: '14px',
|
||||
color: '#495057',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '12px',
|
||||
},
|
||||
cancelButton: {
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
color: '#495057',
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
confirmButton: {
|
||||
padding: '10px 20px',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: '#dc3545',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: '#0066cc',
|
||||
},
|
||||
}
|
||||
|
||||
export default ConfirmModal
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
customFieldsApi,
|
||||
CustomField,
|
||||
@@ -38,6 +38,23 @@ export function CustomFieldEditor({
|
||||
const [formula, setFormula] = useState(field?.formula || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
modalOverlayRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// Reset form when field changes
|
||||
useEffect(() => {
|
||||
@@ -155,10 +172,18 @@ export function CustomFieldEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="custom-field-editor-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>
|
||||
<h2 id="custom-field-editor-title" style={styles.title}>
|
||||
{isEditing ? 'Edit Custom Field' : 'Create Custom Field'}
|
||||
</h2>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
|
||||
@@ -424,7 +449,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
},
|
||||
typeNote: {
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
optionsList: {
|
||||
|
||||
@@ -225,7 +225,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
},
|
||||
formulaHint: {
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
unsupported: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { customFieldsApi, CustomField, FieldType } from '../services/customFields'
|
||||
import { CustomFieldEditor } from './CustomFieldEditor'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
|
||||
interface CustomFieldListProps {
|
||||
projectId: string
|
||||
@@ -16,6 +17,7 @@ const FIELD_TYPE_LABELS: Record<FieldType, string> = {
|
||||
}
|
||||
|
||||
export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
||||
const { showToast } = useToast()
|
||||
const [fields, setFields] = useState<CustomField[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -79,13 +81,14 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
||||
await customFieldsApi.deleteCustomField(deleteConfirm)
|
||||
setDeleteConfirm(null)
|
||||
loadFields()
|
||||
showToast('Custom field deleted successfully', 'success')
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
|
||||
'Failed to delete field'
|
||||
alert(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -274,7 +277,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
},
|
||||
emptyHint: {
|
||||
fontSize: '13px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
marginTop: '8px',
|
||||
},
|
||||
fieldList: {
|
||||
|
||||
@@ -288,7 +288,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
color: '#666',
|
||||
},
|
||||
subtaskBadge: {
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
customValueBadge: {
|
||||
backgroundColor: '#f3e5f5',
|
||||
@@ -304,7 +304,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
emptyColumn: {
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontSize: '13px',
|
||||
border: '2px dashed #ddd',
|
||||
borderRadius: '6px',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNotifications } from '../contexts/NotificationContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
export function NotificationBell() {
|
||||
const { notifications, unreadCount, loading, fetchNotifications, markAsRead, markAllAsRead } =
|
||||
@@ -100,7 +101,9 @@ export function NotificationBell() {
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-gray-500">Loading...</div>
|
||||
<div className="p-2">
|
||||
<SkeletonList count={3} showAvatar={false} />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500">No notifications</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ReactNode } from 'react'
|
||||
import { Skeleton } from './Skeleton'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
@@ -10,7 +11,12 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return <div className="container">Loading...</div>
|
||||
return (
|
||||
<div className="container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<Skeleton variant="rect" width="100%" height={60} style={{ marginBottom: '24px' }} />
|
||||
<Skeleton variant="rect" width="100%" height={400} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
|
||||
@@ -41,11 +41,26 @@ export function ResourceHistory({ resourceType, resourceId, title = 'Change Hist
|
||||
return <div style={styles.empty}>No change history available</div>
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header} onClick={() => setExpanded(!expanded)}>
|
||||
<div
|
||||
style={styles.header}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${title}, ${logs.length} items`}
|
||||
>
|
||||
<span style={styles.title}>{title}</span>
|
||||
<span style={styles.toggleIcon}>{expanded ? '▼' : '▶'}</span>
|
||||
<span style={styles.toggleIcon} aria-hidden="true">{expanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div style={styles.content}>
|
||||
|
||||
106
frontend/src/components/Skeleton.test.tsx
Normal file
106
frontend/src/components/Skeleton.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Skeleton, SkeletonCard, SkeletonList, SkeletonTable, SkeletonGrid, SkeletonKanban } from './Skeleton'
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<Skeleton />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies text variant styles', () => {
|
||||
render(<Skeleton variant="text" />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveStyle({ borderRadius: '4px' })
|
||||
})
|
||||
|
||||
it('applies circle variant styles', () => {
|
||||
render(<Skeleton variant="circle" width={40} height={40} />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveStyle({ borderRadius: '50%' })
|
||||
})
|
||||
|
||||
it('applies custom dimensions', () => {
|
||||
render(<Skeleton width={200} height={100} />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveStyle({ width: '200px', height: '100px' })
|
||||
})
|
||||
|
||||
it('is hidden from screen readers', () => {
|
||||
render(<Skeleton />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonCard', () => {
|
||||
it('renders with default lines', () => {
|
||||
render(<SkeletonCard />)
|
||||
// Card should have 4 skeletons (1 title + 3 content lines by default)
|
||||
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(4)
|
||||
})
|
||||
|
||||
it('renders with custom number of lines', () => {
|
||||
render(<SkeletonCard lines={5} />)
|
||||
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(6) // title + 5 lines
|
||||
})
|
||||
|
||||
it('renders image placeholder when showImage is true', () => {
|
||||
render(<SkeletonCard showImage lines={2} />)
|
||||
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(4) // image + title + 2 lines
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonList', () => {
|
||||
it('renders default number of items', () => {
|
||||
const { container } = render(<SkeletonList />)
|
||||
// Each item has 2 skeletons, default count is 3
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(6)
|
||||
})
|
||||
|
||||
it('renders with avatars when showAvatar is true', () => {
|
||||
const { container } = render(<SkeletonList count={2} showAvatar />)
|
||||
// Each item has 3 skeletons (avatar + 2 text), count is 2
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonTable', () => {
|
||||
it('renders default rows and columns', () => {
|
||||
const { container } = render(<SkeletonTable />)
|
||||
// Header (4 columns) + 5 rows * 4 columns = 24 skeletons
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(24)
|
||||
})
|
||||
|
||||
it('renders with custom rows and columns', () => {
|
||||
const { container } = render(<SkeletonTable rows={3} columns={2} />)
|
||||
// Header (2 columns) + 3 rows * 2 columns = 8 skeletons
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonGrid', () => {
|
||||
it('renders default number of cards', () => {
|
||||
const { container } = render(<SkeletonGrid />)
|
||||
// 6 cards, each with 3 skeletons (title + 2 lines)
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonKanban', () => {
|
||||
it('renders kanban columns', () => {
|
||||
const { container } = render(<SkeletonKanban columns={3} />)
|
||||
// 3 columns, each with header + 2-3 cards
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBeGreaterThan(10)
|
||||
})
|
||||
})
|
||||
240
frontend/src/components/Skeleton.tsx
Normal file
240
frontend/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React from 'react'
|
||||
|
||||
interface SkeletonProps {
|
||||
variant?: 'text' | 'rect' | 'circle'
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
variant = 'rect',
|
||||
width = '100%',
|
||||
height,
|
||||
className = '',
|
||||
style,
|
||||
}: SkeletonProps) {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: variant === 'circle' ? '50%' : variant === 'text' ? '4px' : '8px',
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height:
|
||||
height !== undefined
|
||||
? typeof height === 'number'
|
||||
? `${height}px`
|
||||
: height
|
||||
: variant === 'text'
|
||||
? '1em'
|
||||
: '100px',
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
...style,
|
||||
}
|
||||
|
||||
return <div className={className} style={baseStyle} aria-hidden="true" />
|
||||
}
|
||||
|
||||
// Skeleton for card-like content
|
||||
interface SkeletonCardProps {
|
||||
lines?: number
|
||||
showImage?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonCard({ lines = 3, showImage = false, className = '' }: SkeletonCardProps) {
|
||||
return (
|
||||
<div className={className} style={cardStyles.container}>
|
||||
{showImage && <Skeleton variant="rect" height={120} style={{ marginBottom: '12px' }} />}
|
||||
<Skeleton variant="text" width="60%" height={20} style={{ marginBottom: '8px' }} />
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="text"
|
||||
width={i === lines - 1 ? '40%' : '100%'}
|
||||
height={14}
|
||||
style={{ marginBottom: '6px' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for list items
|
||||
interface SkeletonListProps {
|
||||
count?: number
|
||||
showAvatar?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonList({ count = 3, showAvatar = false, className = '' }: SkeletonListProps) {
|
||||
return (
|
||||
<div className={className} style={listStyles.container}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} style={listStyles.item}>
|
||||
{showAvatar && (
|
||||
<Skeleton variant="circle" width={40} height={40} style={{ marginRight: '12px' }} />
|
||||
)}
|
||||
<div style={listStyles.content}>
|
||||
<Skeleton variant="text" width="70%" height={16} style={{ marginBottom: '6px' }} />
|
||||
<Skeleton variant="text" width="40%" height={12} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for table rows
|
||||
interface SkeletonTableProps {
|
||||
rows?: number
|
||||
columns?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonTable({ rows = 5, columns = 4, className = '' }: SkeletonTableProps) {
|
||||
return (
|
||||
<div className={className} style={tableStyles.container}>
|
||||
{/* Header */}
|
||||
<div style={tableStyles.header}>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={i} variant="text" width="80%" height={14} />
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} style={tableStyles.row}>
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<Skeleton
|
||||
key={colIndex}
|
||||
variant="text"
|
||||
width={colIndex === 0 ? '90%' : '60%'}
|
||||
height={14}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for project/task cards in grid
|
||||
interface SkeletonGridProps {
|
||||
count?: number
|
||||
columns?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonGrid({ count = 6, columns = 3, className = '' }: SkeletonGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonCard key={i} lines={2} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for Kanban board
|
||||
export function SkeletonKanban({ columns = 4 }: { columns?: number }) {
|
||||
return (
|
||||
<div style={kanbanStyles.container}>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<div key={i} style={kanbanStyles.column}>
|
||||
<Skeleton variant="text" width="60%" height={20} style={{ marginBottom: '16px' }} />
|
||||
<SkeletonCard lines={2} />
|
||||
<SkeletonCard lines={2} />
|
||||
{i < 2 && <SkeletonCard lines={2} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cardStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
padding: '16px',
|
||||
backgroundColor: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
},
|
||||
}
|
||||
|
||||
const listStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
}
|
||||
|
||||
const tableStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '16px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
marginBottom: '2px',
|
||||
},
|
||||
row: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f9fafb',
|
||||
marginBottom: '2px',
|
||||
},
|
||||
}
|
||||
|
||||
const kanbanStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
overflowX: 'auto',
|
||||
},
|
||||
column: {
|
||||
minWidth: '280px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
},
|
||||
}
|
||||
|
||||
// CSS keyframes for animation (inject once)
|
||||
const styleId = 'skeleton-styles'
|
||||
if (typeof document !== 'undefined' && !document.getElementById(styleId)) {
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = `
|
||||
@keyframes skeleton-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
383
frontend/src/components/SubtaskList.tsx
Normal file
383
frontend/src/components/SubtaskList.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface Subtask {
|
||||
id: string
|
||||
title: string
|
||||
status_id: string | null
|
||||
status_name: string | null
|
||||
status_color: string | null
|
||||
assignee_id: string | null
|
||||
assignee_name: string | null
|
||||
priority: string
|
||||
}
|
||||
|
||||
interface SubtaskListProps {
|
||||
taskId: string
|
||||
projectId: string
|
||||
onSubtaskClick?: (subtaskId: string) => void
|
||||
onSubtaskCreated?: () => void
|
||||
}
|
||||
|
||||
export function SubtaskList({
|
||||
taskId,
|
||||
projectId,
|
||||
onSubtaskClick,
|
||||
onSubtaskCreated,
|
||||
}: SubtaskListProps) {
|
||||
const [subtasks, setSubtasks] = useState<Subtask[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const fetchSubtasks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.get(`/tasks/${taskId}/subtasks`)
|
||||
setSubtasks(response.data.tasks || [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch subtasks:', err)
|
||||
setError('Failed to load subtasks')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [taskId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubtasks()
|
||||
}, [fetchSubtasks])
|
||||
|
||||
const handleAddSubtask = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newSubtaskTitle.trim() || submitting) return
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
await api.post(`/projects/${projectId}/tasks`, {
|
||||
title: newSubtaskTitle.trim(),
|
||||
parent_task_id: taskId,
|
||||
})
|
||||
setNewSubtaskTitle('')
|
||||
setShowAddForm(false)
|
||||
fetchSubtasks()
|
||||
onSubtaskCreated?.()
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to create subtask:', err)
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } }
|
||||
const errorMessage = axiosError.response?.data?.detail || 'Failed to create subtask'
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelAdd = () => {
|
||||
setNewSubtaskTitle('')
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
const getPriorityColor = (priority: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
low: '#808080',
|
||||
medium: '#0066cc',
|
||||
high: '#ff9800',
|
||||
urgent: '#f44336',
|
||||
}
|
||||
return colors[priority] || colors.medium
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div
|
||||
style={styles.header}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Subtasks section, ${subtasks.length} items`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={styles.title}>Subtasks ({subtasks.length})</span>
|
||||
<span style={styles.toggleIcon}>{expanded ? '\u25BC' : '\u25B6'}</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={styles.content}>
|
||||
{loading ? (
|
||||
<div style={styles.loadingText}>Loading subtasks...</div>
|
||||
) : error ? (
|
||||
<div style={styles.errorText}>{error}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Subtask List */}
|
||||
{subtasks.length > 0 ? (
|
||||
<div style={styles.subtaskList}>
|
||||
{subtasks.map((subtask) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
style={styles.subtaskItem}
|
||||
onClick={() => onSubtaskClick?.(subtask.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSubtaskClick?.(subtask.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={styles.subtaskLeft}>
|
||||
<div
|
||||
style={{
|
||||
...styles.priorityDot,
|
||||
backgroundColor: getPriorityColor(subtask.priority),
|
||||
}}
|
||||
/>
|
||||
<span style={styles.subtaskTitle}>{subtask.title}</span>
|
||||
</div>
|
||||
<div style={styles.subtaskRight}>
|
||||
{subtask.status_name && (
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
backgroundColor: subtask.status_color || '#e0e0e0',
|
||||
}}
|
||||
>
|
||||
{subtask.status_name}
|
||||
</span>
|
||||
)}
|
||||
{subtask.assignee_name && (
|
||||
<span style={styles.assigneeName}>
|
||||
{subtask.assignee_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.emptyText}>No subtasks yet</div>
|
||||
)}
|
||||
|
||||
{/* Add Subtask Form */}
|
||||
{showAddForm ? (
|
||||
<form onSubmit={handleAddSubtask} style={styles.addForm}>
|
||||
<label htmlFor="new-subtask-title" style={styles.visuallyHidden}>
|
||||
Subtask title
|
||||
</label>
|
||||
<input
|
||||
id="new-subtask-title"
|
||||
type="text"
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
placeholder="Enter subtask title..."
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div style={styles.formActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelAdd}
|
||||
style={styles.cancelButton}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
style={styles.submitButton}
|
||||
disabled={!newSubtaskTitle.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
style={styles.addButton}
|
||||
>
|
||||
+ Add Subtask
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e9ecef',
|
||||
marginTop: '16px',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
},
|
||||
title: {
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#495057',
|
||||
},
|
||||
toggleIcon: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
},
|
||||
content: {
|
||||
borderTop: '1px solid #e9ecef',
|
||||
padding: '16px',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: '13px',
|
||||
color: '#6c757d',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: '13px',
|
||||
color: '#dc3545',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: '13px',
|
||||
color: '#6c757d',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
subtaskList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
subtaskItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e9ecef',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s ease',
|
||||
},
|
||||
subtaskLeft: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
priorityDot: {
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
},
|
||||
subtaskTitle: {
|
||||
fontSize: '14px',
|
||||
color: '#212529',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
subtaskRight: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
marginLeft: '12px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
statusBadge: {
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: 'white',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
assigneeName: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
maxWidth: '100px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
addForm: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: '14px',
|
||||
border: '1px solid #ced4da',
|
||||
borderRadius: '6px',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
},
|
||||
formActions: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
},
|
||||
cancelButton: {
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
submitButton: {
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
backgroundColor: '#0066cc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
addButton: {
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: '#0066cc',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px dashed #ced4da',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s ease',
|
||||
},
|
||||
}
|
||||
|
||||
export default SubtaskList
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import api from '../services/api'
|
||||
import { Comments } from './Comments'
|
||||
import { TaskAttachments } from './TaskAttachments'
|
||||
import { SubtaskList } from './SubtaskList'
|
||||
import { UserSelect } from './UserSelect'
|
||||
import { UserSearchResult } from '../services/collaboration'
|
||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||
import { CustomFieldInput } from './CustomFieldInput'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -37,6 +39,7 @@ interface TaskDetailModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onUpdate: () => void
|
||||
onSubtaskClick?: (subtaskId: string) => void
|
||||
}
|
||||
|
||||
export function TaskDetailModal({
|
||||
@@ -45,6 +48,7 @@ export function TaskDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onSubtaskClick,
|
||||
}: TaskDetailModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -69,14 +73,7 @@ export function TaskDetailModal({
|
||||
const [editCustomValues, setEditCustomValues] = useState<Record<string, string | number | null>>({})
|
||||
const [loadingCustomFields, setLoadingCustomFields] = useState(false)
|
||||
|
||||
// Load custom fields for the project
|
||||
useEffect(() => {
|
||||
if (task.project_id) {
|
||||
loadCustomFields()
|
||||
}
|
||||
}, [task.project_id])
|
||||
|
||||
const loadCustomFields = async () => {
|
||||
const loadCustomFields = useCallback(async () => {
|
||||
setLoadingCustomFields(true)
|
||||
try {
|
||||
const response = await customFieldsApi.getCustomFields(task.project_id)
|
||||
@@ -86,7 +83,14 @@ export function TaskDetailModal({
|
||||
} finally {
|
||||
setLoadingCustomFields(false)
|
||||
}
|
||||
}
|
||||
}, [task.project_id])
|
||||
|
||||
// Load custom fields for the project
|
||||
useEffect(() => {
|
||||
if (task.project_id) {
|
||||
loadCustomFields()
|
||||
}
|
||||
}, [task.project_id, loadCustomFields])
|
||||
|
||||
// Initialize custom values from task
|
||||
useEffect(() => {
|
||||
@@ -120,6 +124,28 @@ export function TaskDetailModal({
|
||||
setIsEditing(false)
|
||||
}, [task])
|
||||
|
||||
// Reference to the modal overlay for focus management
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for keyboard accessibility
|
||||
overlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -204,7 +230,15 @@ export function TaskDetailModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="task-detail-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerLeft}>
|
||||
@@ -223,7 +257,7 @@ export function TaskDetailModal({
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h2 style={styles.title}>{task.title}</h2>
|
||||
<h2 id="task-detail-title" style={styles.title}>{task.title}</h2>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.headerActions}>
|
||||
@@ -285,6 +319,16 @@ export function TaskDetailModal({
|
||||
<div style={styles.section}>
|
||||
<TaskAttachments taskId={task.id} />
|
||||
</div>
|
||||
|
||||
{/* Subtasks Section */}
|
||||
<div style={styles.section}>
|
||||
<SubtaskList
|
||||
taskId={task.id}
|
||||
projectId={task.project_id}
|
||||
onSubtaskClick={onSubtaskClick}
|
||||
onSubtaskCreated={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.sidebar}>
|
||||
@@ -420,7 +464,7 @@ export function TaskDetailModal({
|
||||
<div style={styles.customFieldsDivider} />
|
||||
<div style={styles.customFieldsHeader}>Custom Fields</div>
|
||||
{loadingCustomFields ? (
|
||||
<div style={styles.loadingText}>Loading...</div>
|
||||
<SkeletonList count={3} showAvatar={false} />
|
||||
) : (
|
||||
customFields.map((field) => {
|
||||
// Get the value for this field
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { triggersApi, Trigger } from '../services/triggers'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface TriggerListProps {
|
||||
projectId: string
|
||||
@@ -7,9 +10,11 @@ interface TriggerListProps {
|
||||
}
|
||||
|
||||
export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
const { showToast } = useToast()
|
||||
const [triggers, setTriggers] = useState<Trigger[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
const fetchTriggers = useCallback(async () => {
|
||||
try {
|
||||
@@ -32,18 +37,22 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
try {
|
||||
await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active })
|
||||
fetchTriggers()
|
||||
showToast(`Trigger ${trigger.is_active ? 'disabled' : 'enabled'}`, 'success')
|
||||
} catch {
|
||||
setError('Failed to update trigger')
|
||||
showToast('Failed to update trigger', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (triggerId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this trigger?')) return
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteConfirm) return
|
||||
const triggerId = deleteConfirm
|
||||
setDeleteConfirm(null)
|
||||
try {
|
||||
await triggersApi.deleteTrigger(triggerId)
|
||||
fetchTriggers()
|
||||
showToast('Trigger deleted successfully', 'success')
|
||||
} catch {
|
||||
setError('Failed to delete trigger')
|
||||
showToast('Failed to delete trigger', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +76,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-center text-gray-500">Loading triggers...</div>
|
||||
return <SkeletonList count={3} />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -141,7 +150,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(trigger.id)}
|
||||
onClick={() => setDeleteConfirm(trigger.id)}
|
||||
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
Delete
|
||||
@@ -150,6 +159,17 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm !== null}
|
||||
title="Delete Trigger"
|
||||
message="Are you sure you want to delete this trigger? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmStyle="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
flex: 1,
|
||||
},
|
||||
placeholder: {
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontSize: '14px',
|
||||
},
|
||||
clearButton: {
|
||||
@@ -257,7 +257,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
emptyItem: {
|
||||
padding: '12px',
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { reportsApi, WeeklyReportContent, ProjectSummary } from '../services/reports'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string
|
||||
@@ -142,6 +143,7 @@ function ProjectCard({ project }: { project: ProjectSummary }) {
|
||||
}
|
||||
|
||||
export function WeeklyReportPreview() {
|
||||
const { showToast } = useToast()
|
||||
const [report, setReport] = useState<WeeklyReportContent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
@@ -168,10 +170,10 @@ export function WeeklyReportPreview() {
|
||||
try {
|
||||
setGenerating(true)
|
||||
await reportsApi.generateWeeklyReport()
|
||||
alert('Report generated and notification sent!')
|
||||
showToast('Report generated and notification sent!', 'success')
|
||||
fetchPreview()
|
||||
} catch {
|
||||
setError('Failed to generate report')
|
||||
showToast('Failed to generate report', 'error')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { UserWorkloadDetail, LoadLevel, workloadApi } from '../services/workload'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface WorkloadUserDetailProps {
|
||||
userId: string
|
||||
@@ -34,14 +35,27 @@ export function WorkloadUserDetail({
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [detail, setDetail] = useState<UserWorkloadDetail | null>(null)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
loadUserDetail()
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}, [isOpen, userId, weekStart])
|
||||
|
||||
const loadUserDetail = async () => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const loadUserDetail = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -53,7 +67,13 @@ export function WorkloadUserDetail({
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [userId, weekStart])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
loadUserDetail()
|
||||
}
|
||||
}, [isOpen, userId, weekStart, loadUserDetail])
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
@@ -64,11 +84,19 @@ export function WorkloadUserDetail({
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={onClose}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={onClose}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="workload-detail-title"
|
||||
>
|
||||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={styles.header}>
|
||||
<div>
|
||||
<h2 style={styles.title}>{userName}</h2>
|
||||
<h2 id="workload-detail-title" style={styles.title}>{userName}</h2>
|
||||
<span style={styles.subtitle}>Workload Details</span>
|
||||
</div>
|
||||
<button style={styles.closeButton} onClick={onClose} aria-label="Close">
|
||||
@@ -77,7 +105,9 @@ export function WorkloadUserDetail({
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={styles.loading}>Loading...</div>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<SkeletonList count={5} showAvatar={false} />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={styles.error}>{error}</div>
|
||||
) : detail ? (
|
||||
@@ -203,7 +233,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
padding: '0',
|
||||
lineHeight: 1,
|
||||
},
|
||||
@@ -265,7 +295,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
emptyTasks: {
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontSize: '14px',
|
||||
},
|
||||
taskList: {
|
||||
|
||||
162
frontend/src/contexts/ToastContext.tsx
Normal file
162
frontend/src/contexts/ToastContext.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
type: ToastType
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[]
|
||||
showToast: (message: string, type?: ToastType) => void
|
||||
removeToast: (id: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info') => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
const toast: Toast = { id, type, message }
|
||||
|
||||
setToasts((prev) => [...prev, toast])
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 5000)
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Toast Container Component
|
||||
interface ToastContainerProps {
|
||||
toasts: Toast[]
|
||||
removeToast: (id: string) => void
|
||||
}
|
||||
|
||||
function ToastContainer({ toasts, removeToast }: ToastContainerProps) {
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={styles.container} role="region" aria-label="Notifications">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
style={{ ...styles.toast, ...getToastStyle(toast.type) }}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span style={styles.icon}>{getToastIcon(toast.type)}</span>
|
||||
<span style={styles.message}>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
style={styles.closeButton}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getToastIcon(type: ToastType): string {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✓'
|
||||
case 'error':
|
||||
return '✕'
|
||||
case 'warning':
|
||||
return '!'
|
||||
case 'info':
|
||||
return 'i'
|
||||
}
|
||||
}
|
||||
|
||||
function getToastStyle(type: ToastType): React.CSSProperties {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return { backgroundColor: '#d4edda', borderColor: '#c3e6cb', color: '#155724' }
|
||||
case 'error':
|
||||
return { backgroundColor: '#f8d7da', borderColor: '#f5c6cb', color: '#721c24' }
|
||||
case 'warning':
|
||||
return { backgroundColor: '#fff3cd', borderColor: '#ffeeba', color: '#856404' }
|
||||
case 'info':
|
||||
return { backgroundColor: '#cce5ff', borderColor: '#b8daff', color: '#004085' }
|
||||
}
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
zIndex: 1300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
maxWidth: '400px',
|
||||
},
|
||||
toast: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
animation: 'slideIn 0.3s ease-out',
|
||||
},
|
||||
icon: {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '12px',
|
||||
},
|
||||
message: {
|
||||
flex: 1,
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
closeButton: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
opacity: 0.6,
|
||||
padding: '0 4px',
|
||||
},
|
||||
}
|
||||
|
||||
export default ToastProvider
|
||||
@@ -4,6 +4,19 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* A11Y: Global focus-visible styles for keyboard navigation */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #0066cc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* A11Y: Custom focus styles for inputs that use outline: none */
|
||||
.login-input:focus,
|
||||
.login-input:focus-visible {
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.2);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
|
||||
@@ -5,17 +5,20 @@ import App from './App'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { NotificationProvider } from './contexts/NotificationContext'
|
||||
import { ProjectSyncProvider } from './contexts/ProjectSyncContext'
|
||||
import { ToastProvider } from './contexts/ToastContext'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
<ProjectSyncProvider>
|
||||
<App />
|
||||
</ProjectSyncProvider>
|
||||
</NotificationProvider>
|
||||
<ToastProvider>
|
||||
<NotificationProvider>
|
||||
<ProjectSyncProvider>
|
||||
<App />
|
||||
</ProjectSyncProvider>
|
||||
</NotificationProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { auditService, AuditLog, AuditLogFilters } from '../services/audit'
|
||||
import { SkeletonTable } from '../components/Skeleton'
|
||||
import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit'
|
||||
|
||||
interface AuditLogDetailProps {
|
||||
log: AuditLog
|
||||
@@ -8,12 +9,38 @@ interface AuditLogDetailProps {
|
||||
}
|
||||
|
||||
function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div style={styles.modal}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modal}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="audit-log-detail-title"
|
||||
>
|
||||
<div style={styles.modalContent}>
|
||||
<div style={styles.modalHeader}>
|
||||
<h3>Audit Log Details</h3>
|
||||
<button onClick={onClose} style={styles.closeButton}>×</button>
|
||||
<h3 id="audit-log-detail-title">Audit Log Details</h3>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div style={styles.modalBody}>
|
||||
<div style={styles.detailRow}>
|
||||
@@ -96,6 +123,162 @@ function getSensitivityStyle(level: string): React.CSSProperties {
|
||||
}
|
||||
}
|
||||
|
||||
interface IntegrityVerificationModalProps {
|
||||
result: IntegrityCheckResponse | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function IntegrityVerificationModal({ result, isLoading, error, onClose }: IntegrityVerificationModalProps) {
|
||||
const isSuccess = result && result.invalid_count === 0
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// Add spinner animation via style tag
|
||||
useEffect(() => {
|
||||
const styleId = 'integrity-spinner-style'
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = `
|
||||
@keyframes integrity-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.integrity-spinner {
|
||||
animation: integrity-spin 1s linear infinite;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
return () => {
|
||||
// Clean up on unmount if no other modals use it
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modal}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="integrity-verification-title"
|
||||
>
|
||||
<div style={styles.modalContent}>
|
||||
<div style={styles.modalHeader}>
|
||||
<h3 id="integrity-verification-title">Integrity Verification</h3>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">x</button>
|
||||
</div>
|
||||
<div style={styles.modalBody}>
|
||||
{isLoading && (
|
||||
<div style={styles.loadingContainer}>
|
||||
<div className="integrity-spinner" style={styles.spinner}></div>
|
||||
<p style={styles.loadingText}>Verifying audit log integrity...</p>
|
||||
<p style={styles.loadingSubtext}>This may take a moment depending on the number of records.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={styles.errorContainer}>
|
||||
<div style={styles.errorIcon}>!</div>
|
||||
<h4 style={styles.errorTitle}>Verification Failed</h4>
|
||||
<p style={styles.errorMessage}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !isLoading && (
|
||||
<div>
|
||||
{/* Overall Status */}
|
||||
<div style={{
|
||||
...styles.statusBanner,
|
||||
backgroundColor: isSuccess ? '#d4edda' : '#f8d7da',
|
||||
borderColor: isSuccess ? '#c3e6cb' : '#f5c6cb',
|
||||
}}>
|
||||
<div style={{
|
||||
...styles.statusIcon,
|
||||
backgroundColor: isSuccess ? '#28a745' : '#dc3545',
|
||||
}}>
|
||||
{isSuccess ? '✓' : '✗'}
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{
|
||||
...styles.statusTitle,
|
||||
color: isSuccess ? '#155724' : '#721c24',
|
||||
}}>
|
||||
{isSuccess ? 'Integrity Verified' : 'Integrity Issues Detected'}
|
||||
</h4>
|
||||
<p style={{
|
||||
...styles.statusDescription,
|
||||
color: isSuccess ? '#155724' : '#721c24',
|
||||
}}>
|
||||
{isSuccess
|
||||
? 'All audit records have valid checksums and have not been tampered with.'
|
||||
: 'Some audit records have invalid checksums, indicating potential tampering or corruption.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div style={styles.statsContainer}>
|
||||
<div style={styles.statBox}>
|
||||
<span style={styles.statValue}>{result.total_checked}</span>
|
||||
<span style={styles.statLabel}>Total Checked</span>
|
||||
</div>
|
||||
<div style={{ ...styles.statBox, backgroundColor: '#e8f5e9' }}>
|
||||
<span style={{ ...styles.statValue, color: '#28a745' }}>{result.valid_count}</span>
|
||||
<span style={styles.statLabel}>Valid</span>
|
||||
</div>
|
||||
<div style={{ ...styles.statBox, backgroundColor: result.invalid_count > 0 ? '#ffebee' : '#f5f5f5' }}>
|
||||
<span style={{ ...styles.statValue, color: result.invalid_count > 0 ? '#dc3545' : '#666' }}>
|
||||
{result.invalid_count}
|
||||
</span>
|
||||
<span style={styles.statLabel}>Invalid</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invalid Records List */}
|
||||
{result.invalid_records && result.invalid_records.length > 0 && (
|
||||
<div style={styles.invalidRecordsSection}>
|
||||
<h4 style={styles.invalidRecordsTitle}>Invalid Records</h4>
|
||||
<p style={styles.invalidRecordsDescription}>
|
||||
The following record IDs failed integrity verification:
|
||||
</p>
|
||||
<div style={styles.invalidRecordsList}>
|
||||
{result.invalid_records.map((recordId, index) => (
|
||||
<div key={index} style={styles.invalidRecordItem}>
|
||||
<span style={styles.invalidRecordIcon}>!</span>
|
||||
<span style={styles.invalidRecordId}>{recordId}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const { user } = useAuth()
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
@@ -113,13 +296,13 @@ export default function AuditPage() {
|
||||
sensitivity_level: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.is_system_admin) {
|
||||
loadLogs()
|
||||
}
|
||||
}, [filters, user])
|
||||
// Integrity verification state
|
||||
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||
const [verificationResult, setVerificationResult] = useState<IntegrityCheckResponse | null>(null)
|
||||
const [verificationLoading, setVerificationLoading] = useState(false)
|
||||
const [verificationError, setVerificationError] = useState<string | null>(null)
|
||||
|
||||
const loadLogs = async () => {
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await auditService.getAuditLogs(filters)
|
||||
@@ -130,7 +313,13 @@ export default function AuditPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [filters])
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.is_system_admin) {
|
||||
loadLogs()
|
||||
}
|
||||
}, [loadLogs, user?.is_system_admin])
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilters({
|
||||
@@ -242,6 +431,38 @@ export default function AuditPage() {
|
||||
setFilters({ ...filters, offset: newOffset })
|
||||
}
|
||||
|
||||
const handleVerifyIntegrity = async () => {
|
||||
setShowVerificationModal(true)
|
||||
setVerificationLoading(true)
|
||||
setVerificationResult(null)
|
||||
setVerificationError(null)
|
||||
|
||||
try {
|
||||
// Use filter dates if available, otherwise use default range (last 30 days)
|
||||
const endDate = tempFilters.end_date || new Date().toISOString()
|
||||
const startDate = tempFilters.start_date || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const result = await auditService.verifyIntegrity(startDate, endDate)
|
||||
setVerificationResult(result)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to verify integrity:', error)
|
||||
const err = error as { response?: { data?: { detail?: string } }; message?: string }
|
||||
setVerificationError(
|
||||
err.response?.data?.detail ||
|
||||
err.message ||
|
||||
'An error occurred while verifying integrity. Please try again.'
|
||||
)
|
||||
} finally {
|
||||
setVerificationLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseVerificationModal = () => {
|
||||
setShowVerificationModal(false)
|
||||
setVerificationResult(null)
|
||||
setVerificationError(null)
|
||||
}
|
||||
|
||||
if (!user?.is_system_admin) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
@@ -312,12 +533,15 @@ export default function AuditPage() {
|
||||
<button onClick={handleExportPDF} style={styles.exportPdfButton}>
|
||||
Export PDF
|
||||
</button>
|
||||
<button onClick={handleVerifyIntegrity} style={styles.verifyButton}>
|
||||
Verify Integrity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
<SkeletonTable rows={10} columns={6} />
|
||||
) : (
|
||||
<>
|
||||
<div style={styles.summary}>
|
||||
@@ -387,6 +611,16 @@ export default function AuditPage() {
|
||||
{selectedLog && (
|
||||
<AuditLogDetail log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||
)}
|
||||
|
||||
{/* Integrity Verification Modal */}
|
||||
{showVerificationModal && (
|
||||
<IntegrityVerificationModal
|
||||
result={verificationResult}
|
||||
isLoading={verificationLoading}
|
||||
error={verificationError}
|
||||
onClose={handleCloseVerificationModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -559,4 +793,182 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
color: '#666',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
// Verify Integrity Button
|
||||
verifyButton: {
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6f42c1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
// Loading styles
|
||||
loadingContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '40px 20px',
|
||||
},
|
||||
spinner: {
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
border: '4px solid #f3f3f3',
|
||||
borderTop: '4px solid #6f42c1',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: '16px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
loadingSubtext: {
|
||||
marginTop: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
// Error styles
|
||||
errorContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '40px 20px',
|
||||
},
|
||||
errorIcon: {
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
errorTitle: {
|
||||
marginTop: '16px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#dc3545',
|
||||
},
|
||||
errorMessage: {
|
||||
marginTop: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
// Status banner styles
|
||||
statusBanner: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
marginBottom: '20px',
|
||||
},
|
||||
statusIcon: {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0,
|
||||
},
|
||||
statusTitle: {
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
statusDescription: {
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '14px',
|
||||
},
|
||||
// Statistics styles
|
||||
statsContainer: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
},
|
||||
statBox: {
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
// Invalid records styles
|
||||
invalidRecordsSection: {
|
||||
marginTop: '20px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff5f5',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ffcdd2',
|
||||
},
|
||||
invalidRecordsTitle: {
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#c62828',
|
||||
},
|
||||
invalidRecordsDescription: {
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
invalidRecordsList: {
|
||||
maxHeight: '200px',
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
invalidRecordItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ffcdd2',
|
||||
},
|
||||
invalidRecordIcon: {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0,
|
||||
},
|
||||
invalidRecordId: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
color: '#333',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ export default function Login() {
|
||||
try {
|
||||
await login({ email, password })
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401) {
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number } }
|
||||
if (error.response?.status === 401) {
|
||||
setError('Invalid email or password')
|
||||
} else if (err.response?.status === 503) {
|
||||
} else if (error.response?.status === 503) {
|
||||
setError('Authentication service temporarily unavailable')
|
||||
} else {
|
||||
setError('An error occurred. Please try again.')
|
||||
@@ -50,6 +51,7 @@ export default function Login() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={styles.input}
|
||||
className="login-input"
|
||||
placeholder="your.email@company.com"
|
||||
required
|
||||
/>
|
||||
@@ -65,6 +67,7 @@ export default function Login() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={styles.input}
|
||||
className="login-input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ProjectHealthCard } from '../components/ProjectHealthCard'
|
||||
import { SkeletonGrid } from '../components/Skeleton'
|
||||
import {
|
||||
projectHealthApi,
|
||||
ProjectHealthDashboardResponse,
|
||||
@@ -184,9 +185,7 @@ export default function ProjectHealthPage() {
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div style={styles.loadingContainer}>
|
||||
<div style={styles.loading}>Loading project health data...</div>
|
||||
</div>
|
||||
<SkeletonGrid count={6} columns={3} />
|
||||
) : error ? (
|
||||
<div style={styles.errorContainer}>
|
||||
<p style={styles.error}>{error}</p>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import { CustomFieldList } from '../components/CustomFieldList'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { Skeleton } from '../components/Skeleton'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
@@ -15,6 +17,7 @@ interface Project {
|
||||
export default function ProjectSettings() {
|
||||
const { projectId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [project, setProject] = useState<Project | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'custom-fields'>('custom-fields')
|
||||
@@ -29,13 +32,30 @@ export default function ProjectSettings() {
|
||||
setProject(response.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
showToast('Failed to load project settings', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<Skeleton variant="text" width={200} height={32} />
|
||||
<Skeleton variant="rect" width={120} height={40} />
|
||||
</div>
|
||||
<div style={styles.layout}>
|
||||
<div style={styles.sidebar}>
|
||||
<Skeleton variant="rect" width="100%" height={44} style={{ marginBottom: '8px' }} />
|
||||
<Skeleton variant="rect" width="100%" height={44} />
|
||||
</div>
|
||||
<div style={styles.content}>
|
||||
<Skeleton variant="rect" width="100%" height={300} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import { SkeletonGrid } from '../components/Skeleton'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
@@ -23,6 +25,7 @@ interface Space {
|
||||
export default function Projects() {
|
||||
const { spaceId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [space, setSpace] = useState<Space | null>(null)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -33,11 +36,31 @@ export default function Projects() {
|
||||
security_level: 'department',
|
||||
})
|
||||
const [creating, setCreating] = useState(false)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [spaceId])
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && showCreateModal) {
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateModal) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showCreateModal])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [spaceRes, projectsRes] = await Promise.all([
|
||||
@@ -48,6 +71,7 @@ export default function Projects() {
|
||||
setProjects(projectsRes.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
showToast('Failed to load projects', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -62,8 +86,10 @@ export default function Projects() {
|
||||
setShowCreateModal(false)
|
||||
setNewProject({ title: '', description: '', security_level: 'department' })
|
||||
loadData()
|
||||
showToast('Project created successfully', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to create project:', err)
|
||||
showToast('Failed to create project', 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@@ -84,7 +110,15 @@ export default function Projects() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={{ width: '200px', height: '32px', backgroundColor: '#e5e7eb', borderRadius: '4px' }} />
|
||||
<div style={{ width: '120px', height: '40px', backgroundColor: '#e5e7eb', borderRadius: '8px' }} />
|
||||
</div>
|
||||
<SkeletonGrid count={6} columns={3} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -110,6 +144,15 @@ export default function Projects() {
|
||||
key={project.id}
|
||||
style={styles.card}
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(`/projects/${project.id}`)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open project: ${project.title}`}
|
||||
>
|
||||
<div style={styles.cardHeader}>
|
||||
<h3 style={styles.cardTitle}>{project.title}</h3>
|
||||
@@ -135,17 +178,33 @@ export default function Projects() {
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modalOverlay}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-project-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Project</h2>
|
||||
<h2 id="create-project-title" style={styles.modalTitle}>Create New Project</h2>
|
||||
<label htmlFor="project-title" style={styles.visuallyHidden}>
|
||||
Project title
|
||||
</label>
|
||||
<input
|
||||
id="project-title"
|
||||
type="text"
|
||||
placeholder="Project title"
|
||||
value={newProject.title}
|
||||
onChange={(e) => setNewProject({ ...newProject, title: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="project-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="project-description"
|
||||
placeholder="Description (optional)"
|
||||
value={newProject.description}
|
||||
onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
|
||||
@@ -258,7 +317,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
empty: {
|
||||
gridColumn: '1 / -1',
|
||||
@@ -348,4 +407,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonGrid } from '../components/Skeleton'
|
||||
|
||||
interface Space {
|
||||
id: string
|
||||
@@ -14,22 +16,44 @@ interface Space {
|
||||
|
||||
export default function Spaces() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [spaces, setSpaces] = useState<Space[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [newSpace, setNewSpace] = useState({ name: '', description: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadSpaces()
|
||||
}, [])
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && showCreateModal) {
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateModal) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showCreateModal])
|
||||
|
||||
const loadSpaces = async () => {
|
||||
try {
|
||||
const response = await api.get('/spaces')
|
||||
setSpaces(response.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load spaces:', err)
|
||||
showToast('Failed to load spaces', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -44,15 +68,25 @@ export default function Spaces() {
|
||||
setShowCreateModal(false)
|
||||
setNewSpace({ name: '', description: '' })
|
||||
loadSpaces()
|
||||
showToast('Space created successfully', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to create space:', err)
|
||||
showToast('Failed to create space', 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={{ width: '100px', height: '32px', backgroundColor: '#e5e7eb', borderRadius: '4px' }} />
|
||||
<div style={{ width: '120px', height: '40px', backgroundColor: '#e5e7eb', borderRadius: '6px' }} />
|
||||
</div>
|
||||
<SkeletonGrid count={6} columns={3} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -70,6 +104,15 @@ export default function Spaces() {
|
||||
key={space.id}
|
||||
style={styles.card}
|
||||
onClick={() => navigate(`/spaces/${space.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(`/spaces/${space.id}`)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open space: ${space.name}`}
|
||||
>
|
||||
<h3 style={styles.cardTitle}>{space.name}</h3>
|
||||
<p style={styles.cardDescription}>
|
||||
@@ -89,17 +132,33 @@ export default function Spaces() {
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modalOverlay}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-space-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Space</h2>
|
||||
<h2 id="create-space-title" style={styles.modalTitle}>Create New Space</h2>
|
||||
<label htmlFor="space-name" style={styles.visuallyHidden}>
|
||||
Space name
|
||||
</label>
|
||||
<input
|
||||
id="space-name"
|
||||
type="text"
|
||||
placeholder="Space name"
|
||||
value={newSpace.name}
|
||||
onChange={(e) => setNewSpace({ ...newSpace, name: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="space-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="space-description"
|
||||
placeholder="Description (optional)"
|
||||
value={newSpace.description}
|
||||
onChange={(e) => setNewSpace({ ...newSpace, description: e.target.value })}
|
||||
@@ -179,7 +238,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
},
|
||||
cardMeta: {
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
empty: {
|
||||
gridColumn: '1 / -1',
|
||||
@@ -254,4 +313,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { UserSearchResult } from '../services/collaboration'
|
||||
import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext'
|
||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||
import { CustomFieldInput } from '../components/CustomFieldInput'
|
||||
import { SkeletonTable, SkeletonKanban, Skeleton } from '../components/Skeleton'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -98,6 +99,7 @@ export default function Tasks() {
|
||||
})
|
||||
const [showColumnMenu, setShowColumnMenu] = useState(false)
|
||||
const columnMenuRef = useRef<HTMLDivElement>(null)
|
||||
const createModalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
@@ -250,6 +252,25 @@ export default function Tasks() {
|
||||
}
|
||||
}, [showColumnMenu])
|
||||
|
||||
// Handle Escape key to close create modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && showCreateModal) {
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateModal) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
createModalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showCreateModal])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [projectRes, tasksRes, statusesRes] = await Promise.all([
|
||||
@@ -386,6 +407,24 @@ export default function Tasks() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleSubtaskClick = async (subtaskId: string) => {
|
||||
try {
|
||||
const response = await api.get(`/tasks/${subtaskId}`)
|
||||
const subtask = response.data
|
||||
// Ensure subtask has project_id for custom fields loading
|
||||
const subtaskWithProject = {
|
||||
...subtask,
|
||||
project_id: projectId!,
|
||||
// Map API response fields to frontend Task interface
|
||||
time_estimate: subtask.original_estimate,
|
||||
}
|
||||
setSelectedTask(subtaskWithProject)
|
||||
// Modal is already open, just update the task
|
||||
} catch (err) {
|
||||
console.error('Failed to load subtask:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityStyle = (priority: string): React.CSSProperties => {
|
||||
const colors: { [key: string]: string } = {
|
||||
low: '#808080',
|
||||
@@ -401,7 +440,18 @@ export default function Tasks() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<Skeleton variant="text" width={200} height={32} />
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Skeleton variant="rect" width={100} height={36} />
|
||||
<Skeleton variant="rect" width={100} height={36} />
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'kanban' ? <SkeletonKanban columns={4} /> : <SkeletonTable rows={8} columns={5} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -623,17 +673,33 @@ export default function Tasks() {
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div
|
||||
ref={createModalOverlayRef}
|
||||
style={styles.modalOverlay}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-task-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Task</h2>
|
||||
<h2 id="create-task-title" style={styles.modalTitle}>Create New Task</h2>
|
||||
<label htmlFor="task-title" style={styles.visuallyHidden}>
|
||||
Task title
|
||||
</label>
|
||||
<input
|
||||
id="task-title"
|
||||
type="text"
|
||||
placeholder="Task title"
|
||||
value={newTask.title}
|
||||
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="task-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="task-description"
|
||||
placeholder="Description (optional)"
|
||||
value={newTask.description}
|
||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||
@@ -734,6 +800,7 @@ export default function Tasks() {
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
onUpdate={handleTaskUpdate}
|
||||
onSubtaskClick={handleSubtaskClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -879,7 +946,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
color: '#0066cc',
|
||||
},
|
||||
subtaskCount: {
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
statusSelect: {
|
||||
padding: '6px 12px',
|
||||
@@ -1069,4 +1136,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
color: '#888',
|
||||
fontSize: '13px',
|
||||
},
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { WorkloadHeatmap } from '../components/WorkloadHeatmap'
|
||||
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
|
||||
import { SkeletonTable } from '../components/Skeleton'
|
||||
import { workloadApi, WorkloadHeatmapResponse } from '../services/workload'
|
||||
|
||||
// Helper to get Monday of a given week
|
||||
@@ -120,9 +121,7 @@ export default function WorkloadPage() {
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div style={styles.loadingContainer}>
|
||||
<div style={styles.loading}>Loading workload data...</div>
|
||||
</div>
|
||||
<SkeletonTable rows={5} columns={6} />
|
||||
) : error ? (
|
||||
<div style={styles.errorContainer}>
|
||||
<p style={styles.error}>{error}</p>
|
||||
|
||||
32
frontend/src/test/setup.ts
Normal file
32
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { afterEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Mock window.matchMedia (used by some components)
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
})
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
Reference in New Issue
Block a user