Skip to content

Commit 78b68a4

Browse files
committed
Add sort dropdown with Relevance, A-Z, Z-A options
Add user-selectable sort order to Software Catalog with three modes: - Relevance (default): Keyword relevance scoring + Red Hat prioritization - A-Z: Pure alphabetical ascending - Z-A: Pure alphabetical descending Changes: - Add RELEVANCE to CatalogSortOrder enum as default sort mode - Create sortCatalogItems() function to handle all 3 sort modes with search filtering - Update filterBySearchKeyword() to accept optional sortOrder parameter - Re-enable sort dropdown UI in CatalogToolbar with 3 options - Wire up sort state management in CatalogView with URL persistence - Add 13 unit tests for sortCatalogItems
1 parent aaaa07d commit 78b68a4

File tree

6 files changed

+235
-25
lines changed

6 files changed

+235
-25
lines changed

frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogToolbar.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
55
import { ConsoleSelect } from '@console/internal/components/utils/console-select';
66
import { useDebounceCallback } from '@console/shared';
77
import { NO_GROUPING } from '../utils/category-utils';
8-
import { /* CatalogSortOrder, */ CatalogStringMap } from '../utils/types';
8+
import { CatalogSortOrder, CatalogStringMap } from '../utils/types';
99
import CatalogPageHeader from './CatalogPageHeader';
1010
import CatalogPageHeading from './CatalogPageHeading';
1111
import CatalogPageNumItems from './CatalogPageNumItems';
@@ -15,12 +15,12 @@ type CatalogToolbarProps = {
1515
title: string;
1616
totalItems: number;
1717
searchKeyword: string;
18-
// sortOrder: CatalogSortOrder;
18+
sortOrder: CatalogSortOrder;
1919
groupings: CatalogStringMap;
2020
activeGrouping: string;
2121
onGroupingChange: (grouping: string) => void;
2222
onSearchKeywordChange: (searchKeyword: string) => void;
23-
// onSortOrderChange: (sortOrder: CatalogSortOrder) => void;
23+
onSortOrderChange: (sortOrder: CatalogSortOrder) => void;
2424
};
2525

