forked from cardosofelipe/fast-next-template
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>
505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
/**
|
|
* 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' });
|
|
});
|
|
});
|
|
});
|