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:
2026-01-06 18:17:46 +01:00
parent 8e16e2645e
commit 3cb6c8d13b
12 changed files with 1208 additions and 137 deletions

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});
});

View 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);
});
});
});