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