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-organization_mcp_servers').should('be.visible');
cy.findByTestId('mcp-category-title-community_mcp_servers').should('not.exist');
});
});

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 @@ -10,6 +10,7 @@ import useModelCatalogAPIState from '~/app/hooks/modelCatalog/useModelCatalogAPI
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 {
McpCatalogFiltersState,
McpCatalogFilterOptionsList,
Expand Down Expand Up @@ -59,6 +60,7 @@ function useMcpCatalogSetup(providerState: CatalogProviderState) {
React.useState<McpCatalogPaginationState>(defaultPagination);

const { setSelectedSourceLabel } = providerState;
const { emptyCategoryLabels, reportCategoryEmpty } = useEmptyCategoryTracking();

React.useEffect(() => {
setSelectedSourceLabel(initialState.selectedSourceLabel);
Expand Down Expand Up @@ -129,6 +131,8 @@ function useMcpCatalogSetup(providerState: CatalogProviderState) {
setTotalItems,
clearAllFilters,
mcpApiState: apiStateMcpCatalog,
emptyCategoryLabels,
reportCategoryEmpty,
}),
[
apiStateMcpCatalog,
Expand All @@ -140,6 +144,8 @@ function useMcpCatalogSetup(providerState: CatalogProviderState) {
setPageSize,
setTotalItems,
clearAllFilters,
emptyCategoryLabels,
reportCategoryEmpty,
],
);

Expand Down
2 changes: 2 additions & 0 deletions clients/ui/frontend/src/app/context/mcpCatalog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type McpCatalogExtension = {
setPageSize: (pageSize: number) => void;
setTotalItems: (totalItems: number) => void;
mcpApiState: ModelCatalogAPIState;
emptyCategoryLabels: Set<string>;
reportCategoryEmpty: (label: string, isEmpty: boolean) => void;
};

export type McpCatalogContextType = CatalogContextValue<McpCatalogFilterOptionsList> &
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
getDefaultFiltersFromNamedQuery,
} from '~/app/pages/modelCatalog/utils/performanceFilterUtils';
import { getEffectiveSortBy } from '~/app/pages/modelCatalog/utils/modelCatalogUtils';
import useEmptyCategoryTracking from '~/app/hooks/useEmptyCategoryTracking';
import { BFF_API_VERSION, URL_PREFIX } from '~/app/utilities/const';

const MODEL_CATALOG_HOST_PATH = `${URL_PREFIX}/api/${BFF_API_VERSION}/model_catalog`;
Expand Down Expand Up @@ -78,6 +79,8 @@ type ModelCatalogExtension = {
) => string | number | string[] | undefined;
sortBy: ModelCatalogSortOption | null;
setSortBy: (sortBy: ModelCatalogSortOption | null) => void;
emptyCategoryLabels: Set<string>;
reportCategoryEmpty: (label: string, isEmpty: boolean) => void;
};

export type ModelCatalogContextType = CatalogContextValue<CatalogFilterOptionsList> &
Expand All @@ -102,14 +105,11 @@ function useModelCatalogSetup(providerState: CatalogProviderState) {
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);

/**
* Applies filter values from a named query to the filter state.
* Uses getDefaultFiltersFromNamedQuery to parse the namedQuery and applyFilterValue to set each filter.
*/
const applyNamedQueryDefaults = React.useCallback(
(namedQuery: NamedQuery) => {
const defaults = getDefaultFiltersFromNamedQuery(filterOptions, namedQuery);
Expand All @@ -120,20 +120,12 @@ function useModelCatalogSetup(providerState: CatalogProviderState) {
[baseSetFilterData, filterOptions],
);

/**
* Resets performance filters to their default values from namedQueries.
* Performance filters should always have values (defaults when not explicitly set).
* This is the single function for "clearing" or "resetting" performance filters.
*/
const resetPerformanceFiltersToDefaults = React.useCallback(() => {
// First, clear ALL latency filters (only one should be active at a time)
// This ensures any non-default latency filter is removed before applying defaults
ALL_LATENCY_FILTER_KEYS.forEach((latencyKey) => {
baseSetFilterData(latencyKey, undefined);
});
baseSetFilterData(ModelCatalogStringFilterKey.HARDWARE_CONFIGURATION, []);

// Then apply all defaults from namedQueries
const defaultQuery = filterOptions?.namedQueries?.[DEFAULT_PERFORMANCE_FILTERS_QUERY_NAME];
if (defaultQuery) {
applyNamedQueryDefaults(defaultQuery);
Expand All @@ -157,9 +149,6 @@ function useModelCatalogSetup(providerState: CatalogProviderState) {
baseSetFilterData(ModelCatalogStringFilterKey.VALIDATED_CONFIGURATION, []);
}, [baseSetFilterData]);

/**
* Clears all filters: basic filters to empty, performance filters to defaults.
*/
const clearAllFilters = React.useCallback(() => {
clearBasicFilters();
resetPerformanceFiltersToDefaults();
Expand All @@ -168,12 +157,8 @@ function useModelCatalogSetup(providerState: CatalogProviderState) {
const setPerformanceViewEnabled = React.useCallback(
(enabled: boolean) => {
setBasePerformanceViewEnabled(enabled);
// Performance filters always have values (defaults).
// When toggle changes, ensure defaults are applied.
// When toggle is OFF, filters are just not passed in API calls or shown as chips.
resetPerformanceFiltersToDefaults();

// Update sort to default for the new toggle state, preserving user selection if it doesn't match the opposite default
const defaultSort = getEffectiveSortBy(null, enabled);
const oppositeDefault = getEffectiveSortBy(null, !enabled);
setSortBy((currentSortBy) => {
Expand All @@ -187,36 +172,24 @@ function useModelCatalogSetup(providerState: CatalogProviderState) {
[resetPerformanceFiltersToDefaults],
);

/**
* Resets a single performance filter to its default value from namedQueries.
* Used when clicking the undo button on individual performance filter chips.
*
* For latency filters: Only one latency filter can be active at a time.
* When closing any latency chip, we clear ALL latency filters and apply the DEFAULT latency filter.
* This ensures proper reset behavior (e.g., closing ITL chip resets to the default TTFT filter).
*/
const resetSinglePerformanceFilterToDefault = React.useCallback(
(filterKey: keyof ModelCatalogFilterStates) => {
if (isLatencyFilterKey(filterKey)) {
// For latency filters: clear ALL latency filters first
ALL_LATENCY_FILTER_KEYS.forEach((latencyKey) => {
baseSetFilterData(latencyKey, undefined);
});

// Then apply the default latency filter (which may be a different key, e.g., TTFT when closing ITL)
const defaultQuery = filterOptions?.namedQueries?.[DEFAULT_PERFORMANCE_FILTERS_QUERY_NAME];
if (defaultQuery) {
// Find the default latency filter from namedQueries
for (const latencyKey of ALL_LATENCY_FILTER_KEYS) {
const { hasDefault, value } = getSingleFilterDefault(filterOptions, latencyKey);
if (hasDefault && value !== undefined) {
applyFilterValue(baseSetFilterData, latencyKey, value);
break; // Only apply the first (and should be only) default latency filter
break;
}
}
}
} else {
// Non-latency filters: just reset to default
const { value } = getSingleFilterDefault(filterOptions, filterKey);
applyFilterValue(baseSetFilterData, filterKey, value);
}
Expand All @@ -228,10 +201,6 @@ function useModelCatalogSetup(providerState: CatalogProviderState) {
[filterOptions, baseSetFilterData, isOnDetailsPage],
);

/**
* Gets the default value for a performance filter from namedQueries.
* Wrapper around the utility function that provides filterOptions from context.
*/
const getDefaultValueForPerformanceFilter = React.useCallback(
(filterKey: keyof ModelCatalogFilterStates): string | number | string[] | undefined => {
const { value } = getSingleFilterDefault(filterOptions, filterKey);
Expand Down Expand Up @@ -321,6 +290,8 @@ function useModelCatalogSetup(providerState: CatalogProviderState) {
getPerformanceFilterDefaultValue: getDefaultValueForPerformanceFilter,
sortBy,
setSortBy,
emptyCategoryLabels,
reportCategoryEmpty,
}),
[
selectedSource,
Expand All @@ -337,6 +308,9 @@ function useModelCatalogSetup(providerState: CatalogProviderState) {
resetSinglePerformanceFilterToDefault,
getDefaultValueForPerformanceFilter,
sortBy,
setSortBy,
emptyCategoryLabels,
reportCategoryEmpty,
],
);

Expand Down
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(() => {
Comment thread
Philip-Carneiro marked this conversation as resolved.
reportCategoryEmpty(label, itemCount === 0);
}, 100);
return () => clearTimeout(timer);
}, [isLoaded, itemCount, label, searchTerm, reportCategoryEmpty]);
};

export default useReportCategoryEmpty;
Loading
Loading