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 b07135888c..e7150ad4a6 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 @@ -11,6 +11,7 @@ import { 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'; type HandlersProps = { sources?: CatalogSource[]; @@ -53,6 +54,32 @@ const initIntercepts = ({ }); }); + // Intercept requests for sources without labels if they exist + const hasSourcesWithoutLabels = sources.some( + (source) => + source.enabled !== false && + (source.labels.length === 0 || source.labels.every((label) => !label.trim())), + ); + + if (hasSourcesWithoutLabels) { + cy.interceptApi( + `GET /api/:apiVersion/model_catalog/models`, + { + path: { apiVersion: MODEL_CATALOG_API_VERSION }, + query: { sourceLabel: SourceLabel.other }, + }, + mockCatalogModelList({ + items: Array.from({ length: modelsPerCategory }, (_, i) => + mockCatalogModel({ + name: `custom-model-${i + 1}`, + // eslint-disable-next-line camelcase + source_id: sources.find((s) => s.labels.length === 0)?.id || 'custom-source', + }), + ), + }), + ); + } + cy.interceptApi( `GET /api/:apiVersion/model_catalog/models/filter_options`, { @@ -138,6 +165,30 @@ 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 uniqueLabels = new Set(); + defaultSources.forEach((source) => { + source.labels.forEach((label) => { + if (label.trim()) { + uniqueLabels.add(label.trim()); + } + }); + }); + + // Check if there are sources without labels + const hasSourcesWithoutLabels = defaultSources.some( + (source) => + source.enabled !== false && + (source.labels.length === 0 || source.labels.every((label) => !label.trim())), + ); + + // Expected count: unique labels + (1 if sources without labels exist) + const expectedCategoryCount = uniqueLabels.size + (hasSourcesWithoutLabels ? 1 : 0); + cy.interceptApi( `GET /api/:apiVersion/model_catalog/models`, { @@ -149,17 +200,18 @@ describe('Model Catalog Page', () => { }), ).as('getCatalogModelsBySource'); - initIntercepts({}); + initIntercepts({ sources: defaultSources }); modelCatalog.visit(); modelCatalog.findFilterCheckbox('Task', 'text-generation').click(); modelCatalog.findFilterCheckbox('Task', 'text-to-text').click(); modelCatalog.findFilterCheckbox('Provider', 'Google').click(); - cy.wait([ - '@getCatalogModelsBySource', - '@getCatalogModelsBySource', - '@getCatalogModelsBySource', - '@getCatalogModelsBySource', - ]).then((interceptions) => { + + // Wait for the expected number of API calls (one per category section when filters are applied) + const waitCalls = Array.from( + { length: expectedCategoryCount }, + () => '@getCatalogModelsBySource', + ); + cy.wait(waitCalls).then((interceptions) => { const lastInterception = interceptions[interceptions.length - 1]; expect(lastInterception.request.url).to.include( '%28tasks+LIKE+%27%25%22text-generation%22%25%27+OR+tasks+LIKE+%27%25%22text-to-text%22%25%27%29+AND+provider%3D%27Google%27', diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalogAllModelsView.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalogAllModelsView.cy.ts index 15f34df6f1..dead8339a2 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalogAllModelsView.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalogAllModelsView.cy.ts @@ -8,11 +8,13 @@ import { 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'; type HandlersProps = { sources?: CatalogSource[]; modelsPerCategory?: number; isEmpty?: boolean; + includeSourcesWithoutLabels?: boolean; }; const initIntercepts = ({ @@ -20,9 +22,11 @@ const initIntercepts = ({ mockCatalogSource({ id: 'huggingface', name: 'Hugging Face', labels: ['Hugging Face'] }), mockCatalogSource({ id: 'openvino', name: 'OpenVINO', labels: ['OpenVINO'] }), mockCatalogSource({ id: 'community', name: 'Community', labels: ['Community'] }), + mockCatalogSource({ id: 'custom-source', name: 'Custom Source', labels: [] }), ], modelsPerCategory = 4, isEmpty = false, + includeSourcesWithoutLabels = true, }: HandlersProps) => { cy.interceptApi( `GET /api/:apiVersion/model_catalog/sources`, @@ -65,6 +69,36 @@ const initIntercepts = ({ ); }); }); + + // Intercept requests for sources without labels (sourceLabel=null) + if (includeSourcesWithoutLabels) { + const hasSourcesWithoutLabels = sources.some( + (source) => + source.enabled !== false && + (source.labels.length === 0 || source.labels.every((label) => !label.trim())), + ); + + if (hasSourcesWithoutLabels) { + cy.interceptApi( + `GET /api/:apiVersion/model_catalog/models`, + { + path: { apiVersion: MODEL_CATALOG_API_VERSION }, + query: { sourceLabel: SourceLabel.other }, + }, + mockCatalogModelList({ + items: isEmpty + ? [] + : Array.from({ length: modelsPerCategory }, (_, i) => + mockCatalogModel({ + name: `custom-model-${i + 1}`, + // eslint-disable-next-line camelcase + source_id: sources.find((s) => s.labels.length === 0)?.id || 'custom-source', + }), + ), + }), + ); + } + } }; describe('Model Catalog All Models View', () => { @@ -74,7 +108,7 @@ describe('Model Catalog All Models View', () => { }); describe('Category Sections', () => { - it('should display all category sections', () => { + it('should display all category sections when sources without labels exist', () => { modelCatalog.findAllModelsToggle().should('be.visible'); modelCatalog.findCategoryToggle('label-Hugging Face').should('be.visible'); modelCatalog.findCategoryToggle('label-OpenVINO').should('be.visible'); @@ -82,6 +116,24 @@ describe('Model Catalog All Models View', () => { modelCatalog.findCategoryToggle('no-labels').should('be.visible'); }); + it('should hide Community and custom models section when no sources without labels exist', () => { + initIntercepts({ + sources: [ + mockCatalogSource({ id: 'huggingface', name: 'Hugging Face', labels: ['Hugging Face'] }), + mockCatalogSource({ id: 'openvino', name: 'OpenVINO', labels: ['OpenVINO'] }), + mockCatalogSource({ id: 'community', name: 'Community', labels: ['Community'] }), + ], + includeSourcesWithoutLabels: false, + }); + modelCatalog.visit(); + + modelCatalog.findAllModelsToggle().should('be.visible'); + modelCatalog.findCategoryToggle('label-Hugging Face').should('be.visible'); + modelCatalog.findCategoryToggle('label-OpenVINO').should('be.visible'); + modelCatalog.findCategoryToggle('label-Community').should('be.visible'); + modelCatalog.findCategoryToggle('no-labels').should('not.exist'); + }); + it('should show category titles', () => { modelCatalog.findCategoryTitle('OpenVINO').should('contain.text', 'OpenVINO models'); cy.findByTestId('title Hugging Face').should('contain.text', 'Hugging Face models'); @@ -106,6 +158,32 @@ describe('Model Catalog All Models View', () => { describe('Error Handling', () => { it('should display error message when category fails to load', () => { + // Setup intercepts with sources without labels + initIntercepts({ + sources: [ + mockCatalogSource({ id: 'huggingface', name: 'Hugging Face', labels: ['Hugging Face'] }), + mockCatalogSource({ id: 'openvino', name: 'OpenVINO', labels: ['OpenVINO'] }), + mockCatalogSource({ id: 'community', name: 'Community', labels: ['Community'] }), + mockCatalogSource({ id: 'custom-source', name: 'Custom Source', labels: [] }), + ], + includeSourcesWithoutLabels: false, // Don't set up success intercept + }); + + // Manually intercept with error response for sourceLabel=null + cy.intercept( + { + method: 'GET', + pathname: `/model-registry/api/${MODEL_CATALOG_API_VERSION}/model_catalog/models`, + query: { sourceLabel: SourceLabel.other }, + }, + { + statusCode: 500, + body: { error: 'Internal server error' }, + }, + ); + + modelCatalog.visit(); + modelCatalog.findErrorState('null').scrollIntoView().should('be.visible'); modelCatalog .findErrorState('null') diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogAllModelsView.tsx b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogAllModelsView.tsx index 6ed4a36a58..67cc651886 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogAllModelsView.tsx +++ b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogAllModelsView.tsx @@ -4,6 +4,7 @@ import { ModelCatalogContext } from '~/app/context/modelCatalog/ModelCatalogCont import { filterEnabledCatalogSources, getUniqueSourceLabels, + hasSourcesWithoutLabels, } from '~/app/pages/modelCatalog/utils/modelCatalogUtils'; import { CategoryName, SourceLabel } from '~/app/modelCatalogTypes'; import CatalogCategorySection from './CatalogCategorySection'; @@ -20,6 +21,11 @@ const ModelCatalogAllModelsView: React.FC = ({ s return getUniqueSourceLabels(enabledSources); }, [catalogSources]); + const hasSourcesWithoutLabelsValue = React.useMemo( + () => hasSourcesWithoutLabels(catalogSources), + [catalogSources], + ); + const handleShowMoreCategory = React.useCallback( (categoryLabel: string) => { updateSelectedSourceLabel(categoryLabel); @@ -39,15 +45,17 @@ const ModelCatalogAllModelsView: React.FC = ({ s onShowMore={handleShowMoreCategory} /> ))} - + {hasSourcesWithoutLabelsValue && ( + + )} ); }; 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 89521c76dd..e173b58480 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogSourceLabelBlocks.tsx +++ b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogSourceLabelBlocks.tsx @@ -5,6 +5,7 @@ import { CategoryName, SourceLabel } from '~/app/modelCatalogTypes'; import { getUniqueSourceLabels, filterEnabledCatalogSources, + hasSourcesWithoutLabels, } from '~/app/pages/modelCatalog/utils/modelCatalogUtils'; type SourceLabelBlock = { @@ -24,6 +25,7 @@ const ModelCatalogSourceLabelBlocks: React.FC = () => { const enabledSources = filterEnabledCatalogSources(catalogSources); const uniqueLabels = getUniqueSourceLabels(enabledSources); + const hasNoLabels = hasSourcesWithoutLabels(catalogSources); const allBlock: SourceLabelBlock = { id: 'all', @@ -37,13 +39,18 @@ const ModelCatalogSourceLabelBlocks: React.FC = () => { displayName: `${label} models`, })); - const noLabelsBlock: SourceLabelBlock = { - id: 'no-labels', - label: SourceLabel.other, - displayName: `${CategoryName.communityAndCustomModels} models`, - }; + const blocksToReturn: SourceLabelBlock[] = [allBlock, ...labelBlocks]; + + if (hasNoLabels) { + const noLabelsBlock: SourceLabelBlock = { + id: 'no-labels', + label: SourceLabel.other, + displayName: `${CategoryName.communityAndCustomModels} models`, + }; + blocksToReturn.push(noLabelsBlock); + } - return [allBlock, ...labelBlocks, noLabelsBlock]; + return blocksToReturn; }, [catalogSources]); if (!catalogSources) { 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 38f3a3dcce..0dde63505f 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/utils/modelCatalogUtils.ts +++ b/clients/ui/frontend/src/app/pages/modelCatalog/utils/modelCatalogUtils.ts @@ -333,6 +333,20 @@ export const getUniqueSourceLabels = (catalogSources: CatalogSourceList | null): return Array.from(allLabels); }; +export const hasSourcesWithoutLabels = (catalogSources: CatalogSourceList | null): boolean => { + if (!catalogSources) { + return false; + } + + return catalogSources.items.some((source) => { + if (source.enabled !== false) { + // Check if source has no labels or only empty/whitespace labels + return source.labels.length === 0 || source.labels.every((label) => !label.trim()); + } + return false; + }); +}; + export const getSourceFromSourceId = ( sourceId: string, catalogSources: CatalogSourceList | null,