feat(frontend): implement activity feed component (#43)
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 <noreply@anthropic.com>
This commit is contained in:
504
frontend/tests/components/activity/ActivityFeed.test.tsx
Normal file
504
frontend/tests/components/activity/ActivityFeed.test.tsx
Normal file
@@ -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> = {}): 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(<ActivityFeed {...defaultProps} />);
|
||||
expect(screen.getByTestId('activity-feed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the header with title', () => {
|
||||
render(<ActivityFeed {...defaultProps} />);
|
||||
expect(screen.getByText('Activity Feed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom title when provided', () => {
|
||||
render(<ActivityFeed {...defaultProps} title="Project Activity" />);
|
||||
expect(screen.getByText('Project Activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides header when showHeader is false', () => {
|
||||
render(<ActivityFeed {...defaultProps} showHeader={false} />);
|
||||
expect(screen.queryByText('Activity Feed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders events', () => {
|
||||
render(<ActivityFeed {...defaultProps} />);
|
||||
expect(screen.getByTestId('event-item-event-001')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('event-item-event-002')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<ActivityFeed {...defaultProps} className="custom-class" />);
|
||||
expect(screen.getByTestId('activity-feed')).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection State', () => {
|
||||
it('renders connection indicator', () => {
|
||||
render(<ActivityFeed {...defaultProps} />);
|
||||
expect(screen.getByTestId('connection-indicator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Live" when connected', () => {
|
||||
render(<ActivityFeed {...defaultProps} connectionState="connected" />);
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Connecting..." when connecting', () => {
|
||||
render(<ActivityFeed {...defaultProps} connectionState="connecting" />);
|
||||
expect(screen.getByText('Connecting...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Disconnected" when disconnected', () => {
|
||||
render(<ActivityFeed {...defaultProps} connectionState="disconnected" />);
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Error" when error state', () => {
|
||||
render(<ActivityFeed {...defaultProps} connectionState="error" />);
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows reconnect button when disconnected', () => {
|
||||
const onReconnect = jest.fn();
|
||||
render(
|
||||
<ActivityFeed {...defaultProps} connectionState="disconnected" onReconnect={onReconnect} />
|
||||
);
|
||||
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(
|
||||
<ActivityFeed {...defaultProps} connectionState="disconnected" onReconnect={onReconnect} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText('Reconnect'));
|
||||
expect(onReconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('renders search input when enableSearch is true', () => {
|
||||
render(<ActivityFeed {...defaultProps} enableSearch />);
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides search input when enableSearch is false', () => {
|
||||
render(<ActivityFeed {...defaultProps} enableSearch={false} />);
|
||||
expect(screen.queryByTestId('search-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters events based on search query', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActivityFeed {...defaultProps} enableSearch />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} enableSearch />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} enableFiltering />);
|
||||
expect(screen.getByTestId('filter-toggle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides filter toggle when enableFiltering is false', () => {
|
||||
render(<ActivityFeed {...defaultProps} enableFiltering={false} />);
|
||||
expect(screen.queryByTestId('filter-toggle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows filter panel when filter toggle is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActivityFeed {...defaultProps} enableFiltering />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} enableFiltering />);
|
||||
|
||||
// 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(<ActivityFeed {...defaultProps} enableFiltering />);
|
||||
|
||||
// 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(<ActivityFeed {...defaultProps} enableFiltering enableSearch />);
|
||||
|
||||
// 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(<ActivityFeed {...defaultProps} />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} onApprove={jest.fn()} onReject={jest.fn()} />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} onApprove={jest.fn()} onReject={jest.fn()} />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} onApprove={onApprove} />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} onReject={onReject} />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} />);
|
||||
expect(screen.getByText('1 pending')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time-Based Grouping', () => {
|
||||
it('groups events by time period', () => {
|
||||
render(<ActivityFeed {...defaultProps} />);
|
||||
|
||||
// Check for time period headers
|
||||
expect(screen.getByTestId('event-group-today')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows event count in group header', () => {
|
||||
render(<ActivityFeed {...defaultProps} />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} isLoading />);
|
||||
expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides events when loading', () => {
|
||||
render(<ActivityFeed {...defaultProps} isLoading />);
|
||||
expect(screen.queryByTestId('event-item-event-001')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('shows empty state when no events', () => {
|
||||
render(<ActivityFeed {...defaultProps} events={[]} />);
|
||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows appropriate message when no events and no filters', () => {
|
||||
render(<ActivityFeed {...defaultProps} events={[]} />);
|
||||
expect(screen.getByText(/Activity will appear here/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows appropriate message when filtered to empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActivityFeed {...defaultProps} enableSearch />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} onEventClick={onEventClick} />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} compact />);
|
||||
|
||||
// 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(<ActivityFeed {...defaultProps} onReconnect={jest.fn()} connectionState="disconnected" />);
|
||||
|
||||
expect(screen.getByLabelText('Reconnect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('event items are keyboard accessible', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActivityFeed {...defaultProps} />);
|
||||
|
||||
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(<ActivityFeed {...defaultProps} />);
|
||||
|
||||
// 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(<ActivityFeed {...defaultProps} maxHeight={500} />);
|
||||
|
||||
const scrollContainer = container.querySelector('.overflow-y-auto');
|
||||
expect(scrollContainer).toHaveStyle({ maxHeight: '500px' });
|
||||
});
|
||||
|
||||
it('handles string max height', () => {
|
||||
const { container } = render(<ActivityFeed {...defaultProps} maxHeight="auto" />);
|
||||
|
||||
const scrollContainer = container.querySelector('.overflow-y-auto');
|
||||
expect(scrollContainer).toHaveStyle({ maxHeight: 'auto' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user