Skip to content

Commit 3d1ffd1

Browse files
hide empty builtin cat from model catalog
Signed-off-by: Philip Colares Carneiro <philip.colares@gmail.com>
1 parent 2aae5ee commit 3d1ffd1

12 files changed

Lines changed: 270 additions & 40 deletions

File tree

clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpCatalog.cy.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { mockModArchResponse } from 'mod-arch-core';
2-
import { mockCatalogSourceList } from '~/__mocks__';
2+
import { mockCatalogLabel, mockCatalogSourceList, mockCatalogSource } from '~/__mocks__';
33
import { mcpCatalog } from '~/__tests__/cypress/cypress/pages/mcpCatalog';
44
import { MODEL_CATALOG_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api';
55
import {
@@ -98,6 +98,91 @@ describe('MCP Catalog Empty State', () => {
9898
});
9999
});
100100

101+
describe('MCP Catalog Empty Category Hiding', () => {
102+
it('should hide categories that have 0 servers from toggle', () => {
103+
const sources = [
104+
mockCatalogSource({
105+
id: 'community-mcp-source',
106+
name: 'Community MCP Servers',
107+
labels: ['community_mcp_servers'],
108+
}),
109+
mockCatalogSource({
110+
id: 'org-mcp-source',
111+
name: 'Organization MCP Servers',
112+
labels: ['organization_mcp_servers'],
113+
}),
114+
];
115+
116+
cy.interceptApi(
117+
`GET /api/:apiVersion/model_catalog/sources`,
118+
{ path: { apiVersion: MODEL_CATALOG_API_VERSION }, query: { assetType: 'mcp_servers' } },
119+
mockCatalogSourceList({ items: sources }),
120+
);
121+
122+
cy.intercept(
123+
{
124+
method: 'GET',
125+
url: new RegExp(`/api/${MODEL_CATALOG_API_VERSION}/model_catalog/labels`),
126+
},
127+
mockModArchResponse({
128+
items: [
129+
mockCatalogLabel({
130+
name: 'community_mcp_servers',
131+
displayName: 'Community MCP Servers',
132+
}),
133+
mockCatalogLabel({
134+
name: 'organization_mcp_servers',
135+
displayName: 'Organization MCP Servers',
136+
}),
137+
],
138+
size: 2,
139+
pageSize: 10,
140+
nextPageToken: '',
141+
}),
142+
);
143+
144+
cy.interceptApi(
145+
`GET /api/:apiVersion/mcp_catalog/mcp_servers`,
146+
{
147+
path: { apiVersion: MODEL_CATALOG_API_VERSION },
148+
query: { sourceLabel: 'community_mcp_servers' },
149+
},
150+
{ items: [], size: 0, pageSize: 10, nextPageToken: '' },
151+
);
152+
153+
cy.interceptApi(
154+
`GET /api/:apiVersion/mcp_catalog/mcp_servers`,
155+
{
156+
path: { apiVersion: MODEL_CATALOG_API_VERSION },
157+
query: { sourceLabel: 'organization_mcp_servers' },
158+
},
159+
{
160+
items: [
161+
{
162+
id: 'server-1',
163+
name: 'Test Server',
164+
description: 'test',
165+
source_id: 'org-mcp-source', // eslint-disable-line camelcase
166+
},
167+
],
168+
size: 1,
169+
pageSize: 10,
170+
nextPageToken: '',
171+
},
172+
);
173+
174+
cy.intercept(
175+
{ method: 'GET', pathname: MCP_FILTER_OPTIONS_PATH },
176+
mockModArchResponse(mockMcpCatalogFilterOptions()),
177+
);
178+
179+
mcpCatalog.visit();
180+
181+
cy.findByTestId('mcp-category-title-community_mcp_servers').should('not.exist');
182+
cy.findByTestId('mcp-category-title-organization_mcp_servers').should('be.visible');
183+
});
184+
});
185+
101186
describe('MCP Catalog Error State', () => {
102187
it('should show error state when sources fail to load', () => {
103188
cy.intercept(

clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalogAllModelsView.cy.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,44 @@ describe('Model Catalog All Models View', () => {
190190
});
191191

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

197-
modelCatalog.findEmptyState('OpenVINO').scrollIntoView().should('be.visible');
198-
modelCatalog
199-
.findEmptyState('OpenVINO')
200-
.should('contain.text', 'No result foundAdjust your filters and try again.');
197+
modelCatalog.findEmptyState('OpenVINO').should('not.exist');
198+
modelCatalog.findCategoryTitle('OpenVINO').should('not.exist');
199+
modelCatalog.findCategoryTitle('Hugging Face').should('not.exist');
200+
modelCatalog.findCategoryTitle('Community').should('not.exist');
201+
});
202+
203+
it('should hide empty categories from toggle', () => {
204+
initIntercepts({
205+
sources: [
206+
mockCatalogSource({
207+
id: 'huggingface',
208+
name: 'Hugging Face',
209+
labels: ['Hugging Face'],
210+
}),
211+
mockCatalogSource({ id: 'openvino', name: 'OpenVINO', labels: ['OpenVINO'] }),
212+
mockCatalogSource({ id: 'community', name: 'Community', labels: ['Community'] }),
213+
],
214+
includeSourcesWithoutLabels: false,
215+
});
216+
217+
cy.interceptApi(
218+
`GET /api/:apiVersion/model_catalog/models`,
219+
{
220+
path: { apiVersion: MODEL_CATALOG_API_VERSION },
221+
query: { sourceLabel: 'OpenVINO' },
222+
},
223+
mockCatalogModelList({ items: [] }),
224+
);
225+
226+
modelCatalog.visit();
227+
228+
modelCatalog.findCategoryToggle('label-OpenVINO').should('not.exist');
229+
modelCatalog.findCategoryToggle('label-Hugging Face').should('be.visible');
230+
modelCatalog.findCategoryToggle('label-Community').should('be.visible');
201231
});
202232
});
203233
});

