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

@@ -21,6 +21,7 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@tanstack/react-query": "^5.90.5",
"@types/react-syntax-highlighter": "^15.5.13",
"axios": "^1.13.1",
@@ -4688,6 +4689,60 @@
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",

View File

@@ -35,6 +35,7 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@tanstack/react-query": "^5.90.5",
"@types/react-syntax-highlighter": "^15.5.13",
"axios": "^1.13.1",

View File

@@ -1,8 +1,8 @@
/**
* Agent Types List Page
*
* Displays a list of agent types with search and filter functionality.
* Allows navigation to agent type detail and creation pages.
* Displays a list of agent types with search, status, and category filters.
* Supports grid and list view modes with user preference persistence.
*/
'use client';
@@ -10,9 +10,10 @@
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from '@/lib/i18n/routing';
import { toast } from 'sonner';
import { AgentTypeList } from '@/components/agents';
import { AgentTypeList, type ViewMode } from '@/components/agents';
import { useAgentTypes } from '@/lib/api/hooks/useAgentTypes';
import { useDebounce } from '@/lib/hooks/useDebounce';
import type { AgentTypeCategory } from '@/lib/api/types/agentTypes';
export default function AgentTypesPage() {
const router = useRouter();
@@ -20,6 +21,8 @@ export default function AgentTypesPage() {
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('all');
const [viewMode, setViewMode] = useState<ViewMode>('grid');
// Debounce search for API calls
const debouncedSearch = useDebounce(searchQuery, 300);
@@ -31,20 +34,24 @@ export default function AgentTypesPage() {
return undefined; // 'all' returns undefined to not filter
}, [statusFilter]);
// Determine category filter value
const categoryFilterValue = useMemo(() => {
if (categoryFilter === 'all') return undefined;
return categoryFilter as AgentTypeCategory;
}, [categoryFilter]);
// Fetch agent types
const { data, isLoading, error } = useAgentTypes({
search: debouncedSearch || undefined,
is_active: isActiveFilter,
category: categoryFilterValue,
page: 1,
limit: 50,
});
// Filter results client-side for 'all' status
// Get filtered agent types
const filteredAgentTypes = useMemo(() => {
if (!data?.data) return [];
// When status is 'all', we need to fetch both and combine
// For now, the API returns based on is_active filter
return data.data;
}, [data?.data]);
@@ -71,6 +78,16 @@ export default function AgentTypesPage() {
setStatusFilter(status);
}, []);
// Handle category filter change
const handleCategoryFilterChange = useCallback((category: string) => {
setCategoryFilter(category);
}, []);
// Handle view mode change
const handleViewModeChange = useCallback((mode: ViewMode) => {
setViewMode(mode);
}, []);
// Show error toast if fetch fails
if (error) {
toast.error('Failed to load agent types', {
@@ -87,6 +104,10 @@ export default function AgentTypesPage() {
onSearchChange={handleSearchChange}
statusFilter={statusFilter}
onStatusFilterChange={handleStatusFilterChange}
categoryFilter={categoryFilter}
onCategoryFilterChange={handleCategoryFilterChange}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onSelect={handleSelect}
onCreate={handleCreate}
/>

View File

@@ -2,7 +2,8 @@
* AgentTypeDetail Component
*
* Displays detailed information about a single agent type.
* Shows model configuration, permissions, personality, and instance stats.
* Features a hero header with icon/color, category, typical tasks,
* collaboration hints, model configuration, and instance stats.
*/
'use client';
@@ -36,8 +37,13 @@ import {
Cpu,
CheckCircle2,
AlertTriangle,
Sparkles,
Users,
Check,
} from 'lucide-react';
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
import { DynamicIcon } from '@/components/ui/dynamic-icon';
import type { AgentTypeResponse, AgentTypeCategory } from '@/lib/api/types/agentTypes';
import { CATEGORY_METADATA } from '@/lib/api/types/agentTypes';
import { AVAILABLE_MCP_SERVERS } from '@/lib/validations/agentType';
interface AgentTypeDetailProps {
@@ -51,6 +57,30 @@ interface AgentTypeDetailProps {
className?: string;
}
/**
* Category badge with color
*/
function CategoryBadge({ category }: { category: AgentTypeCategory | null }) {
if (!category) return null;
const meta = CATEGORY_METADATA[category];
if (!meta) return null;
return (
<Badge
variant="outline"
className="font-medium"
style={{
borderColor: meta.color,
color: meta.color,
backgroundColor: `${meta.color}10`,
}}
>
{meta.label}
</Badge>
);
}
/**
* Status badge component for agent types
*/
@@ -81,11 +111,22 @@ function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
function AgentTypeDetailSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="flex-1">
<Skeleton className="h-8 w-64" />
<Skeleton className="mt-2 h-4 w-48" />
{/* Hero skeleton */}
<div className="rounded-xl border p-6">
<div className="flex items-start gap-6">
<Skeleton className="h-20 w-20 rounded-xl" />
<div className="flex-1 space-y-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
<div className="flex gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-24" />
</div>
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-20" />
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
@@ -161,57 +202,134 @@ export function AgentTypeDetail({
top_p?: number;
};
const agentColor = agentType.color || '#3B82F6';
return (
<div className={className}>
{/* Header */}
<div className="mb-6 flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Go back</span>
</Button>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">{agentType.name}</h1>
<AgentTypeStatusBadge isActive={agentType.is_active} />
{/* Back button */}
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Agent Types
</Button>
{/* Hero Header */}
<div
className="mb-6 overflow-hidden rounded-xl border"
style={{
background: `linear-gradient(135deg, ${agentColor}08 0%, transparent 60%)`,
borderColor: `${agentColor}30`,
}}
>
<div
className="h-1.5 w-full"
style={{ background: `linear-gradient(90deg, ${agentColor}, ${agentColor}60)` }}
/>
<div className="p-6">
<div className="flex flex-col gap-6 md:flex-row md:items-start">
{/* Icon */}
<div
className="flex h-20 w-20 shrink-0 items-center justify-center rounded-xl"
style={{
backgroundColor: `${agentColor}15`,
boxShadow: `0 8px 32px ${agentColor}20`,
}}
>
<DynamicIcon
name={agentType.icon}
className="h-10 w-10"
style={{ color: agentColor }}
fallback="bot"
/>
</div>
{/* Info */}
<div className="flex-1 space-y-3">
<div>
<h1 className="text-3xl font-bold">{agentType.name}</h1>
<p className="mt-1 text-muted-foreground">
{agentType.description || 'No description provided'}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<AgentTypeStatusBadge isActive={agentType.is_active} />
<CategoryBadge category={agentType.category} />
<span className="text-sm text-muted-foreground">
Last updated:{' '}
{new Date(agentType.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
</div>
{/* Actions */}
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" onClick={onDuplicate}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</Button>
<Button size="sm" onClick={onEdit}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</div>
</div>
<p className="text-muted-foreground">
Last modified:{' '}
{new Date(agentType.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onDuplicate}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</Button>
<Button size="sm" onClick={onEdit}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Description Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Description
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
{agentType.description || 'No description provided'}
</p>
</CardContent>
</Card>
{/* What This Agent Does Best */}
{agentType.typical_tasks.length > 0 && (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Sparkles className="h-5 w-5 text-primary" />
What This Agent Does Best
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{agentType.typical_tasks.map((task, index) => (
<li key={index} className="flex items-start gap-2">
<Check
className="mt-0.5 h-4 w-4 shrink-0 text-primary"
style={{ color: agentColor }}
/>
<span className="text-sm">{task}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Works Well With */}
{agentType.collaboration_hints.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Users className="h-5 w-5" />
Works Well With
</CardTitle>
<CardDescription>
Agents that complement this type for effective collaboration
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{agentType.collaboration_hints.map((hint, index) => (
<Badge key={index} variant="secondary" className="text-sm">
{hint}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Expertise Card */}
<Card>
@@ -355,7 +473,9 @@ export function AgentTypeDetail({
</CardHeader>
<CardContent>
<div className="text-center">
<p className="text-4xl font-bold text-primary">{agentType.instance_count}</p>
<p className="text-4xl font-bold" style={{ color: agentColor }}>
{agentType.instance_count}
</p>
<p className="text-sm text-muted-foreground">Active instances</p>
</div>
<Button variant="outline" className="mt-4 w-full" size="sm" disabled>
@@ -364,6 +484,36 @@ export function AgentTypeDetail({
</CardContent>
</Card>
{/* Agent Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="h-5 w-5" />
Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Slug</span>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{agentType.slug}</code>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sort Order</span>
<span>{agentType.sort_order}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Created</span>
<span>
{new Date(agentType.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>

View File

@@ -1,8 +1,8 @@
/**
* AgentTypeList Component
*
* Displays a grid of agent type cards with search and filter functionality.
* Used on the main agent types page for browsing and selecting agent types.
* Displays agent types in grid or list view with search, status, and category filters.
* Shows icon, color accent, and category for each agent type.
*/
'use client';
@@ -20,8 +20,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Bot, Plus, Search, Cpu } from 'lucide-react';
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Bot, Plus, Search, Cpu, LayoutGrid, List } from 'lucide-react';
import { DynamicIcon } from '@/components/ui/dynamic-icon';
import type { AgentTypeResponse, AgentTypeCategory } from '@/lib/api/types/agentTypes';
import { CATEGORY_METADATA } from '@/lib/api/types/agentTypes';
import { AGENT_TYPE_CATEGORIES } from '@/lib/validations/agentType';
export type ViewMode = 'grid' | 'list';
interface AgentTypeListProps {
agentTypes: AgentTypeResponse[];
@@ -30,6 +36,10 @@ interface AgentTypeListProps {
onSearchChange: (query: string) => void;
statusFilter: string;
onStatusFilterChange: (status: string) => void;
categoryFilter: string;
onCategoryFilterChange: (category: string) => void;
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
onSelect: (id: string) => void;
onCreate: () => void;
className?: string;
@@ -60,11 +70,36 @@ function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
}
/**
* Loading skeleton for agent type cards
* Category badge with color
*/
function CategoryBadge({ category }: { category: AgentTypeCategory | null }) {
if (!category) return null;
const meta = CATEGORY_METADATA[category];
if (!meta) return null;
return (
<Badge
variant="outline"
className="text-xs font-medium"
style={{
borderColor: meta.color,
color: meta.color,
backgroundColor: `${meta.color}10`,
}}
>
{meta.label}
</Badge>
);
}
/**
* Loading skeleton for agent type cards (grid view)
*/
function AgentTypeCardSkeleton() {
return (
<Card className="h-[200px]">
<Card className="h-[220px] overflow-hidden">
<div className="h-1 w-full bg-muted" />
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<Skeleton className="h-10 w-10 rounded-lg" />
@@ -91,6 +126,23 @@ function AgentTypeCardSkeleton() {
);
}
/**
* Loading skeleton for list view
*/
function AgentTypeListSkeleton() {
return (
<div className="flex items-center gap-4 rounded-lg border p-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-96" />
</div>
<Skeleton className="h-5 w-20" />
<Skeleton className="h-5 w-16" />
</div>
);
}
/**
* Extract model display name from model ID
*/
@@ -103,6 +155,169 @@ function getModelDisplayName(modelId: string): string {
return modelId;
}
/**
* Grid card view for agent type
*/
function AgentTypeGridCard({
type,
onSelect,
}: {
type: AgentTypeResponse;
onSelect: (id: string) => void;
}) {
const agentColor = type.color || '#3B82F6';
return (
<Card
className="cursor-pointer overflow-hidden transition-all hover:shadow-lg"
onClick={() => onSelect(type.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(type.id);
}
}}
aria-label={`View ${type.name} agent type`}
style={{
borderTopColor: agentColor,
borderTopWidth: '3px',
}}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div
className="flex h-11 w-11 items-center justify-center rounded-lg"
style={{
backgroundColor: `${agentColor}15`,
}}
>
<DynamicIcon
name={type.icon}
className="h-5 w-5"
style={{ color: agentColor }}
fallback="bot"
/>
</div>
<div className="flex flex-col items-end gap-1">
<AgentTypeStatusBadge isActive={type.is_active} />
<CategoryBadge category={type.category} />
</div>
</div>
<CardTitle className="mt-3 line-clamp-1">{type.name}</CardTitle>
<CardDescription className="line-clamp-2">
{type.description || 'No description provided'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Expertise tags */}
<div className="flex flex-wrap gap-1">
{type.expertise.slice(0, 3).map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
{type.expertise.length > 3 && (
<Badge variant="outline" className="text-xs">
+{type.expertise.length - 3}
</Badge>
)}
{type.expertise.length === 0 && (
<span className="text-xs text-muted-foreground">No expertise defined</span>
)}
</div>
<Separator />
{/* Metadata */}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Cpu className="h-3.5 w-3.5" />
<span className="text-xs">{getModelDisplayName(type.primary_model)}</span>
</div>
<div className="flex items-center gap-1">
<Bot className="h-3.5 w-3.5" />
<span className="text-xs">{type.instance_count} instances</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
/**
* List row view for agent type
*/
function AgentTypeListRow({
type,
onSelect,
}: {
type: AgentTypeResponse;
onSelect: (id: string) => void;
}) {
const agentColor = type.color || '#3B82F6';
return (
<div
className="flex cursor-pointer items-center gap-4 rounded-lg border p-4 transition-all hover:border-primary hover:shadow-md"
onClick={() => onSelect(type.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(type.id);
}
}}
aria-label={`View ${type.name} agent type`}
style={{
borderLeftColor: agentColor,
borderLeftWidth: '4px',
}}
>
{/* Icon */}
<div
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${agentColor}15` }}
>
<DynamicIcon
name={type.icon}
className="h-6 w-6"
style={{ color: agentColor }}
fallback="bot"
/>
</div>
{/* Main content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{type.name}</h3>
<CategoryBadge category={type.category} />
</div>
<p className="line-clamp-1 text-sm text-muted-foreground">
{type.description || 'No description'}
</p>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{getModelDisplayName(type.primary_model)}
</span>
<span>{type.expertise.length} expertise areas</span>
<span>{type.instance_count} instances</span>
</div>
</div>
{/* Status */}
<div className="shrink-0">
<AgentTypeStatusBadge isActive={type.is_active} />
</div>
</div>
);
}
export function AgentTypeList({
agentTypes,
isLoading = false,
@@ -110,6 +325,10 @@ export function AgentTypeList({
onSearchChange,
statusFilter,
onStatusFilterChange,
categoryFilter,
onCategoryFilterChange,
viewMode,
onViewModeChange,
onSelect,
onCreate,
className,
@@ -131,7 +350,7 @@ export function AgentTypeList({
</div>
{/* Filters */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -142,8 +361,25 @@ export function AgentTypeList({
aria-label="Search agent types"
/>
</div>
{/* Category Filter */}
<Select value={categoryFilter} onValueChange={onCategoryFilterChange}>
<SelectTrigger className="w-full sm:w-44" aria-label="Filter by category">
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{AGENT_TYPE_CATEGORIES.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status Filter */}
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
<SelectTrigger className="w-full sm:w-40" aria-label="Filter by status">
<SelectTrigger className="w-full sm:w-36" aria-label="Filter by status">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
@@ -152,10 +388,25 @@ export function AgentTypeList({
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
{/* View Mode Toggle */}
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(value: string) => value && onViewModeChange(value as ViewMode)}
className="hidden sm:flex"
>
<ToggleGroupItem value="grid" aria-label="Grid view" size="sm">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List view" size="sm">
<List className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Loading State */}
{isLoading && (
{/* Loading State - Grid */}
{isLoading && viewMode === 'grid' && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<AgentTypeCardSkeleton key={i} />
@@ -163,71 +414,29 @@ export function AgentTypeList({
</div>
)}
{/* Agent Type Grid */}
{!isLoading && agentTypes.length > 0 && (
{/* Loading State - List */}
{isLoading && viewMode === 'list' && (
<div className="space-y-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<AgentTypeListSkeleton key={i} />
))}
</div>
)}
{/* Agent Type Grid View */}
{!isLoading && agentTypes.length > 0 && viewMode === 'grid' && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{agentTypes.map((type) => (
<Card
key={type.id}
className="cursor-pointer transition-all hover:border-primary hover:shadow-md"
onClick={() => onSelect(type.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(type.id);
}
}}
aria-label={`View ${type.name} agent type`}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Bot className="h-5 w-5 text-primary" />
</div>
<AgentTypeStatusBadge isActive={type.is_active} />
</div>
<CardTitle className="mt-3">{type.name}</CardTitle>
<CardDescription className="line-clamp-2">
{type.description || 'No description provided'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Expertise tags */}
<div className="flex flex-wrap gap-1">
{type.expertise.slice(0, 3).map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
{type.expertise.length > 3 && (
<Badge variant="outline" className="text-xs">
+{type.expertise.length - 3}
</Badge>
)}
{type.expertise.length === 0 && (
<span className="text-xs text-muted-foreground">No expertise defined</span>
)}
</div>
<AgentTypeGridCard key={type.id} type={type} onSelect={onSelect} />
))}
</div>
)}
<Separator />
{/* Metadata */}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Cpu className="h-3.5 w-3.5" />
<span className="text-xs">{getModelDisplayName(type.primary_model)}</span>
</div>
<div className="flex items-center gap-1">
<Bot className="h-3.5 w-3.5" />
<span className="text-xs">{type.instance_count} instances</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Agent Type List View */}
{!isLoading && agentTypes.length > 0 && viewMode === 'list' && (
<div className="space-y-3">
{agentTypes.map((type) => (
<AgentTypeListRow key={type.id} type={type} onSelect={onSelect} />
))}
</div>
)}
@@ -238,11 +447,11 @@ export function AgentTypeList({
<Bot className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 font-semibold">No agent types found</h3>
<p className="text-muted-foreground">
{searchQuery || statusFilter !== 'all'
{searchQuery || statusFilter !== 'all' || categoryFilter !== 'all'
? 'Try adjusting your search or filters'
: 'Create your first agent type to get started'}
</p>
{!searchQuery && statusFilter === 'all' && (
{!searchQuery && statusFilter === 'all' && categoryFilter === 'all' && (
<Button onClick={onCreate} className="mt-4">
<Plus className="mr-2 h-4 w-4" />
Create Agent Type

View File

@@ -5,5 +5,5 @@
*/
export { AgentTypeForm } from './AgentTypeForm';
export { AgentTypeList } from './AgentTypeList';
export { AgentTypeList, type ViewMode } from './AgentTypeList';
export { AgentTypeDetail } from './AgentTypeDetail';

View File

@@ -0,0 +1,84 @@
/**
* DynamicIcon Component
*
* Renders Lucide icons dynamically by name string.
* Useful when icon names come from data (e.g., database).
*/
import * as LucideIcons from 'lucide-react';
import type { LucideProps } from 'lucide-react';
/**
* Map of icon names to their components.
* Uses kebab-case names (e.g., 'clipboard-check') as keys.
*/
const iconMap: Record<string, React.ComponentType<LucideProps>> = {
// Development
'clipboard-check': LucideIcons.ClipboardCheck,
briefcase: LucideIcons.Briefcase,
'file-text': LucideIcons.FileText,
'git-branch': LucideIcons.GitBranch,
code: LucideIcons.Code,
server: LucideIcons.Server,
layout: LucideIcons.Layout,
smartphone: LucideIcons.Smartphone,
// Design
palette: LucideIcons.Palette,
search: LucideIcons.Search,
// Quality
shield: LucideIcons.Shield,
'shield-check': LucideIcons.ShieldCheck,
// Operations
settings: LucideIcons.Settings,
'settings-2': LucideIcons.Settings2,
// AI/ML
brain: LucideIcons.Brain,
microscope: LucideIcons.Microscope,
eye: LucideIcons.Eye,
'message-square': LucideIcons.MessageSquare,
// Data
'bar-chart': LucideIcons.BarChart,
database: LucideIcons.Database,
// Leadership
users: LucideIcons.Users,
target: LucideIcons.Target,
// Domain Expert
calculator: LucideIcons.Calculator,
'heart-pulse': LucideIcons.HeartPulse,
'flask-conical': LucideIcons.FlaskConical,
lightbulb: LucideIcons.Lightbulb,
'book-open': LucideIcons.BookOpen,
// Generic
bot: LucideIcons.Bot,
cpu: LucideIcons.Cpu,
};
interface DynamicIconProps extends Omit<LucideProps, 'name'> {
/** Icon name in kebab-case (e.g., 'clipboard-check', 'bot') */
name: string | null | undefined;
/** Fallback icon name if the specified icon is not found */
fallback?: string;
}
/**
* Renders a Lucide icon dynamically by name.
*
* @example
* ```tsx
* <DynamicIcon name="clipboard-check" className="h-5 w-5" />
* <DynamicIcon name={agent.icon} fallback="bot" />
* ```
*/
export function DynamicIcon({ name, fallback = 'bot', ...props }: DynamicIconProps) {
const iconName = name || fallback;
const IconComponent = iconMap[iconName] || iconMap[fallback] || LucideIcons.Bot;
return <IconComponent {...props} />;
}
/**
* Get available icon names for validation or display
*/
export function getAvailableIconNames(): string[] {
return Object.keys(iconMap);
}

View File

@@ -0,0 +1,93 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps, cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleGroupVariants = cva(
'inline-flex items-center justify-center rounded-md border bg-transparent',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border border-input',
},
},
defaultVariants: {
variant: 'outline',
},
}
);
const toggleGroupItemVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
{
variants: {
variant: {
default: 'bg-transparent hover:bg-muted hover:text-muted-foreground',
outline: 'bg-transparent hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-3',
sm: 'h-9 px-2.5',
lg: 'h-11 px-5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleGroupItemVariants>>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleGroupVariants> &
VariantProps<typeof toggleGroupItemVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn(toggleGroupVariants({ variant }), className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleGroupItemVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleGroupItemVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@@ -44,10 +44,10 @@ const DEFAULT_PAGE_LIMIT = 20;
export function useAgentTypes(params: AgentTypeListParams = {}) {
const { user } = useAuth();
const { page = 1, limit = DEFAULT_PAGE_LIMIT, is_active = true, search } = params;
const { page = 1, limit = DEFAULT_PAGE_LIMIT, is_active = true, search, category } = params;
return useQuery({
queryKey: agentTypeKeys.list({ page, limit, is_active, search }),
queryKey: agentTypeKeys.list({ page, limit, is_active, search, category }),
queryFn: async (): Promise<AgentTypeListResponse> => {
const response = await apiClient.instance.get('/api/v1/agent-types', {
params: {
@@ -55,6 +55,7 @@ export function useAgentTypes(params: AgentTypeListParams = {}) {
limit,
is_active,
...(search ? { search } : {}),
...(category ? { category } : {}),
},
});
return response.data;

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