From 4b5a9c1d0aab43c5f246a6ab5f01eb73c3ddfab8 Mon Sep 17 00:00:00 2001 From: beabigegg Date: Wed, 7 Jan 2026 21:24:36 +0800 Subject: [PATCH] feat: complete LOW priority code quality improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/attachments/router.py | 21 +- backend/app/api/departments/router.py | 6 +- backend/app/api/reports/router.py | 6 +- backend/app/api/triggers/router.py | 36 +- backend/app/api/users/router.py | 4 +- backend/app/schemas/task.py | 8 +- backend/app/schemas/trigger.py | 18 +- backend/app/services/action_executor.py | 484 +++++ backend/app/services/report_service.py | 9 +- backend/app/services/trigger_service.py | 100 +- backend/tests/test_action_executor.py | 700 +++++++ backend/tests/test_encryption.py | 142 ++ backend/tests/test_triggers.py | 116 ++ frontend/package-lock.json | 1813 ++++++++++++++++- frontend/package.json | 12 +- frontend/src/App.tsx | 8 +- frontend/src/components/AttachmentList.tsx | 101 +- .../components/AttachmentVersionHistory.tsx | 437 ++++ frontend/src/components/BlockerDialog.tsx | 5 +- frontend/src/components/CalendarView.tsx | 3 +- frontend/src/components/Comments.tsx | 20 +- frontend/src/components/ConfirmModal.test.tsx | 75 + frontend/src/components/ConfirmModal.tsx | 155 ++ frontend/src/components/CustomFieldEditor.tsx | 33 +- frontend/src/components/CustomFieldInput.tsx | 2 +- frontend/src/components/CustomFieldList.tsx | 7 +- frontend/src/components/KanbanBoard.tsx | 4 +- frontend/src/components/NotificationBell.tsx | 5 +- frontend/src/components/ProtectedRoute.tsx | 8 +- frontend/src/components/ResourceHistory.tsx | 19 +- frontend/src/components/Skeleton.test.tsx | 106 + frontend/src/components/Skeleton.tsx | 240 +++ frontend/src/components/SubtaskList.tsx | 383 ++++ frontend/src/components/TaskDetailModal.tsx | 70 +- frontend/src/components/TriggerList.tsx | 32 +- frontend/src/components/UserSelect.tsx | 4 +- .../src/components/WeeklyReportPreview.tsx | 6 +- .../src/components/WorkloadUserDetail.tsx | 52 +- frontend/src/contexts/ToastContext.tsx | 162 ++ frontend/src/index.css | 13 + frontend/src/main.tsx | 13 +- frontend/src/pages/AuditPage.tsx | 438 +++- frontend/src/pages/Login.tsx | 9 +- frontend/src/pages/ProjectHealthPage.tsx | 5 +- frontend/src/pages/ProjectSettings.tsx | 22 +- frontend/src/pages/Projects.tsx | 80 +- frontend/src/pages/Spaces.tsx | 80 +- frontend/src/pages/Tasks.tsx | 86 +- frontend/src/pages/WorkloadPage.tsx | 5 +- frontend/src/test/setup.ts | 32 + frontend/vitest.config.ts | 16 + issues.md | 94 +- logs/latest | 1 + logs/run-20260106-234936/backend.log | 904 ++++++++ logs/run-20260106-234936/env.log | 18 + logs/run-20260106-234936/frontend.log | 8 + logs/run-20260106-234936/health.log | 2 + logs/run-20260106-234936/run.log | 10 + logs/run-20260107-212333/backend.log | 230 +++ logs/run-20260107-212333/backend.pid | 1 + logs/run-20260107-212333/env.log | 18 + logs/run-20260107-212333/frontend.log | 9 + logs/run-20260107-212333/frontend.pid | 1 + logs/run-20260107-212333/health.log | 2 + logs/run-20260107-212333/run.log | 10 + projectctl.sh | 461 +++++ 66 files changed, 7809 insertions(+), 171 deletions(-) create mode 100644 backend/app/services/action_executor.py create mode 100644 backend/tests/test_action_executor.py create mode 100644 frontend/src/components/AttachmentVersionHistory.tsx create mode 100644 frontend/src/components/ConfirmModal.test.tsx create mode 100644 frontend/src/components/ConfirmModal.tsx create mode 100644 frontend/src/components/Skeleton.test.tsx create mode 100644 frontend/src/components/Skeleton.tsx create mode 100644 frontend/src/components/SubtaskList.tsx create mode 100644 frontend/src/contexts/ToastContext.tsx create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/vitest.config.ts create mode 120000 logs/latest create mode 100644 logs/run-20260106-234936/backend.log create mode 100644 logs/run-20260106-234936/env.log create mode 100644 logs/run-20260106-234936/frontend.log create mode 100644 logs/run-20260106-234936/health.log create mode 100644 logs/run-20260106-234936/run.log create mode 100644 logs/run-20260107-212333/backend.log create mode 100644 logs/run-20260107-212333/backend.pid create mode 100644 logs/run-20260107-212333/env.log create mode 100644 logs/run-20260107-212333/frontend.log create mode 100644 logs/run-20260107-212333/frontend.pid create mode 100644 logs/run-20260107-212333/health.log create mode 100644 logs/run-20260107-212333/run.log create mode 100755 projectctl.sh diff --git a/backend/app/api/attachments/router.py b/backend/app/api/attachments/router.py index f0766dd..d57ced6 100644 --- a/backend/app/api/attachments/router.py +++ b/backend/app/api/attachments/router.py @@ -118,6 +118,9 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E Returns: Tuple of (should_encrypt, encryption_key) + + Raises: + HTTPException: If project is confidential but encryption is not available """ # Only encrypt for confidential projects if project.security_level != "confidential": @@ -125,11 +128,14 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E # Check if encryption is available if not encryption_service.is_encryption_available(): - logger.warning( + logger.error( f"Project {project.id} is confidential but encryption is not configured. " - "Files will be stored unencrypted." + "Rejecting file upload to maintain security." + ) + raise HTTPException( + status_code=400, + detail="Confidential project requires encryption. Please configure ENCRYPTION_MASTER_KEY environment variable." ) - return False, None # Get active encryption key active_key = db.query(EncryptionKey).filter( @@ -137,11 +143,14 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E ).first() if not active_key: - logger.warning( + logger.error( f"Project {project.id} is confidential but no active encryption key exists. " - "Files will be stored unencrypted. Create a key using /api/admin/encryption-keys/rotate" + "Rejecting file upload to maintain security." + ) + raise HTTPException( + status_code=400, + detail="Confidential project requires encryption. Please create an active encryption key first." ) - return False, None return True, active_key diff --git a/backend/app/api/departments/router.py b/backend/app/api/departments/router.py index 5cf6f12..9f9da3e 100644 --- a/backend/app/api/departments/router.py +++ b/backend/app/api/departments/router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import List @@ -13,8 +13,8 @@ router = APIRouter() @router.get("", response_model=List[DepartmentResponse]) async def list_departments( - skip: int = 0, - limit: int = 100, + skip: int = Query(0, ge=0, description="Number of departments to skip"), + limit: int = Query(100, ge=1, le=200, description="Max departments to return"), db: Session = Depends(get_db), current_user: User = Depends(require_permission("users.read")), ): diff --git a/backend/app/api/reports/router.py b/backend/app/api/reports/router.py index 358954f..2e8c2cd 100644 --- a/backend/app/api/reports/router.py +++ b/backend/app/api/reports/router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import Optional @@ -71,8 +71,8 @@ async def generate_weekly_report( @router.get("/api/reports/history", response_model=ReportHistoryListResponse) async def list_report_history( - limit: int = 10, - offset: int = 0, + limit: int = Query(10, ge=1, le=100, description="Number of reports to return"), + offset: int = Query(0, ge=0, description="Number of reports to skip"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): diff --git a/backend/app/api/triggers/router.py b/backend/app/api/triggers/router.py index a194485..ee8deab 100644 --- a/backend/app/api/triggers/router.py +++ b/backend/app/api/triggers/router.py @@ -1,5 +1,5 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import Optional @@ -11,6 +11,8 @@ from app.schemas.trigger import ( ) from app.middleware.auth import get_current_user, check_project_access, check_project_edit_access from app.services.trigger_scheduler import TriggerSchedulerService +from app.services.trigger_service import TriggerService +from app.services.action_executor import ActionValidationError router = APIRouter(tags=["triggers"]) @@ -60,10 +62,11 @@ async def create_trigger( ) # Validate trigger type - if trigger_data.trigger_type not in ["field_change", "schedule"]: + valid_trigger_types = ["field_change", "schedule", "creation"] + if trigger_data.trigger_type not in valid_trigger_types: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid trigger type. Must be 'field_change' or 'schedule'", + detail=f"Invalid trigger type. Must be one of: {', '.join(valid_trigger_types)}", ) # Validate conditions based on trigger type @@ -111,6 +114,16 @@ async def create_trigger( detail=error_msg or "Invalid cron expression", ) + # Validate actions configuration (FEAT-014, FEAT-015) + try: + actions_dicts = [a.model_dump(exclude_none=True) for a in trigger_data.actions] + TriggerService.validate_actions(actions_dicts, db) + except ActionValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + # Create trigger trigger = Trigger( id=str(uuid.uuid4()), @@ -119,7 +132,7 @@ async def create_trigger( description=trigger_data.description, trigger_type=trigger_data.trigger_type, conditions=trigger_data.conditions.model_dump(), - actions=[a.model_dump() for a in trigger_data.actions], + actions=[a.model_dump(exclude_none=True) for a in trigger_data.actions], is_active=trigger_data.is_active, created_by=current_user.id, ) @@ -239,7 +252,16 @@ async def update_trigger( ) trigger.conditions = trigger_data.conditions.model_dump(exclude_none=True) if trigger_data.actions is not None: - trigger.actions = [a.model_dump() for a in trigger_data.actions] + # Validate actions configuration (FEAT-014, FEAT-015) + try: + actions_dicts = [a.model_dump(exclude_none=True) for a in trigger_data.actions] + TriggerService.validate_actions(actions_dicts, db) + except ActionValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + trigger.actions = [a.model_dump(exclude_none=True) for a in trigger_data.actions] if trigger_data.is_active is not None: trigger.is_active = trigger_data.is_active @@ -278,8 +300,8 @@ async def delete_trigger( @router.get("/api/triggers/{trigger_id}/logs", response_model=TriggerLogListResponse) async def list_trigger_logs( trigger_id: str, - limit: int = 50, - offset: int = 0, + limit: int = Query(50, ge=1, le=200, description="Number of logs to return"), + offset: int = Query(0, ge=0, description="Number of logs to skip"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): diff --git a/backend/app/api/users/router.py b/backend/app/api/users/router.py index 85b30fa..cde5e11 100644 --- a/backend/app/api/users/router.py +++ b/backend/app/api/users/router.py @@ -50,8 +50,8 @@ async def search_users( @router.get("", response_model=List[UserResponse]) async def list_users( - skip: int = 0, - limit: int = 100, + skip: int = Query(0, ge=0, description="Number of users to skip"), + limit: int = Query(100, ge=1, le=500, description="Max users to return"), db: Session = Depends(get_db), current_user: User = Depends(require_permission("users.read")), ): diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 236569a..d23f05c 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, computed_field from typing import Optional, List, Any, Dict from datetime import datetime from decimal import Decimal @@ -79,6 +79,12 @@ class TaskResponse(TaskBase): class Config: from_attributes = True + # Alias for original_estimate for frontend compatibility + @computed_field + @property + def time_estimate(self) -> Optional[Decimal]: + return self.original_estimate + class TaskWithDetails(TaskResponse): assignee_name: Optional[str] = None diff --git a/backend/app/schemas/trigger.py b/backend/app/schemas/trigger.py index c7666e5..f695378 100644 --- a/backend/app/schemas/trigger.py +++ b/backend/app/schemas/trigger.py @@ -28,9 +28,23 @@ class TriggerCondition(BaseModel): class TriggerAction(BaseModel): - type: str = Field(default="notify", description="Action type: notify") - target: str = Field(default="assignee", description="Target: assignee, creator, project_owner, user:") + """Action configuration for triggers. + + Supported action types: + - notify: Send notification (requires target, optional template) + - update_field: Update task field (requires field, value) + - auto_assign: Auto-assign task (requires strategy, optional user_id for specific_user) + """ + type: str = Field(..., description="Action type: notify, update_field, auto_assign") + # Notify action fields + target: Optional[str] = Field(None, description="Target: assignee, creator, project_owner, user:") template: Optional[str] = Field(None, description="Message template with variables") + # update_field action fields (FEAT-014) + field: Optional[str] = Field(None, description="Field to update: priority, status_id, due_date") + value: Optional[Any] = Field(None, description="New value for the field") + # auto_assign action fields (FEAT-015) + strategy: Optional[str] = Field(None, description="Strategy: round_robin, least_loaded, specific_user") + user_id: Optional[str] = Field(None, description="User ID for specific_user strategy") class TriggerCreate(BaseModel): diff --git a/backend/app/services/action_executor.py b/backend/app/services/action_executor.py new file mode 100644 index 0000000..a5d8761 --- /dev/null +++ b/backend/app/services/action_executor.py @@ -0,0 +1,484 @@ +"""Action executor service for automation triggers. + +This module provides the action execution framework for the automation system. +It supports extensible action types through a registry pattern. + +FEAT-014: update_field - Update task field values +FEAT-015: auto_assign - Automatic task assignment with multiple strategies +""" +import logging +import uuid +from abc import ABC, abstractmethod +from datetime import datetime, date +from decimal import Decimal +from typing import Any, Dict, List, Optional, Type + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.models import Task, User, TaskStatus + +logger = logging.getLogger(__name__) + + +class ActionExecutionError(Exception): + """Exception raised when action execution fails.""" + pass + + +class ActionValidationError(Exception): + """Exception raised when action config validation fails.""" + pass + + +class BaseAction(ABC): + """Base class for all action types.""" + + action_type: str = "" + + @abstractmethod + def validate_config(self, config: Dict[str, Any], db: Session) -> None: + """Validate action configuration. + + Args: + config: Action configuration dict + db: Database session for validation queries + + Raises: + ActionValidationError: If config is invalid + """ + pass + + @abstractmethod + def execute( + self, + db: Session, + task: Task, + config: Dict[str, Any], + context: Dict[str, Any], + ) -> Dict[str, Any]: + """Execute the action. + + Args: + db: Database session + task: Target task + config: Action configuration + context: Execution context (old_values, new_values, current_user, etc.) + + Returns: + Dict with execution result details + + Raises: + ActionExecutionError: If execution fails + """ + pass + + +class UpdateFieldAction(BaseAction): + """Action to update task field values (FEAT-014). + + Supported fields: + - priority: low, medium, high, urgent + - status_id: Valid status ID for the task's project + - due_date: ISO format date string + + Config format: + { + "field": "priority", + "value": "high" + } + """ + + action_type = "update_field" + + # Standard fields that can be updated + UPDATABLE_FIELDS = { + "priority": ["low", "medium", "high", "urgent"], + "status_id": None, # Validated dynamically + "due_date": None, # Date string validation + } + + def validate_config(self, config: Dict[str, Any], db: Session) -> None: + """Validate update_field configuration.""" + field = config.get("field") + value = config.get("value") + + if not field: + raise ActionValidationError("Missing required 'field' in update_field config") + + if value is None: + raise ActionValidationError("Missing required 'value' in update_field config") + + if field not in self.UPDATABLE_FIELDS: + raise ActionValidationError( + f"Invalid field '{field}'. Supported fields: {list(self.UPDATABLE_FIELDS.keys())}" + ) + + # Validate priority values + if field == "priority": + valid_values = self.UPDATABLE_FIELDS["priority"] + if value not in valid_values: + raise ActionValidationError( + f"Invalid priority value '{value}'. Valid values: {valid_values}" + ) + + # Validate due_date format + if field == "due_date" and value: + try: + if isinstance(value, str): + datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + raise ActionValidationError( + f"Invalid due_date format '{value}'. Use ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)" + ) + + def execute( + self, + db: Session, + task: Task, + config: Dict[str, Any], + context: Dict[str, Any], + ) -> Dict[str, Any]: + """Execute field update action.""" + field = config["field"] + value = config["value"] + old_value = getattr(task, field, None) + + logger.info( + f"Executing update_field action: task={task.id}, field={field}, " + f"old_value={old_value}, new_value={value}" + ) + + # Validate status_id exists for this project + if field == "status_id" and value: + status = db.query(TaskStatus).filter( + TaskStatus.id == value, + TaskStatus.project_id == task.project_id, + ).first() + if not status: + raise ActionExecutionError( + f"Status ID '{value}' not found in project {task.project_id}" + ) + + # Convert due_date string to datetime + if field == "due_date" and value: + if isinstance(value, str): + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + + # Update the field + setattr(task, field, value) + task.updated_at = datetime.utcnow() + + return { + "action_type": self.action_type, + "status": "success", + "field": field, + "old_value": str(old_value) if old_value else None, + "new_value": str(value) if value else None, + } + + +class AutoAssignAction(BaseAction): + """Action for automatic task assignment (FEAT-015). + + Strategies: + - round_robin: Assign to project members in rotation + - least_loaded: Assign to member with lowest workload + - specific_user: Assign to a specific user + + Config format: + { + "strategy": "round_robin" | "least_loaded" | "specific_user", + "user_id": "xxx" // Required only for specific_user strategy + } + """ + + action_type = "auto_assign" + + VALID_STRATEGIES = ["round_robin", "least_loaded", "specific_user"] + + # Class-level state for round-robin tracking per project + _round_robin_index: Dict[str, int] = {} + + def validate_config(self, config: Dict[str, Any], db: Session) -> None: + """Validate auto_assign configuration.""" + strategy = config.get("strategy") + + if not strategy: + raise ActionValidationError("Missing required 'strategy' in auto_assign config") + + if strategy not in self.VALID_STRATEGIES: + raise ActionValidationError( + f"Invalid strategy '{strategy}'. Valid strategies: {self.VALID_STRATEGIES}" + ) + + if strategy == "specific_user": + user_id = config.get("user_id") + if not user_id: + raise ActionValidationError( + "Missing required 'user_id' for specific_user strategy" + ) + # Validate user exists + user = db.query(User).filter( + User.id == user_id, + User.is_active == True, + ).first() + if not user: + raise ActionValidationError(f"User '{user_id}' not found or inactive") + + def execute( + self, + db: Session, + task: Task, + config: Dict[str, Any], + context: Dict[str, Any], + ) -> Dict[str, Any]: + """Execute auto-assign action.""" + strategy = config["strategy"] + old_assignee_id = task.assignee_id + + logger.info( + f"Executing auto_assign action: task={task.id}, strategy={strategy}, " + f"old_assignee={old_assignee_id}" + ) + + if strategy == "specific_user": + new_assignee_id = self._assign_specific_user(db, config) + elif strategy == "round_robin": + new_assignee_id = self._assign_round_robin(db, task) + elif strategy == "least_loaded": + new_assignee_id = self._assign_least_loaded(db, task) + else: + raise ActionExecutionError(f"Unknown strategy: {strategy}") + + if new_assignee_id: + task.assignee_id = new_assignee_id + task.updated_at = datetime.utcnow() + + # Get assignee name for logging + assignee = db.query(User).filter(User.id == new_assignee_id).first() + assignee_name = assignee.name if assignee else "Unknown" + + logger.info( + f"Task {task.id} assigned to user {new_assignee_id} ({assignee_name}) " + f"using {strategy} strategy" + ) + + return { + "action_type": self.action_type, + "status": "success", + "strategy": strategy, + "old_assignee_id": old_assignee_id, + "new_assignee_id": new_assignee_id, + "assignee_name": assignee_name, + } + else: + logger.warning( + f"No eligible assignee found for task {task.id} using {strategy} strategy" + ) + return { + "action_type": self.action_type, + "status": "skipped", + "strategy": strategy, + "reason": "No eligible assignee found", + } + + def _assign_specific_user(self, db: Session, config: Dict[str, Any]) -> Optional[str]: + """Assign to a specific user.""" + user_id = config["user_id"] + user = db.query(User).filter( + User.id == user_id, + User.is_active == True, + ).first() + return user.id if user else None + + def _assign_round_robin(self, db: Session, task: Task) -> Optional[str]: + """Assign using round-robin strategy among project members. + + Gets all active users in the same department as the project owner, + then rotates through them. + """ + project = task.project + if not project: + return None + + # Get project members: owner + users in same department + members = self._get_project_members(db, project) + + if not members: + return None + + # Get or initialize round-robin index for this project + project_id = project.id + if project_id not in self._round_robin_index: + self._round_robin_index[project_id] = 0 + + # Get next member in rotation + index = self._round_robin_index[project_id] % len(members) + selected_user = members[index] + + # Update index for next assignment + self._round_robin_index[project_id] = (index + 1) % len(members) + + return selected_user.id + + def _assign_least_loaded(self, db: Session, task: Task) -> Optional[str]: + """Assign to the project member with lowest workload. + + Workload is calculated based on number of incomplete tasks assigned. + """ + project = task.project + if not project: + return None + + members = self._get_project_members(db, project) + + if not members: + return None + + # Calculate workload for each member (count of incomplete tasks) + member_workloads = [] + for member in members: + # Count incomplete tasks (status.is_done = False or no status) + incomplete_count = ( + db.query(func.count(Task.id)) + .outerjoin(TaskStatus, Task.status_id == TaskStatus.id) + .filter( + Task.assignee_id == member.id, + Task.is_deleted == False, + (TaskStatus.is_done == False) | (Task.status_id == None), + ) + .scalar() + ) + member_workloads.append((member, incomplete_count)) + + # Sort by workload (ascending) and return the least loaded member + member_workloads.sort(key=lambda x: x[1]) + + if member_workloads: + selected_user, workload = member_workloads[0] + logger.debug( + f"Least loaded assignment: user={selected_user.id}, " + f"current_workload={workload}" + ) + return selected_user.id + + return None + + def _get_project_members(self, db: Session, project) -> List[User]: + """Get all potential assignees for a project. + + Returns active users who are either: + - The project owner + - In the same department as the project + """ + owner_id = project.owner_id + department_id = project.department_id + + query = db.query(User).filter(User.is_active == True) + + if department_id: + # Get users in same department OR project owner + query = query.filter( + (User.department_id == department_id) | (User.id == owner_id) + ) + else: + # No department, just return owner + query = query.filter(User.id == owner_id) + + return query.all() + + +class ActionExecutor: + """Registry and executor for action types. + + Usage: + executor = ActionExecutor() + result = executor.execute_action(db, task, action_config, context) + """ + + _actions: Dict[str, Type[BaseAction]] = {} + + @classmethod + def register(cls, action_class: Type[BaseAction]) -> None: + """Register an action type.""" + cls._actions[action_class.action_type] = action_class + logger.debug(f"Registered action type: {action_class.action_type}") + + @classmethod + def get_supported_actions(cls) -> List[str]: + """Get list of supported action types.""" + return list(cls._actions.keys()) + + @classmethod + def validate_action(cls, action: Dict[str, Any], db: Session) -> None: + """Validate an action configuration. + + Args: + action: Action dict with 'type' and other config + db: Database session + + Raises: + ActionValidationError: If action is invalid + """ + action_type = action.get("type") + + if not action_type: + raise ActionValidationError("Missing action 'type'") + + if action_type not in cls._actions: + # Allow unknown actions (like 'notify') to pass through + return + + action_class = cls._actions[action_type] + action_instance = action_class() + config = action.get("config", action) # Support both nested and flat config + action_instance.validate_config(config, db) + + @classmethod + def execute_action( + cls, + db: Session, + task: Task, + action: Dict[str, Any], + context: Dict[str, Any], + ) -> Optional[Dict[str, Any]]: + """Execute a single action. + + Args: + db: Database session + task: Target task + action: Action dict with 'type' and other config + context: Execution context + + Returns: + Execution result dict or None if action type not registered + + Raises: + ActionExecutionError: If execution fails + """ + action_type = action.get("type") + + if action_type not in cls._actions: + # Not a registered action (might be 'notify' handled elsewhere) + return None + + action_class = cls._actions[action_type] + action_instance = action_class() + + # Extract config - support both nested and flat config + config = action.get("config", action) + + try: + result = action_instance.execute(db, task, config, context) + return result + except ActionExecutionError: + raise + except Exception as e: + logger.exception(f"Unexpected error executing {action_type} action") + raise ActionExecutionError(f"Failed to execute {action_type}: {str(e)}") + + +# Register built-in actions +ActionExecutor.register(UpdateFieldAction) +ActionExecutor.register(AutoAssignAction) diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index 71450b7..d53cf81 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -87,16 +87,17 @@ class ReportService: next_week_tasks = [] for task in all_tasks: - status_name = task.status.name.lower() if task.status else "" - is_done = status_name in ["done", "completed", "完成"] + # Use TaskStatus.is_done flag instead of magic strings + is_done = task.status.is_done if task.status else False # Check if completed (updated this week) if is_done: if task.updated_at and task.updated_at >= week_start: completed_tasks.append(task) else: - # Check if in progress - if status_name in ["in progress", "進行中", "doing"]: + # Check if task has active status (not done, not blocked) + # Tasks without a done status are considered in progress + if task.status and not task.status.is_done: in_progress_tasks.append(task) # Check if overdue diff --git a/backend/app/services/trigger_service.py b/backend/app/services/trigger_service.py index 88de663..76e49e3 100644 --- a/backend/app/services/trigger_service.py +++ b/backend/app/services/trigger_service.py @@ -1,9 +1,17 @@ import uuid +import logging from typing import List, Dict, Any, Optional from sqlalchemy.orm import Session from app.models import Trigger, TriggerLog, Task, User, Project from app.services.notification_service import NotificationService +from app.services.action_executor import ( + ActionExecutor, + ActionExecutionError, + ActionValidationError, +) + +logger = logging.getLogger(__name__) class TriggerService: @@ -74,23 +82,78 @@ class TriggerService: old_values: Dict[str, Any], new_values: Dict[str, Any], ) -> TriggerLog: - """Execute trigger actions and log the result.""" + """Execute trigger actions and log the result. + + Uses a database savepoint to ensure atomicity - if any action fails, + all previously executed actions within this trigger are rolled back. + """ actions = trigger.actions if isinstance(trigger.actions, list) else [trigger.actions] executed_actions = [] error_message = None + # Build execution context + context = { + "old_values": old_values, + "new_values": new_values, + "current_user": current_user, + "trigger": trigger, + } + + # Use savepoint for transaction atomicity - if any action fails, + # all changes made by previous actions will be rolled back + savepoint = db.begin_nested() try: for action in actions: action_type = action.get("type") + + # Handle built-in notify action if action_type == "notify": TriggerService._execute_notify_action(db, action, task, current_user, old_values, new_values) executed_actions.append({"type": action_type, "status": "success"}) + # Handle update_field action (FEAT-014) + elif action_type == "update_field": + result = ActionExecutor.execute_action(db, task, action, context) + if result: + executed_actions.append(result) + logger.info( + f"Trigger '{trigger.name}' executed update_field: " + f"field={result.get('field')}, new_value={result.get('new_value')}" + ) + + # Handle auto_assign action (FEAT-015) + elif action_type == "auto_assign": + result = ActionExecutor.execute_action(db, task, action, context) + if result: + executed_actions.append(result) + logger.info( + f"Trigger '{trigger.name}' executed auto_assign: " + f"strategy={result.get('strategy')}, assignee={result.get('new_assignee_id')}" + ) + + # Try to execute via ActionExecutor for extensibility + else: + result = ActionExecutor.execute_action(db, task, action, context) + if result: + executed_actions.append(result) + + # All actions succeeded, commit the savepoint + savepoint.commit() status = "success" - except Exception as e: + except ActionExecutionError as e: + # Rollback all changes made by previously executed actions + savepoint.rollback() status = "failed" error_message = str(e) executed_actions.append({"type": "error", "message": str(e)}) + logger.error(f"Trigger '{trigger.name}' action execution failed, rolling back: {e}") + except Exception as e: + # Rollback all changes made by previously executed actions + savepoint.rollback() + status = "failed" + error_message = str(e) + executed_actions.append({"type": "error", "message": str(e)}) + logger.exception(f"Trigger '{trigger.name}' unexpected error, rolling back: {e}") log = TriggerLog( id=str(uuid.uuid4()), @@ -198,3 +261,36 @@ class TriggerService: ) db.add(log) return log + + @staticmethod + def validate_actions(actions: List[Dict[str, Any]], db: Session) -> None: + """Validate trigger actions configuration. + + Args: + actions: List of action configurations + db: Database session + + Raises: + ActionValidationError: If any action is invalid + """ + valid_action_types = ["notify", "update_field", "auto_assign"] + + for action in actions: + action_type = action.get("type") + if not action_type: + raise ActionValidationError("Missing action 'type'") + + if action_type not in valid_action_types: + raise ActionValidationError( + f"Invalid action type '{action_type}'. " + f"Valid types: {valid_action_types}" + ) + + # Validate via ActionExecutor for extensible actions + if action_type in ["update_field", "auto_assign"]: + ActionExecutor.validate_action(action, db) + + @staticmethod + def get_supported_action_types() -> List[str]: + """Get list of all supported action types.""" + return ["notify"] + ActionExecutor.get_supported_actions() diff --git a/backend/tests/test_action_executor.py b/backend/tests/test_action_executor.py new file mode 100644 index 0000000..02789db --- /dev/null +++ b/backend/tests/test_action_executor.py @@ -0,0 +1,700 @@ +"""Tests for action_executor.py - FEAT-014 and FEAT-015. + +This module tests the action executor framework and the two new action types: +- update_field: Update task field values +- auto_assign: Automatic task assignment with multiple strategies +""" +import pytest +import uuid +from datetime import datetime, timedelta +from decimal import Decimal + +from app.models import User, Space, Project, Task, TaskStatus, Trigger, TriggerLog, Department +from app.services.action_executor import ( + ActionExecutor, + UpdateFieldAction, + AutoAssignAction, + ActionExecutionError, + ActionValidationError, +) +from app.services.trigger_service import TriggerService + + +@pytest.fixture +def test_department(db): + """Create a test department.""" + dept = Department( + id=str(uuid.uuid4()), + name="Engineering", + ) + db.add(dept) + db.commit() + return dept + + +@pytest.fixture +def test_user(db, test_department): + """Create a test user.""" + user = User( + id=str(uuid.uuid4()), + email="testuser@example.com", + name="Test User", + department_id=test_department.id, + role_id="00000000-0000-0000-0000-000000000003", + is_active=True, + is_system_admin=False, + capacity=Decimal("40.00"), + ) + db.add(user) + db.commit() + return user + + +@pytest.fixture +def additional_users(db, test_department): + """Create additional test users for auto-assign tests.""" + users = [] + for i in range(3): + user = User( + id=str(uuid.uuid4()), + email=f"user{i}@example.com", + name=f"User {i}", + department_id=test_department.id, + role_id="00000000-0000-0000-0000-000000000003", + is_active=True, + is_system_admin=False, + capacity=Decimal("40.00"), + ) + db.add(user) + users.append(user) + db.commit() + return users + + +@pytest.fixture +def test_space(db, test_user): + """Create a test space.""" + space = Space( + id=str(uuid.uuid4()), + name="Test Space", + description="Test space for action executor", + owner_id=test_user.id, + ) + db.add(space) + db.commit() + return space + + +@pytest.fixture +def test_project(db, test_space, test_user, test_department): + """Create a test project.""" + project = Project( + id=str(uuid.uuid4()), + space_id=test_space.id, + title="Test Project", + description="Test project for action executor", + owner_id=test_user.id, + department_id=test_department.id, + ) + db.add(project) + db.commit() + return project + + +@pytest.fixture +def test_statuses(db, test_project): + """Create test task statuses.""" + status_todo = TaskStatus( + id=str(uuid.uuid4()), + project_id=test_project.id, + name="To Do", + color="#808080", + position=0, + is_done=False, + ) + status_in_progress = TaskStatus( + id=str(uuid.uuid4()), + project_id=test_project.id, + name="In Progress", + color="#0000FF", + position=1, + is_done=False, + ) + status_done = TaskStatus( + id=str(uuid.uuid4()), + project_id=test_project.id, + name="Done", + color="#00FF00", + position=2, + is_done=True, + ) + db.add_all([status_todo, status_in_progress, status_done]) + db.commit() + return status_todo, status_in_progress, status_done + + +@pytest.fixture +def test_task(db, test_project, test_user, test_statuses): + """Create a test task.""" + task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title="Test Task", + description="Test task for action executor", + status_id=test_statuses[0].id, + created_by=test_user.id, + assignee_id=test_user.id, + priority="medium", + ) + db.add(task) + db.commit() + db.refresh(task) + return task + + +class TestUpdateFieldAction: + """Tests for FEAT-014: Update Field Action.""" + + def test_validate_config_valid_priority(self, db): + """Test validation of valid priority update config.""" + action = UpdateFieldAction() + config = {"field": "priority", "value": "high"} + # Should not raise + action.validate_config(config, db) + + def test_validate_config_valid_status_id(self, db): + """Test validation of valid status_id update config.""" + action = UpdateFieldAction() + config = {"field": "status_id", "value": "some-uuid"} + # Should not raise (status_id validation is deferred to execution) + action.validate_config(config, db) + + def test_validate_config_valid_due_date(self, db): + """Test validation of valid due_date update config.""" + action = UpdateFieldAction() + config = {"field": "due_date", "value": "2025-12-31T23:59:59"} + # Should not raise + action.validate_config(config, db) + + def test_validate_config_missing_field(self, db): + """Test validation fails when field is missing.""" + action = UpdateFieldAction() + config = {"value": "high"} + with pytest.raises(ActionValidationError) as exc: + action.validate_config(config, db) + assert "Missing required 'field'" in str(exc.value) + + def test_validate_config_missing_value(self, db): + """Test validation fails when value is missing.""" + action = UpdateFieldAction() + config = {"field": "priority"} + with pytest.raises(ActionValidationError) as exc: + action.validate_config(config, db) + assert "Missing required 'value'" in str(exc.value) + + def test_validate_config_invalid_field(self, db): + """Test validation fails for unsupported field.""" + action = UpdateFieldAction() + config = {"field": "invalid_field", "value": "test"} + with pytest.raises(ActionValidationError) as exc: + action.validate_config(config, db) + assert "Invalid field" in str(exc.value) + + def test_validate_config_invalid_priority(self, db): + """Test validation fails for invalid priority value.""" + action = UpdateFieldAction() + config = {"field": "priority", "value": "critical"} + with pytest.raises(ActionValidationError) as exc: + action.validate_config(config, db) + assert "Invalid priority value" in str(exc.value) + + def test_validate_config_invalid_due_date_format(self, db): + """Test validation fails for invalid due_date format.""" + action = UpdateFieldAction() + config = {"field": "due_date", "value": "not-a-date"} + with pytest.raises(ActionValidationError) as exc: + action.validate_config(config, db) + assert "Invalid due_date format" in str(exc.value) + + def test_execute_update_priority(self, db, test_task, test_user): + """Test executing priority update.""" + action = UpdateFieldAction() + config = {"field": "priority", "value": "urgent"} + context = {"current_user": test_user} + + assert test_task.priority == "medium" + + result = action.execute(db, test_task, config, context) + db.commit() + + assert result["status"] == "success" + assert result["field"] == "priority" + assert result["old_value"] == "medium" + assert result["new_value"] == "urgent" + assert test_task.priority == "urgent" + + def test_execute_update_status_id(self, db, test_task, test_statuses, test_user): + """Test executing status_id update.""" + action = UpdateFieldAction() + new_status = test_statuses[1] # In Progress + config = {"field": "status_id", "value": new_status.id} + context = {"current_user": test_user} + + result = action.execute(db, test_task, config, context) + db.commit() + + assert result["status"] == "success" + assert result["field"] == "status_id" + assert test_task.status_id == new_status.id + + def test_execute_update_status_id_invalid(self, db, test_task, test_user): + """Test executing status_id update with invalid status.""" + action = UpdateFieldAction() + config = {"field": "status_id", "value": "non-existent-status"} + context = {"current_user": test_user} + + with pytest.raises(ActionExecutionError) as exc: + action.execute(db, test_task, config, context) + assert "not found in project" in str(exc.value) + + def test_execute_update_due_date(self, db, test_task, test_user): + """Test executing due_date update.""" + action = UpdateFieldAction() + due_date_str = "2025-12-31T23:59:59" + config = {"field": "due_date", "value": due_date_str} + context = {"current_user": test_user} + + result = action.execute(db, test_task, config, context) + db.commit() + + assert result["status"] == "success" + assert result["field"] == "due_date" + assert test_task.due_date is not None + + +class TestAutoAssignAction: + """Tests for FEAT-015: Auto Assign Action.""" + + def test_validate_config_valid_round_robin(self, db): + """Test validation of valid round_robin config.""" + action = AutoAssignAction() + config = {"strategy": "round_robin"} + # Should not raise + action.validate_config(config, db) + + def test_validate_config_valid_least_loaded(self, db): + """Test validation of valid least_loaded config.""" + action = AutoAssignAction() + config = {"strategy": "least_loaded"} + # Should not raise + action.validate_config(config, db) + + def test_validate_config_valid_specific_user(self, db, test_user): + """Test validation of valid specific_user config.""" + action = AutoAssignAction() + config = {"strategy": "specific_user", "user_id": test_user.id} + # Should not raise + action.validate_config(config, db) + + def test_validate_config_missing_strategy(self, db): + """Test validation fails when strategy is missing.""" + action = AutoAssignAction() + config = {} + with pytest.raises(ActionValidationError) as exc: + action.validate_config(config, db) + assert "Missing required 'strategy'" in str(exc.value) + + def test_validate_config_invalid_strategy(self, db): + """Test validation fails for invalid strategy.""" + action = AutoAssignAction() + config = {"strategy": "invalid_strategy"} + with pytest.raises(ActionValidationError) as exc: + action.validate_config(config, db) + assert "Invalid strategy" in str(exc.value) + + def test_validate_config_specific_user_missing_user_id(self, db): + """Test validation fails when user_id is missing for specific_user.""" + action = AutoAssignAction() + config = {"strategy": "specific_user"} + with pytest.raises(ActionValidationError) as exc: + action.validate_config(config, db) + assert "Missing required 'user_id'" in str(exc.value) + + def test_validate_config_specific_user_invalid_user_id(self, db): + """Test validation fails for non-existent user.""" + action = AutoAssignAction() + config = {"strategy": "specific_user", "user_id": "non-existent-user"} + with pytest.raises(ActionValidationError) as exc: + action.validate_config(config, db) + assert "not found or inactive" in str(exc.value) + + def test_execute_specific_user(self, db, test_task, test_user, additional_users): + """Test executing specific_user assignment.""" + action = AutoAssignAction() + target_user = additional_users[0] + config = {"strategy": "specific_user", "user_id": target_user.id} + context = {"current_user": test_user} + + result = action.execute(db, test_task, config, context) + db.commit() + + assert result["status"] == "success" + assert result["strategy"] == "specific_user" + assert result["new_assignee_id"] == target_user.id + assert test_task.assignee_id == target_user.id + + def test_execute_round_robin(self, db, test_task, test_user, additional_users, test_project): + """Test executing round_robin assignment.""" + action = AutoAssignAction() + config = {"strategy": "round_robin"} + context = {"current_user": test_user} + + # Clear round robin state for this project + AutoAssignAction._round_robin_index.pop(test_project.id, None) + + # First assignment + result1 = action.execute(db, test_task, config, context) + db.commit() + first_assignee = result1["new_assignee_id"] + + assert result1["status"] == "success" + assert result1["strategy"] == "round_robin" + assert first_assignee is not None + + # Create a new task for second assignment + task2 = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title="Test Task 2", + status_id=test_task.status_id, + created_by=test_user.id, + priority="medium", + ) + db.add(task2) + db.commit() + db.refresh(task2) + + # Second assignment should get next member in rotation + result2 = action.execute(db, task2, config, context) + db.commit() + second_assignee = result2["new_assignee_id"] + + assert result2["status"] == "success" + # Round robin should cycle through members + assert second_assignee is not None + + def test_execute_least_loaded(self, db, test_task, test_user, additional_users, test_project, test_statuses): + """Test executing least_loaded assignment.""" + # Create tasks assigned to different users to create varied workloads + for i, user in enumerate(additional_users): + for j in range(i + 1): # User 0 gets 1 task, User 1 gets 2, etc. + task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title=f"Task for user {i} - {j}", + status_id=test_statuses[0].id, # To Do (not done) + created_by=test_user.id, + assignee_id=user.id, + priority="medium", + ) + db.add(task) + db.commit() + + # Create a new unassigned task + new_task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title="New Task for Assignment", + status_id=test_statuses[0].id, + created_by=test_user.id, + priority="medium", + ) + db.add(new_task) + db.commit() + db.refresh(new_task) + + action = AutoAssignAction() + config = {"strategy": "least_loaded"} + context = {"current_user": test_user} + + result = action.execute(db, new_task, config, context) + db.commit() + + assert result["status"] == "success" + assert result["strategy"] == "least_loaded" + # Should assign to user with fewest tasks + assert result["new_assignee_id"] is not None + + def test_execute_no_eligible_members(self, db, test_user, test_space): + """Test assignment when no eligible members are found.""" + # Create project without department (only owner as member) + project = Project( + id=str(uuid.uuid4()), + space_id=test_space.id, + title="Solo Project", + owner_id=test_user.id, + department_id=None, # No department + ) + db.add(project) + + status = TaskStatus( + id=str(uuid.uuid4()), + project_id=project.id, + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + db.commit() + + task = Task( + id=str(uuid.uuid4()), + project_id=project.id, + title="Solo Task", + status_id=status.id, + created_by=test_user.id, + priority="medium", + ) + db.add(task) + db.commit() + db.refresh(task) + + action = AutoAssignAction() + config = {"strategy": "round_robin"} + context = {"current_user": test_user} + + result = action.execute(db, task, config, context) + + # Should still work - assigns to owner + assert result["status"] == "success" + assert task.assignee_id == test_user.id + + +class TestActionExecutor: + """Tests for ActionExecutor registry and execution.""" + + def test_get_supported_actions(self): + """Test getting supported action types.""" + actions = ActionExecutor.get_supported_actions() + assert "update_field" in actions + assert "auto_assign" in actions + + def test_validate_action_update_field(self, db): + """Test validating update_field action via executor.""" + action = { + "type": "update_field", + "config": {"field": "priority", "value": "high"}, + } + # Should not raise + ActionExecutor.validate_action(action, db) + + def test_validate_action_auto_assign(self, db, test_user): + """Test validating auto_assign action via executor.""" + action = { + "type": "auto_assign", + "config": {"strategy": "specific_user", "user_id": test_user.id}, + } + # Should not raise + ActionExecutor.validate_action(action, db) + + def test_validate_action_unknown_type(self, db): + """Test that unknown action types pass through (for extensibility).""" + action = {"type": "unknown_action"} + # Should not raise - allows other handlers to process + ActionExecutor.validate_action(action, db) + + def test_execute_action_update_field(self, db, test_task, test_user): + """Test executing update_field action via executor.""" + action = { + "type": "update_field", + "config": {"field": "priority", "value": "urgent"}, + } + context = {"current_user": test_user} + + result = ActionExecutor.execute_action(db, test_task, action, context) + db.commit() + + assert result is not None + assert result["status"] == "success" + assert test_task.priority == "urgent" + + def test_execute_action_auto_assign(self, db, test_task, test_user, additional_users): + """Test executing auto_assign action via executor.""" + target_user = additional_users[1] + action = { + "type": "auto_assign", + "config": {"strategy": "specific_user", "user_id": target_user.id}, + } + context = {"current_user": test_user} + + result = ActionExecutor.execute_action(db, test_task, action, context) + db.commit() + + assert result is not None + assert result["status"] == "success" + assert test_task.assignee_id == target_user.id + + def test_execute_action_unknown_type_returns_none(self, db, test_task, test_user): + """Test that unknown action types return None.""" + action = {"type": "unknown_action"} + context = {"current_user": test_user} + + result = ActionExecutor.execute_action(db, test_task, action, context) + + assert result is None + + +class TestTriggerServiceIntegration: + """Tests for TriggerService integration with new actions.""" + + def test_validate_actions_update_field(self, db): + """Test TriggerService validates update_field actions.""" + actions = [{ + "type": "update_field", + "config": {"field": "priority", "value": "high"}, + }] + # Should not raise + TriggerService.validate_actions(actions, db) + + def test_validate_actions_auto_assign(self, db, test_user): + """Test TriggerService validates auto_assign actions.""" + actions = [{ + "type": "auto_assign", + "config": {"strategy": "specific_user", "user_id": test_user.id}, + }] + # Should not raise + TriggerService.validate_actions(actions, db) + + def test_validate_actions_invalid_action_type(self, db): + """Test TriggerService rejects invalid action types.""" + actions = [{"type": "invalid_action"}] + with pytest.raises(ActionValidationError) as exc: + TriggerService.validate_actions(actions, db) + assert "Invalid action type" in str(exc.value) + + def test_get_supported_action_types(self): + """Test getting all supported action types.""" + types = TriggerService.get_supported_action_types() + assert "notify" in types + assert "update_field" in types + assert "auto_assign" in types + + def test_evaluate_triggers_with_update_field_action( + self, db, test_task, test_user, test_project, test_statuses + ): + """Test trigger evaluation with update_field action.""" + # Create trigger that updates priority when status changes + trigger = Trigger( + id=str(uuid.uuid4()), + project_id=test_project.id, + name="Status Change Priority Trigger", + trigger_type="field_change", + conditions={ + "field": "status_id", + "operator": "changed_to", + "value": test_statuses[1].id, # In Progress + }, + actions=[{ + "type": "update_field", + "config": {"field": "priority", "value": "high"}, + }], + is_active=True, + created_by=test_user.id, + ) + db.add(trigger) + db.commit() + + old_values = {"status_id": test_statuses[0].id} + new_values = {"status_id": test_statuses[1].id} + + logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user) + db.commit() + + assert len(logs) == 1 + assert logs[0].status == "success" + assert test_task.priority == "high" + + def test_evaluate_triggers_with_auto_assign_action( + self, db, test_task, test_user, test_project, test_statuses, additional_users + ): + """Test trigger evaluation with auto_assign action.""" + target_user = additional_users[0] + + # Create trigger that auto-assigns when priority changes to urgent + trigger = Trigger( + id=str(uuid.uuid4()), + project_id=test_project.id, + name="Priority Auto Assign Trigger", + trigger_type="field_change", + conditions={ + "field": "priority", + "operator": "changed_to", + "value": "urgent", + }, + actions=[{ + "type": "auto_assign", + "config": {"strategy": "specific_user", "user_id": target_user.id}, + }], + is_active=True, + created_by=test_user.id, + ) + db.add(trigger) + db.commit() + + old_values = {"priority": "medium"} + new_values = {"priority": "urgent"} + + logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user) + db.commit() + + assert len(logs) == 1 + assert logs[0].status == "success" + assert test_task.assignee_id == target_user.id + + def test_evaluate_triggers_with_multiple_actions( + self, db, test_task, test_user, test_project, test_statuses, additional_users + ): + """Test trigger evaluation with multiple actions.""" + target_user = additional_users[0] + + # Create trigger with both update_field and auto_assign + trigger = Trigger( + id=str(uuid.uuid4()), + project_id=test_project.id, + name="Multi Action Trigger", + trigger_type="field_change", + conditions={ + "field": "status_id", + "operator": "changed_to", + "value": test_statuses[1].id, + }, + actions=[ + { + "type": "update_field", + "config": {"field": "priority", "value": "urgent"}, + }, + { + "type": "auto_assign", + "config": {"strategy": "specific_user", "user_id": target_user.id}, + }, + ], + is_active=True, + created_by=test_user.id, + ) + db.add(trigger) + db.commit() + + old_values = {"status_id": test_statuses[0].id} + new_values = {"status_id": test_statuses[1].id} + + logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user) + db.commit() + + assert len(logs) == 1 + assert logs[0].status == "success" + assert test_task.priority == "urgent" + assert test_task.assignee_id == target_user.id + + # Check that both actions are logged + details = logs[0].details + assert len(details["actions_executed"]) == 2 diff --git a/backend/tests/test_encryption.py b/backend/tests/test_encryption.py index 8315271..56886d7 100644 --- a/backend/tests/test_encryption.py +++ b/backend/tests/test_encryption.py @@ -273,3 +273,145 @@ class TestEncryptionKeyValidation: }): settings = Settings() assert settings.ENCRYPTION_MASTER_KEY is None + + +class TestConfidentialProjectUpload: + """Tests for file upload on confidential projects.""" + + @pytest.fixture + def test_user(self, db): + """Create a test user.""" + from app.models import User + import uuid as uuid_module + + user = User( + id=str(uuid_module.uuid4()), + email="testuser_enc@example.com", + name="Test User Encryption", + role_id="00000000-0000-0000-0000-000000000003", + is_active=True, + is_system_admin=False, + ) + db.add(user) + db.commit() + return user + + @pytest.fixture + def test_user_token(self, client, mock_redis, test_user): + """Get a token for test user.""" + from app.core.security import create_access_token, create_token_payload + + token_data = create_token_payload( + user_id=test_user.id, + email=test_user.email, + role="engineer", + department_id=None, + is_system_admin=False, + ) + token = create_access_token(token_data) + mock_redis.setex(f"session:{test_user.id}", 900, token) + return token + + @pytest.fixture + def test_space(self, db, test_user): + """Create a test space.""" + from app.models import Space + import uuid as uuid_module + + space = Space( + id=str(uuid_module.uuid4()), + name="Test Space Encryption", + description="Test space for encryption tests", + owner_id=test_user.id, + ) + db.add(space) + db.commit() + return space + + @pytest.fixture + def confidential_project(self, db, test_space, test_user): + """Create a confidential test project.""" + from app.models import Project + import uuid as uuid_module + + project = Project( + id=str(uuid_module.uuid4()), + space_id=test_space.id, + title="Confidential Project", + description="Test confidential project", + owner_id=test_user.id, + security_level="confidential", + ) + db.add(project) + db.commit() + return project + + @pytest.fixture + def test_task(self, db, confidential_project, test_user): + """Create a test task in confidential project.""" + from app.models import Task + import uuid as uuid_module + + task = Task( + id=str(uuid_module.uuid4()), + project_id=confidential_project.id, + title="Test Task Encryption", + description="Test task for encryption tests", + created_by=test_user.id, + ) + db.add(task) + db.commit() + return task + + def test_upload_confidential_project_encryption_unavailable( + self, client, test_user_token, test_task, db + ): + """Test that uploading to confidential project returns 400 when encryption is unavailable.""" + from io import BytesIO + + # Mock encryption service to return False for is_encryption_available + with patch('app.api.attachments.router.encryption_service') as mock_enc_service: + mock_enc_service.is_encryption_available.return_value = False + + content = b"Test file content" + files = {"file": ("test.pdf", BytesIO(content), "application/pdf")} + + response = client.post( + f"/api/tasks/{test_task.id}/attachments", + headers={"Authorization": f"Bearer {test_user_token}"}, + files=files, + ) + + assert response.status_code == 400 + assert "ENCRYPTION_MASTER_KEY" in response.json()["detail"] + assert "environment variable" in response.json()["detail"] + + def test_upload_confidential_project_no_active_key( + self, client, test_user_token, test_task, db + ): + """Test that uploading to confidential project returns 400 when no active encryption key exists.""" + from io import BytesIO + from app.models import EncryptionKey + + # Make sure no active encryption keys exist + db.query(EncryptionKey).filter(EncryptionKey.is_active == True).update( + {"is_active": False} + ) + db.commit() + + # Mock encryption service to return True for is_encryption_available + with patch('app.api.attachments.router.encryption_service') as mock_enc_service: + mock_enc_service.is_encryption_available.return_value = True + + content = b"Test file content" + files = {"file": ("test.pdf", BytesIO(content), "application/pdf")} + + response = client.post( + f"/api/tasks/{test_task.id}/attachments", + headers={"Authorization": f"Bearer {test_user_token}"}, + files=files, + ) + + assert response.status_code == 400 + assert "active encryption key" in response.json()["detail"] + assert "create" in response.json()["detail"].lower() diff --git a/backend/tests/test_triggers.py b/backend/tests/test_triggers.py index dafe37f..c0d7df7 100644 --- a/backend/tests/test_triggers.py +++ b/backend/tests/test_triggers.py @@ -375,3 +375,119 @@ class TestTriggerAPI: assert response.status_code == 400 assert "Invalid operator" in response.json()["detail"] + + +class TestTriggerActionRollback: + """Tests for trigger action rollback mechanism.""" + + def test_multi_action_rollback_on_failure(self, db, test_task, test_project, test_user, test_status): + """Test that when one action fails, all previous actions are rolled back. + + Scenario: + 1. Create a trigger with 2 actions: update_field (priority) + update_field (invalid status_id) + 2. The first action should update priority to 'high' + 3. The second action should fail because of invalid status_id + 4. The first action's change should be rolled back + """ + # Record original priority + original_priority = test_task.priority + + # Create a trigger with multiple actions where the second one will fail + trigger = Trigger( + id=str(uuid.uuid4()), + project_id=test_project.id, + name="Multi-Action Trigger", + description="Test rollback on failure", + trigger_type="field_change", + conditions={ + "field": "status_id", + "operator": "changed_to", + "value": test_status[1].id, + }, + actions=[ + # First action: update priority (should succeed) + { + "type": "update_field", + "field": "priority", + "value": "high", + }, + # Second action: update to invalid status (should fail) + { + "type": "update_field", + "field": "status_id", + "value": "non-existent-status-id", + }, + ], + is_active=True, + created_by=test_user.id, + ) + db.add(trigger) + db.commit() + + old_values = {"status_id": test_status[0].id} + new_values = {"status_id": test_status[1].id} + + # Execute trigger - second action should fail + logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user) + db.commit() + + # Verify trigger execution failed + assert len(logs) == 1 + assert logs[0].status == "failed" + assert logs[0].error_message is not None + assert "not found" in logs[0].error_message.lower() + + # Refresh task from database to get the actual state + db.refresh(test_task) + + # Verify that the first action's change was rolled back + # Priority should remain unchanged (not 'high') + assert test_task.priority == original_priority, ( + f"Expected priority to be rolled back to '{original_priority}', " + f"but got '{test_task.priority}'" + ) + + def test_all_actions_succeed_committed(self, db, test_task, test_project, test_user, test_status): + """Test that when all actions succeed, changes are committed.""" + # Create a trigger with multiple successful actions + trigger = Trigger( + id=str(uuid.uuid4()), + project_id=test_project.id, + name="Success Trigger", + description="Test successful commit", + trigger_type="field_change", + conditions={ + "field": "status_id", + "operator": "changed_to", + "value": test_status[1].id, + }, + actions=[ + # First action: update priority + { + "type": "update_field", + "field": "priority", + "value": "urgent", + }, + ], + is_active=True, + created_by=test_user.id, + ) + db.add(trigger) + db.commit() + + old_values = {"status_id": test_status[0].id} + new_values = {"status_id": test_status[1].id} + + # Execute trigger + logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user) + db.commit() + + # Verify trigger execution succeeded + assert len(logs) == 1 + assert logs[0].status == "success" + + # Refresh task from database + db.refresh(test_task) + + # Verify that the change was committed + assert test_task.priority == "urgent" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef4e27f..9f2a5b1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,13 +20,87 @@ "react-router-dom": "^6.21.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", + "jsdom": "^27.4.0", "typescript": "^5.2.2", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vitest": "^4.0.16" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.30", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", + "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -262,6 +336,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -310,6 +394,143 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", + "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -599,6 +820,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -616,6 +854,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -633,6 +888,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -701,6 +973,24 @@ "node": ">=12" } }, + "node_modules/@exodus/bytes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz", + "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@exodus/crypto": "^1.0.0-rc.4" + }, + "peerDependenciesMeta": { + "@exodus/crypto": { + "optional": true + } + } + }, "node_modules/@fullcalendar/core": { "version": "6.1.20", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", @@ -1126,6 +1416,110 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1171,6 +1565,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1203,6 +1615,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -1228,6 +1641,143 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1255,6 +1805,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -1324,6 +1884,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1343,6 +1913,53 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1350,6 +1967,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1368,6 +1999,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1377,6 +2015,23 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1398,6 +2053,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1416,6 +2084,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1492,6 +2167,44 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1656,12 +2369,111 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1710,6 +2522,26 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1719,6 +2551,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -1740,6 +2579,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1773,6 +2622,37 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1780,6 +2660,20 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1819,12 +2713,37 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1852,6 +2771,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1894,6 +2820,30 @@ "react-dom": ">=16.8" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.54.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", @@ -1936,6 +2886,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1955,6 +2918,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1965,6 +2935,130 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2071,6 +3165,723 @@ } } }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a51e63f..da3228a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,10 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@fullcalendar/core": "^6.1.20", @@ -21,10 +24,15 @@ "react-router-dom": "^6.21.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", + "jsdom": "^27.4.0", "typescript": "^5.2.2", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vitest": "^4.0.16" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c794ae2..fa0f05c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { useAuth } from './contexts/AuthContext' +import { Skeleton } from './components/Skeleton' import Login from './pages/Login' import Dashboard from './pages/Dashboard' import Spaces from './pages/Spaces' @@ -16,7 +17,12 @@ function App() { const { isAuthenticated, loading } = useAuth() if (loading) { - return
Loading...
+ return ( +
+ + +
+ ) } return ( diff --git a/frontend/src/components/AttachmentList.tsx b/frontend/src/components/AttachmentList.tsx index b030b75..5702347 100644 --- a/frontend/src/components/AttachmentList.tsx +++ b/frontend/src/components/AttachmentList.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [deleting, setDeleting] = useState(null) + const [versionHistory, setVersionHistory] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(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
Loading attachments...
+ return } if (attachments.length === 0) { @@ -84,6 +115,15 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
+ {attachment.current_version > 1 && ( + + )}
))} + + {versionHistory && ( + + )} + + setDeleteConfirm(null)} + /> ) } @@ -163,6 +225,15 @@ const styles: Record = { 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', diff --git a/frontend/src/components/AttachmentVersionHistory.tsx b/frontend/src/components/AttachmentVersionHistory.tsx new file mode 100644 index 0000000..5439b45 --- /dev/null +++ b/frontend/src/components/AttachmentVersionHistory.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [restoring, setRestoring] = useState(null) + const [confirmRestore, setConfirmRestore] = useState(null) + const modalOverlayRef = useRef(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 ( +
+
+
+
+

Version History

+

{filename}

+
+ +
+ +
+ {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading version history...
+ ) : versions.length === 0 ? ( +
No version history available
+ ) : ( +
+ {versions.map((version) => { + const isCurrent = version.version === currentVersion + const isConfirming = confirmRestore === version.version + const isRestoring = restoring === version.version + + return ( +
+
+
+ + Version {version.version} + + {isCurrent && ( + Current + )} +
+
+ {formatDate(version.created_at)} + | + {attachmentService.formatFileSize(version.file_size)} + {version.uploader_name && ( + <> + | + by {version.uploader_name} + + )} +
+
+ +
+ + + {!isCurrent && ( + <> + {isConfirming ? ( +
+ Restore? + + +
+ ) : ( + + )} + + )} +
+
+ ) + })} +
+ )} +
+ +
+ +
+
+
+ ) +} + +const styles: Record = { + 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 diff --git a/frontend/src/components/BlockerDialog.tsx b/frontend/src/components/BlockerDialog.tsx index f0edb66..51a0a0f 100644 --- a/frontend/src/components/BlockerDialog.tsx +++ b/frontend/src/components/BlockerDialog.tsx @@ -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 ? ( -
Loading...
+
+ +
) : ( <> {/* Active blocker */} diff --git a/frontend/src/components/CalendarView.tsx b/frontend/src/components/CalendarView.tsx index 3106323..f908917 100644 --- a/frontend/src/components/CalendarView.tsx +++ b/frontend/src/components/CalendarView.tsx @@ -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({ )} - {loading && Loading...} + {loading && } {/* Calendar */} diff --git a/frontend/src/components/Comments.tsx b/frontend/src/components/Comments.tsx index eb143c5..b7077b6 100644 --- a/frontend/src/components/Comments.tsx +++ b/frontend/src/components/Comments.tsx @@ -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([]) const [showMentions, setShowMentions] = useState(false) + const [deleteConfirm, setDeleteConfirm] = useState(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
Loading comments...
+ if (loading) return return (
@@ -148,7 +151,7 @@ export function Comments({ taskId }: CommentsProps) { Edit + + deleteConfirm && handleDelete(deleteConfirm)} + onCancel={() => setDeleteConfirm(null)} + />
) } diff --git a/frontend/src/components/ConfirmModal.test.tsx b/frontend/src/components/ConfirmModal.test.tsx new file mode 100644 index 0000000..2da0055 --- /dev/null +++ b/frontend/src/components/ConfirmModal.test.tsx @@ -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() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('renders modal when isOpen is true', () => { + render() + 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() + + fireEvent.click(screen.getByText('Confirm')) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('calls onCancel when cancel button is clicked', () => { + const onCancel = vi.fn() + render() + + fireEvent.click(screen.getByText('Cancel')) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('calls onCancel when Escape key is pressed', () => { + const onCancel = vi.fn() + render() + + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('uses custom button text', () => { + render( + + ) + + expect(screen.getByText('Delete')).toBeInTheDocument() + expect(screen.getByText('Go Back')).toBeInTheDocument() + }) + + it('renders confirm button with danger style prop', () => { + const { rerender } = render() + + 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() + const primaryButton = screen.getByText('Confirm') + expect(primaryButton).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..517753f --- /dev/null +++ b/frontend/src/components/ConfirmModal.tsx @@ -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(null) + const confirmButtonRef = useRef(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 ( +
+
+

+ {title} +

+

+ {message} +

+
+ + +
+
+
+ ) +} + +const styles: Record = { + 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 diff --git a/frontend/src/components/CustomFieldEditor.tsx b/frontend/src/components/CustomFieldEditor.tsx index 0ce9384..5623e2a 100644 --- a/frontend/src/components/CustomFieldEditor.tsx +++ b/frontend/src/components/CustomFieldEditor.tsx @@ -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(null) + const modalOverlayRef = useRef(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 ( -
+
-

+

{isEditing ? 'Edit Custom Field' : 'Create Custom Field'}

+ +
+ + ) : ( + + )} + + )} +
+ )} +
+ ) +} + +const styles: Record = { + 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 diff --git a/frontend/src/components/TaskDetailModal.tsx b/frontend/src/components/TaskDetailModal.tsx index 17f9a8e..c040af6 100644 --- a/frontend/src/components/TaskDetailModal.tsx +++ b/frontend/src/components/TaskDetailModal.tsx @@ -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>({}) 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(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 ( -
+
@@ -223,7 +257,7 @@ export function TaskDetailModal({ autoFocus /> ) : ( -

{task.title}

+

{task.title}

)}
@@ -285,6 +319,16 @@ export function TaskDetailModal({
+ + {/* Subtasks Section */} +
+ +
@@ -420,7 +464,7 @@ export function TaskDetailModal({
Custom Fields
{loadingCustomFields ? ( -
Loading...
+ ) : ( customFields.map((field) => { // Get the value for this field diff --git a/frontend/src/components/TriggerList.tsx b/frontend/src/components/TriggerList.tsx index 6300ccf..3655349 100644 --- a/frontend/src/components/TriggerList.tsx +++ b/frontend/src/components/TriggerList.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(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
Loading triggers...
+ return } if (error) { @@ -141,7 +150,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) { )}
))} + + setDeleteConfirm(null)} + />
) } diff --git a/frontend/src/components/UserSelect.tsx b/frontend/src/components/UserSelect.tsx index 05a39bb..44becae 100644 --- a/frontend/src/components/UserSelect.tsx +++ b/frontend/src/components/UserSelect.tsx @@ -189,7 +189,7 @@ const styles: Record = { flex: 1, }, placeholder: { - color: '#999', + color: '#767676', // WCAG AA compliant fontSize: '14px', }, clearButton: { @@ -257,7 +257,7 @@ const styles: Record = { emptyItem: { padding: '12px', textAlign: 'center', - color: '#999', + color: '#767676', // WCAG AA compliant }, } diff --git a/frontend/src/components/WeeklyReportPreview.tsx b/frontend/src/components/WeeklyReportPreview.tsx index 05e4263..cb95969 100644 --- a/frontend/src/components/WeeklyReportPreview.tsx +++ b/frontend/src/components/WeeklyReportPreview.tsx @@ -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(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) } diff --git a/frontend/src/components/WorkloadUserDetail.tsx b/frontend/src/components/WorkloadUserDetail.tsx index 8d525c1..7e7dc95 100644 --- a/frontend/src/components/WorkloadUserDetail.tsx +++ b/frontend/src/components/WorkloadUserDetail.tsx @@ -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(null) const [detail, setDetail] = useState(null) + const modalOverlayRef = useRef(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 ( -
+
e.stopPropagation()}>
-

{userName}

+

{userName}

Workload Details
{loading ? ( -
Loading...
+
+ +
) : error ? (
{error}
) : 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: { diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx new file mode 100644 index 0000000..79ebf9a --- /dev/null +++ b/frontend/src/contexts/ToastContext.tsx @@ -0,0 +1,162 @@ +import { createContext, useContext, useState, useCallback, ReactNode } from 'react' + +type ToastType = 'success' | 'error' | 'warning' | 'info' + +interface Toast { + id: string + type: ToastType + message: string +} + +interface ToastContextValue { + toasts: Toast[] + showToast: (message: string, type?: ToastType) => void + removeToast: (id: string) => void +} + +const ToastContext = createContext(null) + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within a ToastProvider') + } + return context +} + +interface ToastProviderProps { + children: ReactNode +} + +export function ToastProvider({ children }: ToastProviderProps) { + const [toasts, setToasts] = useState([]) + + const showToast = useCallback((message: string, type: ToastType = 'info') => { + const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const toast: Toast = { id, type, message } + + setToasts((prev) => [...prev, toast]) + + // Auto-remove after 5 seconds + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, 5000) + }, []) + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + return ( + + {children} + + + ) +} + +// Toast Container Component +interface ToastContainerProps { + toasts: Toast[] + removeToast: (id: string) => void +} + +function ToastContainer({ toasts, removeToast }: ToastContainerProps) { + if (toasts.length === 0) return null + + return ( +
+ {toasts.map((toast) => ( +
+ {getToastIcon(toast.type)} + {toast.message} + +
+ ))} +
+ ) +} + +function getToastIcon(type: ToastType): string { + switch (type) { + case 'success': + return '✓' + case 'error': + return '✕' + case 'warning': + return '!' + case 'info': + return 'i' + } +} + +function getToastStyle(type: ToastType): React.CSSProperties { + switch (type) { + case 'success': + return { backgroundColor: '#d4edda', borderColor: '#c3e6cb', color: '#155724' } + case 'error': + return { backgroundColor: '#f8d7da', borderColor: '#f5c6cb', color: '#721c24' } + case 'warning': + return { backgroundColor: '#fff3cd', borderColor: '#ffeeba', color: '#856404' } + case 'info': + return { backgroundColor: '#cce5ff', borderColor: '#b8daff', color: '#004085' } + } +} + +const styles: Record = { + container: { + position: 'fixed', + bottom: '20px', + right: '20px', + zIndex: 1300, + display: 'flex', + flexDirection: 'column', + gap: '10px', + maxWidth: '400px', + }, + toast: { + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '12px 16px', + borderRadius: '6px', + border: '1px solid', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + animation: 'slideIn 0.3s ease-out', + }, + icon: { + width: '20px', + height: '20px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 'bold', + fontSize: '12px', + }, + message: { + flex: 1, + fontSize: '14px', + lineHeight: 1.4, + }, + closeButton: { + background: 'none', + border: 'none', + cursor: 'pointer', + fontSize: '16px', + opacity: 0.6, + padding: '0 4px', + }, +} + +export default ToastProvider diff --git a/frontend/src/index.css b/frontend/src/index.css index 6bbb4fa..9e48444 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,6 +4,19 @@ box-sizing: border-box; } +/* A11Y: Global focus-visible styles for keyboard navigation */ +*:focus-visible { + outline: 2px solid #0066cc; + outline-offset: 2px; +} + +/* A11Y: Custom focus styles for inputs that use outline: none */ +.login-input:focus, +.login-input:focus-visible { + border-color: #0066cc; + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.2); +} + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 352ede0..4f87035 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,17 +5,20 @@ import App from './App' import { AuthProvider } from './contexts/AuthContext' import { NotificationProvider } from './contexts/NotificationContext' import { ProjectSyncProvider } from './contexts/ProjectSyncContext' +import { ToastProvider } from './contexts/ToastContext' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + + + + + + + , diff --git a/frontend/src/pages/AuditPage.tsx b/frontend/src/pages/AuditPage.tsx index 6a3d8c9..906b394 100644 --- a/frontend/src/pages/AuditPage.tsx +++ b/frontend/src/pages/AuditPage.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useAuth } from '../contexts/AuthContext' -import { auditService, AuditLog, AuditLogFilters } from '../services/audit' +import { SkeletonTable } from '../components/Skeleton' +import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit' interface AuditLogDetailProps { log: AuditLog @@ -8,12 +9,38 @@ interface AuditLogDetailProps { } function AuditLogDetail({ log, onClose }: AuditLogDetailProps) { + const modalOverlayRef = useRef(null) + + // Handle Escape key to close modal - document-level listener + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + // Focus the overlay for accessibility + modalOverlayRef.current?.focus() + + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + return ( -
+
-

Audit Log Details

- +

Audit Log Details

+
@@ -96,6 +123,162 @@ function getSensitivityStyle(level: string): React.CSSProperties { } } +interface IntegrityVerificationModalProps { + result: IntegrityCheckResponse | null + isLoading: boolean + error: string | null + onClose: () => void +} + +function IntegrityVerificationModal({ result, isLoading, error, onClose }: IntegrityVerificationModalProps) { + const isSuccess = result && result.invalid_count === 0 + const modalOverlayRef = useRef(null) + + // Handle Escape key to close modal - document-level listener + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + // Focus the overlay for accessibility + modalOverlayRef.current?.focus() + + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + + // Add spinner animation via style tag + useEffect(() => { + const styleId = 'integrity-spinner-style' + if (!document.getElementById(styleId)) { + const style = document.createElement('style') + style.id = styleId + style.textContent = ` + @keyframes integrity-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + .integrity-spinner { + animation: integrity-spin 1s linear infinite; + } + ` + document.head.appendChild(style) + } + return () => { + // Clean up on unmount if no other modals use it + } + }, []) + + return ( +
+
+
+

Integrity Verification

+ +
+
+ {isLoading && ( +
+
+

Verifying audit log integrity...

+

This may take a moment depending on the number of records.

+
+ )} + + {error && ( +
+
!
+

Verification Failed

+

{error}

+
+ )} + + {result && !isLoading && ( +
+ {/* Overall Status */} +
+
+ {isSuccess ? '✓' : '✗'} +
+
+

+ {isSuccess ? 'Integrity Verified' : 'Integrity Issues Detected'} +

+

+ {isSuccess + ? 'All audit records have valid checksums and have not been tampered with.' + : 'Some audit records have invalid checksums, indicating potential tampering or corruption.'} +

+
+
+ + {/* Statistics */} +
+
+ {result.total_checked} + Total Checked +
+
+ {result.valid_count} + Valid +
+
0 ? '#ffebee' : '#f5f5f5' }}> + 0 ? '#dc3545' : '#666' }}> + {result.invalid_count} + + Invalid +
+
+ + {/* Invalid Records List */} + {result.invalid_records && result.invalid_records.length > 0 && ( +
+

Invalid Records

+

+ The following record IDs failed integrity verification: +

+
+ {result.invalid_records.map((recordId, index) => ( +
+ ! + {recordId} +
+ ))} +
+
+ )} +
+ )} +
+
+
+ ) +} + export default function AuditPage() { const { user } = useAuth() const [logs, setLogs] = useState([]) @@ -113,13 +296,13 @@ export default function AuditPage() { sensitivity_level: '', }) - useEffect(() => { - if (user?.is_system_admin) { - loadLogs() - } - }, [filters, user]) + // Integrity verification state + const [showVerificationModal, setShowVerificationModal] = useState(false) + const [verificationResult, setVerificationResult] = useState(null) + const [verificationLoading, setVerificationLoading] = useState(false) + const [verificationError, setVerificationError] = useState(null) - const loadLogs = async () => { + const loadLogs = useCallback(async () => { setLoading(true) try { const response = await auditService.getAuditLogs(filters) @@ -130,7 +313,13 @@ export default function AuditPage() { } finally { setLoading(false) } - } + }, [filters]) + + useEffect(() => { + if (user?.is_system_admin) { + loadLogs() + } + }, [loadLogs, user?.is_system_admin]) const handleApplyFilters = () => { setFilters({ @@ -242,6 +431,38 @@ export default function AuditPage() { setFilters({ ...filters, offset: newOffset }) } + const handleVerifyIntegrity = async () => { + setShowVerificationModal(true) + setVerificationLoading(true) + setVerificationResult(null) + setVerificationError(null) + + try { + // Use filter dates if available, otherwise use default range (last 30 days) + const endDate = tempFilters.end_date || new Date().toISOString() + const startDate = tempFilters.start_date || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + + const result = await auditService.verifyIntegrity(startDate, endDate) + setVerificationResult(result) + } catch (error: unknown) { + console.error('Failed to verify integrity:', error) + const err = error as { response?: { data?: { detail?: string } }; message?: string } + setVerificationError( + err.response?.data?.detail || + err.message || + 'An error occurred while verifying integrity. Please try again.' + ) + } finally { + setVerificationLoading(false) + } + } + + const handleCloseVerificationModal = () => { + setShowVerificationModal(false) + setVerificationResult(null) + setVerificationError(null) + } + if (!user?.is_system_admin) { return (
@@ -312,12 +533,15 @@ export default function AuditPage() { +
{/* Results */} {loading ? ( -
Loading...
+ ) : ( <>
@@ -387,6 +611,16 @@ export default function AuditPage() { {selectedLog && ( setSelectedLog(null)} /> )} + + {/* Integrity Verification Modal */} + {showVerificationModal && ( + + )}
) } @@ -559,4 +793,182 @@ const styles: Record = { color: '#666', wordBreak: 'break-all', }, + // Verify Integrity Button + verifyButton: { + padding: '8px 16px', + backgroundColor: '#6f42c1', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontWeight: 'bold', + }, + // Loading styles + loadingContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '40px 20px', + }, + spinner: { + width: '48px', + height: '48px', + border: '4px solid #f3f3f3', + borderTop: '4px solid #6f42c1', + borderRadius: '50%', + }, + loadingText: { + marginTop: '16px', + fontSize: '16px', + fontWeight: 'bold', + color: '#333', + }, + loadingSubtext: { + marginTop: '8px', + fontSize: '14px', + color: '#666', + }, + // Error styles + errorContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '40px 20px', + }, + errorIcon: { + width: '48px', + height: '48px', + backgroundColor: '#dc3545', + color: 'white', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '24px', + fontWeight: 'bold', + }, + errorTitle: { + marginTop: '16px', + fontSize: '18px', + fontWeight: 'bold', + color: '#dc3545', + }, + errorMessage: { + marginTop: '8px', + fontSize: '14px', + color: '#666', + textAlign: 'center', + }, + // Status banner styles + statusBanner: { + display: 'flex', + alignItems: 'flex-start', + gap: '16px', + padding: '16px', + borderRadius: '8px', + border: '1px solid', + marginBottom: '20px', + }, + statusIcon: { + width: '40px', + height: '40px', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: 'white', + fontSize: '20px', + fontWeight: 'bold', + flexShrink: 0, + }, + statusTitle: { + margin: 0, + fontSize: '16px', + fontWeight: 'bold', + }, + statusDescription: { + margin: '4px 0 0 0', + fontSize: '14px', + }, + // Statistics styles + statsContainer: { + display: 'flex', + gap: '16px', + marginBottom: '20px', + }, + statBox: { + flex: 1, + padding: '16px', + backgroundColor: '#f8f9fa', + borderRadius: '8px', + textAlign: 'center', + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, + statValue: { + fontSize: '28px', + fontWeight: 'bold', + color: '#333', + }, + statLabel: { + fontSize: '12px', + color: '#666', + textTransform: 'uppercase', + letterSpacing: '0.5px', + }, + // Invalid records styles + invalidRecordsSection: { + marginTop: '20px', + padding: '16px', + backgroundColor: '#fff5f5', + borderRadius: '8px', + border: '1px solid #ffcdd2', + }, + invalidRecordsTitle: { + margin: '0 0 8px 0', + fontSize: '16px', + fontWeight: 'bold', + color: '#c62828', + }, + invalidRecordsDescription: { + margin: '0 0 12px 0', + fontSize: '14px', + color: '#666', + }, + invalidRecordsList: { + maxHeight: '200px', + overflow: 'auto', + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + invalidRecordItem: { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '8px 12px', + backgroundColor: 'white', + borderRadius: '4px', + border: '1px solid #ffcdd2', + }, + invalidRecordIcon: { + width: '20px', + height: '20px', + backgroundColor: '#dc3545', + color: 'white', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '12px', + fontWeight: 'bold', + flexShrink: 0, + }, + invalidRecordId: { + fontFamily: 'monospace', + fontSize: '13px', + color: '#333', + wordBreak: 'break-all', + }, } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 15a22b8..c5c9f3f 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -18,10 +18,11 @@ export default function Login() { try { await login({ email, password }) navigate('/') - } catch (err: any) { - if (err.response?.status === 401) { + } catch (err: unknown) { + const error = err as { response?: { status?: number } } + if (error.response?.status === 401) { setError('Invalid email or password') - } else if (err.response?.status === 503) { + } else if (error.response?.status === 503) { setError('Authentication service temporarily unavailable') } else { setError('An error occurred. Please try again.') @@ -50,6 +51,7 @@ export default function Login() { value={email} onChange={(e) => setEmail(e.target.value)} style={styles.input} + className="login-input" placeholder="your.email@company.com" required /> @@ -65,6 +67,7 @@ export default function Login() { value={password} onChange={(e) => setPassword(e.target.value)} style={styles.input} + className="login-input" placeholder="Enter your password" required /> diff --git a/frontend/src/pages/ProjectHealthPage.tsx b/frontend/src/pages/ProjectHealthPage.tsx index f1a2551..9812bd5 100644 --- a/frontend/src/pages/ProjectHealthPage.tsx +++ b/frontend/src/pages/ProjectHealthPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { ProjectHealthCard } from '../components/ProjectHealthCard' +import { SkeletonGrid } from '../components/Skeleton' import { projectHealthApi, ProjectHealthDashboardResponse, @@ -184,9 +185,7 @@ export default function ProjectHealthPage() { {/* Content */} {loading ? ( -
-
Loading project health data...
-
+ ) : error ? (

{error}

diff --git a/frontend/src/pages/ProjectSettings.tsx b/frontend/src/pages/ProjectSettings.tsx index 2bbb5a7..eed0fa8 100644 --- a/frontend/src/pages/ProjectSettings.tsx +++ b/frontend/src/pages/ProjectSettings.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate } from 'react-router-dom' import api from '../services/api' import { CustomFieldList } from '../components/CustomFieldList' +import { useToast } from '../contexts/ToastContext' +import { Skeleton } from '../components/Skeleton' interface Project { id: string @@ -15,6 +17,7 @@ interface Project { export default function ProjectSettings() { const { projectId } = useParams() const navigate = useNavigate() + const { showToast } = useToast() const [project, setProject] = useState(null) const [loading, setLoading] = useState(true) const [activeTab, setActiveTab] = useState<'general' | 'custom-fields'>('custom-fields') @@ -29,13 +32,30 @@ export default function ProjectSettings() { setProject(response.data) } catch (err) { console.error('Failed to load project:', err) + showToast('Failed to load project settings', 'error') } finally { setLoading(false) } } if (loading) { - return
Loading...
+ return ( +
+
+ + +
+
+
+ + +
+
+ +
+
+
+ ) } if (!project) { diff --git a/frontend/src/pages/Projects.tsx b/frontend/src/pages/Projects.tsx index e49f234..d7bc2ff 100644 --- a/frontend/src/pages/Projects.tsx +++ b/frontend/src/pages/Projects.tsx @@ -1,6 +1,8 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import api from '../services/api' +import { SkeletonGrid } from '../components/Skeleton' +import { useToast } from '../contexts/ToastContext' interface Project { id: string @@ -23,6 +25,7 @@ interface Space { export default function Projects() { const { spaceId } = useParams() const navigate = useNavigate() + const { showToast } = useToast() const [space, setSpace] = useState(null) const [projects, setProjects] = useState([]) const [loading, setLoading] = useState(true) @@ -33,11 +36,31 @@ export default function Projects() { security_level: 'department', }) const [creating, setCreating] = useState(false) + const modalOverlayRef = useRef(null) useEffect(() => { loadData() }, [spaceId]) + // Handle Escape key to close modal - document-level listener + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && showCreateModal) { + setShowCreateModal(false) + } + } + + if (showCreateModal) { + document.addEventListener('keydown', handleKeyDown) + // Focus the overlay for accessibility + modalOverlayRef.current?.focus() + } + + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [showCreateModal]) + const loadData = async () => { try { const [spaceRes, projectsRes] = await Promise.all([ @@ -48,6 +71,7 @@ export default function Projects() { setProjects(projectsRes.data) } catch (err) { console.error('Failed to load data:', err) + showToast('Failed to load projects', 'error') } finally { setLoading(false) } @@ -62,8 +86,10 @@ export default function Projects() { setShowCreateModal(false) setNewProject({ title: '', description: '', security_level: 'department' }) loadData() + showToast('Project created successfully', 'success') } catch (err) { console.error('Failed to create project:', err) + showToast('Failed to create project', 'error') } finally { setCreating(false) } @@ -84,7 +110,15 @@ export default function Projects() { } if (loading) { - return
Loading...
+ return ( +
+
+
+
+
+ +
+ ) } return ( @@ -110,6 +144,15 @@ export default function Projects() { key={project.id} style={styles.card} onClick={() => navigate(`/projects/${project.id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + navigate(`/projects/${project.id}`) + } + }} + role="button" + tabIndex={0} + aria-label={`Open project: ${project.title}`} >

{project.title}

@@ -135,17 +178,33 @@ export default function Projects() {
{showCreateModal && ( -
+
-

Create New Project

+

Create New Project

+ setNewProject({ ...newProject, title: e.target.value })} style={styles.input} + autoFocus /> +