clients/ui/frontend/src/app/context/mcpCatalog/McpCatalogContext.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export const McpCatalogContext = React.createContext<McpCatalogContextType>({
5555
filterOptions: null,
5656
filterOptionsLoaded: false,
5757
filterOptionsLoadError: undefined,
58+
emptyCategoryLabels: new Set<string>(),
59+
reportCategoryEmpty: () => undefined,
5860
});
5961

6062
const MODEL_CATALOG_PATH = `${URL_PREFIX}/api/${BFF_API_VERSION}/model_catalog`;
@@ -89,6 +91,25 @@ export const McpCatalogContextProvider: React.FC<McpCatalogContextProviderProps>
8991
const [selectedSourceLabel, setSelectedSourceLabel] = React.useState<string | undefined>(
9092
initialState.selectedSourceLabel,
9193
);
94+
const [emptyCategoryLabels, setEmptyCategoryLabels] = React.useState<Set<string>>(
95+
() => new Set<string>(),
96+
);
97+
const emptyCategoryLabelsRef = React.useRef(emptyCategoryLabels);
98+
emptyCategoryLabelsRef.current = emptyCategoryLabels;
99+
100+
const reportCategoryEmpty = React.useCallback((label: string, isEmpty: boolean) => {
101+
const { current } = emptyCategoryLabelsRef;
102+
const hasLabel = current.has(label);
103+
if (isEmpty && !hasLabel) {
104+
const next = new Set(current);
105+
next.add(label);
106+
setEmptyCategoryLabels(next);
107+
} else if (!isEmpty && hasLabel) {
108+
const next = new Set(current);
109+
next.delete(label);
110+
setEmptyCategoryLabels(next);
111+
}
112+
}, []);
92113

93114
React.useEffect(() => {
94115
syncToUrl({ searchQuery, filters, selectedSourceLabel });
@@ -137,6 +158,8 @@ export const McpCatalogContextProvider: React.FC<McpCatalogContextProviderProps>
137158
filterOptions,
138159
filterOptionsLoaded,
139160
filterOptionsLoadError,
161+
emptyCategoryLabels,
162+
reportCategoryEmpty,
140163
}),
141164
[
142165
apiStateMcpCatalog,
@@ -158,6 +181,8 @@ export const McpCatalogContextProvider: React.FC<McpCatalogContextProviderProps>
158181
setPageSize,
159182
setTotalItems,
160183
clearAllFilters,
184+
emptyCategoryLabels,
185+
reportCategoryEmpty,
161186
],
162187
);
163188

clients/ui/frontend/src/app/context/modelCatalog/ModelCatalogContext.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ export type ModelCatalogContextType = {
6969
) => string | number | string[] | undefined;
7070
sortBy: ModelCatalogSortOption | null;
7171
setSortBy: (sortBy: ModelCatalogSortOption | null) => void;
72+
emptyCategoryLabels: Set<string>;
73+
reportCategoryEmpty: (label: string, isEmpty: boolean) => void;
7274
};
7375

