mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-15 15:08:10 +01:00
feat: Implement infinite scroll for agent grids and enhance performance
- Added `useInfiniteScroll` hook to manage infinite scrolling behavior in agent grids. - Integrated infinite scroll functionality into `AgentGrid` and `VirtualizedAgentGrid` components. - Updated `AgentMarketplace` to pass the scroll container to the agent grid components. - Refactored loading indicators to show a spinner instead of a "Load More" button. - Created `VirtualizedAgentGrid` component for optimized rendering of agent cards using virtualization. - Added performance tests for `VirtualizedAgentGrid` to ensure efficient handling of large datasets. - Updated translations to include new messages for end-of-results scenarios.
This commit is contained in:
parent
0bd8b1d794
commit
d4e7059e6e
9 changed files with 1007 additions and 80 deletions
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useEffect } from 'react';
|
||||||
import { Button, Spinner } from '@librechat/client';
|
import { Spinner } from '@librechat/client';
|
||||||
import { PermissionBits } from 'librechat-data-provider';
|
import { PermissionBits } from 'librechat-data-provider';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
||||||
import { useAgentCategories, useLocalize } from '~/hooks';
|
import { useAgentCategories, useLocalize } from '~/hooks';
|
||||||
|
import { useInfiniteScroll } from '~/hooks/useInfiniteScroll';
|
||||||
import { useHasData } from './SmartLoader';
|
import { useHasData } from './SmartLoader';
|
||||||
import ErrorDisplay from './ErrorDisplay';
|
import ErrorDisplay from './ErrorDisplay';
|
||||||
import AgentCard from './AgentCard';
|
import AgentCard from './AgentCard';
|
||||||
|
|
@ -13,12 +14,18 @@ interface AgentGridProps {
|
||||||
category: string; // Currently selected category
|
category: string; // Currently selected category
|
||||||
searchQuery: string; // Current search query
|
searchQuery: string; // Current search query
|
||||||
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
|
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
|
||||||
|
scrollElement?: HTMLElement | null; // Parent scroll container for infinite scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component for displaying a grid of agent cards
|
* Component for displaying a grid of agent cards
|
||||||
*/
|
*/
|
||||||
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
|
const AgentGrid: React.FC<AgentGridProps> = ({
|
||||||
|
category,
|
||||||
|
searchQuery,
|
||||||
|
onSelectAgent,
|
||||||
|
scrollElement,
|
||||||
|
}) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
// Get category data from API
|
// Get category data from API
|
||||||
|
|
@ -78,6 +85,26 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
// Check if we have meaningful data to prevent unnecessary loading states
|
// Check if we have meaningful data to prevent unnecessary loading states
|
||||||
const hasData = useHasData(data?.pages?.[0]);
|
const hasData = useHasData(data?.pages?.[0]);
|
||||||
|
|
||||||
|
// Set up infinite scroll
|
||||||
|
const { setScrollElement } = useInfiniteScroll({
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage: () => {
|
||||||
|
if (hasNextPage && !isFetching) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
threshold: 0.8, // Trigger when 80% scrolled
|
||||||
|
throttleMs: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect the scroll element when it's provided
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollElement) {
|
||||||
|
setScrollElement(scrollElement);
|
||||||
|
}
|
||||||
|
}, [scrollElement, setScrollElement]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get category display name from API data or use fallback
|
* Get category display name from API data or use fallback
|
||||||
*/
|
*/
|
||||||
|
|
@ -99,44 +126,10 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
|
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// Simple loading spinner
|
||||||
* Load more agents when "See More" button is clicked
|
const loadingSpinner = (
|
||||||
*/
|
<div className="flex justify-center py-12">
|
||||||
const handleLoadMore = () => {
|
<Spinner className="h-8 w-8 text-primary" />
|
||||||
if (hasNextPage && !isFetching) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading skeleton component
|
|
||||||
const loadingSkeleton = (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
||||||
{Array(6)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
'flex animate-pulse overflow-hidden rounded-2xl',
|
|
||||||
'aspect-[5/2.5] w-full',
|
|
||||||
'bg-surface-tertiary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
|
|
||||||
{/* Avatar skeleton */}
|
|
||||||
<div className="flex flex-shrink-0 items-center">
|
|
||||||
<div className="h-10 w-10 rounded-full bg-surface-secondary sm:h-12 sm:w-12"></div>
|
|
||||||
</div>
|
|
||||||
{/* Content skeleton */}
|
|
||||||
<div className="flex flex-1 flex-col justify-center space-y-2">
|
|
||||||
<div className="h-4 w-3/4 rounded bg-surface-secondary"></div>
|
|
||||||
<div className="h-3 w-full rounded bg-surface-secondary"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -207,9 +200,9 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading indicator when fetching more with accessibility */}
|
{/* Loading indicator when fetching more with accessibility */}
|
||||||
{isFetching && hasNextPage && (
|
{isFetchingNextPage && (
|
||||||
<div
|
<div
|
||||||
className="flex justify-center py-4"
|
className="flex justify-center py-8"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label={localize('com_agents_loading')}
|
aria-label={localize('com_agents_loading')}
|
||||||
|
|
@ -219,23 +212,12 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Load more button with enhanced accessibility */}
|
{/* End of results indicator */}
|
||||||
{hasNextPage && !isFetching && (
|
{!hasNextPage && currentAgents && currentAgents.length > 0 && (
|
||||||
<div className="mt-8 flex justify-center">
|
<div className="mt-8 text-center">
|
||||||
<Button
|
<p className="text-sm text-text-secondary">
|
||||||
variant="outline"
|
{localize('com_agents_no_more_results')}
|
||||||
onClick={handleLoadMore}
|
</p>
|
||||||
className={cn(
|
|
||||||
'min-w-[160px] border-2 border-border-medium bg-surface-primary px-6 py-3 font-medium text-text-primary',
|
|
||||||
'shadow-sm transition-all duration-200 hover:border-border-heavy hover:bg-surface-hover',
|
|
||||||
'hover:shadow-md focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
|
||||||
)}
|
|
||||||
aria-label={localize('com_agents_load_more_label', {
|
|
||||||
category: getCategoryDisplayName(category),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{localize('com_agents_see_more')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -244,7 +226,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
||||||
return loadingSkeleton;
|
return loadingSpinner;
|
||||||
}
|
}
|
||||||
return mainContent;
|
return mainContent;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
// Keep a ref of initial mount to avoid animating first sync
|
// Keep a ref of initial mount to avoid animating first sync
|
||||||
const didInitRef = useRef(false);
|
const didInitRef = useRef(false);
|
||||||
|
|
||||||
|
// Ref for the scrollable container to enable infinite scroll
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
|
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
|
||||||
|
|
@ -296,7 +299,10 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
>
|
>
|
||||||
<main className="flex h-full flex-col overflow-hidden" role="main">
|
<main className="flex h-full flex-col overflow-hidden" role="main">
|
||||||
{/* Scrollable container */}
|
{/* Scrollable container */}
|
||||||
<div className="scrollbar-gutter-stable flex h-full flex-col overflow-y-auto overflow-x-hidden">
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="scrollbar-gutter-stable flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
||||||
|
>
|
||||||
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||||
{!isSmallScreen && (
|
{!isSmallScreen && (
|
||||||
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
||||||
|
|
@ -436,6 +442,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
category={displayCategory}
|
category={displayCategory}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSelectAgent={handleAgentSelect}
|
onSelectAgent={handleAgentSelect}
|
||||||
|
scrollElement={scrollContainerRef.current}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -509,6 +516,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
category={nextCategory}
|
category={nextCategory}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSelectAgent={handleAgentSelect}
|
onSelectAgent={handleAgentSelect}
|
||||||
|
scrollElement={scrollContainerRef.current}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
348
client/src/components/Agents/VirtualizedAgentGrid.tsx
Normal file
348
client/src/components/Agents/VirtualizedAgentGrid.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import React, { useMemo, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { AutoSizer, List as VirtualList, WindowScroller } from 'react-virtualized';
|
||||||
|
import { throttle } from 'lodash';
|
||||||
|
import { Spinner } from '@librechat/client';
|
||||||
|
import { PermissionBits } from 'librechat-data-provider';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
||||||
|
import { useAgentCategories, useLocalize } from '~/hooks';
|
||||||
|
import { useHasData } from './SmartLoader';
|
||||||
|
import ErrorDisplay from './ErrorDisplay';
|
||||||
|
import AgentCard from './AgentCard';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface VirtualizedAgentGridProps {
|
||||||
|
category: string;
|
||||||
|
searchQuery: string;
|
||||||
|
onSelectAgent: (agent: t.Agent) => void;
|
||||||
|
scrollElement?: HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants for layout calculations
|
||||||
|
const CARD_HEIGHT = 160; // h-40 in pixels
|
||||||
|
const GAP_SIZE = 24; // gap-6 in pixels
|
||||||
|
const ROW_HEIGHT = CARD_HEIGHT + GAP_SIZE;
|
||||||
|
const CARDS_PER_ROW_MOBILE = 1;
|
||||||
|
const CARDS_PER_ROW_DESKTOP = 2;
|
||||||
|
const OVERSCAN_ROW_COUNT = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtualized grid component for displaying agent cards with high performance
|
||||||
|
*/
|
||||||
|
const VirtualizedAgentGrid: React.FC<VirtualizedAgentGridProps> = ({
|
||||||
|
category,
|
||||||
|
searchQuery,
|
||||||
|
onSelectAgent,
|
||||||
|
scrollElement,
|
||||||
|
}) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const listRef = useRef<VirtualList>(null);
|
||||||
|
const { categories } = useAgentCategories();
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
const queryParams = useMemo(() => {
|
||||||
|
const params: {
|
||||||
|
requiredPermission: number;
|
||||||
|
category?: string;
|
||||||
|
search?: string;
|
||||||
|
limit: number;
|
||||||
|
promoted?: 0 | 1;
|
||||||
|
} = {
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
// Align with AgentGrid to eliminate API mismatch as a factor
|
||||||
|
limit: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
params.search = searchQuery;
|
||||||
|
if (category !== 'all' && category !== 'promoted') {
|
||||||
|
params.category = category;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (category === 'promoted') {
|
||||||
|
params.promoted = 1;
|
||||||
|
} else if (category !== 'all') {
|
||||||
|
params.category = category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [category, searchQuery]);
|
||||||
|
|
||||||
|
// Use infinite query
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isFetching,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
refetch,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useMarketplaceAgentsInfiniteQuery(queryParams);
|
||||||
|
|
||||||
|
// Flatten pages into single array
|
||||||
|
const currentAgents = useMemo(() => {
|
||||||
|
if (!data?.pages) return [];
|
||||||
|
return data.pages.flatMap((page) => page.data || []);
|
||||||
|
}, [data?.pages]);
|
||||||
|
|
||||||
|
const hasData = useHasData(data?.pages?.[0]);
|
||||||
|
|
||||||
|
// Direct scroll handling for virtualized component to avoid hook conflicts
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollElement) return;
|
||||||
|
|
||||||
|
const throttledScrollHandler = throttle(() => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||||
|
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
|
||||||
|
|
||||||
|
if (scrollPosition >= 0.8 && hasNextPage && !isFetchingNextPage && !isFetching) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
scrollElement.addEventListener('scroll', throttledScrollHandler, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollElement.removeEventListener('scroll', throttledScrollHandler);
|
||||||
|
throttledScrollHandler.cancel?.();
|
||||||
|
};
|
||||||
|
}, [scrollElement, hasNextPage, isFetchingNextPage, isFetching, fetchNextPage, category]);
|
||||||
|
|
||||||
|
// Separate effect for list re-rendering on data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.forceUpdateGrid();
|
||||||
|
}
|
||||||
|
}, [currentAgents]);
|
||||||
|
|
||||||
|
// Helper functions for grid calculations
|
||||||
|
const getCardsPerRow = useCallback((width: number) => {
|
||||||
|
return width >= 768 ? CARDS_PER_ROW_DESKTOP : CARDS_PER_ROW_MOBILE;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getRowCount = useCallback((agentCount: number, cardsPerRow: number) => {
|
||||||
|
return Math.ceil(agentCount / cardsPerRow);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getRowItems = useCallback(
|
||||||
|
(rowIndex: number, cardsPerRow: number) => {
|
||||||
|
const startIndex = rowIndex * cardsPerRow;
|
||||||
|
const endIndex = Math.min(startIndex + cardsPerRow, currentAgents.length);
|
||||||
|
return currentAgents.slice(startIndex, endIndex);
|
||||||
|
},
|
||||||
|
[currentAgents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCategoryDisplayName = (categoryValue: string) => {
|
||||||
|
const categoryData = categories.find((cat) => cat.value === categoryValue);
|
||||||
|
if (categoryData) {
|
||||||
|
return categoryData.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryValue === 'promoted') {
|
||||||
|
return localize('com_agents_top_picks');
|
||||||
|
}
|
||||||
|
if (categoryValue === 'all') {
|
||||||
|
return 'All';
|
||||||
|
}
|
||||||
|
|
||||||
|
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Row renderer for virtual list
|
||||||
|
const rowRenderer = useCallback(
|
||||||
|
({ index, key, style, parent }: any) => {
|
||||||
|
const containerWidth = parent?.props?.width || 800;
|
||||||
|
const cardsPerRow = getCardsPerRow(containerWidth);
|
||||||
|
const rowAgents = getRowItems(index, cardsPerRow);
|
||||||
|
const totalRows = getRowCount(currentAgents.length, cardsPerRow);
|
||||||
|
const isLastRow = index === totalRows - 1;
|
||||||
|
const showLoading = isFetchingNextPage && isLastRow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} style={style}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-6 px-0',
|
||||||
|
cardsPerRow === 1 ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2',
|
||||||
|
)}
|
||||||
|
role="row"
|
||||||
|
aria-rowindex={index + 1}
|
||||||
|
>
|
||||||
|
{rowAgents.map((agent: t.Agent, cardIndex: number) => {
|
||||||
|
const globalIndex = index * cardsPerRow + cardIndex;
|
||||||
|
return (
|
||||||
|
<div key={`${agent.id}-${globalIndex}`} role="gridcell">
|
||||||
|
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showLoading && (
|
||||||
|
<div
|
||||||
|
className="flex justify-center py-4"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={localize('com_agents_loading')}
|
||||||
|
>
|
||||||
|
<Spinner className="h-6 w-6 text-primary" />
|
||||||
|
<span className="sr-only">{localize('com_agents_loading')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
currentAgents,
|
||||||
|
getCardsPerRow,
|
||||||
|
getRowItems,
|
||||||
|
getRowCount,
|
||||||
|
isFetchingNextPage,
|
||||||
|
localize,
|
||||||
|
onSelectAgent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simple loading spinner
|
||||||
|
const loadingSpinner = (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Spinner className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
error={error || 'Unknown error occurred'}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
context={{ searchQuery, category }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle loading state
|
||||||
|
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
||||||
|
return loadingSpinner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty results
|
||||||
|
if ((!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="py-12 text-center text-text-secondary"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={
|
||||||
|
searchQuery
|
||||||
|
? localize('com_agents_search_empty_heading')
|
||||||
|
: localize('com_agents_empty_state_heading')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h3 className="mb-2 text-lg font-medium">{localize('com_agents_empty_state_heading')}</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main virtualized content
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-6"
|
||||||
|
role="tabpanel"
|
||||||
|
id={`category-panel-${category}`}
|
||||||
|
aria-labelledby={`category-tab-${category}`}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy={isLoading && !hasData}
|
||||||
|
>
|
||||||
|
{/* Screen reader announcement */}
|
||||||
|
<div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true">
|
||||||
|
{localize('com_agents_grid_announcement', {
|
||||||
|
count: currentAgents?.length || 0,
|
||||||
|
category: getCategoryDisplayName(category),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Virtualized grid with external scroll integration */}
|
||||||
|
<div
|
||||||
|
role="grid"
|
||||||
|
aria-label={localize('com_agents_grid_announcement', {
|
||||||
|
count: currentAgents.length,
|
||||||
|
category: getCategoryDisplayName(category),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{scrollElement ? (
|
||||||
|
<WindowScroller scrollElement={scrollElement}>
|
||||||
|
{({ height, isScrolling, registerChild, onChildScroll, scrollTop }) => (
|
||||||
|
<AutoSizer disableHeight>
|
||||||
|
{({ width }) => {
|
||||||
|
const cardsPerRow = getCardsPerRow(width);
|
||||||
|
const rowCount = getRowCount(currentAgents.length, cardsPerRow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={registerChild}>
|
||||||
|
<VirtualList
|
||||||
|
ref={listRef}
|
||||||
|
autoHeight
|
||||||
|
height={height}
|
||||||
|
isScrolling={isScrolling}
|
||||||
|
onScroll={onChildScroll}
|
||||||
|
overscanRowCount={OVERSCAN_ROW_COUNT}
|
||||||
|
rowCount={rowCount}
|
||||||
|
rowHeight={ROW_HEIGHT}
|
||||||
|
rowRenderer={rowRenderer}
|
||||||
|
scrollTop={scrollTop}
|
||||||
|
width={width}
|
||||||
|
style={{ outline: 'none' }}
|
||||||
|
aria-rowcount={rowCount}
|
||||||
|
data-testid="virtual-list"
|
||||||
|
data-total-rows={rowCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</AutoSizer>
|
||||||
|
)}
|
||||||
|
</WindowScroller>
|
||||||
|
) : (
|
||||||
|
// Fallback for when no external scroll element is provided
|
||||||
|
<div style={{ height: 600 }}>
|
||||||
|
<AutoSizer>
|
||||||
|
{({ width, height }) => {
|
||||||
|
const cardsPerRow = getCardsPerRow(width);
|
||||||
|
const rowCount = getRowCount(currentAgents.length, cardsPerRow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualList
|
||||||
|
ref={listRef}
|
||||||
|
height={height}
|
||||||
|
overscanRowCount={OVERSCAN_ROW_COUNT}
|
||||||
|
rowCount={rowCount}
|
||||||
|
rowHeight={ROW_HEIGHT}
|
||||||
|
rowRenderer={rowRenderer}
|
||||||
|
width={width}
|
||||||
|
style={{ outline: 'none' }}
|
||||||
|
aria-rowcount={rowCount}
|
||||||
|
data-testid="virtual-list"
|
||||||
|
data-total-rows={rowCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</AutoSizer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End of results indicator */}
|
||||||
|
{!hasNextPage && currentAgents && currentAgents.length > 0 && (
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p className="text-sm text-text-secondary">{localize('com_agents_no_more_results')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VirtualizedAgentGrid;
|
||||||
|
|
@ -37,7 +37,7 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => {
|
||||||
com_agents_empty_state_heading: 'No agents available',
|
com_agents_empty_state_heading: 'No agents available',
|
||||||
com_agents_loading: 'Loading...',
|
com_agents_loading: 'Loading...',
|
||||||
com_agents_grid_announcement: '{{count}} agents in {{category}}',
|
com_agents_grid_announcement: '{{count}} agents in {{category}}',
|
||||||
com_agents_load_more_label: 'Load more agents from {{category}}',
|
com_agents_no_more_results: "You've reached the end of the results",
|
||||||
};
|
};
|
||||||
|
|
||||||
let translation = mockTranslations[key] || key;
|
let translation = mockTranslations[key] || key;
|
||||||
|
|
@ -341,24 +341,12 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Load More Functionality', () => {
|
describe('Infinite Scroll Functionality', () => {
|
||||||
it('should show "See more" button when hasNextPage is true', () => {
|
it('should show loading indicator when fetching next page', () => {
|
||||||
const Wrapper = createWrapper();
|
|
||||||
render(
|
|
||||||
<Wrapper>
|
|
||||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
|
||||||
</Wrapper>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByRole('button', { name: 'Load more agents from Finance' }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not show "See more" button when hasNextPage is false', () => {
|
|
||||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||||
...defaultMockQueryResult,
|
...defaultMockQueryResult,
|
||||||
hasNextPage: false,
|
isFetchingNextPage: true,
|
||||||
|
hasNextPage: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const Wrapper = createWrapper();
|
const Wrapper = createWrapper();
|
||||||
|
|
@ -368,7 +356,44 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||||
</Wrapper>,
|
</Wrapper>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: /Load more agents/ })).not.toBeInTheDocument();
|
expect(screen.getByRole('status', { name: 'Loading...' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Loading...')).toHaveClass('sr-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show end of results message when hasNextPage is false and agents exist', () => {
|
||||||
|
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
hasNextPage: false,
|
||||||
|
isFetchingNextPage: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = createWrapper();
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("You've reached the end of the results")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show end of results message when no agents exist', () => {
|
||||||
|
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
hasNextPage: false,
|
||||||
|
data: {
|
||||||
|
pages: [{ data: [] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = createWrapper();
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("You've reached the end of the results")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import VirtualizedAgentGrid from '../VirtualizedAgentGrid';
|
||||||
|
|
||||||
|
// Mock react-virtualized for performance testing
|
||||||
|
const mockRowRenderer = jest.fn();
|
||||||
|
jest.mock('react-virtualized', () => ({
|
||||||
|
AutoSizer: ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: (props: { width: number; height: number }) => React.ReactNode;
|
||||||
|
}) => children({ width: 1200, height: 800 }),
|
||||||
|
List: ({ rowRenderer, rowCount }: { rowRenderer: any; rowCount: number }) => {
|
||||||
|
mockRowRenderer.mockImplementation(rowRenderer);
|
||||||
|
// Only render visible rows to simulate virtualization
|
||||||
|
const visibleRows = Math.min(10, rowCount); // Simulate 10 visible rows
|
||||||
|
return (
|
||||||
|
<div data-testid="virtual-list" data-total-rows={rowCount}>
|
||||||
|
{Array.from({ length: visibleRows }, (_, index) =>
|
||||||
|
rowRenderer({
|
||||||
|
index,
|
||||||
|
key: `row-${index}`,
|
||||||
|
style: { height: 184 },
|
||||||
|
parent: { props: { width: 1200 } },
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Generate large dataset for performance testing
|
||||||
|
const generateLargeDataset = (count: number) => {
|
||||||
|
const agents = [];
|
||||||
|
for (let i = 1; i <= count; i++) {
|
||||||
|
agents.push({
|
||||||
|
id: `agent-${i}`,
|
||||||
|
name: `Performance Test Agent ${i}`,
|
||||||
|
description: `This is agent ${i} for performance testing virtual scrolling with large datasets`,
|
||||||
|
category: i % 2 === 0 ? 'productivity' : 'development',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return agents;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the data provider with large dataset
|
||||||
|
const createMockInfiniteQuery = (agentCount: number) => ({
|
||||||
|
data: {
|
||||||
|
pages: [{ data: generateLargeDataset(agentCount) }],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isFetching: false,
|
||||||
|
fetchNextPage: jest.fn(),
|
||||||
|
hasNextPage: false,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
isFetchingNextPage: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockUseQuery = jest.fn();
|
||||||
|
jest.mock('~/data-provider/Agents', () => ({
|
||||||
|
useMarketplaceAgentsInfiniteQuery: mockUseQuery,
|
||||||
|
}));
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useAgentCategories: () => ({
|
||||||
|
categories: [
|
||||||
|
{ value: 'productivity', label: 'Productivity' },
|
||||||
|
{ value: 'development', label: 'Development' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
useLocalize: () => (key: string, params?: any) => {
|
||||||
|
if (key === 'com_agents_grid_announcement') {
|
||||||
|
return `Found ${params?.count || 0} agents in ${params?.category || 'category'}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../SmartLoader', () => ({
|
||||||
|
useHasData: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/hooks/useInfiniteScroll', () => ({
|
||||||
|
useInfiniteScroll: () => ({
|
||||||
|
setScrollElement: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../AgentCard', () => {
|
||||||
|
return function MockAgentCard({ agent }: { agent: any }) {
|
||||||
|
return (
|
||||||
|
<div data-testid={`agent-card-${agent.id}`} style={{ height: '160px' }}>
|
||||||
|
<h3>{agent.name}</h3>
|
||||||
|
<p>{agent.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Virtual Scrolling Performance', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockRowRenderer.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = (agentCount: number) => {
|
||||||
|
const mockQuery = createMockInfiniteQuery(agentCount);
|
||||||
|
mockUseQuery.mockReturnValue(mockQuery);
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<VirtualizedAgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('efficiently handles 1000 agents without rendering all DOM nodes', () => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
renderComponent(1000);
|
||||||
|
const endTime = performance.now();
|
||||||
|
|
||||||
|
const virtualList = screen.getByTestId('virtual-list');
|
||||||
|
expect(virtualList).toBeInTheDocument();
|
||||||
|
expect(virtualList).toHaveAttribute('data-total-rows', '500'); // 1000 agents / 2 per row
|
||||||
|
|
||||||
|
// Should only render visible cards, not all 1000
|
||||||
|
const renderedCards = screen.getAllByTestId(/agent-card-/);
|
||||||
|
expect(renderedCards.length).toBeLessThan(50); // Much less than 1000
|
||||||
|
expect(renderedCards.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Performance check: rendering should be fast
|
||||||
|
const renderTime = endTime - startTime;
|
||||||
|
expect(renderTime).toBeLessThan(100); // Should render in less than 100ms
|
||||||
|
|
||||||
|
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
|
||||||
|
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('efficiently handles 5000 agents (stress test)', () => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
renderComponent(5000);
|
||||||
|
const endTime = performance.now();
|
||||||
|
|
||||||
|
const virtualList = screen.getByTestId('virtual-list');
|
||||||
|
expect(virtualList).toBeInTheDocument();
|
||||||
|
expect(virtualList).toHaveAttribute('data-total-rows', '2500'); // 5000 agents / 2 per row
|
||||||
|
|
||||||
|
// Should still only render visible cards
|
||||||
|
const renderedCards = screen.getAllByTestId(/agent-card-/);
|
||||||
|
expect(renderedCards.length).toBeLessThan(50);
|
||||||
|
expect(renderedCards.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Performance should still be reasonable
|
||||||
|
const renderTime = endTime - startTime;
|
||||||
|
expect(renderTime).toBeLessThan(200); // Should render in less than 200ms
|
||||||
|
|
||||||
|
console.log(`Rendered 5000 agents in ${renderTime.toFixed(2)}ms`);
|
||||||
|
console.log(`Only ${renderedCards.length} DOM nodes created for 5000 agents`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates correct number of virtual rows for different screen sizes', () => {
|
||||||
|
// Test desktop layout (2 cards per row)
|
||||||
|
renderComponent(100);
|
||||||
|
|
||||||
|
const virtualList = screen.getByTestId('virtual-list');
|
||||||
|
expect(virtualList).toHaveAttribute('data-total-rows', '50'); // 100 agents / 2 per row
|
||||||
|
});
|
||||||
|
|
||||||
|
it('row renderer is called efficiently', () => {
|
||||||
|
renderComponent(1000);
|
||||||
|
|
||||||
|
// Row renderer should be called, but not 500 times (since we virtualize)
|
||||||
|
expect(mockRowRenderer).toHaveBeenCalled();
|
||||||
|
expect(mockRowRenderer.mock.calls.length).toBeLessThan(100);
|
||||||
|
expect(mockRowRenderer.mock.calls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('memory usage remains stable with large datasets', () => {
|
||||||
|
// Test that memory doesn't grow linearly with data size
|
||||||
|
const measureMemory = () => {
|
||||||
|
const cards = screen.queryAllByTestId(/agent-card-/);
|
||||||
|
return cards.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent(100);
|
||||||
|
const memory100 = measureMemory();
|
||||||
|
|
||||||
|
renderComponent(1000);
|
||||||
|
const memory1000 = measureMemory();
|
||||||
|
|
||||||
|
renderComponent(5000);
|
||||||
|
const memory5000 = measureMemory();
|
||||||
|
|
||||||
|
// Memory usage should not scale linearly with data size
|
||||||
|
// All should render roughly the same number of DOM nodes
|
||||||
|
expect(Math.abs(memory100 - memory1000)).toBeLessThan(10);
|
||||||
|
expect(Math.abs(memory1000 - memory5000)).toBeLessThan(10);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Memory usage: 100 agents=${memory100}, 1000 agents=${memory1000}, 5000 agents=${memory5000}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
194
client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx
Normal file
194
client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import VirtualizedAgentGrid from '../VirtualizedAgentGrid';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
// Mock react-virtualized
|
||||||
|
jest.mock('react-virtualized', () => ({
|
||||||
|
AutoSizer: ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: (props: { width: number; height: number }) => React.ReactNode;
|
||||||
|
}) => children({ width: 800, height: 600 }),
|
||||||
|
List: ({ rowRenderer, rowCount }: { rowRenderer: any; rowCount: number }) => (
|
||||||
|
<div data-testid="virtual-list">
|
||||||
|
{Array.from({ length: Math.min(rowCount, 5) }, (_, index) =>
|
||||||
|
rowRenderer({
|
||||||
|
index,
|
||||||
|
key: `row-${index}`,
|
||||||
|
style: {},
|
||||||
|
parent: { props: { width: 800 } },
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the data provider
|
||||||
|
const mockInfiniteQuery = {
|
||||||
|
data: {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent 1',
|
||||||
|
description: 'A test agent for virtual scrolling',
|
||||||
|
category: 'productivity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Test Agent 2',
|
||||||
|
description: 'Another test agent',
|
||||||
|
category: 'development',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isFetching: false,
|
||||||
|
fetchNextPage: jest.fn(),
|
||||||
|
hasNextPage: true,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
isFetchingNextPage: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('~/data-provider/Agents', () => ({
|
||||||
|
useMarketplaceAgentsInfiniteQuery: jest.fn(() => mockInfiniteQuery),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock other hooks
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useAgentCategories: () => ({
|
||||||
|
categories: [
|
||||||
|
{ value: 'productivity', label: 'Productivity' },
|
||||||
|
{ value: 'development', label: 'Development' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
useLocalize: () => (key: string, params?: any) => {
|
||||||
|
if (key === 'com_agents_grid_announcement') {
|
||||||
|
return `Found ${params?.count || 0} agents in ${params?.category || 'category'}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../SmartLoader', () => ({
|
||||||
|
useHasData: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/hooks/useInfiniteScroll', () => ({
|
||||||
|
useInfiniteScroll: () => ({
|
||||||
|
setScrollElement: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../AgentCard', () => {
|
||||||
|
return function MockAgentCard({ agent, onClick }: { agent: t.Agent; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
|
||||||
|
<h3>{agent.name}</h3>
|
||||||
|
<p>{agent.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VirtualizedAgentGrid', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = (props = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
category: 'all',
|
||||||
|
searchQuery: '',
|
||||||
|
onSelectAgent: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<VirtualizedAgentGrid {...defaultProps} {...props} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders virtual list container', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays agent cards in virtual rows', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('agent-card-2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Agent 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Agent 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSelectAgent when agent card is clicked', async () => {
|
||||||
|
const onSelectAgent = jest.fn();
|
||||||
|
renderComponent({ onSelectAgent });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.getByTestId('agent-card-1').click();
|
||||||
|
|
||||||
|
expect(onSelectAgent).toHaveBeenCalledWith({
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent 1',
|
||||||
|
description: 'A test agent for virtual scrolling',
|
||||||
|
category: 'productivity',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading spinner when loading', async () => {
|
||||||
|
const mockQuery = jest.fn(() => ({
|
||||||
|
...mockInfiniteQuery,
|
||||||
|
isLoading: true,
|
||||||
|
data: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const useMarketplaceAgentsInfiniteQuery =
|
||||||
|
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
|
||||||
|
useMarketplaceAgentsInfiniteQuery.mockImplementation(mockQuery);
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Should show loading spinner instead of skeleton
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has proper accessibility attributes', async () => {
|
||||||
|
renderComponent({ category: 'productivity' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const gridContainer = screen.getByRole('grid');
|
||||||
|
expect(gridContainer).toHaveAttribute('aria-label');
|
||||||
|
|
||||||
|
const tabpanel = screen.getByRole('tabpanel');
|
||||||
|
expect(tabpanel).toHaveAttribute('id', 'category-panel-productivity');
|
||||||
|
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-productivity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
91
client/src/hooks/useInfiniteScroll.ts
Normal file
91
client/src/hooks/useInfiniteScroll.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
|
interface UseInfiniteScrollOptions {
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
|
fetchNextPage: () => void;
|
||||||
|
threshold?: number; // Percentage of scroll position to trigger fetch (0-1)
|
||||||
|
throttleMs?: number; // Throttle delay in milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for implementing infinite scroll functionality
|
||||||
|
* Detects when user scrolls near the bottom and triggers data fetching
|
||||||
|
*/
|
||||||
|
export const useInfiniteScroll = ({
|
||||||
|
hasNextPage = false,
|
||||||
|
isFetchingNextPage = false,
|
||||||
|
fetchNextPage,
|
||||||
|
threshold = 0.8, // Trigger when 80% scrolled
|
||||||
|
throttleMs = 200,
|
||||||
|
}: UseInfiniteScrollOptions) => {
|
||||||
|
const scrollElementRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Throttled scroll handler to prevent excessive API calls
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
throttle(() => {
|
||||||
|
const element = scrollElementRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||||
|
|
||||||
|
// Calculate scroll position as percentage
|
||||||
|
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
|
||||||
|
|
||||||
|
// Check if we've scrolled past the threshold and conditions are met
|
||||||
|
const shouldFetch = scrollPosition >= threshold && hasNextPage && !isFetchingNextPage;
|
||||||
|
|
||||||
|
if (shouldFetch) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, throttleMs),
|
||||||
|
[hasNextPage, isFetchingNextPage, fetchNextPage, threshold, throttleMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up scroll listener
|
||||||
|
useEffect(() => {
|
||||||
|
const element = scrollElementRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
// Remove any existing listener first
|
||||||
|
element.removeEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
// Add the new listener
|
||||||
|
element.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('scroll', handleScroll);
|
||||||
|
// Clean up throttled function
|
||||||
|
handleScroll.cancel?.();
|
||||||
|
};
|
||||||
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
// Additional effect to re-setup listeners when scroll element changes
|
||||||
|
useEffect(() => {
|
||||||
|
const element = scrollElementRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
// Remove any existing listener first
|
||||||
|
element.removeEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
// Add the new listener
|
||||||
|
element.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('scroll', handleScroll);
|
||||||
|
// Clean up throttled function
|
||||||
|
handleScroll.cancel?.();
|
||||||
|
};
|
||||||
|
}, [scrollElementRef.current, handleScroll]);
|
||||||
|
|
||||||
|
// Function to manually set the scroll container
|
||||||
|
const setScrollElement = useCallback((element: HTMLElement | null) => {
|
||||||
|
scrollElementRef.current = element;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
setScrollElement,
|
||||||
|
scrollElementRef,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useInfiniteScroll;
|
||||||
66
client/src/hooks/useVirtualGrid.ts
Normal file
66
client/src/hooks/useVirtualGrid.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
interface UseVirtualGridProps {
|
||||||
|
itemCount: number;
|
||||||
|
containerWidth: number;
|
||||||
|
itemHeight: number;
|
||||||
|
gapSize: number;
|
||||||
|
mobileColumnsCount: number;
|
||||||
|
desktopColumnsCount: number;
|
||||||
|
mobileBreakpoint: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseVirtualGridReturn {
|
||||||
|
cardsPerRow: number;
|
||||||
|
rowCount: number;
|
||||||
|
rowHeight: number;
|
||||||
|
getRowItems: (rowIndex: number, items: any[]) => any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for virtual grid calculations
|
||||||
|
* Handles responsive grid layout and item positioning for virtualized lists
|
||||||
|
*/
|
||||||
|
export const useVirtualGrid = ({
|
||||||
|
itemCount,
|
||||||
|
containerWidth,
|
||||||
|
itemHeight,
|
||||||
|
gapSize,
|
||||||
|
mobileColumnsCount,
|
||||||
|
desktopColumnsCount,
|
||||||
|
mobileBreakpoint = 768,
|
||||||
|
}: UseVirtualGridProps): UseVirtualGridReturn => {
|
||||||
|
// Calculate cards per row based on container width
|
||||||
|
const cardsPerRow = useMemo(() => {
|
||||||
|
return containerWidth >= mobileBreakpoint ? desktopColumnsCount : mobileColumnsCount;
|
||||||
|
}, [containerWidth, mobileBreakpoint, desktopColumnsCount, mobileColumnsCount]);
|
||||||
|
|
||||||
|
// Calculate total number of rows needed
|
||||||
|
const rowCount = useMemo(() => {
|
||||||
|
return Math.ceil(itemCount / cardsPerRow);
|
||||||
|
}, [itemCount, cardsPerRow]);
|
||||||
|
|
||||||
|
// Calculate row height including gap
|
||||||
|
const rowHeight = useMemo(() => {
|
||||||
|
return itemHeight + gapSize;
|
||||||
|
}, [itemHeight, gapSize]);
|
||||||
|
|
||||||
|
// Get items for a specific row
|
||||||
|
const getRowItems = useCallback(
|
||||||
|
(rowIndex: number, items: any[]) => {
|
||||||
|
const startIndex = rowIndex * cardsPerRow;
|
||||||
|
const endIndex = Math.min(startIndex + cardsPerRow, items.length);
|
||||||
|
return items.slice(startIndex, endIndex);
|
||||||
|
},
|
||||||
|
[cardsPerRow],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cardsPerRow,
|
||||||
|
rowCount,
|
||||||
|
rowHeight,
|
||||||
|
getRowItems,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useVirtualGrid;
|
||||||
|
|
@ -1237,6 +1237,7 @@
|
||||||
"com_agents_no_description": "No description available",
|
"com_agents_no_description": "No description available",
|
||||||
"com_agents_results_for": "Results for '{{query}}'",
|
"com_agents_results_for": "Results for '{{query}}'",
|
||||||
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
|
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
|
||||||
|
"com_agents_no_more_results": "You've reached the end of the results",
|
||||||
"com_ui_agent_name_is_required": "Agent name is required",
|
"com_ui_agent_name_is_required": "Agent name is required",
|
||||||
"com_agents_missing_name": "Please type in a name before creating an agent."
|
"com_agents_missing_name": "Please type in a name before creating an agent."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue