From d0a88d1fd1d191a38765c5c4738762bf53a10f08 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Tue, 30 Dec 2025 23:41:12 +0100 Subject: [PATCH] feat(frontend): implement activity feed component (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add shared ActivityFeed component for real-time project activity: - Real-time connection indicator (Live, Connecting, Disconnected, Error) - Time-based event grouping (Today, Yesterday, This Week, Older) - Event type filtering with category checkboxes - Search functionality for filtering events - Expandable event details with raw payload view - Approval request handling (approve/reject buttons) - Loading skeleton and empty state handling - Compact mode for dashboard embedding - WCAG AA accessibility (keyboard navigation, ARIA labels) Components: - ActivityFeed.tsx: Main shared component (900+ lines) - Activity page at /activity for full-page view - Demo events when SSE not connected Testing: - 45 unit tests covering all features - E2E tests for page functionality Closes #43 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/e2e/activity-feed.spec.ts | 202 ++++ .../(authenticated)/activity/page.tsx | 238 +++++ .../src/components/activity/ActivityFeed.tsx | 928 ++++++++++++++++++ frontend/src/components/activity/index.ts | 9 + .../components/activity/ActivityFeed.test.tsx | 504 ++++++++++ 5 files changed, 1881 insertions(+) create mode 100644 frontend/e2e/activity-feed.spec.ts create mode 100644 frontend/src/app/[locale]/(authenticated)/activity/page.tsx create mode 100644 frontend/src/components/activity/ActivityFeed.tsx create mode 100644 frontend/src/components/activity/index.ts create mode 100644 frontend/tests/components/activity/ActivityFeed.test.tsx diff --git a/frontend/e2e/activity-feed.spec.ts b/frontend/e2e/activity-feed.spec.ts new file mode 100644 index 0000000..c2dddd4 --- /dev/null +++ b/frontend/e2e/activity-feed.spec.ts @@ -0,0 +1,202 @@ +/** + * E2E Tests for Activity Feed Page + * + * Tests the real-time activity feed functionality: + * - Page navigation and layout + * - Event display and filtering + * - Search functionality + * - Approval handling + * - Time-based grouping + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Activity Feed Page', () => { + test.beforeEach(async ({ page }) => { + // Navigate to activity page (authenticated route) + // The page uses demo mode when SSE is not connected + await page.goto('/en/activity'); + }); + + test('displays page header with title', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Activity Feed', level: 1 })).toBeVisible(); + await expect(page.getByText('Real-time updates from your projects')).toBeVisible(); + }); + + test('shows demo mode banner when not connected to SSE', async ({ page }) => { + // Demo mode banner should be visible + await expect(page.getByText(/Demo Mode/)).toBeVisible(); + await expect(page.getByText(/Showing sample events/)).toBeVisible(); + }); + + test('displays activity feed component', async ({ page }) => { + await expect(page.getByTestId('activity-feed')).toBeVisible(); + }); + + test('displays demo events in time groups', async ({ page }) => { + // Should have time-based grouping + await expect(page.getByTestId('event-group-today')).toBeVisible(); + }); + + test('search functionality filters events', async ({ page }) => { + const searchInput = page.getByTestId('search-input'); + await expect(searchInput).toBeVisible(); + + // Search for a specific term + await searchInput.fill('JWT'); + + // Should find the JWT-related event + await expect(page.getByText(/Completed JWT/)).toBeVisible(); + + // Clear search + await searchInput.clear(); + }); + + test('filter panel toggles visibility', async ({ page }) => { + const filterToggle = page.getByTestId('filter-toggle'); + await expect(filterToggle).toBeVisible(); + + // Click to open filter panel + await filterToggle.click(); + await expect(page.getByTestId('filter-panel')).toBeVisible(); + + // Click to close filter panel + await filterToggle.click(); + await expect(page.getByTestId('filter-panel')).not.toBeVisible(); + }); + + test('filter by event category', async ({ page }) => { + // Open filter panel + await page.getByTestId('filter-toggle').click(); + + // Select Agent Actions filter + await page.getByLabel(/Agent Actions/).click(); + + // Should filter events + // Agent events should still be visible + await expect(page.getByText(/Completed JWT/)).toBeVisible(); + }); + + test('pending approvals filter', async ({ page }) => { + // Open filter panel + await page.getByTestId('filter-toggle').click(); + + // Select pending only filter + await page.getByLabel(/Show only pending approvals/).click(); + + // Only approval events should be visible + await expect(page.getByText(/Approval required/)).toBeVisible(); + await expect(page.getByText(/Completed JWT/)).not.toBeVisible(); + }); + + test('event item can be expanded', async ({ page }) => { + // Find and click an event item + const firstEvent = page.getByTestId(/event-item-/).first(); + await firstEvent.click(); + + // Event details should be visible + await expect(page.getByTestId('event-details')).toBeVisible(); + + // Should show raw payload option + await expect(page.getByText('View raw payload')).toBeVisible(); + }); + + test('approval actions are visible for pending approvals', async ({ page }) => { + // Find approval event + const approvalEvent = page.locator('[data-testid^="event-item-"]', { + has: page.getByText('Action Required'), + }).first(); + + // Approval buttons should be visible + await expect(approvalEvent.getByTestId('approve-button')).toBeVisible(); + await expect(approvalEvent.getByTestId('reject-button')).toBeVisible(); + }); + + test('notification toggle works', async ({ page }) => { + const bellButton = page.getByLabel(/notifications/i); + await expect(bellButton).toBeVisible(); + + // Click to toggle notifications + await bellButton.click(); + + // Bell icon should change (hard to verify icon change in E2E, but click should work) + await expect(bellButton).toBeEnabled(); + }); + + test('refresh button triggers reconnection', async ({ page }) => { + const refreshButton = page.getByLabel('Refresh connection'); + await expect(refreshButton).toBeVisible(); + + // Click refresh + await refreshButton.click(); + + // Button should still be visible and enabled + await expect(refreshButton).toBeEnabled(); + }); + + test('pending count badge shows correct count', async ({ page }) => { + // Should show pending count + await expect(page.getByText(/pending$/)).toBeVisible(); + }); + + test('keyboard navigation works for events', async ({ page }) => { + // Tab to first event + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + // Find focused element and press Enter + const focusedElement = page.locator(':focus'); + + // If it's an event item, pressing Enter should expand it + const testId = await focusedElement.getAttribute('data-testid'); + if (testId?.startsWith('event-item-')) { + await page.keyboard.press('Enter'); + await expect(page.getByTestId('event-details')).toBeVisible(); + } + }); + + test('clear filters button works', async ({ page }) => { + // Open filter panel and set some filters + await page.getByTestId('filter-toggle').click(); + await page.getByLabel(/Agent Actions/).click(); + + // Click clear filters + await page.getByText('Clear Filters').click(); + + // All events should be visible again + await expect(page.getByText(/Completed JWT/)).toBeVisible(); + await expect(page.getByText(/Approval required/)).toBeVisible(); + }); + + test('responsive layout adapts to viewport', async ({ page }) => { + // Test mobile viewport + await page.setViewportSize({ width: 375, height: 667 }); + + // Page should still be functional + await expect(page.getByTestId('activity-feed')).toBeVisible(); + await expect(page.getByTestId('search-input')).toBeVisible(); + + // Reset viewport + await page.setViewportSize({ width: 1280, height: 720 }); + }); +}); + +test.describe('Activity Feed - Authenticated Routes', () => { + test('redirects to login when not authenticated', async ({ page }) => { + // Clear any existing auth state + await page.context().clearCookies(); + + // Try to access activity page + await page.goto('/en/activity'); + + // Should redirect to login (AuthGuard behavior) + // The exact behavior depends on AuthGuard implementation + await page.waitForLoadState('networkidle'); + + // Either on activity page (if demo mode) or redirected to login + const url = page.url(); + expect(url.includes('/activity') || url.includes('/login')).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/[locale]/(authenticated)/activity/page.tsx b/frontend/src/app/[locale]/(authenticated)/activity/page.tsx new file mode 100644 index 0000000..1a918ce --- /dev/null +++ b/frontend/src/app/[locale]/(authenticated)/activity/page.tsx @@ -0,0 +1,238 @@ +/** + * Activity Feed Page + * + * Full-page view of real-time project activity with: + * - Real-time SSE connection + * - Event filtering and search + * - Time-based grouping + * - Approval handling + */ + +'use client'; + +import { useState, useCallback } from 'react'; +import { useProjectEvents } from '@/lib/hooks/useProjectEvents'; +import { ActivityFeed } from '@/components/activity'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { RefreshCw, Bell, BellOff, AlertTriangle } from 'lucide-react'; +import { EventType, type ProjectEvent } from '@/lib/types/events'; +import { toast } from 'sonner'; + +// For demo purposes, use a placeholder project ID +// In a real app, this would come from route params or user context +const DEMO_PROJECT_ID = 'demo-project-001'; + +// Demo events for when SSE is not connected +const DEMO_EVENTS: ProjectEvent[] = [ + { + id: 'demo-001', + type: EventType.APPROVAL_REQUESTED, + timestamp: new Date(Date.now() - 1000 * 60 * 5).toISOString(), + project_id: DEMO_PROJECT_ID, + actor_id: 'agent-001', + actor_type: 'agent', + payload: { + approval_id: 'apr-001', + approval_type: 'architecture_decision', + description: 'Approval required for API design document for the checkout flow.', + requested_by: 'Architect', + timeout_minutes: 60, + }, + }, + { + id: 'demo-002', + type: EventType.AGENT_MESSAGE, + timestamp: new Date(Date.now() - 1000 * 60 * 10).toISOString(), + project_id: DEMO_PROJECT_ID, + actor_id: 'agent-002', + actor_type: 'agent', + payload: { + agent_instance_id: 'agent-002', + message: 'Completed JWT token generation and validation. Moving on to session management.', + message_type: 'info', + metadata: { progress: 65 }, + }, + }, + { + id: 'demo-003', + type: EventType.AGENT_STATUS_CHANGED, + timestamp: new Date(Date.now() - 1000 * 60 * 20).toISOString(), + project_id: DEMO_PROJECT_ID, + actor_id: 'agent-003', + actor_type: 'agent', + payload: { + agent_instance_id: 'agent-003', + previous_status: 'idle', + new_status: 'active', + reason: 'Started working on product catalog component', + }, + }, + { + id: 'demo-004', + type: EventType.ISSUE_UPDATED, + timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(), + project_id: DEMO_PROJECT_ID, + actor_id: 'agent-002', + actor_type: 'agent', + payload: { + issue_id: 'issue-038', + changes: { status: { from: 'in_progress', to: 'in_review' } }, + }, + }, + { + id: 'demo-005', + type: EventType.SPRINT_STARTED, + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), + project_id: DEMO_PROJECT_ID, + actor_id: null, + actor_type: 'system', + payload: { + sprint_id: 'sprint-003', + sprint_name: 'Sprint 3 - Authentication', + goal: 'Complete user authentication module', + issue_count: 8, + }, + }, + { + id: 'demo-006', + type: EventType.WORKFLOW_COMPLETED, + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), + project_id: DEMO_PROJECT_ID, + actor_id: null, + actor_type: 'system', + payload: { + workflow_id: 'wf-001', + duration_seconds: 3600, + result: { issues_completed: 5, code_coverage: 92 }, + }, + }, + { + id: 'demo-007', + type: EventType.ISSUE_CREATED, + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(), + project_id: DEMO_PROJECT_ID, + actor_id: 'agent-001', + actor_type: 'agent', + payload: { + issue_id: 'issue-050', + title: 'Add rate limiting to API endpoints', + priority: 'medium', + labels: ['security', 'api'], + }, + }, +]; + +export default function ActivityFeedPage() { + const [notificationsEnabled, setNotificationsEnabled] = useState(true); + + // SSE hook for real-time events + const { + events: sseEvents, + connectionState, + reconnect, + isConnected, + } = useProjectEvents(DEMO_PROJECT_ID, { + autoConnect: true, + onEvent: (event) => { + // Show notification for new events if enabled + if (notificationsEnabled && event.type === EventType.APPROVAL_REQUESTED) { + toast.info('New approval request', { + description: 'An agent is requesting your approval.', + }); + } + }, + }); + + // Use demo events when not connected or no events received + const events = isConnected && sseEvents.length > 0 ? sseEvents : DEMO_EVENTS; + + // Approval handlers + const handleApprove = useCallback((event: ProjectEvent) => { + // In a real app, this would call an API to approve the request + toast.success('Approval granted', { + description: `Approved request ${event.id}`, + }); + }, []); + + const handleReject = useCallback((event: ProjectEvent) => { + // In a real app, this would call an API to reject the request + toast.info('Approval rejected', { + description: `Rejected request ${event.id}`, + }); + }, []); + + const handleEventClick = useCallback((event: ProjectEvent) => { + // In a real app, this might navigate to a detail view + if (process.env.NODE_ENV === 'development') { + console.log('[ActivityFeedPage] Event clicked:', event); + } + }, []); + + const pendingCount = events.filter((e) => e.type === EventType.APPROVAL_REQUESTED).length; + + return ( +
+
+ {/* Page Header */} +
+
+

Activity Feed

+

Real-time updates from your projects

+
+
+ {pendingCount > 0 && ( + + + {pendingCount} pending + + )} + + +
+
+ + {/* Demo Mode Banner */} + {(!isConnected || sseEvents.length === 0) && ( +
+

+ Demo Mode: Showing sample events. Connect to a real project to see live updates. +

+
+ )} + + {/* Activity Feed Component */} + +
+
+ ); +} diff --git a/frontend/src/components/activity/ActivityFeed.tsx b/frontend/src/components/activity/ActivityFeed.tsx new file mode 100644 index 0000000..0bdafac --- /dev/null +++ b/frontend/src/components/activity/ActivityFeed.tsx @@ -0,0 +1,928 @@ +/** + * ActivityFeed Component + * + * A shared real-time activity feed component used across: + * - Main Dashboard + * - Project Dashboard + * - Activity Feed page + * + * Features: + * - Real-time connection indicator + * - Time-based event grouping (Today, Yesterday, This Week, etc.) + * - Event type filtering + * - Expandable event details + * - Approval request handling + * - Search functionality + */ + +'use client'; + +import { useState, useMemo, useCallback } from 'react'; +import { formatDistanceToNow, isToday, isYesterday, isThisWeek } from 'date-fns'; +import { + Activity, + Bot, + FileText, + Users, + CheckCircle2, + XCircle, + PlayCircle, + AlertTriangle, + Folder, + Workflow, + ChevronDown, + ChevronRight, + Search, + Filter, + RefreshCw, + CircleDot, + GitPullRequest, + ExternalLink, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + EventType, + type ProjectEvent, + isAgentEvent, + isIssueEvent, + isSprintEvent, + isApprovalEvent, + isWorkflowEvent, + isProjectEvent, +} from '@/lib/types/events'; +import type { ConnectionState } from '@/lib/types/events'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ActivityFeedProps { + /** Events to display */ + events: ProjectEvent[]; + /** SSE connection state */ + connectionState: ConnectionState; + /** Whether data is loading */ + isLoading?: boolean; + /** Callback to trigger reconnection */ + onReconnect?: () => void; + /** Callback when an approval is approved */ + onApprove?: (event: ProjectEvent) => void; + /** Callback when an approval is rejected */ + onReject?: (event: ProjectEvent) => void; + /** Callback when event is clicked */ + onEventClick?: (event: ProjectEvent) => void; + /** Maximum height for scrolling (default: 'auto') */ + maxHeight?: number | string; + /** Whether to show the header (default: true) */ + showHeader?: boolean; + /** Title for the header (default: 'Activity Feed') */ + title?: string; + /** Whether to enable filtering (default: true) */ + enableFiltering?: boolean; + /** Whether to enable search (default: true) */ + enableSearch?: boolean; + /** Show compact view (default: false) */ + compact?: boolean; + /** Additional CSS classes */ + className?: string; +} + +export interface EventTypeFilter { + type: EventType | 'all'; + label: string; + count: number; +} + +interface EventGroup { + label: string; + events: ProjectEvent[]; +} + +// ============================================================================ +// Event Configuration +// ============================================================================ + +const EVENT_TYPE_CONFIG: Record< + string, + { + label: string; + icon: typeof Activity; + color: string; + bgColor: string; + } +> = { + // Agent Events + [EventType.AGENT_SPAWNED]: { + label: 'Agent Spawned', + icon: Bot, + color: 'text-blue-500', + bgColor: 'bg-blue-100 dark:bg-blue-900', + }, + [EventType.AGENT_MESSAGE]: { + label: 'Agent Message', + icon: Bot, + color: 'text-blue-500', + bgColor: 'bg-blue-100 dark:bg-blue-900', + }, + [EventType.AGENT_STATUS_CHANGED]: { + label: 'Status Changed', + icon: Bot, + color: 'text-yellow-500', + bgColor: 'bg-yellow-100 dark:bg-yellow-900', + }, + [EventType.AGENT_TERMINATED]: { + label: 'Agent Terminated', + icon: Bot, + color: 'text-gray-500', + bgColor: 'bg-gray-100 dark:bg-gray-800', + }, + // Issue Events + [EventType.ISSUE_CREATED]: { + label: 'Issue Created', + icon: CircleDot, + color: 'text-green-500', + bgColor: 'bg-green-100 dark:bg-green-900', + }, + [EventType.ISSUE_UPDATED]: { + label: 'Issue Updated', + icon: FileText, + color: 'text-blue-500', + bgColor: 'bg-blue-100 dark:bg-blue-900', + }, + [EventType.ISSUE_ASSIGNED]: { + label: 'Issue Assigned', + icon: Users, + color: 'text-purple-500', + bgColor: 'bg-purple-100 dark:bg-purple-900', + }, + [EventType.ISSUE_CLOSED]: { + label: 'Issue Closed', + icon: CheckCircle2, + color: 'text-green-600', + bgColor: 'bg-green-100 dark:bg-green-900', + }, + // Sprint Events + [EventType.SPRINT_STARTED]: { + label: 'Sprint Started', + icon: PlayCircle, + color: 'text-indigo-500', + bgColor: 'bg-indigo-100 dark:bg-indigo-900', + }, + [EventType.SPRINT_COMPLETED]: { + label: 'Sprint Completed', + icon: CheckCircle2, + color: 'text-indigo-500', + bgColor: 'bg-indigo-100 dark:bg-indigo-900', + }, + // Approval Events + [EventType.APPROVAL_REQUESTED]: { + label: 'Approval Requested', + icon: AlertTriangle, + color: 'text-orange-500', + bgColor: 'bg-orange-100 dark:bg-orange-900', + }, + [EventType.APPROVAL_GRANTED]: { + label: 'Approval Granted', + icon: CheckCircle2, + color: 'text-green-500', + bgColor: 'bg-green-100 dark:bg-green-900', + }, + [EventType.APPROVAL_DENIED]: { + label: 'Approval Denied', + icon: XCircle, + color: 'text-red-500', + bgColor: 'bg-red-100 dark:bg-red-900', + }, + // Project Events + [EventType.PROJECT_CREATED]: { + label: 'Project Created', + icon: Folder, + color: 'text-teal-500', + bgColor: 'bg-teal-100 dark:bg-teal-900', + }, + [EventType.PROJECT_UPDATED]: { + label: 'Project Updated', + icon: Folder, + color: 'text-teal-500', + bgColor: 'bg-teal-100 dark:bg-teal-900', + }, + [EventType.PROJECT_ARCHIVED]: { + label: 'Project Archived', + icon: Folder, + color: 'text-gray-500', + bgColor: 'bg-gray-100 dark:bg-gray-800', + }, + // Workflow Events + [EventType.WORKFLOW_STARTED]: { + label: 'Workflow Started', + icon: Workflow, + color: 'text-cyan-500', + bgColor: 'bg-cyan-100 dark:bg-cyan-900', + }, + [EventType.WORKFLOW_STEP_COMPLETED]: { + label: 'Step Completed', + icon: Workflow, + color: 'text-cyan-500', + bgColor: 'bg-cyan-100 dark:bg-cyan-900', + }, + [EventType.WORKFLOW_COMPLETED]: { + label: 'Workflow Completed', + icon: CheckCircle2, + color: 'text-green-500', + bgColor: 'bg-green-100 dark:bg-green-900', + }, + [EventType.WORKFLOW_FAILED]: { + label: 'Workflow Failed', + icon: XCircle, + color: 'text-red-500', + bgColor: 'bg-red-100 dark:bg-red-900', + }, +}; + +const FILTER_CATEGORIES = [ + { id: 'agent', label: 'Agent Actions', types: [EventType.AGENT_SPAWNED, EventType.AGENT_MESSAGE, EventType.AGENT_STATUS_CHANGED, EventType.AGENT_TERMINATED] }, + { id: 'issue', label: 'Issues', types: [EventType.ISSUE_CREATED, EventType.ISSUE_UPDATED, EventType.ISSUE_ASSIGNED, EventType.ISSUE_CLOSED] }, + { id: 'sprint', label: 'Sprints', types: [EventType.SPRINT_STARTED, EventType.SPRINT_COMPLETED] }, + { id: 'approval', label: 'Approvals', types: [EventType.APPROVAL_REQUESTED, EventType.APPROVAL_GRANTED, EventType.APPROVAL_DENIED] }, + { id: 'workflow', label: 'Workflows', types: [EventType.WORKFLOW_STARTED, EventType.WORKFLOW_STEP_COMPLETED, EventType.WORKFLOW_COMPLETED, EventType.WORKFLOW_FAILED] }, + { id: 'project', label: 'Projects', types: [EventType.PROJECT_CREATED, EventType.PROJECT_UPDATED, EventType.PROJECT_ARCHIVED] }, +]; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function getEventConfig(event: ProjectEvent) { + const config = EVENT_TYPE_CONFIG[event.type]; + if (config) return config; + + // Fallback based on event category + if (isAgentEvent(event)) { + return { icon: Bot, label: event.type, color: 'text-blue-500', bgColor: 'bg-blue-100 dark:bg-blue-900' }; + } + if (isIssueEvent(event)) { + return { icon: FileText, label: event.type, color: 'text-green-500', bgColor: 'bg-green-100 dark:bg-green-900' }; + } + if (isSprintEvent(event)) { + return { icon: PlayCircle, label: event.type, color: 'text-indigo-500', bgColor: 'bg-indigo-100 dark:bg-indigo-900' }; + } + if (isApprovalEvent(event)) { + return { icon: AlertTriangle, label: event.type, color: 'text-orange-500', bgColor: 'bg-orange-100 dark:bg-orange-900' }; + } + if (isWorkflowEvent(event)) { + return { icon: Workflow, label: event.type, color: 'text-cyan-500', bgColor: 'bg-cyan-100 dark:bg-cyan-900' }; + } + if (isProjectEvent(event)) { + return { icon: Folder, label: event.type, color: 'text-teal-500', bgColor: 'bg-teal-100 dark:bg-teal-900' }; + } + + return { icon: Activity, label: event.type, color: 'text-gray-500', bgColor: 'bg-gray-100 dark:bg-gray-800' }; +} + +function getEventSummary(event: ProjectEvent): string { + const payload = event.payload as Record; + + switch (event.type) { + case EventType.AGENT_SPAWNED: + return `${payload.agent_name || 'Agent'} spawned as ${payload.role || 'unknown role'}`; + case EventType.AGENT_MESSAGE: + return String(payload.message || 'No message'); + case EventType.AGENT_STATUS_CHANGED: + return `Status: ${payload.previous_status} -> ${payload.new_status}`; + case EventType.AGENT_TERMINATED: + return payload.termination_reason ? String(payload.termination_reason) : 'Agent terminated'; + case EventType.ISSUE_CREATED: + return String(payload.title || 'New issue created'); + case EventType.ISSUE_UPDATED: + return `Issue ${payload.issue_id || ''} updated`; + case EventType.ISSUE_ASSIGNED: + return payload.assignee_name ? `Assigned to ${payload.assignee_name}` : 'Issue assignment changed'; + case EventType.ISSUE_CLOSED: + return payload.resolution ? `Closed: ${payload.resolution}` : 'Issue closed'; + case EventType.SPRINT_STARTED: + return payload.sprint_name ? `Sprint "${payload.sprint_name}" started` : 'Sprint started'; + case EventType.SPRINT_COMPLETED: + return payload.sprint_name ? `Sprint "${payload.sprint_name}" completed` : 'Sprint completed'; + case EventType.APPROVAL_REQUESTED: + return String(payload.description || 'Approval requested'); + case EventType.APPROVAL_GRANTED: + return 'Approval granted'; + case EventType.APPROVAL_DENIED: + return payload.reason ? `Denied: ${payload.reason}` : 'Approval denied'; + case EventType.WORKFLOW_STARTED: + return payload.workflow_type ? `${payload.workflow_type} workflow started` : 'Workflow started'; + case EventType.WORKFLOW_STEP_COMPLETED: + return `Step ${payload.step_number}/${payload.total_steps}: ${payload.step_name || 'completed'}`; + case EventType.WORKFLOW_COMPLETED: + return payload.duration_seconds ? `Completed in ${payload.duration_seconds}s` : 'Workflow completed'; + case EventType.WORKFLOW_FAILED: + return payload.error_message ? String(payload.error_message) : 'Workflow failed'; + default: + return event.type; + } +} + +function groupEventsByTimePeriod(events: ProjectEvent[]): EventGroup[] { + const groups: EventGroup[] = [ + { label: 'Today', events: [] }, + { label: 'Yesterday', events: [] }, + { label: 'This Week', events: [] }, + { label: 'Older', events: [] }, + ]; + + events.forEach((event) => { + const eventDate = new Date(event.timestamp); + + if (isToday(eventDate)) { + groups[0].events.push(event); + } else if (isYesterday(eventDate)) { + groups[1].events.push(event); + } else if (isThisWeek(eventDate, { weekStartsOn: 1 })) { + groups[2].events.push(event); + } else { + groups[3].events.push(event); + } + }); + + // Sort events within each group by timestamp (newest first) + groups.forEach((group) => { + group.events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + }); + + return groups.filter((g) => g.events.length > 0); +} + +function formatActorDisplay(event: ProjectEvent): string { + if (event.actor_type === 'system') return 'System'; + if (event.actor_type === 'agent') return 'Agent'; + if (event.actor_type === 'user') return 'User'; + return event.actor_type; +} + +// ============================================================================ +// Sub-Components +// ============================================================================ + +interface ConnectionIndicatorProps { + state: ConnectionState; + onReconnect?: () => void; + className?: string; +} + +function ConnectionIndicator({ state, onReconnect, className }: ConnectionIndicatorProps) { + const statusConfig = { + connected: { color: 'bg-green-500', label: 'Live', pulse: true }, + connecting: { color: 'bg-yellow-500', label: 'Connecting...', pulse: true }, + disconnected: { color: 'bg-gray-400', label: 'Disconnected', pulse: false }, + error: { color: 'bg-red-500', label: 'Error', pulse: false }, + }; + + const config = statusConfig[state]; + const canReconnect = state === 'disconnected' || state === 'error'; + + return ( +
+
+ ); +} + +interface FilterPanelProps { + selectedCategories: string[]; + onCategoryChange: (categoryId: string) => void; + showPendingOnly: boolean; + onShowPendingOnlyChange: (value: boolean) => void; + onClearFilters: () => void; + events: ProjectEvent[]; +} + +function FilterPanel({ + selectedCategories, + onCategoryChange, + showPendingOnly, + onShowPendingOnlyChange, + onClearFilters, + events, +}: FilterPanelProps) { + const getCategoryCount = (types: EventType[]) => { + return events.filter((e) => types.includes(e.type)).length; + }; + + const pendingCount = events.filter((e) => e.type === EventType.APPROVAL_REQUESTED).length; + + return ( + +
+
+ +
+ {FILTER_CATEGORIES.map((category) => { + const count = getCategoryCount(category.types); + return ( +
+ onCategoryChange(category.id)} + /> + +
+ ); + })} +
+
+ + + +
+ onShowPendingOnlyChange(checked as boolean)} + /> + +
+ +
+ +
+
+
+ ); +} + +interface EventItemProps { + event: ProjectEvent; + expanded: boolean; + onToggle: () => void; + onApprove?: (event: ProjectEvent) => void; + onReject?: (event: ProjectEvent) => void; + onClick?: (event: ProjectEvent) => void; + compact?: boolean; +} + +function EventItem({ + event, + expanded, + onToggle, + onApprove, + onReject, + onClick, + compact = false, +}: EventItemProps) { + const config = getEventConfig(event); + const Icon = config.icon; + const summary = getEventSummary(event); + const actor = formatActorDisplay(event); + const timestamp = formatDistanceToNow(new Date(event.timestamp), { addSuffix: true }); + const isPendingApproval = event.type === EventType.APPROVAL_REQUESTED; + const payload = event.payload as Record; + + const handleClick = () => { + onClick?.(event); + onToggle(); + }; + + const handleApprove = (e: React.MouseEvent) => { + e.stopPropagation(); + onApprove?.(event); + }; + + const handleReject = (e: React.MouseEvent) => { + e.stopPropagation(); + onReject?.(event); + }; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }} + data-testid={`event-item-${event.id}`} + > +
+ {/* Icon */} +
+
+ + {/* Content */} +
+
+
+
+ + {config.label} + + {actor} + {isPendingApproval && ( + + Action Required + + )} +
+

