diff --git a/clients/ui/frontend/src/__mocks__/mockCatalogSourceList.ts b/clients/ui/frontend/src/__mocks__/mockCatalogSourceList.ts index 070c296764..809ddf6ae3 100644 --- a/clients/ui/frontend/src/__mocks__/mockCatalogSourceList.ts +++ b/clients/ui/frontend/src/__mocks__/mockCatalogSourceList.ts @@ -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 => ({ items: [ mockCatalogSourceActive(), diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalog.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalog.cy.ts index ad73605ed7..d04b853869 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalog.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalog.cy.ts @@ -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 = { @@ -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, @@ -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, @@ -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, @@ -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 }); @@ -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(); @@ -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. @@ -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(); @@ -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(); @@ -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 }); @@ -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 }); @@ -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 }); @@ -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 }); @@ -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 }); @@ -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(); @@ -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); + }); +}); diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalog.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalog.tsx index 5cc5522a43..7dee8bf365 100644 --- a/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalog.tsx +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalog.tsx @@ -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); @@ -44,28 +76,41 @@ const McpCatalog: React.FC = () => { loaded provideChildrenPadding > - - - - - - - - - {isAllServersView ? ( - - ) : ( - - )} - - - - + {catalogSourcesLoaded && hasNoCategories ? ( + + ) : ( + + + + + + + + + {isAllServersView && !isSingleCategory ? ( + + ) : ( + + )} + + + + + )} ); diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogGalleryView.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogGalleryView.tsx index 73920a9586..e34ef8cd38 100644 --- a/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogGalleryView.tsx +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogGalleryView.tsx @@ -3,6 +3,7 @@ import { Alert, Bullseye, Button, + Content, EmptyState, Flex, Grid, @@ -13,8 +14,15 @@ import { import { SearchIcon } from '@patternfly/react-icons'; import { McpCatalogContext } from '~/app/context/mcpCatalog/McpCatalogContext'; import { useMcpServersBySourceLabelWithAPI } from '~/app/hooks/mcpServerCatalog/useMcpServersBySourceLabel'; -import { MCP_CATALOG_GRID_SPAN } from '~/app/pages/mcpCatalog/const'; +import { + MCP_CATALOG_GRID_SPAN, + OTHER_MCP_SERVERS_DISPLAY_NAME, +} from '~/app/pages/mcpCatalog/const'; import { mcpFiltersToFilterQuery } from '~/app/pages/mcpCatalog/utils/mcpCatalogUtils'; +import { + getLabelDisplayName, + getLabelDescription, +} from '~/app/pages/modelCatalog/utils/modelCatalogUtils'; import EmptyModelCatalogState from '~/app/pages/modelCatalog/EmptyModelCatalogState'; import ScrollViewOnMount from '~/app/shared/components/ScrollViewOnMount'; import McpCatalogCard from '~/app/pages/mcpCatalog/components/McpCatalogCard'; @@ -23,11 +31,23 @@ const PAGE_SIZE = 10; type McpCatalogGalleryViewProps = { handleFilterReset: () => void; + isSingleCategory?: boolean; + singleCategoryLabel?: string; }; -const McpCatalogGalleryView: React.FC = ({ handleFilterReset }) => { - const { mcpApiState, selectedSourceLabel, searchQuery, filters, catalogLabelsLoaded } = - React.useContext(McpCatalogContext); +const McpCatalogGalleryView: React.FC = ({ + handleFilterReset, + isSingleCategory = false, + singleCategoryLabel, +}) => { + const { + mcpApiState, + selectedSourceLabel, + searchQuery, + filters, + catalogLabels, + catalogLabelsLoaded, + } = React.useContext(McpCatalogContext); const filterQuery = React.useMemo(() => mcpFiltersToFilterQuery(filters), [filters]); @@ -80,9 +100,34 @@ const McpCatalogGalleryView: React.FC = ({ handleFil ); } + const effectiveCategoryLabel = singleCategoryLabel || selectedSourceLabel || ''; + const categoryTitle = isSingleCategory + ? getLabelDisplayName( + effectiveCategoryLabel, + catalogLabels, + OTHER_MCP_SERVERS_DISPLAY_NAME, + 'servers', + ) + : undefined; + const categoryDescription = isSingleCategory + ? getLabelDescription(effectiveCategoryLabel, catalogLabels) + : undefined; + return ( <> + {isSingleCategory && categoryTitle && ( +
+ + {categoryTitle} + + {categoryDescription && ( + + {categoryDescription} + + )} +
+ )} {items.map((server) => ( { return null; } + const activeCategoryCount = blocks.length - 1; + if (activeCategoryCount <= 1) { + return null; + } + const isSelected = (block: SourceLabelBlock) => block.label === undefined ? selectedSourceLabel === undefined diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalog.tsx b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalog.tsx index 528977e83a..14fbc50ac5 100755 --- a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalog.tsx +++ b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalog.tsx @@ -1,19 +1,50 @@ import * as React from 'react'; import { PageSection, Sidebar, SidebarContent, SidebarPanel } 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 ModelCatalogFilters from '~/app/pages/modelCatalog/components/ModelCatalogFilters'; import { ModelCatalogContext } from '~/app/context/modelCatalog/ModelCatalogContext'; import { CategoryName } from '~/app/modelCatalogTypes'; import { useHasVisibleFiltersApplied } from '~/app/hooks/modelCatalog/useHasVisibleFiltersApplied'; +import { getActiveSourceLabels } from '~/app/pages/modelCatalog/utils/modelCatalogUtils'; +import EmptyModelCatalogState from '~/app/pages/modelCatalog/EmptyModelCatalogState'; import ModelCatalogSourceLabelSelectorNavigator from './ModelCatalogSourceLabelSelectorNavigator'; import ModelCatalogAllModelsView from './ModelCatalogAllModelsView'; import ModelCatalogGalleryView from './ModelCatalogGalleryView'; const ModelCatalog: React.FC = () => { const [searchTerm, setSearchTerm] = React.useState(''); - const { selectedSourceLabel, clearAllFilters } = React.useContext(ModelCatalogContext); + const { + selectedSourceLabel, + updateSelectedSourceLabel, + clearAllFilters, + catalogSources, + catalogLabels, + catalogSourcesLoaded, + } = React.useContext(ModelCatalogContext); const filtersApplied = useHasVisibleFiltersApplied(); + + 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]) { + updateSelectedSourceLabel(activeCategories[0]); + } + }, [ + catalogSourcesLoaded, + isSingleCategory, + activeCategories, + selectedSourceLabel, + updateSelectedSourceLabel, + ]); + const isAllModelsView = selectedSourceLabel === CategoryName.allModels && !searchTerm && !filtersApplied; @@ -41,29 +72,40 @@ const ModelCatalog: React.FC = () => { loaded provideChildrenPadding > - - - - - - - - {isAllModelsView ? ( - - ) : ( - - )} - - - + {catalogSourcesLoaded && hasNoCategories ? ( + + ) : ( + + + + + + + + {isAllModelsView && !isSingleCategory ? ( + + ) : ( + + )} + + + + )} ); diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogGalleryView.tsx b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogGalleryView.tsx index f08c7a8f5f..ac616fe0f5 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogGalleryView.tsx +++ b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogGalleryView.tsx @@ -2,6 +2,7 @@ import { Alert, Bullseye, Button, + Content, EmptyState, EmptyStateVariant, Flex, @@ -22,6 +23,8 @@ import { getActiveLatencyFieldName, getSortParams, generateCategoryName, + getLabelDisplayName, + getLabelDescription, hasFiltersApplied, isValueDifferentFromDefault, } from '~/app/pages/modelCatalog/utils/modelCatalogUtils'; @@ -38,11 +41,15 @@ import { type ModelCatalogPageProps = { searchTerm: string; handleFilterReset: () => void; + isSingleCategory?: boolean; + singleCategoryLabel?: string; }; const ModelCatalogGalleryView: React.FC = ({ searchTerm, handleFilterReset, + isSingleCategory = false, + singleCategoryLabel, }) => { const { selectedSourceLabel, @@ -51,6 +58,7 @@ const ModelCatalogGalleryView: React.FC = ({ filterOptionsLoaded, filterOptionsLoadError, catalogSources, + catalogLabels, catalogLabelsLoaded, catalogLabelsLoadError, setPerformanceViewEnabled, @@ -270,9 +278,29 @@ const ModelCatalogGalleryView: React.FC = ({ ); } + const effectiveCategoryLabel = singleCategoryLabel || selectedSourceLabel || ''; + const categoryTitle = isSingleCategory + ? getLabelDisplayName(effectiveCategoryLabel, catalogLabels) + : undefined; + const categoryDescription = isSingleCategory + ? getLabelDescription(effectiveCategoryLabel, catalogLabels) + : undefined; + return ( <> + {isSingleCategory && categoryTitle && ( +
+ + {categoryTitle} + + {categoryDescription && ( + + {categoryDescription} + + )} +
+ )} {catalogModels.items.map((model: CatalogModel) => ( diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogSourceLabelBlocks.tsx b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogSourceLabelBlocks.tsx index 47a608d466..1c16d6ac4e 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogSourceLabelBlocks.tsx +++ b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogSourceLabelBlocks.tsx @@ -62,6 +62,11 @@ const ModelCatalogSourceLabelBlocks: React.FC = () => { return null; } + const activeCategoryCount = blocks.length - 1; + if (activeCategoryCount <= 1) { + return null; + } + const handleToggleClick = (label: string) => { updateSelectedSourceLabel(label); }; diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/utils/__tests__/modelCatalogUtils.spec.ts b/clients/ui/frontend/src/app/pages/modelCatalog/utils/__tests__/modelCatalogUtils.spec.ts index 99629039ad..beaa330722 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/utils/__tests__/modelCatalogUtils.spec.ts +++ b/clients/ui/frontend/src/app/pages/modelCatalog/utils/__tests__/modelCatalogUtils.spec.ts @@ -2,6 +2,8 @@ /* eslint-disable camelcase */ import { CatalogFilterOptionsList, + CatalogLabel, + CatalogLabelList, CatalogSource, CatalogSourceList, ModelCatalogFilterStates, @@ -32,6 +34,7 @@ import { hasFiltersApplied, getArchitecturesFromArtifacts, getModelName, + getActiveSourceLabels, } from '~/app/pages/modelCatalog/utils/modelCatalogUtils'; import { mockCatalogModelArtifact } from '~/__mocks__/mockCatalogModelArtifactList'; import { ModelRegistryMetadataType } from '~/app/types'; @@ -1383,3 +1386,229 @@ describe('getModelName', () => { expect(result).toBe('my-model_v1'); }); }); + +describe('getActiveSourceLabels', () => { + const createSource = (overrides: Partial = {}): CatalogSource => ({ + id: 'source-1', + name: 'Test Source', + labels: ['Red Hat'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + ...overrides, + }); + + const createSourceList = (items: CatalogSource[] = []): CatalogSourceList => ({ + items, + size: items.length, + pageSize: 10, + nextPageToken: '', + }); + + it('returns empty array when catalogSources is null', () => { + expect(getActiveSourceLabels(null, null)).toEqual([]); + }); + + it('returns empty array when no sources are enabled or available', () => { + const sources = createSourceList([ + createSource({ + id: '1', + enabled: false, + status: CatalogSourceStatus.DISABLED, + }), + createSource({ + id: '2', + enabled: true, + status: CatalogSourceStatus.ERROR, + }), + ]); + expect(getActiveSourceLabels(sources, null)).toEqual([]); + }); + + it('returns a single label when only one category exists', () => { + const sources = createSourceList([ + createSource({ + id: '1', + labels: ['Red Hat'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + ]); + expect(getActiveSourceLabels(sources, null)).toEqual(['Red Hat']); + }); + + it('returns multiple labels when multiple categories exist', () => { + const sources = createSourceList([ + createSource({ + id: '1', + labels: ['Red Hat'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + createSource({ + id: '2', + labels: ['Community'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + ]); + const result = getActiveSourceLabels(sources, null); + expect(result).toHaveLength(2); + expect(result).toContain('Red Hat'); + expect(result).toContain('Community'); + }); + + it('includes "null" label for sources without labels', () => { + const sources = createSourceList([ + createSource({ + id: '1', + labels: ['Red Hat'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + createSource({ + id: '2', + labels: [], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + ]); + const result = getActiveSourceLabels(sources, null); + expect(result).toContain('Red Hat'); + expect(result).toContain('null'); + }); + + it('excludes disabled sources from active categories', () => { + const sources = createSourceList([ + createSource({ + id: '1', + labels: ['Red Hat'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + createSource({ + id: '2', + labels: ['Excluded'], + enabled: false, + status: CatalogSourceStatus.AVAILABLE, + }), + ]); + const result = getActiveSourceLabels(sources, null); + expect(result).toEqual(['Red Hat']); + }); + + it('excludes sources with error status', () => { + const sources = createSourceList([ + createSource({ + id: '1', + labels: ['Red Hat'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + createSource({ + id: '2', + labels: ['Error Source'], + enabled: true, + status: CatalogSourceStatus.ERROR, + }), + ]); + const result = getActiveSourceLabels(sources, null); + expect(result).toEqual(['Red Hat']); + }); + + it('includes partially-available sources', () => { + const sources = createSourceList([ + createSource({ + id: '1', + labels: ['Red Hat'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + createSource({ + id: '2', + labels: ['Partial'], + enabled: true, + status: CatalogSourceStatus.PARTIALLY_AVAILABLE, + }), + ]); + const result = getActiveSourceLabels(sources, null); + expect(result).toHaveLength(2); + expect(result).toContain('Red Hat'); + expect(result).toContain('Partial'); + }); + + it('deduplicates labels from multiple sources with the same label', () => { + const sources = createSourceList([ + createSource({ + id: '1', + labels: ['Red Hat'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + createSource({ + id: '2', + labels: ['Red Hat'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + ]); + const result = getActiveSourceLabels(sources, null); + expect(result).toEqual(['Red Hat']); + }); + + it('returns only "null" label when all sources have no labels', () => { + const sources = createSourceList([ + createSource({ + id: '1', + labels: [], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + createSource({ + id: '2', + labels: [], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + ]); + const result = getActiveSourceLabels(sources, null); + expect(result).toEqual(['null']); + }); + + it('orders labels by catalogLabels priority when catalogLabels is provided', () => { + const sources = createSourceList([ + createSource({ + id: '1', + labels: ['Community'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + createSource({ + id: '2', + labels: ['Red Hat'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + createSource({ + id: '3', + labels: ['Partner'], + enabled: true, + status: CatalogSourceStatus.AVAILABLE, + }), + ]); + + const createLabel = (name: string | null): CatalogLabel => ({ + name, + displayName: name ?? undefined, + }); + + const catalogLabels: CatalogLabelList = { + items: [createLabel('Red Hat'), createLabel('Partner'), createLabel('Community')], + size: 3, + pageSize: 10, + nextPageToken: '', + }; + + const result = getActiveSourceLabels(sources, catalogLabels); + expect(result).toEqual(['Red Hat', 'Partner', 'Community']); + }); +}); diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/utils/modelCatalogUtils.ts b/clients/ui/frontend/src/app/pages/modelCatalog/utils/modelCatalogUtils.ts index 02c755794c..133c8d4e1d 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/utils/modelCatalogUtils.ts +++ b/clients/ui/frontend/src/app/pages/modelCatalog/utils/modelCatalogUtils.ts @@ -750,6 +750,21 @@ export const orderLabelsByPriority = ( return orderedLabels; }; +export const getActiveSourceLabels = ( + catalogSources: CatalogSourceList | null, + catalogLabels: CatalogLabelList | null, +): string[] => { + const enabledSources = filterEnabledCatalogSources(catalogSources); + const uniqueLabels = getUniqueSourceLabels(enabledSources); + const orderedLabels = orderLabelsByPriority(uniqueLabels, catalogLabels); + + if (hasSourcesWithoutLabels(enabledSources)) { + return [...orderedLabels, SourceLabel.other]; + } + + return orderedLabels; +}; + /** * Formats model type value for display in the UI. * Converts raw API values (generative, predictive, unknown) to user-friendly display labels.