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:
55
frontend/package-lock.json
generated
55
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
*/
|
||||
|
||||
export { AgentTypeForm } from './AgentTypeForm';
|
||||
export { AgentTypeList } from './AgentTypeList';
|
||||
export { AgentTypeList, type ViewMode } from './AgentTypeList';
|
||||
export { AgentTypeDetail } from './AgentTypeDetail';
|
||||
|
||||
84
frontend/src/components/ui/dynamic-icon.tsx
Normal file
84
frontend/src/components/ui/dynamic-icon.tsx
Normal 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);
|
||||
}
|
||||
93
frontend/src/components/ui/toggle-group.tsx
Normal file
93
frontend/src/components/ui/toggle-group.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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