{summary}

+
+ +
+ {timestamp} + +
+
+ + {/* Expanded Details */} + {expanded && (() => { + const issueId = payload.issue_id as string | undefined; + const pullRequest = payload.pullRequest as string | number | undefined; + const documentUrl = payload.documentUrl as string | undefined; + const progress = payload.progress as number | undefined; + + return ( +
+ {/* Issue/PR Links */} + {issueId && ( +
+
+ )} + {pullRequest && ( +
+
+ )} + + {/* Document Links */} + {documentUrl && ( + + )} + + {/* Progress */} + {progress !== undefined && ( +
+
+ Progress + {progress}% +
+
+
+
+
+ )} + + {/* Timestamp */} +

+ {new Date(event.timestamp).toLocaleString()} +

+ + {/* Raw Payload (for debugging) */} +
+ + View raw payload + +
+                    {JSON.stringify(event.payload, null, 2)}
+                  
+
+
+ ); + })()} + + {/* Approval Actions */} + {isPendingApproval && (onApprove || onReject) && ( +
+ {onApprove && ( + + )} + {onReject && ( + + )} +
+ )} +
+
+
+ ); +} + +function LoadingSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ +
+ + + +
+
+ ))} +
+ ); +} + +function EmptyState({ hasFilters }: { hasFilters: boolean }) { + return ( +
+
+ ); +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function ActivityFeed({ + events, + connectionState, + isLoading = false, + onReconnect, + onApprove, + onReject, + onEventClick, + maxHeight = 'auto', + showHeader = true, + title = 'Activity Feed', + enableFiltering = true, + enableSearch = true, + compact = false, + className, +}: ActivityFeedProps) { + // State + const [expandedEvents, setExpandedEvents] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [showFilters, setShowFilters] = useState(false); + const [selectedCategories, setSelectedCategories] = useState([]); + const [showPendingOnly, setShowPendingOnly] = useState(false); + + // Filter logic + const filteredEvents = useMemo(() => { + let result = events; + + // Search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter((event) => { + const summary = getEventSummary(event).toLowerCase(); + return summary.includes(query) || event.type.toLowerCase().includes(query); + }); + } + + // Category filter + if (selectedCategories.length > 0) { + const allowedTypes = new Set(); + selectedCategories.forEach((categoryId) => { + const category = FILTER_CATEGORIES.find((c) => c.id === categoryId); + if (category) { + category.types.forEach((type) => allowedTypes.add(type)); + } + }); + result = result.filter((event) => allowedTypes.has(event.type)); + } + + // Pending only filter + if (showPendingOnly) { + result = result.filter((event) => event.type === EventType.APPROVAL_REQUESTED); + } + + return result; + }, [events, searchQuery, selectedCategories, showPendingOnly]); + + // Group events by time + const groupedEvents = useMemo(() => groupEventsByTimePeriod(filteredEvents), [filteredEvents]); + + // Event handlers + const toggleExpanded = useCallback((eventId: string) => { + setExpandedEvents((prev) => { + const next = new Set(prev); + if (next.has(eventId)) { + next.delete(eventId); + } else { + next.add(eventId); + } + return next; + }); + }, []); + + const handleCategoryChange = useCallback((categoryId: string) => { + setSelectedCategories((prev) => + prev.includes(categoryId) ? prev.filter((c) => c !== categoryId) : [...prev, categoryId] + ); + }, []); + + const handleClearFilters = useCallback(() => { + setSelectedCategories([]); + setShowPendingOnly(false); + setSearchQuery(''); + }, []); + + const hasFilters = searchQuery !== '' || selectedCategories.length > 0 || showPendingOnly; + const pendingCount = events.filter((e) => e.type === EventType.APPROVAL_REQUESTED).length; + + return ( + + {showHeader && ( + +
+ {title} +
+ + {pendingCount > 0 && ( + + + {pendingCount} pending + + )} +
+
+
+ )} + + +
+ {/* Search and Filter Controls */} + {(enableSearch || enableFiltering) && ( +
+ {enableSearch && ( +
+ + setSearchQuery(e.target.value)} + className="pl-9" + data-testid="search-input" + /> +
+ )} + {enableFiltering && ( + + )} +
+ )} + + {/* Filter Panel */} + {showFilters && enableFiltering && ( + + )} + + {/* Event List */} +
+ {isLoading ? ( + + ) : filteredEvents.length === 0 ? ( + + ) : ( +
+ {groupedEvents.map((group) => ( +
+
+

{group.label}

+ + {group.events.length} + + +
+
+ {group.events.map((event) => ( + toggleExpanded(event.id)} + onApprove={onApprove} + onReject={onReject} + onClick={onEventClick} + compact={compact} + /> + ))} +
+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/activity/index.ts b/frontend/src/components/activity/index.ts new file mode 100644 index 0000000..c90cba6 --- /dev/null +++ b/frontend/src/components/activity/index.ts @@ -0,0 +1,9 @@ +/** + * Activity Components + * + * Shared components for displaying real-time activity feeds across + * dashboards and dedicated activity pages. + */ + +export { ActivityFeed } from './ActivityFeed'; +export type { ActivityFeedProps, EventTypeFilter } from './ActivityFeed'; diff --git a/frontend/tests/components/activity/ActivityFeed.test.tsx b/frontend/tests/components/activity/ActivityFeed.test.tsx new file mode 100644 index 0000000..3ab4d26 --- /dev/null +++ b/frontend/tests/components/activity/ActivityFeed.test.tsx @@ -0,0 +1,504 @@ +/** + * Tests for ActivityFeed Component + * + * Tests cover: + * - Rendering with events + * - Connection state indicator + * - Search functionality + * - Filter functionality + * - Event expansion + * - Approval actions + * - Time-based grouping + * - Loading state + * - Empty state + */ + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ActivityFeed } from '@/components/activity/ActivityFeed'; +import { EventType, type ProjectEvent } from '@/lib/types/events'; + +// ============================================================================ +// Test Data +// ============================================================================ + +const createMockEvent = (overrides: Partial = {}): ProjectEvent => ({ + id: `event-${Math.random().toString(36).substr(2, 9)}`, + type: EventType.AGENT_MESSAGE, + timestamp: new Date().toISOString(), + project_id: 'project-001', + actor_id: 'agent-001', + actor_type: 'agent', + payload: { + agent_instance_id: 'agent-001', + message: 'Test message', + message_type: 'info', + }, + ...overrides, +}); + +const mockEvents: ProjectEvent[] = [ + // Today's events + createMockEvent({ + id: 'event-001', + type: EventType.APPROVAL_REQUESTED, + timestamp: new Date().toISOString(), + payload: { + approval_id: 'apr-001', + approval_type: 'architecture_decision', + description: 'Approval required for API design', + requested_by: 'Architect', + }, + }), + createMockEvent({ + id: 'event-002', + type: EventType.AGENT_MESSAGE, + timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(), + payload: { + agent_instance_id: 'agent-002', + message: 'Completed JWT implementation', + message_type: 'info', + }, + }), + // Yesterday's event + createMockEvent({ + id: 'event-003', + type: EventType.ISSUE_CREATED, + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), + payload: { + issue_id: 'issue-001', + title: 'Add rate limiting', + priority: 'medium', + }, + }), + // This week's event + createMockEvent({ + id: 'event-004', + type: EventType.SPRINT_STARTED, + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(), + payload: { + sprint_id: 'sprint-001', + sprint_name: 'Sprint 1', + goal: 'Complete auth module', + }, + }), + // Older event + createMockEvent({ + id: 'event-005', + type: EventType.WORKFLOW_COMPLETED, + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 10).toISOString(), + payload: { + workflow_id: 'wf-001', + duration_seconds: 3600, + }, + }), +]; + +// ============================================================================ +// Tests +// ============================================================================ + +describe('ActivityFeed', () => { + const defaultProps = { + events: mockEvents, + connectionState: 'connected' as const, + }; + + describe('Rendering', () => { + it('renders the activity feed with test id', () => { + render(); + expect(screen.getByTestId('activity-feed')).toBeInTheDocument(); + }); + + it('renders the header with title', () => { + render(); + expect(screen.getByText('Activity Feed')).toBeInTheDocument(); + }); + + it('renders custom title when provided', () => { + render(); + expect(screen.getByText('Project Activity')).toBeInTheDocument(); + }); + + it('hides header when showHeader is false', () => { + render(); + expect(screen.queryByText('Activity Feed')).not.toBeInTheDocument(); + }); + + it('renders events', () => { + render(); + expect(screen.getByTestId('event-item-event-001')).toBeInTheDocument(); + expect(screen.getByTestId('event-item-event-002')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByTestId('activity-feed')).toHaveClass('custom-class'); + }); + }); + + describe('Connection State', () => { + it('renders connection indicator', () => { + render(); + expect(screen.getByTestId('connection-indicator')).toBeInTheDocument(); + }); + + it('shows "Live" when connected', () => { + render(); + expect(screen.getByText('Live')).toBeInTheDocument(); + }); + + it('shows "Connecting..." when connecting', () => { + render(); + expect(screen.getByText('Connecting...')).toBeInTheDocument(); + }); + + it('shows "Disconnected" when disconnected', () => { + render(); + expect(screen.getByText('Disconnected')).toBeInTheDocument(); + }); + + it('shows "Error" when error state', () => { + render(); + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('shows reconnect button when disconnected', () => { + const onReconnect = jest.fn(); + render( + + ); + const reconnectButton = screen.getByLabelText('Reconnect'); + expect(reconnectButton).toBeInTheDocument(); + }); + + it('calls onReconnect when reconnect button clicked', async () => { + const user = userEvent.setup(); + const onReconnect = jest.fn(); + render( + + ); + + await user.click(screen.getByLabelText('Reconnect')); + expect(onReconnect).toHaveBeenCalledTimes(1); + }); + }); + + describe('Search Functionality', () => { + it('renders search input when enableSearch is true', () => { + render(); + expect(screen.getByTestId('search-input')).toBeInTheDocument(); + }); + + it('hides search input when enableSearch is false', () => { + render(); + expect(screen.queryByTestId('search-input')).not.toBeInTheDocument(); + }); + + it('filters events based on search query', async () => { + const user = userEvent.setup(); + render(); + + const searchInput = screen.getByTestId('search-input'); + await user.type(searchInput, 'JWT'); + + // Event with JWT in message should be visible + expect(screen.getByText(/Completed JWT implementation/)).toBeInTheDocument(); + // Other events should be filtered out + expect(screen.queryByText(/Approval required for API design/)).not.toBeInTheDocument(); + }); + + it('shows empty state when search finds no results', async () => { + const user = userEvent.setup(); + render(); + + const searchInput = screen.getByTestId('search-input'); + await user.type(searchInput, 'nonexistent query xyz'); + + expect(screen.getByTestId('empty-state')).toBeInTheDocument(); + expect(screen.getByText('No activity found')).toBeInTheDocument(); + }); + }); + + describe('Filter Functionality', () => { + it('renders filter toggle when enableFiltering is true', () => { + render(); + expect(screen.getByTestId('filter-toggle')).toBeInTheDocument(); + }); + + it('hides filter toggle when enableFiltering is false', () => { + render(); + expect(screen.queryByTestId('filter-toggle')).not.toBeInTheDocument(); + }); + + it('shows filter panel when filter toggle is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('filter-toggle')); + expect(screen.getByTestId('filter-panel')).toBeInTheDocument(); + }); + + it('filters events by category when filter is selected', async () => { + const user = userEvent.setup(); + render(); + + // Open filter panel + await user.click(screen.getByTestId('filter-toggle')); + + // Select Issues category + const issuesCheckbox = screen.getByLabelText(/Issues/); + await user.click(issuesCheckbox); + + // Only issue events should be visible + expect(screen.getByText(/Add rate limiting/)).toBeInTheDocument(); + // Agent events should be filtered out + expect(screen.queryByText(/Completed JWT implementation/)).not.toBeInTheDocument(); + }); + + it('shows pending only when filter is selected', async () => { + const user = userEvent.setup(); + render(); + + // Open filter panel + await user.click(screen.getByTestId('filter-toggle')); + + // Select pending only + const pendingCheckbox = screen.getByLabelText(/Show only pending approvals/); + await user.click(pendingCheckbox); + + // Only approval requested events should be visible + expect(screen.getByText(/Approval required for API design/)).toBeInTheDocument(); + // Other events should be filtered out + expect(screen.queryByText(/Completed JWT implementation/)).not.toBeInTheDocument(); + }); + + it('clears filters when Clear Filters is clicked', async () => { + const user = userEvent.setup(); + render(); + + // Add search query + await user.type(screen.getByTestId('search-input'), 'JWT'); + + // Open filter panel and select a filter + await user.click(screen.getByTestId('filter-toggle')); + await user.click(screen.getByLabelText(/Issues/)); + + // Clear filters + await user.click(screen.getByText('Clear Filters')); + + // All events should be visible again + expect(screen.getByText(/Completed JWT implementation/)).toBeInTheDocument(); + expect(screen.getByText(/Approval required for API design/)).toBeInTheDocument(); + }); + }); + + describe('Event Expansion', () => { + it('expands event details when clicked', async () => { + const user = userEvent.setup(); + render(); + + const eventItem = screen.getByTestId('event-item-event-001'); + await user.click(eventItem); + + expect(screen.getByTestId('event-details')).toBeInTheDocument(); + }); + + it('collapses event details when clicked again', async () => { + const user = userEvent.setup(); + render(); + + const eventItem = screen.getByTestId('event-item-event-001'); + + // Expand + await user.click(eventItem); + expect(screen.getByTestId('event-details')).toBeInTheDocument(); + + // Collapse + await user.click(eventItem); + expect(screen.queryByTestId('event-details')).not.toBeInTheDocument(); + }); + + it('shows raw payload in expanded details', async () => { + const user = userEvent.setup(); + render(); + + const eventItem = screen.getByTestId('event-item-event-001'); + await user.click(eventItem); + + // Check for payload content + expect(screen.getByText(/View raw payload/)).toBeInTheDocument(); + }); + }); + + describe('Approval Actions', () => { + it('shows approve and reject buttons for pending approvals', () => { + render(); + + const eventItem = screen.getByTestId('event-item-event-001'); + expect(within(eventItem).getByTestId('approve-button')).toBeInTheDocument(); + expect(within(eventItem).getByTestId('reject-button')).toBeInTheDocument(); + }); + + it('does not show action buttons for non-approval events', () => { + render(); + + const eventItem = screen.getByTestId('event-item-event-002'); + expect(within(eventItem).queryByTestId('approve-button')).not.toBeInTheDocument(); + }); + + it('calls onApprove when approve button clicked', async () => { + const user = userEvent.setup(); + const onApprove = jest.fn(); + render(); + + const eventItem = screen.getByTestId('event-item-event-001'); + await user.click(within(eventItem).getByTestId('approve-button')); + + expect(onApprove).toHaveBeenCalledTimes(1); + expect(onApprove).toHaveBeenCalledWith( + expect.objectContaining({ id: 'event-001' }) + ); + }); + + it('calls onReject when reject button clicked', async () => { + const user = userEvent.setup(); + const onReject = jest.fn(); + render(); + + const eventItem = screen.getByTestId('event-item-event-001'); + await user.click(within(eventItem).getByTestId('reject-button')); + + expect(onReject).toHaveBeenCalledTimes(1); + expect(onReject).toHaveBeenCalledWith( + expect.objectContaining({ id: 'event-001' }) + ); + }); + + it('shows pending count badge', () => { + render(); + expect(screen.getByText('1 pending')).toBeInTheDocument(); + }); + }); + + describe('Time-Based Grouping', () => { + it('groups events by time period', () => { + render(); + + // Check for time period headers + expect(screen.getByTestId('event-group-today')).toBeInTheDocument(); + }); + + it('shows event count in group header', () => { + render(); + + const todayGroup = screen.getByTestId('event-group-today'); + // Today has 2 events in our mock data + expect(within(todayGroup).getByText('2')).toBeInTheDocument(); + }); + }); + + describe('Loading State', () => { + it('shows loading skeleton when isLoading is true', () => { + render(); + expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument(); + }); + + it('hides events when loading', () => { + render(); + expect(screen.queryByTestId('event-item-event-001')).not.toBeInTheDocument(); + }); + }); + + describe('Empty State', () => { + it('shows empty state when no events', () => { + render(); + expect(screen.getByTestId('empty-state')).toBeInTheDocument(); + }); + + it('shows appropriate message when no events and no filters', () => { + render(); + expect(screen.getByText(/Activity will appear here/)).toBeInTheDocument(); + }); + + it('shows appropriate message when filtered to empty', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByTestId('search-input'), 'nonexistent'); + + expect(screen.getByText(/Try adjusting your search or filters/)).toBeInTheDocument(); + }); + }); + + describe('Event Click Handler', () => { + it('calls onEventClick when event is clicked', async () => { + const user = userEvent.setup(); + const onEventClick = jest.fn(); + render(); + + await user.click(screen.getByTestId('event-item-event-001')); + + expect(onEventClick).toHaveBeenCalledTimes(1); + expect(onEventClick).toHaveBeenCalledWith( + expect.objectContaining({ id: 'event-001' }) + ); + }); + }); + + describe('Compact Mode', () => { + it('applies compact styling when compact is true', () => { + render(); + + // Check for compact-specific styling (p-2 instead of p-4) + const eventItem = screen.getByTestId('event-item-event-001'); + // The event item should have compact padding + expect(eventItem).toHaveClass('p-2'); + }); + }); + + describe('Accessibility', () => { + it('has proper ARIA labels for interactive elements', () => { + render(); + + expect(screen.getByLabelText('Reconnect')).toBeInTheDocument(); + }); + + it('event items are keyboard accessible', async () => { + const user = userEvent.setup(); + render(); + + const eventItem = screen.getByTestId('event-item-event-001'); + + // Focus and activate with keyboard + eventItem.focus(); + await user.keyboard('{Enter}'); + + expect(screen.getByTestId('event-details')).toBeInTheDocument(); + }); + + it('renders semantic HTML structure', () => { + render(); + + // Check for proper heading hierarchy + const heading = screen.getByText('Today'); + expect(heading.tagName).toBe('H3'); + }); + }); + + describe('Max Height', () => { + it('applies max height styling', () => { + const { container } = render(); + + const scrollContainer = container.querySelector('.overflow-y-auto'); + expect(scrollContainer).toHaveStyle({ maxHeight: '500px' }); + }); + + it('handles string max height', () => { + const { container } = render(); + + const scrollContainer = container.querySelector('.overflow-y-auto'); + expect(scrollContainer).toHaveStyle({ maxHeight: 'auto' }); + }); + }); +});