forked from cardosofelipe/fast-next-template
feat(agents): implement grid/list view toggle and enhance filters
- Added grid and list view modes to AgentTypeList with user preference management. - Enhanced filtering with category selection alongside existing search and status filters. - Updated AgentTypeDetail with category badges and improved layout. - Added unit tests for grid/list views and category filtering in AgentTypeList. - Introduced `@radix-ui/react-toggle-group` for view mode toggle in AgentTypeList.
This commit is contained in:
@@ -65,9 +65,8 @@ describe('AgentTypeDetail', () => {
|
||||
expect(screen.getByText('Inactive')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description card', () => {
|
||||
it('renders description in hero header', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Designs system architecture and makes technology decisions')
|
||||
).toBeInTheDocument();
|
||||
@@ -137,7 +136,7 @@ describe('AgentTypeDetail', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /go back/i }));
|
||||
await user.click(screen.getByRole('button', { name: /back to agent types/i }));
|
||||
expect(defaultProps.onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -218,4 +217,146 @@ describe('AgentTypeDetail', () => {
|
||||
);
|
||||
expect(screen.getByText('None configured')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Hero Header', () => {
|
||||
it('renders hero header with agent name', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 1, name: 'Software Architect' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dynamic icon in hero header', () => {
|
||||
const { container } = render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(container.querySelector('svg.lucide-git-branch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies agent color to hero header gradient', () => {
|
||||
const { container } = render(<AgentTypeDetail {...defaultProps} />);
|
||||
const heroHeader = container.querySelector('[style*="linear-gradient"]');
|
||||
expect(heroHeader).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders category badge in hero header', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Development')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows last updated date in hero header', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText(/Last updated:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Jan 18, 2025/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Typical Tasks Card', () => {
|
||||
it('renders "What This Agent Does Best" card', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('What This Agent Does Best')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays all typical tasks', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Design system architecture')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create ADRs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render typical tasks card when empty', () => {
|
||||
render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, typical_tasks: [] }} />
|
||||
);
|
||||
expect(screen.queryByText('What This Agent Does Best')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collaboration Hints Card', () => {
|
||||
it('renders "Works Well With" card', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Works Well With')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays collaboration hints as badges', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('backend-engineer')).toBeInTheDocument();
|
||||
expect(screen.getByText('frontend-engineer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render collaboration hints card when empty', () => {
|
||||
render(
|
||||
<AgentTypeDetail
|
||||
{...defaultProps}
|
||||
agentType={{ ...mockAgentType, collaboration_hints: [] }}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText('Works Well With')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category Badge', () => {
|
||||
it('renders category badge with correct label', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Development')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render category badge when category is null', () => {
|
||||
render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, category: null }} />
|
||||
);
|
||||
// Should not have a Development badge in the hero header area
|
||||
// The word "Development" should not appear
|
||||
expect(screen.queryByText('Development')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Details Card', () => {
|
||||
it('renders details card with slug', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Slug')).toBeInTheDocument();
|
||||
expect(screen.getByText('software-architect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders details card with sort order', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Sort Order')).toBeInTheDocument();
|
||||
expect(screen.getByText('40')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders details card with creation date', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Created')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Jan 10, 2025/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Icon', () => {
|
||||
it('renders fallback icon when icon is null', () => {
|
||||
const { container } = render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, icon: null }} />
|
||||
);
|
||||
// Should fall back to 'bot' icon
|
||||
expect(container.querySelector('svg.lucide-bot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct icon based on agent type', () => {
|
||||
const agentWithBrainIcon = { ...mockAgentType, icon: 'brain' };
|
||||
const { container } = render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={agentWithBrainIcon} />
|
||||
);
|
||||
expect(container.querySelector('svg.lucide-brain')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Color Styling', () => {
|
||||
it('applies custom color to instance count', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
const instanceCount = screen.getByText('2');
|
||||
expect(instanceCount).toHaveStyle({ color: 'rgb(59, 130, 246)' });
|
||||
});
|
||||
|
||||
it('uses default color when color is null', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, color: null }} />);
|
||||
// Should still render without errors
|
||||
expect(screen.getByText('Software Architect')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,10 @@ describe('AgentTypeList', () => {
|
||||
onSearchChange: jest.fn(),
|
||||
statusFilter: 'all',
|
||||
onStatusFilterChange: jest.fn(),
|
||||
categoryFilter: 'all',
|
||||
onCategoryFilterChange: jest.fn(),
|
||||
viewMode: 'grid' as const,
|
||||
onViewModeChange: jest.fn(),
|
||||
onSelect: jest.fn(),
|
||||
onCreate: jest.fn(),
|
||||
};
|
||||
@@ -208,4 +212,158 @@ describe('AgentTypeList', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} className="custom-class" />);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
describe('Category Filter', () => {
|
||||
it('renders category filter dropdown', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByRole('combobox', { name: /filter by category/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "All Categories" as default option', () => {
|
||||
render(<AgentTypeList {...defaultProps} categoryFilter="all" />);
|
||||
expect(screen.getByText('All Categories')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays category badge on agent cards', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
// Both agents have 'development' category
|
||||
const developmentBadges = screen.getAllByText('Development');
|
||||
expect(developmentBadges.length).toBe(2);
|
||||
});
|
||||
|
||||
it('shows filter hint in empty state when category filter is applied', () => {
|
||||
render(<AgentTypeList {...defaultProps} agentTypes={[]} categoryFilter="design" />);
|
||||
expect(screen.getByText('Try adjusting your search or filters')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('View Mode Toggle', () => {
|
||||
it('renders view mode toggle buttons', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByRole('radio', { name: /grid view/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /list view/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders grid view by default', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="grid" />);
|
||||
// Grid view uses CSS grid
|
||||
expect(container.querySelector('.grid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders list view when viewMode is list', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
// List view uses space-y-3 for vertical stacking
|
||||
expect(container.querySelector('.space-y-3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onViewModeChange when grid toggle is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onViewModeChange = jest.fn();
|
||||
render(
|
||||
<AgentTypeList {...defaultProps} viewMode="list" onViewModeChange={onViewModeChange} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: /grid view/i }));
|
||||
expect(onViewModeChange).toHaveBeenCalledWith('grid');
|
||||
});
|
||||
|
||||
it('calls onViewModeChange when list toggle is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onViewModeChange = jest.fn();
|
||||
render(
|
||||
<AgentTypeList {...defaultProps} viewMode="grid" onViewModeChange={onViewModeChange} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: /list view/i }));
|
||||
expect(onViewModeChange).toHaveBeenCalledWith('list');
|
||||
});
|
||||
|
||||
it('shows list-specific loading skeletons when viewMode is list', () => {
|
||||
const { container } = render(
|
||||
<AgentTypeList {...defaultProps} agentTypes={[]} isLoading={true} viewMode="list" />
|
||||
);
|
||||
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('List View', () => {
|
||||
it('shows agent info in list rows', () => {
|
||||
render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
expect(screen.getByText('Product Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Software Architect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows category badge in list view', () => {
|
||||
render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
const developmentBadges = screen.getAllByText('Development');
|
||||
expect(developmentBadges.length).toBe(2);
|
||||
});
|
||||
|
||||
it('shows expertise count in list view', () => {
|
||||
render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
// Both agents have 3 expertise areas
|
||||
const expertiseTexts = screen.getAllByText('3 expertise areas');
|
||||
expect(expertiseTexts.length).toBe(2);
|
||||
});
|
||||
|
||||
it('calls onSelect when list row is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = jest.fn();
|
||||
render(<AgentTypeList {...defaultProps} viewMode="list" onSelect={onSelect} />);
|
||||
|
||||
await user.click(screen.getByText('Product Owner'));
|
||||
expect(onSelect).toHaveBeenCalledWith('type-001');
|
||||
});
|
||||
|
||||
it('supports keyboard navigation on list rows', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = jest.fn();
|
||||
render(<AgentTypeList {...defaultProps} viewMode="list" onSelect={onSelect} />);
|
||||
|
||||
const rows = screen.getAllByRole('button', { name: /view .* agent type/i });
|
||||
rows[0].focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onSelect).toHaveBeenCalledWith('type-001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Icons', () => {
|
||||
it('renders agent icon in grid view', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="grid" />);
|
||||
// Check for svg icons with lucide classes
|
||||
const icons = container.querySelectorAll('svg.lucide-clipboard-check, svg.lucide-git-branch');
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders agent icon in list view', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
const icons = container.querySelectorAll('svg.lucide-clipboard-check, svg.lucide-git-branch');
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Color Accent', () => {
|
||||
it('applies color to card border in grid view', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="grid" />);
|
||||
const card = container.querySelector('[style*="border-top-color"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies color to row border in list view', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
const row = container.querySelector('[style*="border-left-color"]');
|
||||
expect(row).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category Badge Component', () => {
|
||||
it('does not render category badge when category is null', () => {
|
||||
const agentWithNoCategory: AgentTypeResponse = {
|
||||
...mockAgentTypes[0],
|
||||
category: null,
|
||||
};
|
||||
render(<AgentTypeList {...defaultProps} agentTypes={[agentWithNoCategory]} />);
|
||||
expect(screen.queryByText('Development')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
158
frontend/tests/components/ui/DynamicIcon.test.tsx
Normal file
158
frontend/tests/components/ui/DynamicIcon.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Tests for DynamicIcon Component
|
||||
* Verifies dynamic icon rendering by name string
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DynamicIcon, getAvailableIconNames } from '@/components/ui/dynamic-icon';
|
||||
|
||||
describe('DynamicIcon', () => {
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders an icon by name', () => {
|
||||
render(<DynamicIcon name="bot" data-testid="icon" />);
|
||||
const icon = screen.getByTestId('icon');
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon.tagName).toBe('svg');
|
||||
});
|
||||
|
||||
it('renders different icons by name', () => {
|
||||
const { rerender } = render(<DynamicIcon name="code" data-testid="icon" />);
|
||||
expect(screen.getByTestId('icon')).toHaveClass('lucide-code');
|
||||
|
||||
rerender(<DynamicIcon name="brain" data-testid="icon" />);
|
||||
expect(screen.getByTestId('icon')).toHaveClass('lucide-brain');
|
||||
|
||||
rerender(<DynamicIcon name="shield" data-testid="icon" />);
|
||||
expect(screen.getByTestId('icon')).toHaveClass('lucide-shield');
|
||||
});
|
||||
|
||||
it('renders kebab-case icon names correctly', () => {
|
||||
render(<DynamicIcon name="clipboard-check" data-testid="icon" />);
|
||||
expect(screen.getByTestId('icon')).toHaveClass('lucide-clipboard-check');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback Behavior', () => {
|
||||
it('renders fallback icon when name is null', () => {
|
||||
render(<DynamicIcon name={null} data-testid="icon" />);
|
||||
expect(screen.getByTestId('icon')).toHaveClass('lucide-bot');
|
||||
});
|
||||
|
||||
it('renders fallback icon when name is undefined', () => {
|
||||
render(<DynamicIcon name={undefined} data-testid="icon" />);
|
||||
expect(screen.getByTestId('icon')).toHaveClass('lucide-bot');
|
||||
});
|
||||
|
||||
it('renders fallback icon when name is not found', () => {
|
||||
render(<DynamicIcon name="nonexistent-icon" data-testid="icon" />);
|
||||
expect(screen.getByTestId('icon')).toHaveClass('lucide-bot');
|
||||
});
|
||||
|
||||
it('uses custom fallback when specified', () => {
|
||||
render(<DynamicIcon name={null} fallback="code" data-testid="icon" />);
|
||||
expect(screen.getByTestId('icon')).toHaveClass('lucide-code');
|
||||
});
|
||||
|
||||
it('falls back to bot when custom fallback is also invalid', () => {
|
||||
render(<DynamicIcon name="invalid" fallback="also-invalid" data-testid="icon" />);
|
||||
expect(screen.getByTestId('icon')).toHaveClass('lucide-bot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Props Forwarding', () => {
|
||||
it('forwards className to icon', () => {
|
||||
render(<DynamicIcon name="bot" className="h-5 w-5 text-primary" data-testid="icon" />);
|
||||
const icon = screen.getByTestId('icon');
|
||||
expect(icon).toHaveClass('h-5');
|
||||
expect(icon).toHaveClass('w-5');
|
||||
expect(icon).toHaveClass('text-primary');
|
||||
});
|
||||
|
||||
it('forwards style to icon', () => {
|
||||
render(<DynamicIcon name="bot" style={{ color: 'red' }} data-testid="icon" />);
|
||||
const icon = screen.getByTestId('icon');
|
||||
expect(icon).toHaveStyle({ color: 'rgb(255, 0, 0)' });
|
||||
});
|
||||
|
||||
it('forwards aria-hidden to icon', () => {
|
||||
render(<DynamicIcon name="bot" aria-hidden="true" data-testid="icon" />);
|
||||
const icon = screen.getByTestId('icon');
|
||||
expect(icon).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Available Icons', () => {
|
||||
it('includes development icons', () => {
|
||||
const icons = getAvailableIconNames();
|
||||
expect(icons).toContain('clipboard-check');
|
||||
expect(icons).toContain('briefcase');
|
||||
expect(icons).toContain('code');
|
||||
expect(icons).toContain('server');
|
||||
});
|
||||
|
||||
it('includes design icons', () => {
|
||||
const icons = getAvailableIconNames();
|
||||
expect(icons).toContain('palette');
|
||||
expect(icons).toContain('search');
|
||||
});
|
||||
|
||||
it('includes quality icons', () => {
|
||||
const icons = getAvailableIconNames();
|
||||
expect(icons).toContain('shield');
|
||||
expect(icons).toContain('shield-check');
|
||||
});
|
||||
|
||||
it('includes ai_ml icons', () => {
|
||||
const icons = getAvailableIconNames();
|
||||
expect(icons).toContain('brain');
|
||||
expect(icons).toContain('microscope');
|
||||
expect(icons).toContain('eye');
|
||||
});
|
||||
|
||||
it('includes data icons', () => {
|
||||
const icons = getAvailableIconNames();
|
||||
expect(icons).toContain('bar-chart');
|
||||
expect(icons).toContain('database');
|
||||
});
|
||||
|
||||
it('includes domain expert icons', () => {
|
||||
const icons = getAvailableIconNames();
|
||||
expect(icons).toContain('calculator');
|
||||
expect(icons).toContain('heart-pulse');
|
||||
expect(icons).toContain('flask-conical');
|
||||
expect(icons).toContain('lightbulb');
|
||||
expect(icons).toContain('book-open');
|
||||
});
|
||||
|
||||
it('includes generic icons', () => {
|
||||
const icons = getAvailableIconNames();
|
||||
expect(icons).toContain('bot');
|
||||
expect(icons).toContain('cpu');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Categories Coverage', () => {
|
||||
const iconTestCases = [
|
||||
// Development
|
||||
{ name: 'clipboard-check', expectedClass: 'lucide-clipboard-check' },
|
||||
{ name: 'briefcase', expectedClass: 'lucide-briefcase' },
|
||||
{ name: 'file-text', expectedClass: 'lucide-file-text' },
|
||||
{ name: 'git-branch', expectedClass: 'lucide-git-branch' },
|
||||
{ name: 'layout', expectedClass: 'lucide-panels-top-left' },
|
||||
{ name: 'smartphone', expectedClass: 'lucide-smartphone' },
|
||||
// Operations
|
||||
{ name: 'settings', expectedClass: 'lucide-settings' },
|
||||
{ name: 'settings-2', expectedClass: 'lucide-settings-2' },
|
||||
// AI/ML
|
||||
{ name: 'message-square', expectedClass: 'lucide-message-square' },
|
||||
// Leadership
|
||||
{ name: 'users', expectedClass: 'lucide-users' },
|
||||
{ name: 'target', expectedClass: 'lucide-target' },
|
||||
];
|
||||
|
||||
it.each(iconTestCases)('renders $name icon correctly', ({ name, expectedClass }) => {
|
||||
render(<DynamicIcon name={name} data-testid="icon" />);
|
||||
expect(screen.getByTestId('icon')).toHaveClass(expectedClass);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user