2626
const CatalogToolbar = forwardRef<HTMLInputElement, CatalogToolbarProps>(
@@ -29,22 +29,22 @@ const CatalogToolbar = forwardRef<HTMLInputElement, CatalogToolbarProps>(
2929
title,
3030
totalItems,
3131
searchKeyword,
32-
// sortOrder,
32+
sortOrder,
3333
groupings,
3434
activeGrouping,
3535
onGroupingChange,
3636
onSearchKeywordChange,
37-
// onSortOrderChange,
37+
onSortOrderChange,
3838
},
3939
inputRef,
4040
) => {
4141
const { t } = useTranslation();
4242

43-
// TODO: Add sort order back in with a new sort by "Relevance" selection, that is the default sort order
44-
// const catalogSortItems = {
45-
// [CatalogSortOrder.ASC]: t('console-shared~A-Z'),
46-
// [CatalogSortOrder.DESC]: t('console-shared~Z-A'),
47-
// };
43+
const catalogSortItems = {
44+
[CatalogSortOrder.RELEVANCE]: t('console-shared~Relevance'),
45+
[CatalogSortOrder.ASC]: t('console-shared~A-Z'),
46+
[CatalogSortOrder.DESC]: t('console-shared~Z-A'),
47+
};
4848

4949
const showGrouping = !_.isEmpty(groupings);
5050

@@ -72,15 +72,16 @@ const CatalogToolbar = forwardRef<HTMLInputElement, CatalogToolbarProps>(
7272
aria-label={t('console-shared~Filter by keyword...')}
7373
/>
7474
</FlexItem>
75-
{/* <FlexItem>
75+
<FlexItem>
7676
<ConsoleSelect
7777
className="co-catalog-page__sort"
7878
items={catalogSortItems}
7979
title={catalogSortItems[sortOrder]}
8080
alwaysShowTitle
8181
onChange={onSortOrderChange}
82+
selectedKey={sortOrder}
8283
/>
83-
</FlexItem> */}
84+
</FlexItem>
8485
{showGrouping && (
8586
<FlexItem>
8687
<ConsoleSelect

frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogView.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
CatalogFilterGroupMap,
3434
CatalogFilters as FiltersType,
3535
CatalogQueryParams,
36-
// CatalogSortOrder,
36+
CatalogSortOrder,
3737
CatalogStringMap,
3838
CatalogType,
3939
CatalogTypeCounts,
@@ -80,8 +80,9 @@ const CatalogView: React.FC<CatalogViewProps> = ({
8080
const activeCategoryId = queryParams.get(CatalogQueryParams.CATEGORY) ?? ALL_CATEGORY;
8181
const activeSearchKeyword = queryParams.get(CatalogQueryParams.KEYWORD) ?? '';
8282
const activeGrouping = queryParams.get(CatalogQueryParams.GROUPING) ?? NO_GROUPING;
83-
// const sortOrder =
84-
// (queryParams.get(CatalogQueryParams.SORT_ORDER) as CatalogSortOrder) ?? CatalogSortOrder.ASC;
83+
const sortOrder =
84+
(queryParams.get(CatalogQueryParams.SORT_ORDER) as CatalogSortOrder) ??
85+
CatalogSortOrder.RELEVANCE;
8586
const activeFilters = React.useMemo(() => {
8687
const attributeFilters = {};
8788

@@ -139,9 +140,9 @@ const CatalogView: React.FC<CatalogViewProps> = ({
139140
updateURLParams(CatalogQueryParams.GROUPING, grouping);
140141
}, []);
141142

142-
// const handleSortOrderChange = React.useCallback((order) => {
143-
// updateURLParams(CatalogQueryParams.SORT_ORDER, order);
144-
// }, []);
143+
const handleSortOrderChange = React.useCallback((order) => {
144+
updateURLParams(CatalogQueryParams.SORT_ORDER, order);
145+
}, []);
145146

146147
const handleShowAllToggle = React.useCallback((groupName) => {
147148
setFilterGroupsShowAll((showAll) => {
@@ -175,6 +176,7 @@ const CatalogView: React.FC<CatalogViewProps> = ({
175176
const filteredBySearchItems = filterBySearchKeyword(
176177
filteredByCategoryItems,
177178
activeSearchKeyword,
179+
sortOrder,
178180
);
179181
const filteredByAttributes = filterByAttributes(filteredBySearchItems, activeFilters);
180182

@@ -185,7 +187,7 @@ const CatalogView: React.FC<CatalogViewProps> = ({
185187
setCatalogTypeCounts(typeCounts);
186188

187189
// Console table for final filtered results (only for operators)
188-
if (filteredByAttributes.length > 0 && filteredByAttributes[0]?.type === 'operator') {
190+
if (filteredByAttributes.length > 0) {
189191
// Check if we have active filters beyond just search and category
190192
const hasAttributeFilters = Object.values(activeFilters).some((filterGroup) =>
191193
Object.values(filterGroup).some((filter) => filter.active),
@@ -261,6 +263,7 @@ const CatalogView: React.FC<CatalogViewProps> = ({
261263
categorizedIds,
262264
filterGroups,
263265
items,
266+
sortOrder,
264267
]);
265268

266269
const totalItems = filteredItems.length;
@@ -335,11 +338,11 @@ const CatalogView: React.FC<CatalogViewProps> = ({
335338
title={activeCategory.label}
336339
totalItems={totalItems}
337340
searchKeyword={activeSearchKeyword}
338-
// sortOrder={sortOrder}
341+
sortOrder={sortOrder}
339342
groupings={groupings}
340343
activeGrouping={activeGrouping}
341344
onGroupingChange={handleGroupingChange}
342-
// onSortOrderChange={handleSortOrderChange}
345+
onSortOrderChange={handleSortOrderChange}
343346
onSearchKeywordChange={handleSearchKeywordChange}
344347
/>
345348
{totalItems > 0 ? (

frontend/packages/console-shared/src/components/catalog/utils/__tests__/catalog-utils.spec.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import {
33
keywordCompare,
44
calculateCatalogItemRelevanceScore,
55
getRedHatPriority,
6+
sortCatalogItems,
67
} from '../catalog-utils';
8+
import { CatalogSortOrder } from '../types';
79

810
// Mock CatalogItem data for testing
911
const createMockCatalogItem = (overrides: Partial<CatalogItem> = {}): CatalogItem => ({
@@ -313,3 +315,154 @@ describe('keywordCompare', () => {
313315
expect(result[1].attributes?.provider).toBe('Other Company');
314316
});
315317
});
318+
319+
describe('sortCatalogItems', () => {
320+
const testItems = [
321+
createMockCatalogItem({
322+
uid: 'item-1',
323+
name: 'Zebra Operator',
324+
attributes: { provider: 'Red Hat' },
325+
description: 'Contains gitops in description',
326+
}),
327+
createMockCatalogItem({
328+
uid: 'item-2',
329+
name: 'Argo CD',
330+
attributes: { provider: 'CNCF' },
331+
description: 'GitOps tool for Kubernetes',
332+
tags: ['gitops'],
333+
}),
334+
createMockCatalogItem({
335+
uid: 'item-3',
336+
name: 'Database Manager',
337+
attributes: { provider: 'Red Hat' },
338+
description: 'Database management',
339+
}),
340+
createMockCatalogItem({
341+
uid: 'item-4',
342+
name: 'Flux',
343+
attributes: { provider: 'Weaveworks' },
344+
description: 'GitOps for Kubernetes',
345+
tags: ['gitops'],
346+
}),
347+
];
348+
349+
describe('filtering behavior', () => {
350+
it('filters items by search keyword before sorting', () => {
351+
const result = sortCatalogItems(testItems, CatalogSortOrder.ASC, 'gitops');
352+
353+
// Should only return items matching 'gitops'
354+
expect(result).toHaveLength(3);
355+
expect(result.every((item) => item.name !== 'Database Manager')).toBe(true);
356+
});
357+
358+
it('returns all items when no search keyword provided', () => {
359+
const result = sortCatalogItems(testItems, CatalogSortOrder.ASC, '');
360+
361+
expect(result).toHaveLength(4);
362+
});
363+
364+
it('returns empty array when no items match search keyword', () => {
365+
const result = sortCatalogItems(testItems, CatalogSortOrder.ASC, 'nonexistent');
366+
367+
expect(result).toHaveLength(0);
368+
});
369+
});
370+
371+
describe('RELEVANCE mode', () => {
372+
it('delegates to keywordCompare correctly', () => {
373+
const result = sortCatalogItems(testItems, CatalogSortOrder.RELEVANCE, 'gitops');
374+
375+
// Red Hat item should be first due to priority + relevance
376+
expect(result[0].name).toBe('Zebra Operator');
377+
expect(result[0].attributes?.provider).toBe('Red Hat');
378+
});
379+
380+
it('uses keywordCompare with no search term', () => {
381+
const result = sortCatalogItems(testItems, CatalogSortOrder.RELEVANCE, '');
382+
383+
// Should return all items sorted by Red Hat priority + alphabetical
384+
expect(result).toHaveLength(4);
385+
});
386+
});
387+
388+
describe('A-Z mode', () => {
389+
it('sorts alphabetically ascending without Red Hat priority', () => {
390+
const result = sortCatalogItems(testItems, CatalogSortOrder.ASC, 'gitops');
391+
392+
// Pure alphabetical order (Argo CD, Flux, Zebra Operator)
393+
expect(result[0].name).toBe('Argo CD');
394+
expect(result[1].name).toBe('Flux');
395+
expect(result[2].name).toBe('Zebra Operator');
396+
});
397+
398+
it('sorts all items alphabetically when no search term', () => {
399+
const result = sortCatalogItems(testItems, CatalogSortOrder.ASC, '');
400+
401+
// Pure alphabetical: Argo CD, Database Manager, Flux, Zebra Operator
402+
expect(result[0].name).toBe('Argo CD');
403+
expect(result[1].name).toBe('Database Manager');
404+
expect(result[2].name).toBe('Flux');
405+
expect(result[3].name).toBe('Zebra Operator');
406+
});
407+
408+
it('ignores Red Hat priority in alphabetical sorting', () => {
409+
const items = [
410+
createMockCatalogItem({
411+
name: 'Zebra Service',
412+
attributes: { provider: 'Red Hat' }, // High priority
413+
}),
414+
createMockCatalogItem({
415+
name: 'Alpha Service',
416+
attributes: { provider: 'Community' }, // Low priority
417+
}),
418+
];
419+
420+
const result = sortCatalogItems(items, CatalogSortOrder.ASC, '');
421+
422+
// Alpha should come before Zebra despite lower Red Hat priority
423+
expect(result[0].name).toBe('Alpha Service');
424+
expect(result[1].name).toBe('Zebra Service');
425+
});
426+
});
427+
428+
describe('Z-A mode', () => {
429+
it('sorts alphabetically descending without Red Hat priority', () => {
430+
const result = sortCatalogItems(testItems, CatalogSortOrder.DESC, 'gitops');
431+
432+
// Reverse alphabetical order
433+
expect(result[0].name).toBe('Zebra Operator');
434+
expect(result[1].name).toBe('Flux');
435+
expect(result[2].name).toBe('Argo CD');
436+
});
437+
438+
it('sorts all items reverse alphabetically when no search term', () => {
439+
const result = sortCatalogItems(testItems, CatalogSortOrder.DESC, '');
440+
441+
expect(result[0].name).toBe('Zebra Operator');
442+
expect(result[1].name).toBe('Flux');
443+
expect(result[2].name).toBe('Database Manager');
444+
expect(result[3].name).toBe('Argo CD');
445+
});
446+
});
447+
448+
describe('edge cases', () => {
449+
it('handles empty items array', () => {
450+
const result = sortCatalogItems([], CatalogSortOrder.ASC, 'test');
451+
452+
expect(result).toEqual([]);
453+
});
454+
455+
it('handles null/undefined items', () => {
456+
const result = sortCatalogItems(null as any, CatalogSortOrder.ASC, 'test');
457+
458+
expect(result).toBeNull();
459+
});
460+
461+
it('defaults to RELEVANCE mode when no sortOrder provided', () => {
462+
const result = sortCatalogItems(testItems, undefined as any, 'gitops');
463+
464+
// Should behave like RELEVANCE mode
465+
expect(result[0].name).toBe('Zebra Operator'); // Red Hat priority
466+
});
467+
});
468+
});

frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import { normalizeIconClass } from '@console/internal/components/catalog/catalog-item-icon';
1313
import { history } from '@console/internal/components/utils';
1414
import catalogImg from '@console/internal/imgs/logos/catalog-icon.svg';
15-
import { CatalogType, CatalogTypeCounts } from './types';
15+
import { CatalogSortOrder, CatalogType, CatalogTypeCounts } from './types';
1616

1717
enum CatalogVisibilityState {
1818
Enabled = 'Enabled',
@@ -208,6 +208,57 @@ export const keywordCompare = (filterString: string, items: CatalogItem[]): Cata
208208
return sortedItems.map(({ relevanceScore, redHatPriority, ...item }) => item);
209209
};
210210

211+
/**
212+
* Sort catalog items based on the selected sort order.
213+
* @param items - Array of catalog items to sort
214+
* @param sortOrder - Sort order: RELEVANCE (default), ASC (A-Z), or DESC (Z-A)
215+
* @param searchKeyword - Optional search keyword for filtering and relevance scoring
216+
* @returns Sorted and filtered array of catalog items
217+
*/
218+
export const sortCatalogItems = (
219+
items: CatalogItem[],
220+
sortOrder: CatalogSortOrder = CatalogSortOrder.RELEVANCE,
221+
searchKeyword = '',
222+
): CatalogItem[] => {
223+
if (!items || items.length === 0) {
224+
return items;
225+
}
226+
227+
// First, filter items by search keyword if provided
228+
let filteredItems = items;
229+
if (searchKeyword) {
230+
const searchTerm = searchKeyword.toLowerCase();
231+
filteredItems = items.filter((item) => {
232+
const relevanceScore = calculateCatalogItemRelevanceScore(searchTerm, item);
233+
return relevanceScore > 0;
234+
});
235+
}
236+
237+
// Then, sort the filtered items based on the selected sort order
238+
switch (sortOrder) {
239+
case CatalogSortOrder.RELEVANCE:
240+
// Use the existing keywordCompare function for relevance-based sorting
241+
// Note: keywordCompare handles its own filtering, so we pass the original items
242+
return keywordCompare(searchKeyword, items);
243+
244+
case CatalogSortOrder.ASC:
245+
// Sort alphabetically A-Z (pure alphabetical, no Red Hat prioritization)
246+
return [...filteredItems].sort((a, b) => {
247+
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
248+
});
249+
250+
case CatalogSortOrder.DESC:
251+
// Sort alphabetically Z-A (pure alphabetical, no Red Hat prioritization)
252+
return [...filteredItems].sort((a, b) => {
253+
return b.name.toLowerCase().localeCompare(a.name.toLowerCase());
254+
});
255+
256+
default:
257+
// Fallback to relevance sorting
258+
return keywordCompare(searchKeyword, items);
259+
}
260+
};
261+
211262
export const getIconProps = (item: CatalogItem) => {
212263
const { icon } = item;
213264
if (!icon) {

frontend/packages/console-shared/src/components/catalog/utils/filter-utils.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as _ from 'lodash';
22
import { CatalogItem } from '@console/dynamic-plugin-sdk/src/extensions';
3-
import { keywordCompare } from './catalog-utils';
4-
import { CatalogFilter, CatalogFilterCounts, CatalogFilters } from './types';
3+
import { sortCatalogItems } from './catalog-utils';
4+
import { CatalogFilter, CatalogFilterCounts, CatalogFilters, CatalogSortOrder } from './types';
55

66
export const filterByGroup = (
77
items: CatalogItem[],
@@ -61,8 +61,9 @@ export const filterByAttributes = (
6161
export const filterBySearchKeyword = (
6262
items: CatalogItem[],
6363
searchKeyword: string,
64+
sortOrder: CatalogSortOrder = CatalogSortOrder.RELEVANCE,
6465
): CatalogItem[] => {
65-
return keywordCompare(searchKeyword, items);
66+
return sortCatalogItems(items, sortOrder, searchKeyword);
6667
};
6768

6869
export const filterByCategory = (

0 commit comments

Comments
 (0)