7476
type ModelCatalogContextProviderProps = {
@@ -116,6 +118,8 @@ export const ModelCatalogContext = React.createContext<ModelCatalogContextType>(
116118
getPerformanceFilterDefaultValue: () => undefined,
117119
sortBy: null,
118120
setSortBy: () => undefined,
121+
emptyCategoryLabels: new Set<string>(),
122+
reportCategoryEmpty: () => undefined,
119123
});
120124

121125
export const ModelCatalogContextProvider: React.FC<ModelCatalogContextProviderProps> = ({
@@ -150,6 +154,25 @@ export const ModelCatalogContextProvider: React.FC<ModelCatalogContextProviderPr
150154
React.useState(false);
151155
const [lastViewedModelName, setLastViewedModelName] = React.useState<string | null>(null);
152156
const [sortBy, setSortBy] = React.useState<ModelCatalogSortOption | null>(null);
157+
const [emptyCategoryLabels, setEmptyCategoryLabels] = React.useState<Set<string>>(
158+
() => new Set<string>(),
159+
);
160+
const emptyCategoryLabelsRef = React.useRef(emptyCategoryLabels);
161+
emptyCategoryLabelsRef.current = emptyCategoryLabels;
162+
163+
const reportCategoryEmpty = React.useCallback((label: string, isEmpty: boolean) => {
164+
const { current } = emptyCategoryLabelsRef;
165+
const hasLabel = current.has(label);
166+
if (isEmpty && !hasLabel) {
167+
const next = new Set(current);
168+
next.add(label);
169+
setEmptyCategoryLabels(next);
170+
} else if (!isEmpty && hasLabel) {
171+
const next = new Set(current);
172+
next.delete(label);
173+
setEmptyCategoryLabels(next);
174+
}
175+
}, []);
153176

154177
const location = useLocation();
155178
const isOnDetailsPage = location.pathname.includes(ModelDetailsTab.PERFORMANCE_INSIGHTS);
@@ -350,6 +373,8 @@ export const ModelCatalogContextProvider: React.FC<ModelCatalogContextProviderPr
350373
getPerformanceFilterDefaultValue: getDefaultValueForPerformanceFilter,
351374
sortBy,
352375
setSortBy,
376+
emptyCategoryLabels,
377+
reportCategoryEmpty,
353378
}),
354379
[
355380
catalogSourcesLoaded,
@@ -379,6 +404,8 @@ export const ModelCatalogContextProvider: React.FC<ModelCatalogContextProviderPr
379404
getDefaultValueForPerformanceFilter,
380405
sortBy,
381406
setSortBy,
407+
emptyCategoryLabels,
408+
reportCategoryEmpty,
382409
],
383410
);
384411

clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalog.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const McpCatalog: React.FC = () => {
2424
catalogSources,
2525
catalogLabels,
2626
catalogSourcesLoaded,
27+
emptyCategoryLabels,
2728
} = React.useContext(McpCatalogContext);
2829

2930
const filtersApplied = hasMcpFiltersApplied(filters, searchQuery);
@@ -34,14 +35,19 @@ const McpCatalog: React.FC = () => {
3435
[catalogSources, catalogLabels],
3536
);
3637

37-
const isSingleCategory = activeCategories.length === 1;
38-
const hasNoCategories = activeCategories.length === 0;
38+
const effectiveActiveCategories = React.useMemo(
39+
() => activeCategories.filter((c) => !emptyCategoryLabels.has(c)),
40+
[activeCategories, emptyCategoryLabels],
41+
);
42+
43+
const isSingleCategory = effectiveActiveCategories.length === 1;
44+
const hasNoCategories = effectiveActiveCategories.length === 0;
3945

4046
React.useEffect(() => {
4147
if (catalogSourcesLoaded && isSingleCategory) {
42-
setSelectedSourceLabel(activeCategories[0]);
48+
setSelectedSourceLabel(effectiveActiveCategories[0]);
4349
}
44-
}, [catalogSourcesLoaded, isSingleCategory, activeCategories, setSelectedSourceLabel]);
50+
}, [catalogSourcesLoaded, isSingleCategory, effectiveActiveCategories, setSelectedSourceLabel]);
4551

