feat: complete LOW priority code quality improvements
Backend: - LOW-002: Add Query validation with max page size limits (100) - LOW-003: Replace magic strings with TaskStatus.is_done flag - LOW-004: Add 'creation' trigger type validation - Add action_executor.py with UpdateFieldAction and AutoAssignAction Frontend: - LOW-005: Replace TypeScript 'any' with 'unknown' + type guards - LOW-006: Add ConfirmModal component with A11Y support - LOW-007: Add ToastContext for user feedback notifications - LOW-009: Add Skeleton components (17 loading states replaced) - LOW-010: Setup Vitest with 21 tests for ConfirmModal and Skeleton Components updated: - App.tsx, ProtectedRoute.tsx, Spaces.tsx, Projects.tsx, Tasks.tsx - ProjectSettings.tsx, AuditPage.tsx, WorkloadPage.tsx, ProjectHealthPage.tsx - Comments.tsx, AttachmentList.tsx, TriggerList.tsx, TaskDetailModal.tsx - NotificationBell.tsx, BlockerDialog.tsx, CalendarView.tsx, WorkloadUserDetail.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { auditService, AuditLog, AuditLogFilters } from '../services/audit'
|
||||
import { SkeletonTable } from '../components/Skeleton'
|
||||
import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit'
|
||||
|
||||
interface AuditLogDetailProps {
|
||||
log: AuditLog
|
||||
@@ -8,12 +9,38 @@ interface AuditLogDetailProps {
|
||||
}
|
||||
|
||||
function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div style={styles.modal}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modal}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="audit-log-detail-title"
|
||||
>
|
||||
<div style={styles.modalContent}>
|
||||
<div style={styles.modalHeader}>
|
||||
<h3>Audit Log Details</h3>
|
||||
<button onClick={onClose} style={styles.closeButton}>×</button>
|
||||
<h3 id="audit-log-detail-title">Audit Log Details</h3>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div style={styles.modalBody}>
|
||||
<div style={styles.detailRow}>
|
||||
@@ -96,6 +123,162 @@ function getSensitivityStyle(level: string): React.CSSProperties {
|
||||
}
|
||||
}
|
||||
|
||||
interface IntegrityVerificationModalProps {
|
||||
result: IntegrityCheckResponse | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function IntegrityVerificationModal({ result, isLoading, error, onClose }: IntegrityVerificationModalProps) {
|
||||
const isSuccess = result && result.invalid_count === 0
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// Add spinner animation via style tag
|
||||
useEffect(() => {
|
||||
const styleId = 'integrity-spinner-style'
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = `
|
||||
@keyframes integrity-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.integrity-spinner {
|
||||
animation: integrity-spin 1s linear infinite;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
return () => {
|
||||
// Clean up on unmount if no other modals use it
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modal}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="integrity-verification-title"
|
||||
>
|
||||
<div style={styles.modalContent}>
|
||||
<div style={styles.modalHeader}>
|
||||
<h3 id="integrity-verification-title">Integrity Verification</h3>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">x</button>
|
||||
</div>
|
||||
<div style={styles.modalBody}>
|
||||
{isLoading && (
|
||||
<div style={styles.loadingContainer}>
|
||||
<div className="integrity-spinner" style={styles.spinner}></div>
|
||||
<p style={styles.loadingText}>Verifying audit log integrity...</p>
|
||||
<p style={styles.loadingSubtext}>This may take a moment depending on the number of records.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={styles.errorContainer}>
|
||||
<div style={styles.errorIcon}>!</div>
|
||||
<h4 style={styles.errorTitle}>Verification Failed</h4>
|
||||
<p style={styles.errorMessage}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !isLoading && (
|
||||
<div>
|
||||
{/* Overall Status */}
|
||||
<div style={{
|
||||
...styles.statusBanner,
|
||||
backgroundColor: isSuccess ? '#d4edda' : '#f8d7da',
|
||||
borderColor: isSuccess ? '#c3e6cb' : '#f5c6cb',
|
||||
}}>
|
||||
<div style={{
|
||||
...styles.statusIcon,
|
||||
backgroundColor: isSuccess ? '#28a745' : '#dc3545',
|
||||
}}>
|
||||
{isSuccess ? '✓' : '✗'}
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{
|
||||
...styles.statusTitle,
|
||||
color: isSuccess ? '#155724' : '#721c24',
|
||||
}}>
|
||||
{isSuccess ? 'Integrity Verified' : 'Integrity Issues Detected'}
|
||||
</h4>
|
||||
<p style={{
|
||||
...styles.statusDescription,
|
||||
color: isSuccess ? '#155724' : '#721c24',
|
||||
}}>
|
||||
{isSuccess
|
||||
? 'All audit records have valid checksums and have not been tampered with.'
|
||||
: 'Some audit records have invalid checksums, indicating potential tampering or corruption.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div style={styles.statsContainer}>
|
||||
<div style={styles.statBox}>
|
||||
<span style={styles.statValue}>{result.total_checked}</span>
|
||||
<span style={styles.statLabel}>Total Checked</span>
|
||||
</div>
|
||||
<div style={{ ...styles.statBox, backgroundColor: '#e8f5e9' }}>
|
||||
<span style={{ ...styles.statValue, color: '#28a745' }}>{result.valid_count}</span>
|
||||
<span style={styles.statLabel}>Valid</span>
|
||||
</div>
|
||||
<div style={{ ...styles.statBox, backgroundColor: result.invalid_count > 0 ? '#ffebee' : '#f5f5f5' }}>
|
||||
<span style={{ ...styles.statValue, color: result.invalid_count > 0 ? '#dc3545' : '#666' }}>
|
||||
{result.invalid_count}
|
||||
</span>
|
||||
<span style={styles.statLabel}>Invalid</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invalid Records List */}
|
||||
{result.invalid_records && result.invalid_records.length > 0 && (
|
||||
<div style={styles.invalidRecordsSection}>
|
||||
<h4 style={styles.invalidRecordsTitle}>Invalid Records</h4>
|
||||
<p style={styles.invalidRecordsDescription}>
|
||||
The following record IDs failed integrity verification:
|
||||
</p>
|
||||
<div style={styles.invalidRecordsList}>
|
||||
{result.invalid_records.map((recordId, index) => (
|
||||
<div key={index} style={styles.invalidRecordItem}>
|
||||
<span style={styles.invalidRecordIcon}>!</span>
|
||||
<span style={styles.invalidRecordId}>{recordId}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const { user } = useAuth()
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
@@ -113,13 +296,13 @@ export default function AuditPage() {
|
||||
sensitivity_level: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.is_system_admin) {
|
||||
loadLogs()
|
||||
}
|
||||
}, [filters, user])
|
||||
// Integrity verification state
|
||||
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||
const [verificationResult, setVerificationResult] = useState<IntegrityCheckResponse | null>(null)
|
||||
const [verificationLoading, setVerificationLoading] = useState(false)
|
||||
const [verificationError, setVerificationError] = useState<string | null>(null)
|
||||
|
||||
const loadLogs = async () => {
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await auditService.getAuditLogs(filters)
|
||||
@@ -130,7 +313,13 @@ export default function AuditPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [filters])
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.is_system_admin) {
|
||||
loadLogs()
|
||||
}
|
||||
}, [loadLogs, user?.is_system_admin])
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilters({
|
||||
@@ -242,6 +431,38 @@ export default function AuditPage() {
|
||||
setFilters({ ...filters, offset: newOffset })
|
||||
}
|
||||
|
||||
const handleVerifyIntegrity = async () => {
|
||||
setShowVerificationModal(true)
|
||||
setVerificationLoading(true)
|
||||
setVerificationResult(null)
|
||||
setVerificationError(null)
|
||||
|
||||
try {
|
||||
// Use filter dates if available, otherwise use default range (last 30 days)
|
||||
const endDate = tempFilters.end_date || new Date().toISOString()
|
||||
const startDate = tempFilters.start_date || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const result = await auditService.verifyIntegrity(startDate, endDate)
|
||||
setVerificationResult(result)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to verify integrity:', error)
|
||||
const err = error as { response?: { data?: { detail?: string } }; message?: string }
|
||||
setVerificationError(
|
||||
err.response?.data?.detail ||
|
||||
err.message ||
|
||||
'An error occurred while verifying integrity. Please try again.'
|
||||
)
|
||||
} finally {
|
||||
setVerificationLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseVerificationModal = () => {
|
||||
setShowVerificationModal(false)
|
||||
setVerificationResult(null)
|
||||
setVerificationError(null)
|
||||
}
|
||||
|
||||
if (!user?.is_system_admin) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
@@ -312,12 +533,15 @@ export default function AuditPage() {
|
||||
<button onClick={handleExportPDF} style={styles.exportPdfButton}>
|
||||
Export PDF
|
||||
</button>
|
||||
<button onClick={handleVerifyIntegrity} style={styles.verifyButton}>
|
||||
Verify Integrity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
<SkeletonTable rows={10} columns={6} />
|
||||
) : (
|
||||
<>
|
||||
<div style={styles.summary}>
|
||||
@@ -387,6 +611,16 @@ export default function AuditPage() {
|
||||
{selectedLog && (
|
||||
<AuditLogDetail log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||
)}
|
||||
|
||||
{/* Integrity Verification Modal */}
|
||||
{showVerificationModal && (
|
||||
<IntegrityVerificationModal
|
||||
result={verificationResult}
|
||||
isLoading={verificationLoading}
|
||||
error={verificationError}
|
||||
onClose={handleCloseVerificationModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -559,4 +793,182 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
color: '#666',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
// Verify Integrity Button
|
||||
verifyButton: {
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6f42c1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
// Loading styles
|
||||
loadingContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '40px 20px',
|
||||
},
|
||||
spinner: {
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
border: '4px solid #f3f3f3',
|
||||
borderTop: '4px solid #6f42c1',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: '16px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
loadingSubtext: {
|
||||
marginTop: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
// Error styles
|
||||
errorContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '40px 20px',
|
||||
},
|
||||
errorIcon: {
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
errorTitle: {
|
||||
marginTop: '16px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#dc3545',
|
||||
},
|
||||
errorMessage: {
|
||||
marginTop: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
// Status banner styles
|
||||
statusBanner: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
marginBottom: '20px',
|
||||
},
|
||||
statusIcon: {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0,
|
||||
},
|
||||
statusTitle: {
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
statusDescription: {
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '14px',
|
||||
},
|
||||
// Statistics styles
|
||||
statsContainer: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
},
|
||||
statBox: {
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
// Invalid records styles
|
||||
invalidRecordsSection: {
|
||||
marginTop: '20px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff5f5',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ffcdd2',
|
||||
},
|
||||
invalidRecordsTitle: {
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#c62828',
|
||||
},
|
||||
invalidRecordsDescription: {
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
invalidRecordsList: {
|
||||
maxHeight: '200px',
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
invalidRecordItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ffcdd2',
|
||||
},
|
||||
invalidRecordIcon: {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0,
|
||||
},
|
||||
invalidRecordId: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
color: '#333',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ export default function Login() {
|
||||
try {
|
||||
await login({ email, password })
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401) {
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number } }
|
||||
if (error.response?.status === 401) {
|
||||
setError('Invalid email or password')
|
||||
} else if (err.response?.status === 503) {
|
||||
} else if (error.response?.status === 503) {
|
||||
setError('Authentication service temporarily unavailable')
|
||||
} else {
|
||||
setError('An error occurred. Please try again.')
|
||||
@@ -50,6 +51,7 @@ export default function Login() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={styles.input}
|
||||
className="login-input"
|
||||
placeholder="your.email@company.com"
|
||||
required
|
||||
/>
|
||||
@@ -65,6 +67,7 @@ export default function Login() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={styles.input}
|
||||
className="login-input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ProjectHealthCard } from '../components/ProjectHealthCard'
|
||||
import { SkeletonGrid } from '../components/Skeleton'
|
||||
import {
|
||||
projectHealthApi,
|
||||
ProjectHealthDashboardResponse,
|
||||
@@ -184,9 +185,7 @@ export default function ProjectHealthPage() {
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div style={styles.loadingContainer}>
|
||||
<div style={styles.loading}>Loading project health data...</div>
|
||||
</div>
|
||||
<SkeletonGrid count={6} columns={3} />
|
||||
) : error ? (
|
||||
<div style={styles.errorContainer}>
|
||||
<p style={styles.error}>{error}</p>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import { CustomFieldList } from '../components/CustomFieldList'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { Skeleton } from '../components/Skeleton'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
@@ -15,6 +17,7 @@ interface Project {
|
||||
export default function ProjectSettings() {
|
||||
const { projectId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [project, setProject] = useState<Project | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'custom-fields'>('custom-fields')
|
||||
@@ -29,13 +32,30 @@ export default function ProjectSettings() {
|
||||
setProject(response.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
showToast('Failed to load project settings', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<Skeleton variant="text" width={200} height={32} />
|
||||
<Skeleton variant="rect" width={120} height={40} />
|
||||
</div>
|
||||
<div style={styles.layout}>
|
||||
<div style={styles.sidebar}>
|
||||
<Skeleton variant="rect" width="100%" height={44} style={{ marginBottom: '8px' }} />
|
||||
<Skeleton variant="rect" width="100%" height={44} />
|
||||
</div>
|
||||
<div style={styles.content}>
|
||||
<Skeleton variant="rect" width="100%" height={300} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import { SkeletonGrid } from '../components/Skeleton'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
@@ -23,6 +25,7 @@ interface Space {
|
||||
export default function Projects() {
|
||||
const { spaceId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [space, setSpace] = useState<Space | null>(null)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -33,11 +36,31 @@ export default function Projects() {
|
||||
security_level: 'department',
|
||||
})
|
||||
const [creating, setCreating] = useState(false)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [spaceId])
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && showCreateModal) {
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateModal) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showCreateModal])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [spaceRes, projectsRes] = await Promise.all([
|
||||
@@ -48,6 +71,7 @@ export default function Projects() {
|
||||
setProjects(projectsRes.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
showToast('Failed to load projects', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -62,8 +86,10 @@ export default function Projects() {
|
||||
setShowCreateModal(false)
|
||||
setNewProject({ title: '', description: '', security_level: 'department' })
|
||||
loadData()
|
||||
showToast('Project created successfully', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to create project:', err)
|
||||
showToast('Failed to create project', 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@@ -84,7 +110,15 @@ export default function Projects() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={{ width: '200px', height: '32px', backgroundColor: '#e5e7eb', borderRadius: '4px' }} />
|
||||
<div style={{ width: '120px', height: '40px', backgroundColor: '#e5e7eb', borderRadius: '8px' }} />
|
||||
</div>
|
||||
<SkeletonGrid count={6} columns={3} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -110,6 +144,15 @@ export default function Projects() {
|
||||
key={project.id}
|
||||
style={styles.card}
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(`/projects/${project.id}`)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open project: ${project.title}`}
|
||||
>
|
||||
<div style={styles.cardHeader}>
|
||||
<h3 style={styles.cardTitle}>{project.title}</h3>
|
||||
@@ -135,17 +178,33 @@ export default function Projects() {
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modalOverlay}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-project-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Project</h2>
|
||||
<h2 id="create-project-title" style={styles.modalTitle}>Create New Project</h2>
|
||||
<label htmlFor="project-title" style={styles.visuallyHidden}>
|
||||
Project title
|
||||
</label>
|
||||
<input
|
||||
id="project-title"
|
||||
type="text"
|
||||
placeholder="Project title"
|
||||
value={newProject.title}
|
||||
onChange={(e) => setNewProject({ ...newProject, title: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="project-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="project-description"
|
||||
placeholder="Description (optional)"
|
||||
value={newProject.description}
|
||||
onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
|
||||
@@ -258,7 +317,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
empty: {
|
||||
gridColumn: '1 / -1',
|
||||
@@ -348,4 +407,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonGrid } from '../components/Skeleton'
|
||||
|
||||
interface Space {
|
||||
id: string
|
||||
@@ -14,22 +16,44 @@ interface Space {
|
||||
|
||||
export default function Spaces() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [spaces, setSpaces] = useState<Space[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [newSpace, setNewSpace] = useState({ name: '', description: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadSpaces()
|
||||
}, [])
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && showCreateModal) {
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateModal) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showCreateModal])
|
||||
|
||||
const loadSpaces = async () => {
|
||||
try {
|
||||
const response = await api.get('/spaces')
|
||||
setSpaces(response.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load spaces:', err)
|
||||
showToast('Failed to load spaces', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -44,15 +68,25 @@ export default function Spaces() {
|
||||
setShowCreateModal(false)
|
||||
setNewSpace({ name: '', description: '' })
|
||||
loadSpaces()
|
||||
showToast('Space created successfully', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to create space:', err)
|
||||
showToast('Failed to create space', 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={{ width: '100px', height: '32px', backgroundColor: '#e5e7eb', borderRadius: '4px' }} />
|
||||
<div style={{ width: '120px', height: '40px', backgroundColor: '#e5e7eb', borderRadius: '6px' }} />
|
||||
</div>
|
||||
<SkeletonGrid count={6} columns={3} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -70,6 +104,15 @@ export default function Spaces() {
|
||||
key={space.id}
|
||||
style={styles.card}
|
||||
onClick={() => navigate(`/spaces/${space.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(`/spaces/${space.id}`)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open space: ${space.name}`}
|
||||
>
|
||||
<h3 style={styles.cardTitle}>{space.name}</h3>
|
||||
<p style={styles.cardDescription}>
|
||||
@@ -89,17 +132,33 @@ export default function Spaces() {
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modalOverlay}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-space-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Space</h2>
|
||||
<h2 id="create-space-title" style={styles.modalTitle}>Create New Space</h2>
|
||||
<label htmlFor="space-name" style={styles.visuallyHidden}>
|
||||
Space name
|
||||
</label>
|
||||
<input
|
||||
id="space-name"
|
||||
type="text"
|
||||
placeholder="Space name"
|
||||
value={newSpace.name}
|
||||
onChange={(e) => setNewSpace({ ...newSpace, name: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="space-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="space-description"
|
||||
placeholder="Description (optional)"
|
||||
value={newSpace.description}
|
||||
onChange={(e) => setNewSpace({ ...newSpace, description: e.target.value })}
|
||||
@@ -179,7 +238,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
},
|
||||
cardMeta: {
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
empty: {
|
||||
gridColumn: '1 / -1',
|
||||
@@ -254,4 +313,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { UserSearchResult } from '../services/collaboration'
|
||||
import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext'
|
||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||
import { CustomFieldInput } from '../components/CustomFieldInput'
|
||||
import { SkeletonTable, SkeletonKanban, Skeleton } from '../components/Skeleton'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -98,6 +99,7 @@ export default function Tasks() {
|
||||
})
|
||||
const [showColumnMenu, setShowColumnMenu] = useState(false)
|
||||
const columnMenuRef = useRef<HTMLDivElement>(null)
|
||||
const createModalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
@@ -250,6 +252,25 @@ export default function Tasks() {
|
||||
}
|
||||
}, [showColumnMenu])
|
||||
|
||||
// Handle Escape key to close create modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && showCreateModal) {
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateModal) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
createModalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showCreateModal])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [projectRes, tasksRes, statusesRes] = await Promise.all([
|
||||
@@ -386,6 +407,24 @@ export default function Tasks() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleSubtaskClick = async (subtaskId: string) => {
|
||||
try {
|
||||
const response = await api.get(`/tasks/${subtaskId}`)
|
||||
const subtask = response.data
|
||||
// Ensure subtask has project_id for custom fields loading
|
||||
const subtaskWithProject = {
|
||||
...subtask,
|
||||
project_id: projectId!,
|
||||
// Map API response fields to frontend Task interface
|
||||
time_estimate: subtask.original_estimate,
|
||||
}
|
||||
setSelectedTask(subtaskWithProject)
|
||||
// Modal is already open, just update the task
|
||||
} catch (err) {
|
||||
console.error('Failed to load subtask:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityStyle = (priority: string): React.CSSProperties => {
|
||||
const colors: { [key: string]: string } = {
|
||||
low: '#808080',
|
||||
@@ -401,7 +440,18 @@ export default function Tasks() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<Skeleton variant="text" width={200} height={32} />
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Skeleton variant="rect" width={100} height={36} />
|
||||
<Skeleton variant="rect" width={100} height={36} />
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'kanban' ? <SkeletonKanban columns={4} /> : <SkeletonTable rows={8} columns={5} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -623,17 +673,33 @@ export default function Tasks() {
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div
|
||||
ref={createModalOverlayRef}
|
||||
style={styles.modalOverlay}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-task-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Task</h2>
|
||||
<h2 id="create-task-title" style={styles.modalTitle}>Create New Task</h2>
|
||||
<label htmlFor="task-title" style={styles.visuallyHidden}>
|
||||
Task title
|
||||
</label>
|
||||
<input
|
||||
id="task-title"
|
||||
type="text"
|
||||
placeholder="Task title"
|
||||
value={newTask.title}
|
||||
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="task-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="task-description"
|
||||
placeholder="Description (optional)"
|
||||
value={newTask.description}
|
||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||
@@ -734,6 +800,7 @@ export default function Tasks() {
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
onUpdate={handleTaskUpdate}
|
||||
onSubtaskClick={handleSubtaskClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -879,7 +946,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
color: '#0066cc',
|
||||
},
|
||||
subtaskCount: {
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
statusSelect: {
|
||||
padding: '6px 12px',
|
||||
@@ -1069,4 +1136,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
color: '#888',
|
||||
fontSize: '13px',
|
||||
},
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { WorkloadHeatmap } from '../components/WorkloadHeatmap'
|
||||
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
|
||||
import { SkeletonTable } from '../components/Skeleton'
|
||||
import { workloadApi, WorkloadHeatmapResponse } from '../services/workload'
|
||||
|
||||
// Helper to get Monday of a given week
|
||||
@@ -120,9 +121,7 @@ export default function WorkloadPage() {
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div style={styles.loadingContainer}>
|
||||
<div style={styles.loading}>Loading workload data...</div>
|
||||
</div>
|
||||
<SkeletonTable rows={5} columns={6} />
|
||||
) : error ? (
|
||||
<div style={styles.errorContainer}>
|
||||
<p style={styles.error}>{error}</p>
|
||||
|
||||
Reference in New Issue
Block a user