diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/MarketplaceLanding.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/MarketplaceLanding.tsx index c6fffcfbe42..aaed9155f7d 100644 --- a/packages/manager/src/features/Marketplace/MarketplaceLanding/MarketplaceLanding.tsx +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/MarketplaceLanding.tsx @@ -87,10 +87,11 @@ export const MarketplaceLanding = () => { updateSearchParam('query', searchString || undefined); }; - // Filter products here based on search and type filters. If no filters are set, shows all available products. + // Filter products here based on category, search and type filters. If no filters are set, shows all available products. const filteredProducts = React.useMemo( - () => filterProducts(PRODUCTS, { searchQuery, selectedType }), - [searchQuery, selectedType] + () => + filterProducts(PRODUCTS, { searchQuery, selectedCategory, selectedType }), + [searchQuery, selectedCategory, selectedType] ); // Group filtered products by category @@ -107,22 +108,37 @@ export const MarketplaceLanding = () => { return map; }, [filteredProducts]); + // Get categories that have at least one filtered product + const categoriesWithFilteredProducts = React.useMemo( + () => Object.keys(filteredProductsByCategory) as Category[], + [filteredProductsByCategory] + ); + // Filter categories based on: // 1. Selected category from dropdown (if set) - // 2. All categories, sorted by product count (if no filters) + // 2. All categories that have filtered products, sorted by product count (if no category selected) const filteredCategories = React.useMemo(() => { if (selectedCategory) { - return categoriesWithProducts.filter((cat) => cat === selectedCategory); + return categoriesWithFilteredProducts.filter( + (cat) => cat === selectedCategory + ); } - // No filters - show all categories, sorted by product count (highest to lowest) - return [...categoriesWithProducts].sort((a, b) => { + + // Show all categories sorted by product count (highest to lowest) + return [...categoriesWithFilteredProducts].sort((a, b) => { const countA = filteredProductsByCategory[a]?.length || 0; const countB = filteredProductsByCategory[b]?.length || 0; return countB - countA; }); - }, [selectedCategory, categoriesWithProducts, filteredProductsByCategory]); + }, [ + selectedCategory, + categoriesWithFilteredProducts, + filteredProductsByCategory, + ]); - const hasFiltersApplied = Boolean(searchQuery || selectedType); + const hasFiltersApplied = Boolean( + searchQuery || selectedCategory || selectedType + ); // Show empty state if there are no products to display (either no products exist, or filters return no results) const showEmptyState = filteredProducts.length === 0; diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.test.ts b/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.test.ts index beb62002a30..37f932d8915 100644 --- a/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.test.ts +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.test.ts @@ -91,4 +91,65 @@ describe('filterProducts', () => { const filtered = filterProducts(products, { searchQuery: 'randomtext' }); expect(filtered).toHaveLength(0); }); + + it('filters by category', () => { + const filtered = filterProducts(products, { + selectedCategory: 'Kubernetes', + }); + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe('SpinKube'); + }); + + it('filters by category and search query: category first, then search', () => { + const filtered = filterProducts(products, { + selectedCategory: 'Kubernetes', + searchQuery: 'spin', + }); + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe('SpinKube'); + }); + + it('filters by category and search query: returns empty when search does not match category products', () => { + const filtered = filterProducts(products, { + selectedCategory: 'Kubernetes', + searchQuery: 'titan', // TITAN-Edge is not in Kubernetes category + }); + expect(filtered).toHaveLength(0); + }); + + it('filters by category and type: category first, then type', () => { + const filtered = filterProducts(products, { + selectedCategory: 'Development Tools', + selectedType: 'SaaS & APIs', + }); + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe('APImetrics'); + }); + + it('filters by category and type: returns empty when type does not match category products', () => { + const filtered = filterProducts(products, { + selectedCategory: 'Development Tools', + selectedType: 'Virtual Machines', // No VM products in Development Tools + }); + expect(filtered).toHaveLength(0); + }); + + it('filters by all three filters: category, type, and search', () => { + const filtered = filterProducts(products, { + selectedCategory: 'Kubernetes', + selectedType: 'SaaS & APIs', + searchQuery: 'kube', + }); + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe('SpinKube'); + }); + + it('filters by all three filters: returns empty when all filters applied but no match', () => { + const filtered = filterProducts(products, { + selectedCategory: 'Kubernetes', + selectedType: 'SaaS & APIs', + searchQuery: 'titan', // TITAN-Edge is not in Kubernetes + }); + expect(filtered).toHaveLength(0); + }); }); diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.ts b/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.ts index bd64286ff52..23e4a51bd59 100644 --- a/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.ts +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.ts @@ -1,21 +1,32 @@ -import type { Product } from '../shared'; +import type { Category, Product } from '../shared'; /** - * Filters the given list of products by type and/or search query. + * Filters the given list of products by category, type and/or search query. * * - If no filters are provided, returns all products. + * - If a category is provided, only products that have that category are included (applied first). * - If a type is provided, only products matching that type are included. * - If a search query is provided, only products whose name, short description, * partner name, or type name include the query (case-insensitive) are included. * * @param products The list of products to filter. - * @param filters An object containing optional searchQuery and selectedType. + * @param filters An object containing optional selectedCategory, searchQuery and selectedType. */ export const filterProducts = ( products: Product[], - filters: { searchQuery?: string; selectedType?: string } + filters: { + searchQuery?: string; + selectedCategory?: string; + selectedType?: string; + } ): Product[] => { let result = products; + if (filters.selectedCategory) { + // Apply category filter first if present + result = result.filter((p) => + p.categories.includes(filters.selectedCategory as Category) + ); + } if (filters.selectedType) { result = result.filter((p) => p.type.name === filters.selectedType); }