Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mockModArchResponse } from 'mod-arch-core';
import { mockCatalogSourceList } from '~/__mocks__';
import { mockCatalogLabel, mockCatalogSourceList, mockCatalogSource } from '~/__mocks__';
import { mcpCatalog } from '~/__tests__/cypress/cypress/pages/mcpCatalog';
import { MODEL_CATALOG_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api';
import {
Expand Down Expand Up @@ -98,6 +98,91 @@ describe('MCP Catalog Empty State', () => {
});
});

describe('MCP Catalog Empty Category Hiding', () => {
it('should hide categories that have 0 servers from toggle', () => {
const sources = [
mockCatalogSource({
id: 'community-mcp-source',
name: 'Community MCP Servers',
labels: ['community_mcp_servers'],
}),
mockCatalogSource({
id: 'org-mcp-source',
name: 'Organization MCP Servers',
labels: ['organization_mcp_servers'],
}),
];

cy.interceptApi(
`GET /api/:apiVersion/model_catalog/sources`,
{ path: { apiVersion: MODEL_CATALOG_API_VERSION }, query: { assetType: 'mcp_servers' } },
mockCatalogSourceList({ items: sources }),
);

cy.intercept(
{
method: 'GET',
url: new RegExp(`/api/${MODEL_CATALOG_API_VERSION}/model_catalog/labels`),
},
mockModArchResponse({
items: [
mockCatalogLabel({
name: 'community_mcp_servers',
displayName: 'Community MCP Servers',
}),
mockCatalogLabel({
name: 'organization_mcp_servers',
displayName: 'Organization MCP Servers',
}),
],
size: 2,
pageSize: 10,
nextPageToken: '',
}),
);

cy.interceptApi(
`GET /api/:apiVersion/mcp_catalog/mcp_servers`,
{
path: { apiVersion: MODEL_CATALOG_API_VERSION },
query: { sourceLabel: 'community_mcp_servers' },
},
{ items: [], size: 0, pageSize: 10, nextPageToken: '' },
);

cy.interceptApi(
`GET /api/:apiVersion/mcp_catalog/mcp_servers`,
{
path: { apiVersion: MODEL_CATALOG_API_VERSION },
query: { sourceLabel: 'organization_mcp_servers' },
},
{
items: [
{
id: 'server-1',
name: 'Test Server',
description: 'test',
source_id: 'org-mcp-source', // eslint-disable-line camelcase
},
],
size: 1,
pageSize: 10,
nextPageToken: '',
},
);

cy.intercept(
{ method: 'GET', pathname: MCP_FILTER_OPTIONS_PATH },
mockModArchResponse(mockMcpCatalogFilterOptions()),
);

mcpCatalog.visit();

cy.findByTestId('mcp-category-title-community_mcp_servers').should('not.exist');
cy.findByTestId('mcp-category-title-organization_mcp_servers').should('be.visible');
});
});

