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:
beabigegg
2026-01-07 21:24:36 +08:00
parent 2d80a8384e
commit 4b5a9c1d0a
66 changed files with 7809 additions and 171 deletions

View File

@@ -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 (

View File

@@ -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',

View 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

View File

@@ -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 */}

View File

@@ -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 */}

View File

@@ -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>
)
}

View 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()
})
})

View 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

View File

@@ -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: {

View File

@@ -225,7 +225,7 @@ const styles: Record<string, React.CSSProperties> = {
},
formulaHint: {
fontSize: '11px',
color: '#999',
color: '#767676', // WCAG AA compliant
fontStyle: 'italic',
},
unsupported: {

View File

@@ -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: {

View File

@@ -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',

View File

@@ -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>
) : (

View File

@@ -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) {

View File

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

View 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)
})
})

View 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)
}

View 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

View File

@@ -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

View File

@@ -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>
)
}

View File

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

View File

@@ -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)
}

View File

@@ -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: {

View 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

View File

@@ -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;

View File

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

View File

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

View File

@@ -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
/>

View File

@@ -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>

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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>

View 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 })