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