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,21 +1,30 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { attachmentService, Attachment } from '../services/attachments'
|
||||
import { AttachmentVersionHistory } from './AttachmentVersionHistory'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface AttachmentListProps {
|
||||
taskId: string
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
interface VersionHistoryState {
|
||||
attachmentId: string
|
||||
currentVersion: number
|
||||
filename: string
|
||||
}
|
||||
|
||||
export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
const { showToast } = useToast()
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
const [versionHistory, setVersionHistory] = useState<VersionHistoryState | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Attachment | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAttachments()
|
||||
}, [taskId])
|
||||
|
||||
const loadAttachments = async () => {
|
||||
const loadAttachments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await attachmentService.listAttachments(taskId)
|
||||
@@ -25,37 +34,59 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [taskId])
|
||||
|
||||
useEffect(() => {
|
||||
loadAttachments()
|
||||
}, [loadAttachments])
|
||||
|
||||
const handleDownload = async (attachment: Attachment) => {
|
||||
try {
|
||||
await attachmentService.downloadAttachment(attachment.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to download attachment:', error)
|
||||
alert('Failed to download file')
|
||||
showToast('Failed to download file', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (attachment: Attachment) => {
|
||||
if (!confirm(`Are you sure you want to delete "${attachment.filename}"?`)) {
|
||||
return
|
||||
}
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteConfirm) return
|
||||
|
||||
const attachment = deleteConfirm
|
||||
setDeleteConfirm(null)
|
||||
setDeleting(attachment.id)
|
||||
try {
|
||||
await attachmentService.deleteAttachment(attachment.id)
|
||||
setAttachments(prev => prev.filter(a => a.id !== attachment.id))
|
||||
onRefresh?.()
|
||||
showToast('File deleted successfully', 'success')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete attachment:', error)
|
||||
alert('Failed to delete file')
|
||||
showToast('Failed to delete file', 'error')
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenHistory = (attachment: Attachment) => {
|
||||
setVersionHistory({
|
||||
attachmentId: attachment.id,
|
||||
currentVersion: attachment.current_version,
|
||||
filename: attachment.filename,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseHistory = () => {
|
||||
setVersionHistory(null)
|
||||
}
|
||||
|
||||
const handleVersionRestored = () => {
|
||||
loadAttachments()
|
||||
onRefresh?.()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading attachments...</div>
|
||||
return <SkeletonList count={2} />
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
@@ -84,6 +115,15 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.actions}>
|
||||
{attachment.current_version > 1 && (
|
||||
<button
|
||||
style={styles.historyBtn}
|
||||
onClick={() => handleOpenHistory(attachment)}
|
||||
title="Version History"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={styles.downloadBtn}
|
||||
onClick={() => handleDownload(attachment)}
|
||||
@@ -93,7 +133,7 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
</button>
|
||||
<button
|
||||
style={styles.deleteBtn}
|
||||
onClick={() => handleDelete(attachment)}
|
||||
onClick={() => setDeleteConfirm(attachment)}
|
||||
disabled={deleting === attachment.id}
|
||||
title="Delete"
|
||||
>
|
||||
@@ -102,6 +142,28 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{versionHistory && (
|
||||
<AttachmentVersionHistory
|
||||
attachmentId={versionHistory.attachmentId}
|
||||
currentVersion={versionHistory.currentVersion}
|
||||
filename={versionHistory.filename}
|
||||
isOpen={true}
|
||||
onClose={handleCloseHistory}
|
||||
onRestore={handleVersionRestored}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm !== null}
|
||||
title="Delete File"
|
||||
message={`Are you sure you want to delete "${deleteConfirm?.filename}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmStyle="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -163,6 +225,15 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
},
|
||||
historyBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
downloadBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#007bff',
|
||||
|
||||
437
frontend/src/components/AttachmentVersionHistory.tsx
Normal file
437
frontend/src/components/AttachmentVersionHistory.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { attachmentService, AttachmentVersion, VersionHistoryResponse } from '../services/attachments'
|
||||
|
||||
interface AttachmentVersionHistoryProps {
|
||||
attachmentId: string
|
||||
currentVersion: number
|
||||
filename: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onRestore: () => void
|
||||
}
|
||||
|
||||
export function AttachmentVersionHistory({
|
||||
attachmentId,
|
||||
currentVersion,
|
||||
filename,
|
||||
isOpen,
|
||||
onClose,
|
||||
onRestore,
|
||||
}: AttachmentVersionHistoryProps) {
|
||||
const [versions, setVersions] = useState<AttachmentVersion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [restoring, setRestoring] = useState<number | null>(null)
|
||||
const [confirmRestore, setConfirmRestore] = useState<number | null>(null)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const loadVersionHistory = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response: VersionHistoryResponse = await attachmentService.getVersionHistory(attachmentId)
|
||||
setVersions(response.versions)
|
||||
} catch (err) {
|
||||
console.error('Failed to load version history:', err)
|
||||
setError('Failed to load version history')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [attachmentId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadVersionHistory()
|
||||
}
|
||||
}, [isOpen, loadVersionHistory])
|
||||
|
||||
const handleRestore = async (version: number) => {
|
||||
setRestoring(version)
|
||||
setError(null)
|
||||
try {
|
||||
await attachmentService.restoreVersion(attachmentId, version)
|
||||
setConfirmRestore(null)
|
||||
onRestore()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to restore version:', err)
|
||||
setError('Failed to restore version. Please try again.')
|
||||
} finally {
|
||||
setRestoring(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadVersion = async (version: number) => {
|
||||
try {
|
||||
await attachmentService.downloadAttachment(attachmentId, version)
|
||||
} catch (err) {
|
||||
console.error('Failed to download version:', err)
|
||||
setError('Failed to download version')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="version-history-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerContent}>
|
||||
<h3 id="version-history-title" style={styles.title}>Version History</h3>
|
||||
<p style={styles.subtitle}>{filename}</p>
|
||||
</div>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.content}>
|
||||
{error && (
|
||||
<div style={styles.error}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={styles.loading}>Loading version history...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div style={styles.empty}>No version history available</div>
|
||||
) : (
|
||||
<div style={styles.versionList}>
|
||||
{versions.map((version) => {
|
||||
const isCurrent = version.version === currentVersion
|
||||
const isConfirming = confirmRestore === version.version
|
||||
const isRestoring = restoring === version.version
|
||||
|
||||
return (
|
||||
<div
|
||||
key={version.id}
|
||||
style={{
|
||||
...styles.versionItem,
|
||||
...(isCurrent ? styles.currentVersionItem : {}),
|
||||
}}
|
||||
>
|
||||
<div style={styles.versionInfo}>
|
||||
<div style={styles.versionHeader}>
|
||||
<span style={styles.versionNumber}>
|
||||
Version {version.version}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span style={styles.currentBadge}>Current</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.versionMeta}>
|
||||
<span>{formatDate(version.created_at)}</span>
|
||||
<span style={styles.separator}>|</span>
|
||||
<span>{attachmentService.formatFileSize(version.file_size)}</span>
|
||||
{version.uploader_name && (
|
||||
<>
|
||||
<span style={styles.separator}>|</span>
|
||||
<span>by {version.uploader_name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.versionActions}>
|
||||
<button
|
||||
style={styles.downloadVersionBtn}
|
||||
onClick={() => handleDownloadVersion(version.version)}
|
||||
title="Download this version"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
|
||||
{!isCurrent && (
|
||||
<>
|
||||
{isConfirming ? (
|
||||
<div style={styles.confirmGroup}>
|
||||
<span style={styles.confirmText}>Restore?</span>
|
||||
<button
|
||||
style={styles.confirmYesBtn}
|
||||
onClick={() => handleRestore(version.version)}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
{isRestoring ? '...' : 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
style={styles.confirmNoBtn}
|
||||
onClick={() => setConfirmRestore(null)}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
style={styles.restoreBtn}
|
||||
onClick={() => setConfirmRestore(version.version)}
|
||||
title="Restore to this version"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.footer}>
|
||||
<button onClick={onClose} style={styles.closeFooterBtn}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1100,
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
width: '90%',
|
||||
maxWidth: '600px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
headerContent: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#212529',
|
||||
},
|
||||
subtitle: {
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '14px',
|
||||
color: '#6c757d',
|
||||
},
|
||||
closeButton: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px 24px',
|
||||
},
|
||||
error: {
|
||||
padding: '12px',
|
||||
backgroundColor: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '16px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
loading: {
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
},
|
||||
empty: {
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
},
|
||||
versionList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
},
|
||||
versionItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '14px 16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e9ecef',
|
||||
},
|
||||
currentVersionItem: {
|
||||
backgroundColor: '#e8f4fd',
|
||||
borderColor: '#b8daff',
|
||||
},
|
||||
versionInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
versionHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
versionNumber: {
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#212529',
|
||||
},
|
||||
currentBadge: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
},
|
||||
versionMeta: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
alignItems: 'center',
|
||||
},
|
||||
separator: {
|
||||
color: '#adb5bd',
|
||||
},
|
||||
versionActions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginLeft: '16px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
downloadVersionBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
restoreBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
confirmGroup: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
},
|
||||
confirmText: {
|
||||
fontSize: '12px',
|
||||
color: '#495057',
|
||||
fontWeight: 500,
|
||||
},
|
||||
confirmYesBtn: {
|
||||
padding: '4px 10px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
confirmNoBtn: {
|
||||
padding: '4px 10px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
footer: {
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #eee',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
closeFooterBtn: {
|
||||
padding: '8px 20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
color: '#495057',
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}
|
||||
|
||||
export default AttachmentVersionHistory
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { blockersApi, Blocker } from '../services/collaboration'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface BlockerDialogProps {
|
||||
taskId: string
|
||||
@@ -104,7 +105,9 @@ export function BlockerDialog({
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-4">Loading...</div>
|
||||
<div className="py-2">
|
||||
<SkeletonList count={2} showAvatar={false} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Active blocker */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import { EventClickArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'
|
||||
import api from '../services/api'
|
||||
import { Skeleton } from './Skeleton'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -354,7 +355,7 @@ export function CalendarView({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loading && <span style={styles.loadingIndicator}>Loading...</span>}
|
||||
{loading && <Skeleton variant="text" width={80} height={20} />}
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { commentsApi, usersApi, Comment, UserSearchResult } from '../services/collaboration'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface CommentsProps {
|
||||
taskId: string
|
||||
@@ -18,6 +20,7 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [mentionSuggestions, setMentionSuggestions] = useState<UserSearchResult[]>([])
|
||||
const [showMentions, setShowMentions] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
const fetchComments = useCallback(async () => {
|
||||
try {
|
||||
@@ -71,10 +74,10 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
}
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
if (!confirm('Delete this comment?')) return
|
||||
try {
|
||||
await commentsApi.delete(commentId)
|
||||
await fetchComments()
|
||||
setDeleteConfirm(null)
|
||||
} catch (err) {
|
||||
setError('Failed to delete comment')
|
||||
}
|
||||
@@ -112,7 +115,7 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
setShowMentions(false)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4 text-gray-500">Loading comments...</div>
|
||||
if (loading) return <SkeletonList count={3} showAvatar />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -148,7 +151,7 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
onClick={() => setDeleteConfirm(comment.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Delete
|
||||
@@ -253,6 +256,17 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
{submitting ? 'Posting...' : 'Post Comment'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm !== null}
|
||||
title="Delete Comment"
|
||||
message="Are you sure you want to delete this comment? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmStyle="danger"
|
||||
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
75
frontend/src/components/ConfirmModal.test.tsx
Normal file
75
frontend/src/components/ConfirmModal.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
|
||||
describe('ConfirmModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
title: 'Confirm Action',
|
||||
message: 'Are you sure you want to proceed?',
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
render(<ConfirmModal {...defaultProps} isOpen={false} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders modal when isOpen is true', () => {
|
||||
render(<ConfirmModal {...defaultProps} />)
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument()
|
||||
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm when confirm button is clicked', () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<ConfirmModal {...defaultProps} onConfirm={onConfirm} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Confirm'))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<ConfirmModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when Escape key is pressed', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<ConfirmModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses custom button text', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
{...defaultProps}
|
||||
confirmText="Delete"
|
||||
cancelText="Go Back"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('Go Back')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders confirm button with danger style prop', () => {
|
||||
const { rerender } = render(<ConfirmModal {...defaultProps} confirmStyle="danger" />)
|
||||
|
||||
const dangerButton = screen.getByText('Confirm')
|
||||
// The danger button should be rendered
|
||||
expect(dangerButton).toBeInTheDocument()
|
||||
|
||||
// When rendered with primary style, the button should also be rendered
|
||||
rerender(<ConfirmModal {...defaultProps} confirmStyle="primary" />)
|
||||
const primaryButton = screen.getByText('Confirm')
|
||||
expect(primaryButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
155
frontend/src/components/ConfirmModal.tsx
Normal file
155
frontend/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
confirmStyle?: 'danger' | 'primary'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmStyle = 'danger',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
const confirmButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus confirm button when modal opens
|
||||
confirmButtonRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onCancel])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
aria-describedby="confirm-modal-message"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 id="confirm-modal-title" style={styles.title}>
|
||||
{title}
|
||||
</h2>
|
||||
<p id="confirm-modal-message" style={styles.message}>
|
||||
{message}
|
||||
</p>
|
||||
<div style={styles.actions}>
|
||||
<button onClick={onCancel} style={styles.cancelButton}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmButtonRef}
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
...styles.confirmButton,
|
||||
...(confirmStyle === 'danger' ? styles.dangerButton : styles.primaryButton),
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1200,
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
width: '400px',
|
||||
maxWidth: '90%',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
title: {
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#212529',
|
||||
},
|
||||
message: {
|
||||
margin: '0 0 24px 0',
|
||||
fontSize: '14px',
|
||||
color: '#495057',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '12px',
|
||||
},
|
||||
cancelButton: {
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
color: '#495057',
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
confirmButton: {
|
||||
padding: '10px 20px',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: '#dc3545',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: '#0066cc',
|
||||
},
|
||||
}
|
||||
|
||||
export default ConfirmModal
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
customFieldsApi,
|
||||
CustomField,
|
||||
@@ -38,6 +38,23 @@ export function CustomFieldEditor({
|
||||
const [formula, setFormula] = useState(field?.formula || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
modalOverlayRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// Reset form when field changes
|
||||
useEffect(() => {
|
||||
@@ -155,10 +172,18 @@ export function CustomFieldEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="custom-field-editor-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>
|
||||
<h2 id="custom-field-editor-title" style={styles.title}>
|
||||
{isEditing ? 'Edit Custom Field' : 'Create Custom Field'}
|
||||
</h2>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
|
||||
@@ -424,7 +449,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
},
|
||||
typeNote: {
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
optionsList: {
|
||||
|
||||
@@ -225,7 +225,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
},
|
||||
formulaHint: {
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
unsupported: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { customFieldsApi, CustomField, FieldType } from '../services/customFields'
|
||||
import { CustomFieldEditor } from './CustomFieldEditor'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
|
||||
interface CustomFieldListProps {
|
||||
projectId: string
|
||||
@@ -16,6 +17,7 @@ const FIELD_TYPE_LABELS: Record<FieldType, string> = {
|
||||
}
|
||||
|
||||
export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
||||
const { showToast } = useToast()
|
||||
const [fields, setFields] = useState<CustomField[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -79,13 +81,14 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
||||
await customFieldsApi.deleteCustomField(deleteConfirm)
|
||||
setDeleteConfirm(null)
|
||||
loadFields()
|
||||
showToast('Custom field deleted successfully', 'success')
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
|
||||
'Failed to delete field'
|
||||
alert(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -274,7 +277,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
},
|
||||
emptyHint: {
|
||||
fontSize: '13px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
marginTop: '8px',
|
||||
},
|
||||
fieldList: {
|
||||
|
||||
@@ -288,7 +288,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
color: '#666',
|
||||
},
|
||||
subtaskBadge: {
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
customValueBadge: {
|
||||
backgroundColor: '#f3e5f5',
|
||||
@@ -304,7 +304,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
emptyColumn: {
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontSize: '13px',
|
||||
border: '2px dashed #ddd',
|
||||
borderRadius: '6px',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNotifications } from '../contexts/NotificationContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
export function NotificationBell() {
|
||||
const { notifications, unreadCount, loading, fetchNotifications, markAsRead, markAllAsRead } =
|
||||
@@ -100,7 +101,9 @@ export function NotificationBell() {
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-gray-500">Loading...</div>
|
||||
<div className="p-2">
|
||||
<SkeletonList count={3} showAvatar={false} />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500">No notifications</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ReactNode } from 'react'
|
||||
import { Skeleton } from './Skeleton'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
@@ -10,7 +11,12 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return <div className="container">Loading...</div>
|
||||
return (
|
||||
<div className="container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<Skeleton variant="rect" width="100%" height={60} style={{ marginBottom: '24px' }} />
|
||||
<Skeleton variant="rect" width="100%" height={400} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
|
||||
@@ -41,11 +41,26 @@ export function ResourceHistory({ resourceType, resourceId, title = 'Change Hist
|
||||
return <div style={styles.empty}>No change history available</div>
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header} onClick={() => setExpanded(!expanded)}>
|
||||
<div
|
||||
style={styles.header}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${title}, ${logs.length} items`}
|
||||
>
|
||||
<span style={styles.title}>{title}</span>
|
||||
<span style={styles.toggleIcon}>{expanded ? '▼' : '▶'}</span>
|
||||
<span style={styles.toggleIcon} aria-hidden="true">{expanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div style={styles.content}>
|
||||
|
||||
106
frontend/src/components/Skeleton.test.tsx
Normal file
106
frontend/src/components/Skeleton.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Skeleton, SkeletonCard, SkeletonList, SkeletonTable, SkeletonGrid, SkeletonKanban } from './Skeleton'
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<Skeleton />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies text variant styles', () => {
|
||||
render(<Skeleton variant="text" />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveStyle({ borderRadius: '4px' })
|
||||
})
|
||||
|
||||
it('applies circle variant styles', () => {
|
||||
render(<Skeleton variant="circle" width={40} height={40} />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveStyle({ borderRadius: '50%' })
|
||||
})
|
||||
|
||||
it('applies custom dimensions', () => {
|
||||
render(<Skeleton width={200} height={100} />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveStyle({ width: '200px', height: '100px' })
|
||||
})
|
||||
|
||||
it('is hidden from screen readers', () => {
|
||||
render(<Skeleton />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonCard', () => {
|
||||
it('renders with default lines', () => {
|
||||
render(<SkeletonCard />)
|
||||
// Card should have 4 skeletons (1 title + 3 content lines by default)
|
||||
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(4)
|
||||
})
|
||||
|
||||
it('renders with custom number of lines', () => {
|
||||
render(<SkeletonCard lines={5} />)
|
||||
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(6) // title + 5 lines
|
||||
})
|
||||
|
||||
it('renders image placeholder when showImage is true', () => {
|
||||
render(<SkeletonCard showImage lines={2} />)
|
||||
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(4) // image + title + 2 lines
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonList', () => {
|
||||
it('renders default number of items', () => {
|
||||
const { container } = render(<SkeletonList />)
|
||||
// Each item has 2 skeletons, default count is 3
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(6)
|
||||
})
|
||||
|
||||
it('renders with avatars when showAvatar is true', () => {
|
||||
const { container } = render(<SkeletonList count={2} showAvatar />)
|
||||
// Each item has 3 skeletons (avatar + 2 text), count is 2
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonTable', () => {
|
||||
it('renders default rows and columns', () => {
|
||||
const { container } = render(<SkeletonTable />)
|
||||
// Header (4 columns) + 5 rows * 4 columns = 24 skeletons
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(24)
|
||||
})
|
||||
|
||||
it('renders with custom rows and columns', () => {
|
||||
const { container } = render(<SkeletonTable rows={3} columns={2} />)
|
||||
// Header (2 columns) + 3 rows * 2 columns = 8 skeletons
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonGrid', () => {
|
||||
it('renders default number of cards', () => {
|
||||
const { container } = render(<SkeletonGrid />)
|
||||
// 6 cards, each with 3 skeletons (title + 2 lines)
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonKanban', () => {
|
||||
it('renders kanban columns', () => {
|
||||
const { container } = render(<SkeletonKanban columns={3} />)
|
||||
// 3 columns, each with header + 2-3 cards
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBeGreaterThan(10)
|
||||
})
|
||||
})
|
||||
240
frontend/src/components/Skeleton.tsx
Normal file
240
frontend/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React from 'react'
|
||||
|
||||
interface SkeletonProps {
|
||||
variant?: 'text' | 'rect' | 'circle'
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
variant = 'rect',
|
||||
width = '100%',
|
||||
height,
|
||||
className = '',
|
||||
style,
|
||||
}: SkeletonProps) {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: variant === 'circle' ? '50%' : variant === 'text' ? '4px' : '8px',
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height:
|
||||
height !== undefined
|
||||
? typeof height === 'number'
|
||||
? `${height}px`
|
||||
: height
|
||||
: variant === 'text'
|
||||
? '1em'
|
||||
: '100px',
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
...style,
|
||||
}
|
||||
|
||||
return <div className={className} style={baseStyle} aria-hidden="true" />
|
||||
}
|
||||
|
||||
// Skeleton for card-like content
|
||||
interface SkeletonCardProps {
|
||||
lines?: number
|
||||
showImage?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonCard({ lines = 3, showImage = false, className = '' }: SkeletonCardProps) {
|
||||
return (
|
||||
<div className={className} style={cardStyles.container}>
|
||||
{showImage && <Skeleton variant="rect" height={120} style={{ marginBottom: '12px' }} />}
|
||||
<Skeleton variant="text" width="60%" height={20} style={{ marginBottom: '8px' }} />
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="text"
|
||||
width={i === lines - 1 ? '40%' : '100%'}
|
||||
height={14}
|
||||
style={{ marginBottom: '6px' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for list items
|
||||
interface SkeletonListProps {
|
||||
count?: number
|
||||
showAvatar?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonList({ count = 3, showAvatar = false, className = '' }: SkeletonListProps) {
|
||||
return (
|
||||
<div className={className} style={listStyles.container}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} style={listStyles.item}>
|
||||
{showAvatar && (
|
||||
<Skeleton variant="circle" width={40} height={40} style={{ marginRight: '12px' }} />
|
||||
)}
|
||||
<div style={listStyles.content}>
|
||||
<Skeleton variant="text" width="70%" height={16} style={{ marginBottom: '6px' }} />
|
||||
<Skeleton variant="text" width="40%" height={12} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for table rows
|
||||
interface SkeletonTableProps {
|
||||
rows?: number
|
||||
columns?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonTable({ rows = 5, columns = 4, className = '' }: SkeletonTableProps) {
|
||||
return (
|
||||
<div className={className} style={tableStyles.container}>
|
||||
{/* Header */}
|
||||
<div style={tableStyles.header}>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={i} variant="text" width="80%" height={14} />
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} style={tableStyles.row}>
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<Skeleton
|
||||
key={colIndex}
|
||||
variant="text"
|
||||
width={colIndex === 0 ? '90%' : '60%'}
|
||||
height={14}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for project/task cards in grid
|
||||
interface SkeletonGridProps {
|
||||
count?: number
|
||||
columns?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonGrid({ count = 6, columns = 3, className = '' }: SkeletonGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonCard key={i} lines={2} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for Kanban board
|
||||
export function SkeletonKanban({ columns = 4 }: { columns?: number }) {
|
||||
return (
|
||||
<div style={kanbanStyles.container}>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<div key={i} style={kanbanStyles.column}>
|
||||
<Skeleton variant="text" width="60%" height={20} style={{ marginBottom: '16px' }} />
|
||||
<SkeletonCard lines={2} />
|
||||
<SkeletonCard lines={2} />
|
||||
{i < 2 && <SkeletonCard lines={2} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cardStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
padding: '16px',
|
||||
backgroundColor: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
},
|
||||
}
|
||||
|
||||
const listStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
}
|
||||
|
||||
const tableStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '16px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
marginBottom: '2px',
|
||||
},
|
||||
row: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f9fafb',
|
||||
marginBottom: '2px',
|
||||
},
|
||||
}
|
||||
|
||||
const kanbanStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
overflowX: 'auto',
|
||||
},
|
||||
column: {
|
||||
minWidth: '280px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
},
|
||||
}
|
||||
|
||||
// CSS keyframes for animation (inject once)
|
||||
const styleId = 'skeleton-styles'
|
||||
if (typeof document !== 'undefined' && !document.getElementById(styleId)) {
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = `
|
||||
@keyframes skeleton-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
383
frontend/src/components/SubtaskList.tsx
Normal file
383
frontend/src/components/SubtaskList.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface Subtask {
|
||||
id: string
|
||||
title: string
|
||||
status_id: string | null
|
||||
status_name: string | null
|
||||
status_color: string | null
|
||||
assignee_id: string | null
|
||||
assignee_name: string | null
|
||||
priority: string
|
||||
}
|
||||
|
||||
interface SubtaskListProps {
|
||||
taskId: string
|
||||
projectId: string
|
||||
onSubtaskClick?: (subtaskId: string) => void
|
||||
onSubtaskCreated?: () => void
|
||||
}
|
||||
|
||||
export function SubtaskList({
|
||||
taskId,
|
||||
projectId,
|
||||
onSubtaskClick,
|
||||
onSubtaskCreated,
|
||||
}: SubtaskListProps) {
|
||||
const [subtasks, setSubtasks] = useState<Subtask[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const fetchSubtasks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.get(`/tasks/${taskId}/subtasks`)
|
||||
setSubtasks(response.data.tasks || [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch subtasks:', err)
|
||||
setError('Failed to load subtasks')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [taskId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubtasks()
|
||||
}, [fetchSubtasks])
|
||||
|
||||
const handleAddSubtask = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newSubtaskTitle.trim() || submitting) return
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
await api.post(`/projects/${projectId}/tasks`, {
|
||||
title: newSubtaskTitle.trim(),
|
||||
parent_task_id: taskId,
|
||||
})
|
||||
setNewSubtaskTitle('')
|
||||
setShowAddForm(false)
|
||||
fetchSubtasks()
|
||||
onSubtaskCreated?.()
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to create subtask:', err)
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } }
|
||||
const errorMessage = axiosError.response?.data?.detail || 'Failed to create subtask'
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelAdd = () => {
|
||||
setNewSubtaskTitle('')
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
const getPriorityColor = (priority: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
low: '#808080',
|
||||
medium: '#0066cc',
|
||||
high: '#ff9800',
|
||||
urgent: '#f44336',
|
||||
}
|
||||
return colors[priority] || colors.medium
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div
|
||||
style={styles.header}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Subtasks section, ${subtasks.length} items`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={styles.title}>Subtasks ({subtasks.length})</span>
|
||||
<span style={styles.toggleIcon}>{expanded ? '\u25BC' : '\u25B6'}</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={styles.content}>
|
||||
{loading ? (
|
||||
<div style={styles.loadingText}>Loading subtasks...</div>
|
||||
) : error ? (
|
||||
<div style={styles.errorText}>{error}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Subtask List */}
|
||||
{subtasks.length > 0 ? (
|
||||
<div style={styles.subtaskList}>
|
||||
{subtasks.map((subtask) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
style={styles.subtaskItem}
|
||||
onClick={() => onSubtaskClick?.(subtask.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSubtaskClick?.(subtask.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={styles.subtaskLeft}>
|
||||
<div
|
||||
style={{
|
||||
...styles.priorityDot,
|
||||
backgroundColor: getPriorityColor(subtask.priority),
|
||||
}}
|
||||
/>
|
||||
<span style={styles.subtaskTitle}>{subtask.title}</span>
|
||||
</div>
|
||||
<div style={styles.subtaskRight}>
|
||||
{subtask.status_name && (
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
backgroundColor: subtask.status_color || '#e0e0e0',
|
||||
}}
|
||||
>
|
||||
{subtask.status_name}
|
||||
</span>
|
||||
)}
|
||||
{subtask.assignee_name && (
|
||||
<span style={styles.assigneeName}>
|
||||
{subtask.assignee_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.emptyText}>No subtasks yet</div>
|
||||
)}
|
||||
|
||||
{/* Add Subtask Form */}
|
||||
{showAddForm ? (
|
||||
<form onSubmit={handleAddSubtask} style={styles.addForm}>
|
||||
<label htmlFor="new-subtask-title" style={styles.visuallyHidden}>
|
||||
Subtask title
|
||||
</label>
|
||||
<input
|
||||
id="new-subtask-title"
|
||||
type="text"
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
placeholder="Enter subtask title..."
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div style={styles.formActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelAdd}
|
||||
style={styles.cancelButton}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
style={styles.submitButton}
|
||||
disabled={!newSubtaskTitle.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
style={styles.addButton}
|
||||
>
|
||||
+ Add Subtask
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e9ecef',
|
||||
marginTop: '16px',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
},
|
||||
title: {
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#495057',
|
||||
},
|
||||
toggleIcon: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
},
|
||||
content: {
|
||||
borderTop: '1px solid #e9ecef',
|
||||
padding: '16px',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: '13px',
|
||||
color: '#6c757d',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: '13px',
|
||||
color: '#dc3545',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: '13px',
|
||||
color: '#6c757d',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
subtaskList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
subtaskItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e9ecef',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s ease',
|
||||
},
|
||||
subtaskLeft: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
priorityDot: {
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
},
|
||||
subtaskTitle: {
|
||||
fontSize: '14px',
|
||||
color: '#212529',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
subtaskRight: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
marginLeft: '12px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
statusBadge: {
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: 'white',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
assigneeName: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
maxWidth: '100px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
addForm: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: '14px',
|
||||
border: '1px solid #ced4da',
|
||||
borderRadius: '6px',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
},
|
||||
formActions: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
},
|
||||
cancelButton: {
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
submitButton: {
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
backgroundColor: '#0066cc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
addButton: {
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: '#0066cc',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px dashed #ced4da',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s ease',
|
||||
},
|
||||
}
|
||||
|
||||
export default SubtaskList
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import api from '../services/api'
|
||||
import { Comments } from './Comments'
|
||||
import { TaskAttachments } from './TaskAttachments'
|
||||
import { SubtaskList } from './SubtaskList'
|
||||
import { UserSelect } from './UserSelect'
|
||||
import { UserSearchResult } from '../services/collaboration'
|
||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||
import { CustomFieldInput } from './CustomFieldInput'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -37,6 +39,7 @@ interface TaskDetailModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onUpdate: () => void
|
||||
onSubtaskClick?: (subtaskId: string) => void
|
||||
}
|
||||
|
||||
export function TaskDetailModal({
|
||||
@@ -45,6 +48,7 @@ export function TaskDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onSubtaskClick,
|
||||
}: TaskDetailModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -69,14 +73,7 @@ export function TaskDetailModal({
|
||||
const [editCustomValues, setEditCustomValues] = useState<Record<string, string | number | null>>({})
|
||||
const [loadingCustomFields, setLoadingCustomFields] = useState(false)
|
||||
|
||||
// Load custom fields for the project
|
||||
useEffect(() => {
|
||||
if (task.project_id) {
|
||||
loadCustomFields()
|
||||
}
|
||||
}, [task.project_id])
|
||||
|
||||
const loadCustomFields = async () => {
|
||||
const loadCustomFields = useCallback(async () => {
|
||||
setLoadingCustomFields(true)
|
||||
try {
|
||||
const response = await customFieldsApi.getCustomFields(task.project_id)
|
||||
@@ -86,7 +83,14 @@ export function TaskDetailModal({
|
||||
} finally {
|
||||
setLoadingCustomFields(false)
|
||||
}
|
||||
}
|
||||
}, [task.project_id])
|
||||
|
||||
// Load custom fields for the project
|
||||
useEffect(() => {
|
||||
if (task.project_id) {
|
||||
loadCustomFields()
|
||||
}
|
||||
}, [task.project_id, loadCustomFields])
|
||||
|
||||
// Initialize custom values from task
|
||||
useEffect(() => {
|
||||
@@ -120,6 +124,28 @@ export function TaskDetailModal({
|
||||
setIsEditing(false)
|
||||
}, [task])
|
||||
|
||||
// Reference to the modal overlay for focus management
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for keyboard accessibility
|
||||
overlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -204,7 +230,15 @@ export function TaskDetailModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="task-detail-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerLeft}>
|
||||
@@ -223,7 +257,7 @@ export function TaskDetailModal({
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h2 style={styles.title}>{task.title}</h2>
|
||||
<h2 id="task-detail-title" style={styles.title}>{task.title}</h2>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.headerActions}>
|
||||
@@ -285,6 +319,16 @@ export function TaskDetailModal({
|
||||
<div style={styles.section}>
|
||||
<TaskAttachments taskId={task.id} />
|
||||
</div>
|
||||
|
||||
{/* Subtasks Section */}
|
||||
<div style={styles.section}>
|
||||
<SubtaskList
|
||||
taskId={task.id}
|
||||
projectId={task.project_id}
|
||||
onSubtaskClick={onSubtaskClick}
|
||||
onSubtaskCreated={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.sidebar}>
|
||||
@@ -420,7 +464,7 @@ export function TaskDetailModal({
|
||||
<div style={styles.customFieldsDivider} />
|
||||
<div style={styles.customFieldsHeader}>Custom Fields</div>
|
||||
{loadingCustomFields ? (
|
||||
<div style={styles.loadingText}>Loading...</div>
|
||||
<SkeletonList count={3} showAvatar={false} />
|
||||
) : (
|
||||
customFields.map((field) => {
|
||||
// Get the value for this field
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { triggersApi, Trigger } from '../services/triggers'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface TriggerListProps {
|
||||
projectId: string
|
||||
@@ -7,9 +10,11 @@ interface TriggerListProps {
|
||||
}
|
||||
|
||||
export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
const { showToast } = useToast()
|
||||
const [triggers, setTriggers] = useState<Trigger[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
const fetchTriggers = useCallback(async () => {
|
||||
try {
|
||||
@@ -32,18 +37,22 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
try {
|
||||
await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active })
|
||||
fetchTriggers()
|
||||
showToast(`Trigger ${trigger.is_active ? 'disabled' : 'enabled'}`, 'success')
|
||||
} catch {
|
||||
setError('Failed to update trigger')
|
||||
showToast('Failed to update trigger', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (triggerId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this trigger?')) return
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteConfirm) return
|
||||
const triggerId = deleteConfirm
|
||||
setDeleteConfirm(null)
|
||||
try {
|
||||
await triggersApi.deleteTrigger(triggerId)
|
||||
fetchTriggers()
|
||||
showToast('Trigger deleted successfully', 'success')
|
||||
} catch {
|
||||
setError('Failed to delete trigger')
|
||||
showToast('Failed to delete trigger', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +76,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-center text-gray-500">Loading triggers...</div>
|
||||
return <SkeletonList count={3} />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -141,7 +150,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(trigger.id)}
|
||||
onClick={() => setDeleteConfirm(trigger.id)}
|
||||
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
Delete
|
||||
@@ -150,6 +159,17 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm !== null}
|
||||
title="Delete Trigger"
|
||||
message="Are you sure you want to delete this trigger? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmStyle="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
flex: 1,
|
||||
},
|
||||
placeholder: {
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontSize: '14px',
|
||||
},
|
||||
clearButton: {
|
||||
@@ -257,7 +257,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
emptyItem: {
|
||||
padding: '12px',
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { reportsApi, WeeklyReportContent, ProjectSummary } from '../services/reports'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string
|
||||
@@ -142,6 +143,7 @@ function ProjectCard({ project }: { project: ProjectSummary }) {
|
||||
}
|
||||
|
||||
export function WeeklyReportPreview() {
|
||||
const { showToast } = useToast()
|
||||
const [report, setReport] = useState<WeeklyReportContent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
@@ -168,10 +170,10 @@ export function WeeklyReportPreview() {
|
||||
try {
|
||||
setGenerating(true)
|
||||
await reportsApi.generateWeeklyReport()
|
||||
alert('Report generated and notification sent!')
|
||||
showToast('Report generated and notification sent!', 'success')
|
||||
fetchPreview()
|
||||
} catch {
|
||||
setError('Failed to generate report')
|
||||
showToast('Failed to generate report', 'error')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { UserWorkloadDetail, LoadLevel, workloadApi } from '../services/workload'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface WorkloadUserDetailProps {
|
||||
userId: string
|
||||
@@ -34,14 +35,27 @@ export function WorkloadUserDetail({
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [detail, setDetail] = useState<UserWorkloadDetail | null>(null)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
loadUserDetail()
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}, [isOpen, userId, weekStart])
|
||||
|
||||
const loadUserDetail = async () => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const loadUserDetail = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -53,7 +67,13 @@ export function WorkloadUserDetail({
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [userId, weekStart])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
loadUserDetail()
|
||||
}
|
||||
}, [isOpen, userId, weekStart, loadUserDetail])
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
@@ -64,11 +84,19 @@ export function WorkloadUserDetail({
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={onClose}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={onClose}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="workload-detail-title"
|
||||
>
|
||||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={styles.header}>
|
||||
<div>
|
||||
<h2 style={styles.title}>{userName}</h2>
|
||||
<h2 id="workload-detail-title" style={styles.title}>{userName}</h2>
|
||||
<span style={styles.subtitle}>Workload Details</span>
|
||||
</div>
|
||||
<button style={styles.closeButton} onClick={onClose} aria-label="Close">
|
||||
@@ -77,7 +105,9 @@ export function WorkloadUserDetail({
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={styles.loading}>Loading...</div>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<SkeletonList count={5} showAvatar={false} />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={styles.error}>{error}</div>
|
||||
) : detail ? (
|
||||
@@ -203,7 +233,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
padding: '0',
|
||||
lineHeight: 1,
|
||||
},
|
||||
@@ -265,7 +295,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
emptyTasks: {
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontSize: '14px',
|
||||
},
|
||||
taskList: {
|
||||
|
||||
Reference in New Issue
Block a user