Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions clients/ui/frontend/src/__mocks__/mockCatalogSourceList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@ export const mockCatalogSourceActive = (): CatalogSource => ({
status: 'available',
});

export const mockTwoProviderSources = (): CatalogSource[] => [
mockCatalogSource({ labels: ['Provider one'] }),
mockCatalogSource({
id: 'source-2',
name: 'Provider Two Source',
labels: ['Provider two'],
}),
];

export const mockProviderAndCustomSources = (): CatalogSource[] => [
mockCatalogSource({ labels: ['Provider one'] }),
mockCatalogSource({ id: 'custom-source', name: 'Custom Source', labels: [] }),
];

export const mockDefaultSources = (): CatalogSource[] => [
mockCatalogSource({}),
mockCatalogSource({ id: 'source-2', name: 'source 2' }),
];

export const mockCatalogSourceList = (partial?: Partial<CatalogSourceList>): CatalogSourceList => ({
items: [
mockCatalogSourceActive(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {
mockCatalogPerformanceMetricsArtifact,
mockCatalogSource,
mockCatalogSourceList,
mockDefaultSources,
mockProviderAndCustomSources,
mockTwoProviderSources,
} from '~/__mocks__';
import type { CatalogSource } from '~/app/modelCatalogTypes';
import { MODEL_CATALOG_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api';
import { mockCatalogFilterOptionsList } from '~/__mocks__/mockCatalogFilterOptionsList';
import { SourceLabel } from '~/app/modelCatalogTypes';
import { SourceLabel, type CatalogSource } from '~/app/modelCatalogTypes';
import { ModelRegistryMetadataType } from '~/app/types';

type FilteredModelsInterceptConfig = {
Expand Down Expand Up @@ -90,7 +92,7 @@ const calculateExpectedCategoryCount = (sources: CatalogSource[]): number => {
};

const initIntercepts = ({
sources = [mockCatalogSource({}), mockCatalogSource({ id: 'source-2', name: 'source 2' })],
sources = mockDefaultSources(),
modelsPerCategory = 4,
hasValidatedModels = false,
includeAllModelsIntercept = true,
Expand Down Expand Up @@ -269,15 +271,9 @@ describe('Model Catalog Page', () => {
});

it('checkbox should work', () => {
// Calculate expected category count based on sources
const defaultSources = [
mockCatalogSource({}),
mockCatalogSource({ id: 'source-2', name: 'source 2' }),
];

const expectedCategoryCount = calculateExpectedCategoryCount(defaultSources);
const expectedCategoryCount = calculateExpectedCategoryCount(mockDefaultSources());

initIntercepts({ sources: defaultSources, includeAllModelsIntercept: false });
initIntercepts({ sources: mockDefaultSources(), includeAllModelsIntercept: false });

setupFilteredModelsIntercept({
returnModelsForFilters: true,
Expand Down Expand Up @@ -322,14 +318,9 @@ describe('Model Catalog Page', () => {
});

it('tensor type filter combined with other filters should work', () => {
const defaultSources = [
mockCatalogSource({}),
mockCatalogSource({ id: 'source-2', name: 'source 2' }),
];
const expectedCategoryCount = calculateExpectedCategoryCount(mockDefaultSources());

const expectedCategoryCount = calculateExpectedCategoryCount(defaultSources);

initIntercepts({ sources: defaultSources, includeAllModelsIntercept: false });
initIntercepts({ sources: mockDefaultSources(), includeAllModelsIntercept: false });

setupFilteredModelsIntercept({
returnModelsForFilters: true,
Expand Down Expand Up @@ -357,10 +348,7 @@ describe('Performance Empty State', () => {
describe('Community & Custom Section', () => {
it('should show performance empty state when toggle is ON', () => {
initIntercepts({
sources: [
mockCatalogSource({ labels: ['Provider one'] }),
mockCatalogSource({ id: 'custom-source', name: 'Custom Source', labels: [] }),
],
sources: mockProviderAndCustomSources(),
hasValidatedModels: true,
});
setupFilteredModelsIntercept({ returnModelsForFilters: false });
Expand All @@ -380,10 +368,7 @@ describe('Performance Empty State', () => {

it('should show models when toggle is OFF', () => {
initIntercepts({
sources: [
mockCatalogSource({ labels: ['Provider one'] }),
mockCatalogSource({ id: 'custom-source', name: 'Custom Source', labels: [] }),
],
sources: mockProviderAndCustomSources(),
});
modelCatalog.visit();

Expand All @@ -397,7 +382,7 @@ describe('Performance Empty State', () => {
describe('Labeled Section Without Validated Models', () => {
it('should show performance empty state when toggle is ON and no validated models', () => {
initIntercepts({
sources: [mockCatalogSource({ labels: ['Provider one'] })],
sources: mockTwoProviderSources(),
hasValidatedModels: false,
});
// No user filters/search; this scenario should hit the special "No performance data" state.
Expand All @@ -414,7 +399,7 @@ describe('Performance Empty State', () => {

it('should show models when toggle is OFF', () => {
initIntercepts({
sources: [mockCatalogSource({ labels: ['Provider one'] })],
sources: mockTwoProviderSources(),
hasValidatedModels: false,
});
modelCatalog.visit();
Expand All @@ -428,7 +413,7 @@ describe('Performance Empty State', () => {
describe('Labeled Section With Validated Models', () => {
it('should show models when toggle is ON and section has validated models', () => {
initIntercepts({
sources: [mockCatalogSource({ labels: ['Provider one'] })],
sources: mockTwoProviderSources(),
hasValidatedModels: true,
});
modelCatalog.visit();
Expand All @@ -444,10 +429,7 @@ describe('Performance Empty State', () => {
describe('Empty State Actions', () => {
it('should turn off toggle when clicking "Turn Model performance view off"', () => {
initIntercepts({
sources: [
mockCatalogSource({ labels: ['Provider one'] }),
mockCatalogSource({ id: 'custom-source', name: 'Custom Source', labels: [] }),
],
sources: mockProviderAndCustomSources(),
hasValidatedModels: true,
});
setupFilteredModelsIntercept({ returnModelsForFilters: false });
Expand All @@ -465,10 +447,7 @@ describe('Performance Empty State', () => {

it('should navigate to All models when clicking "View all models with performance data"', () => {
initIntercepts({
sources: [
mockCatalogSource({ labels: ['Provider one'] }),
mockCatalogSource({ id: 'custom-source', name: 'Custom Source', labels: [] }),
],
sources: mockProviderAndCustomSources(),
hasValidatedModels: true,
});
setupFilteredModelsIntercept({ returnModelsForFilters: false });
Expand All @@ -485,7 +464,7 @@ describe('Performance Empty State', () => {

it('should show performance empty state after clicking Reset filters when toggle is ON', () => {
initIntercepts({
sources: [mockCatalogSource({ labels: ['Provider one'] })],
sources: mockTwoProviderSources(),
hasValidatedModels: false,
});
setupFilteredModelsIntercept({ returnModelsForFilters: false });
Expand All @@ -508,10 +487,7 @@ describe('Performance Empty State', () => {

it('should work correctly when toggling performance view', () => {
initIntercepts({
sources: [
mockCatalogSource({ labels: ['Provider one'] }),
mockCatalogSource({ id: 'custom-source', name: 'Custom Source', labels: [] }),
],
sources: mockProviderAndCustomSources(),
hasValidatedModels: true,
});
setupFilteredModelsIntercept({ returnModelsForFilters: false });
Expand All @@ -535,7 +511,7 @@ describe('Performance Empty State', () => {

it('should show "No results found" when toggle is ON and user applies filter that returns 0 results', () => {
initIntercepts({
sources: [mockCatalogSource({ labels: ['Provider one'] })],
sources: mockTwoProviderSources(),
hasValidatedModels: true,
});
setupFilteredModelsIntercept({ returnModelsForFilters: false });
Expand All @@ -556,7 +532,7 @@ describe('Performance Empty State', () => {
describe('All Models Section', () => {
it('should show models in All models section even when toggle is ON', () => {
initIntercepts({
sources: [mockCatalogSource({ labels: ['Provider one'] })],
sources: mockTwoProviderSources(),
hasValidatedModels: true,
});
modelCatalog.visit();
Expand All @@ -569,3 +545,35 @@ describe('All Models Section', () => {
modelCatalog.findPerformanceEmptyState().should('not.exist');
});
});

describe('Single Category Behavior', () => {
it('should hide category toggle when only one category is active', () => {
initIntercepts({
sources: [mockCatalogSource({ labels: ['Provider one'] })],
});
modelCatalog.visit();

cy.findByTestId('label-Provider one').should('not.exist');
cy.findByTestId('all').should('not.exist');
});

it('should auto-select the single active category and show its models', () => {
initIntercepts({
sources: [mockCatalogSource({ labels: ['Provider one'] })],
});
modelCatalog.visit();

modelCatalog.findModelCatalogCards().should('have.length.at.least', 1);
});

it('should show full grid view for the auto-selected single category', () => {
initIntercepts({
sources: [mockCatalogSource({ labels: ['Provider one'] })],
hasValidatedModels: true,
});
modelCatalog.visit();

modelCatalog.togglePerformanceView();
modelCatalog.findModelCatalogCards().should('have.length.at.least', 1);
});
});
93 changes: 69 additions & 24 deletions clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalog.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,54 @@
import * as React from 'react';
import { PageSection, Sidebar, SidebarContent, SidebarPanel, Stack } from '@patternfly/react-core';
import { ApplicationsPage, ProjectObjectType, TitleWithIcon } from 'mod-arch-shared';
import { SearchIcon } from '@patternfly/react-icons';
import ScrollViewOnMount from '~/app/shared/components/ScrollViewOnMount';
import { McpCatalogContext } from '~/app/context/mcpCatalog/McpCatalogContext';
import { hasMcpFiltersApplied } from '~/app/pages/mcpCatalog/utils/mcpCatalogUtils';
import McpCatalogFilters from '~/app/pages/mcpCatalog/components/McpCatalogFilters';
import { MCP_CATALOG_TITLE, MCP_CATALOG_DESCRIPTION } from '~/app/pages/mcpCatalog/const';
import { getActiveSourceLabels } from '~/app/pages/modelCatalog/utils/modelCatalogUtils';
import EmptyModelCatalogState from '~/app/pages/modelCatalog/EmptyModelCatalogState';
import McpCatalogSourceLabelSelector from './McpCatalogSourceLabelSelector';
import McpCatalogAllServersView from './McpCatalogAllServersView';
import McpCatalogGalleryView from './McpCatalogGalleryView';

const McpCatalog: React.FC = () => {
const { searchQuery, setSearchQuery, clearAllFilters, selectedSourceLabel, filters } =
React.useContext(McpCatalogContext);
const {
searchQuery,
setSearchQuery,
clearAllFilters,
selectedSourceLabel,
setSelectedSourceLabel,
filters,
catalogSources,
catalogLabels,
catalogSourcesLoaded,
} = React.useContext(McpCatalogContext);

const filtersApplied = hasMcpFiltersApplied(filters, searchQuery);
const isAllServersView = selectedSourceLabel === undefined && !filtersApplied;

const activeCategories = React.useMemo(
() => getActiveSourceLabels(catalogSources, catalogLabels),
[catalogSources, catalogLabels],
);

const isSingleCategory = activeCategories.length === 1;
const hasNoCategories = activeCategories.length === 0;

React.useEffect(() => {
if (catalogSourcesLoaded && isSingleCategory && selectedSourceLabel !== activeCategories[0]) {
setSelectedSourceLabel(activeCategories[0]);
}
}, [
catalogSourcesLoaded,
isSingleCategory,
activeCategories,
selectedSourceLabel,
setSelectedSourceLabel,
]);

const handleSearch = React.useCallback(
(term: string) => {
setSearchQuery(term);
Expand Down Expand Up @@ -44,28 +76,41 @@ const McpCatalog: React.FC = () => {
loaded
provideChildrenPadding
>
<Sidebar hasBorder hasGutter>
<SidebarPanel variant="sticky">
<McpCatalogFilters />
</SidebarPanel>
<SidebarContent>
<Stack hasGutter>
<McpCatalogSourceLabelSelector
searchTerm={searchQuery}
onSearch={handleSearch}
onClearSearch={handleClearSearch}
onResetAllFilters={handleResetAllFilters}
/>
<PageSection isFilled padding={{ default: 'noPadding' }}>
{isAllServersView ? (
<McpCatalogAllServersView searchTerm={searchQuery} />
) : (
<McpCatalogGalleryView handleFilterReset={handleResetAllFilters} />
)}
</PageSection>
</Stack>
</SidebarContent>
</Sidebar>
{catalogSourcesLoaded && hasNoCategories ? (
<EmptyModelCatalogState
testid="empty-mcp-catalog-no-categories"
title="No MCP servers available"
headerIcon={SearchIcon}
description="There are no MCP server categories available. Configure sources in settings to get started."
/>
) : (
<Sidebar hasBorder hasGutter>
<SidebarPanel variant="sticky">
<McpCatalogFilters />
</SidebarPanel>
<SidebarContent>
<Stack hasGutter>
<McpCatalogSourceLabelSelector
searchTerm={searchQuery}
onSearch={handleSearch}
onClearSearch={handleClearSearch}
onResetAllFilters={handleResetAllFilters}
/>
<PageSection isFilled padding={{ default: 'noPadding' }}>
{isAllServersView && !isSingleCategory ? (
<McpCatalogAllServersView searchTerm={searchQuery} />
) : (
<McpCatalogGalleryView
handleFilterReset={handleResetAllFilters}
isSingleCategory={isSingleCategory}
singleCategoryLabel={isSingleCategory ? activeCategories[0] : undefined}
/>
)}
</PageSection>
</Stack>
</SidebarContent>
</Sidebar>
)}
</ApplicationsPage>
</>
);
Expand Down
Loading
Loading