describe('MCP Catalog Error State', () => {
it('should show error state when sources fail to load', () => {
cy.intercept(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,44 @@ describe('Model Catalog All Models View', () => {
});

describe('Empty States', () => {
it('should show empty state when category has no models', () => {
it('should hide empty categories instead of showing empty state', () => {
initIntercepts({ isEmpty: true });
modelCatalog.visit();

modelCatalog.findEmptyState('OpenVINO').scrollIntoView().should('be.visible');
modelCatalog
.findEmptyState('OpenVINO')
.should('contain.text', 'No result foundAdjust your filters and try again.');
modelCatalog.findEmptyState('OpenVINO').should('not.exist');
modelCatalog.findCategoryTitle('OpenVINO').should('not.exist');
modelCatalog.findCategoryTitle('Hugging Face').should('not.exist');
modelCatalog.findCategoryTitle('Community').should('not.exist');
});

it('should hide empty categories from toggle', () => {
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,
});

cy.interceptApi(
`GET /api/:apiVersion/model_catalog/models`,
{
path: { apiVersion: MODEL_CATALOG_API_VERSION },
query: { sourceLabel: 'OpenVINO' },
},
mockCatalogModelList({ items: [] }),
);

modelCatalog.visit();

modelCatalog.findCategoryToggle('label-OpenVINO').should('not.exist');
modelCatalog.findCategoryToggle('label-Hugging Face').should('be.visible');
modelCatalog.findCategoryToggle('label-Community').should('be.visible');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import useModelCatalogAPIState, {
import { useCatalogSources } from '~/app/hooks/modelCatalog/useCatalogSources';
import { useCatalogLabels } from '~/app/hooks/modelCatalog/useCatalogLabels';
import { useMcpServerFilterOptionListWithAPI } from '~/app/hooks/mcpServerCatalog/useMcpServerFilterOptionList';
import useEmptyCategoryTracking from '~/app/hooks/useEmptyCategoryTracking';
import type {
McpCatalogContextType,
McpCatalogPaginationState,
Expand Down Expand Up @@ -55,6 +56,8 @@ export const McpCatalogContext = React.createContext<McpCatalogContextType>({
filterOptions: null,
filterOptionsLoaded: false,
filterOptionsLoadError: undefined,
emptyCategoryLabels: new Set<string>(),
reportCategoryEmpty: () => undefined,
});

const MODEL_CATALOG_PATH = `${URL_PREFIX}/api/${BFF_API_VERSION}/model_catalog`;
Expand Down Expand Up @@ -89,6 +92,7 @@ export const McpCatalogContextProvider: React.FC<McpCatalogContextProviderProps>
const [selectedSourceLabel, setSelectedSourceLabel] = React.useState<string | undefined>(
initialState.selectedSourceLabel,
);
const { emptyCategoryLabels, reportCategoryEmpty } = useEmptyCategoryTracking();

React.useEffect(() => {
syncToUrl({ searchQuery, filters, selectedSourceLabel });
Expand Down Expand Up @@ -137,6 +141,8 @@ export const McpCatalogContextProvider: React.FC<McpCatalogContextProviderProps>
filterOptions,
filterOptionsLoaded,
filterOptionsLoadError,
emptyCategoryLabels,
reportCategoryEmpty,
}),
[
apiStateMcpCatalog,
Expand All @@ -158,6 +164,8 @@ export const McpCatalogContextProvider: React.FC<McpCatalogContextProviderProps>
setPageSize,
setTotalItems,
clearAllFilters,
emptyCategoryLabels,
reportCategoryEmpty,
],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useCatalogSources } from '~/app/hooks/modelCatalog/useCatalogSources';
import useModelCatalogAPIState, {
ModelCatalogAPIState,
} from '~/app/hooks/modelCatalog/useModelCatalogAPIState';
import useEmptyCategoryTracking from '~/app/hooks/useEmptyCategoryTracking';
import {
CatalogFilterOptionsList,
CatalogLabelList,
Expand Down Expand Up @@ -69,6 +70,8 @@ export type ModelCatalogContextType = {
) => string | number | string[] | undefined;
sortBy: ModelCatalogSortOption | null;
setSortBy: (sortBy: ModelCatalogSortOption | null) => void;
emptyCategoryLabels: Set<string>;
reportCategoryEmpty: (label: string, isEmpty: boolean) => void;
};

type ModelCatalogContextProviderProps = {
Expand Down Expand Up @@ -116,6 +119,8 @@ export const ModelCatalogContext = React.createContext<ModelCatalogContextType>(
getPerformanceFilterDefaultValue: () => undefined,
sortBy: null,
setSortBy: () => undefined,
emptyCategoryLabels: new Set<string>(),
reportCategoryEmpty: () => undefined,
});

export const ModelCatalogContextProvider: React.FC<ModelCatalogContextProviderProps> = ({
Expand Down Expand Up @@ -150,6 +155,7 @@ export const ModelCatalogContextProvider: React.FC<ModelCatalogContextProviderPr
React.useState(false);
const [lastViewedModelName, setLastViewedModelName] = React.useState<string | null>(null);
const [sortBy, setSortBy] = React.useState<ModelCatalogSortOption | null>(null);
const { emptyCategoryLabels, reportCategoryEmpty } = useEmptyCategoryTracking();

const location = useLocation();
const isOnDetailsPage = location.pathname.includes(ModelDetailsTab.PERFORMANCE_INSIGHTS);
Expand Down Expand Up @@ -350,6 +356,8 @@ export const ModelCatalogContextProvider: React.FC<ModelCatalogContextProviderPr
getPerformanceFilterDefaultValue: getDefaultValueForPerformanceFilter,
sortBy,
setSortBy,
emptyCategoryLabels,
reportCategoryEmpty,
}),
[
catalogSourcesLoaded,
Expand Down Expand Up @@ -379,6 +387,8 @@ export const ModelCatalogContextProvider: React.FC<ModelCatalogContextProviderPr
getDefaultValueForPerformanceFilter,
sortBy,
setSortBy,
emptyCategoryLabels,
reportCategoryEmpty,
],
);

Expand Down
45 changes: 45 additions & 0 deletions clients/ui/frontend/src/app/hooks/useEffectiveCategories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as React from 'react';
import { CatalogLabelList, CatalogSourceList } from '~/app/modelCatalogTypes';
import { getActiveSourceLabels } from '~/app/pages/modelCatalog/utils/modelCatalogUtils';

type UseEffectiveCategoriesResult = {
effectiveActiveCategories: string[];
isSingleCategory: boolean;
hasNoCategories: boolean;
};

const useEffectiveCategories = (
catalogSources: CatalogSourceList | null,
catalogLabels: CatalogLabelList | null,
emptyCategoryLabels: Set<string>,
catalogSourcesLoaded: boolean,
updateSelectedSourceLabel: (label: string | undefined) => void,
): UseEffectiveCategoriesResult => {
const activeCategories = React.useMemo(
() => getActiveSourceLabels(catalogSources, catalogLabels),
[catalogSources, catalogLabels],
);

const effectiveActiveCategories = React.useMemo(
() => activeCategories.filter((c) => !emptyCategoryLabels.has(c)),
[activeCategories, emptyCategoryLabels],
);

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

React.useEffect(() => {
if (catalogSourcesLoaded && isSingleCategory) {
updateSelectedSourceLabel(effectiveActiveCategories[0]);
}
}, [
catalogSourcesLoaded,
isSingleCategory,
effectiveActiveCategories,
updateSelectedSourceLabel,
]);

return { effectiveActiveCategories, isSingleCategory, hasNoCategories };
};

export default useEffectiveCategories;
33 changes: 33 additions & 0 deletions clients/ui/frontend/src/app/hooks/useEmptyCategoryTracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as React from 'react';

type UseEmptyCategoryTrackingResult = {
emptyCategoryLabels: Set<string>;
reportCategoryEmpty: (label: string, isEmpty: boolean) => void;
};

const useEmptyCategoryTracking = (): UseEmptyCategoryTrackingResult => {
const [emptyCategoryLabels, setEmptyCategoryLabels] = React.useState<Set<string>>(
() => new Set<string>(),
);

const reportCategoryEmpty = React.useCallback((label: string, isEmpty: boolean) => {
setEmptyCategoryLabels((prev) => {
const hasLabel = prev.has(label);
if (isEmpty && !hasLabel) {
const next = new Set(prev);
next.add(label);
return next;
}
if (!isEmpty && hasLabel) {
const next = new Set(prev);
next.delete(label);
return next;
}
return prev;
});
}, []);

return { emptyCategoryLabels, reportCategoryEmpty };
};

export default useEmptyCategoryTracking;
21 changes: 21 additions & 0 deletions clients/ui/frontend/src/app/hooks/useReportCategoryEmpty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';

const useReportCategoryEmpty = (
reportCategoryEmpty: (label: string, isEmpty: boolean) => void,
label: string,
isLoaded: boolean,
itemCount: number,
searchTerm: string,
): void => {
React.useEffect(() => {
if (!isLoaded || searchTerm) {
return undefined;
}
const timer = setTimeout(() => {
reportCategoryEmpty(label, itemCount === 0);
}, 100);
return () => clearTimeout(timer);
}, [isLoaded, itemCount, label, searchTerm, reportCategoryEmpty]);
};

export default useReportCategoryEmpty;
Loading
Loading