4652
const handleSearch = React.useCallback(
4753
(term: string) => {

clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogCategorySection.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const McpCatalogCategorySection: React.FC<McpCatalogCategorySectionProps> = ({
3838
pageSize,
3939
onShowMore,
4040
}) => {
41-
const { mcpApiState, catalogLabels } = React.useContext(McpCatalogContext);
41+
const { mcpApiState, catalogLabels, reportCategoryEmpty } = React.useContext(McpCatalogContext);
4242
const { mcpServers, mcpServersLoaded, mcpServersLoadError } = useMcpServersBySourceLabelWithAPI(
4343
mcpApiState,
4444
{
@@ -58,6 +58,21 @@ const McpCatalogCategorySection: React.FC<McpCatalogCategorySectionProps> = ({
5858
);
5959
const description = getLabelDescription(label, catalogLabels);
6060

61+
const reportTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
62+
React.useEffect(() => {
63+
clearTimeout(reportTimerRef.current);
64+
if (mcpServersLoaded && !searchTerm) {
65+
reportTimerRef.current = setTimeout(() => {
66+
reportCategoryEmpty(label, mcpServers.items.length === 0);
67+
}, 100);
68+
}
69+
return () => clearTimeout(reportTimerRef.current);
70+
}, [mcpServersLoaded, mcpServers.items.length, label, searchTerm, reportCategoryEmpty]);
71+
72+
if (mcpServersLoaded && mcpServers.items.length === 0 && !searchTerm) {
73+
return null;
74+
}
75+
6176
return (
6277
<StackItem className="pf-v6-u-pb-xl">
6378
<Flex

clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogSourceLabelBlocks.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@ const ALL_SERVERS_LABEL = 'All MCP servers';
1616
type SourceLabelBlock = { id: string; label?: string; displayName: string };
1717

1818
const McpCatalogSourceLabelBlocks: React.FC = () => {
19-
const { catalogSources, catalogLabels, selectedSourceLabel, setSelectedSourceLabel } =
20-
React.useContext(McpCatalogContext);
19+
const {
20+
catalogSources,
21+
catalogLabels,
22+
selectedSourceLabel,
23+
setSelectedSourceLabel,
24+
emptyCategoryLabels,
25+
} = React.useContext(McpCatalogContext);
2126

2227
const blocks: SourceLabelBlock[] = React.useMemo(() => {
2328
if (!catalogSources) {
@@ -31,20 +36,22 @@ const McpCatalogSourceLabelBlocks: React.FC = () => {
3136

3237
const allBlock: SourceLabelBlock = { id: 'all', displayName: ALL_SERVERS_LABEL };
3338

34-
const labelBlocks: SourceLabelBlock[] = orderedLabels.map((label) => ({
35-
id: `label-${label}`,
36-
label,
37-
displayName: getLabelDisplayName(
39+
const labelBlocks: SourceLabelBlock[] = orderedLabels
40+
.filter((label) => !emptyCategoryLabels.has(label))
41+
.map((label) => ({
42+
id: `label-${label}`,
3843
label,
39-
catalogLabels,
40-
OTHER_MCP_SERVERS_DISPLAY_NAME,
41-
'servers',
42-
),
43-
}));
44+
displayName: getLabelDisplayName(
45+
label,
46+
catalogLabels,
47+
OTHER_MCP_SERVERS_DISPLAY_NAME,
48+
'servers',
49+
),
50+
}));
4451

4552
const result: SourceLabelBlock[] = [allBlock, ...labelBlocks];
4653

47-
if (hasNoLabels) {
54+
if (hasNoLabels && !emptyCategoryLabels.has(SourceLabel.other)) {
4855
result.push({
4956
id: 'no-labels',
5057
label: SourceLabel.other,
@@ -58,7 +65,7 @@ const McpCatalogSourceLabelBlocks: React.FC = () => {
5865
}
5966

6067
return result;
61-
}, [catalogSources, catalogLabels]);
68+
}, [catalogSources, catalogLabels, emptyCategoryLabels]);
6269

6370
if (!catalogSources) {
6471
return null;

clients/ui/frontend/src/app/pages/mcpCatalog/screens/__tests__/McpCatalogGalleryView.spec.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ const defaultContext: McpCatalogContextType = {
4949
filterOptions: null,
5050
filterOptionsLoaded: true,
5151
filterOptionsLoadError: undefined,
52+
emptyCategoryLabels: new Set<string>(),
53+
reportCategoryEmpty: jest.fn(),
5254
};
5355

5456
const defaultHookResult: McpServersResult = {

0 commit comments

Comments
 (0)