Skip to content

Commit 18e43d7

Browse files
upcoming: [UIE-9816] - Add Marketplace filters to the Products landing page (linode#13292)
* Add filters & waypoint lazyloading * Added changeset: Add Marketplace filters to the Products landing page * Minor clean up * searchDerivedTypeIds only populated if no type is selected * Update margin bottom * Minor change * Use URL search params for marketplace filters * Center empty state vertically * Auto-fetch next batch if current batch has no results when filters are applied * Add pendo ids * Ensure onLoaded once per filter change * Revamp - save progress * Update tests * Add few changes * Doc props * Update changeset * Input slot props fix * Order category sections by product count * Reorganizing utils, types and add test cases * Keep logos for both theme modes under same dir * Minor fix * Separate type imports
1 parent e5a1889 commit 18e43d7

File tree

18 files changed

+648
-249
lines changed

18 files changed

+648
-249
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
Refactor Marketplace V2 and add filters to the Products landing page ([#13292](https://github.com/linode/manager/pull/13292))
Lines changed: 19 additions & 0 deletions
Loading
Lines changed: 19 additions & 0 deletions
Loading

packages/manager/src/GoTo.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useNavigate } from '@tanstack/react-router';
44
import * as React from 'react';
55

66
import { useIsDatabasesEnabled } from './features/Databases/utilities';
7-
import { useIsMarketplaceV2Enabled } from './features/Marketplace/utils';
7+
import { useIsMarketplaceV2Enabled } from './features/Marketplace/shared';
88
import { useIsNetworkLoadBalancerEnabled } from './features/NetworkLoadBalancers/utils';
99
import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils';
1010
import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener';

packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export const DebouncedSearchTextField = React.memo(
103103

104104
// Synchronize the internal state with the prop value when the value prop changes.
105105
React.useEffect(() => {
106-
if (value && value !== textFieldValue) {
106+
if (value !== textFieldValue) {
107107
setTextFieldValue(value);
108108
}
109109
}, [value]);

packages/manager/src/components/PrimaryNav/PrimaryNav.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { useIsACLPEnabled } from 'src/features/CloudPulse/Utils/utils';
2222
import { useIsDatabasesEnabled } from 'src/features/Databases/utilities';
2323
import { useIsACLPLogsEnabled } from 'src/features/Delivery/deliveryUtils';
2424
import { useIsIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled';
25-
import { useIsMarketplaceV2Enabled } from 'src/features/Marketplace/utils';
25+
import { useIsMarketplaceV2Enabled } from 'src/features/Marketplace/shared';
2626
import { useIsNetworkLoadBalancerEnabled } from 'src/features/NetworkLoadBalancers/utils';
2727
import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils';
2828
import { useFlags } from 'src/hooks/useFlags';
Lines changed: 23 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1,194 +1,62 @@
1-
import {
2-
useAllMarketplacePartnersMapQuery,
3-
useAllMarketplaceTypesMapQuery,
4-
useInfiniteMarketplaceProductsQuery,
5-
} from '@linode/queries';
61
import { useTheme } from '@linode/ui';
72
import { useNavigate } from '@tanstack/react-router';
83
import * as React from 'react';
94

10-
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
11-
12-
import { useIsMarketplaceV2Enabled } from '../utils';
5+
import { getLogoUrl } from '../shared';
136
import { CategorySectionView } from './CategorySectionView';
147

8+
import type { Category, Product } from '../shared';
159
import type { ProductCardData } from './ProductSelectionCard';
16-
import type { Filter, MarketplaceCategory } from '@linode/api-v4';
17-
18-
const INITIAL_DISPLAY_COUNT = 6;
19-
const LOAD_MORE_INCREMENT = 6;
20-
21-
export interface GlobalFilters {
22-
categoryId?: number;
23-
// IDs derived from search query matching category/type/partner names
24-
searchDerivedPartnerIds?: number[];
25-
searchDerivedTypeIds?: number[];
26-
searchQuery?: string;
27-
typeId?: number;
28-
}
2910

3011
export interface CategorySectionProps {
31-
category: MarketplaceCategory;
32-
filters: GlobalFilters;
12+
/**
13+
* The unique name of the category this section represents.
14+
*/
15+
categoryName: Category;
16+
/**
17+
* The list of products belonging to this category.
18+
*/
19+
products: Product[];
3320
}
3421

3522
export interface ProductCardItem extends ProductCardData {
3623
id: number;
3724
}
3825

39-
const useProductsDisplay = (
40-
categoryId: number,
41-
productsCount: number,
42-
filters: GlobalFilters
43-
) => {
44-
const [displayCount, setDisplayCount] = React.useState(
45-
Math.min(productsCount, INITIAL_DISPLAY_COUNT)
46-
);
47-
48-
const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled();
49-
50-
const apiFilter: Filter = {
51-
category_id: categoryId,
52-
...(filters.searchQuery
53-
? {
54-
'+or': [
55-
{ name: { '+contains': filters.searchQuery } },
56-
{ short_description: { '+contains': filters.searchQuery } },
57-
// Include search-derived IDs in the OR condition (excluding duplicates)
58-
...(filters.searchDerivedTypeIds?.map((id) => ({ type_id: id })) ??
59-
[]),
60-
...(filters.searchDerivedPartnerIds?.map((id) => ({
61-
partner_id: id,
62-
})) ?? []),
63-
],
64-
}
65-
: {}),
66-
...(filters.typeId ? { type_id: filters.typeId } : {}),
67-
};
68-
69-
const {
70-
data: productsData,
71-
error,
72-
fetchNextPage,
73-
isFetchingNextPage,
74-
isLoading,
75-
} = useInfiniteMarketplaceProductsQuery(
76-
apiFilter,
77-
isMarketplaceV2FeatureEnabled ?? false
78-
);
79-
80-
const products = React.useMemo(
81-
() => productsData?.pages.flatMap((page) => page.data) ?? [],
82-
[productsData]
83-
);
84-
85-
return {
86-
products,
87-
displayCount,
88-
setDisplayCount,
89-
error,
90-
fetchNextPage,
91-
isFetchingNextPage,
92-
isLoading,
93-
};
94-
};
26+
const PRODUCTS_PER_BATCH = 6;
9527

9628
export const CategorySection = (props: CategorySectionProps) => {
97-
const { category, filters } = props;
29+
const { categoryName, products } = props;
9830
const theme = useTheme();
9931
const navigate = useNavigate();
10032

101-
const {
102-
products,
103-
displayCount,
104-
setDisplayCount,
105-
error: productsError,
106-
fetchNextPage,
107-
isFetchingNextPage,
108-
isLoading: isProductsLoading,
109-
} = useProductsDisplay(category.id, category.products_count, filters);
110-
111-
const { data: partnersMap, isLoading: isPartnerLoading } =
112-
useAllMarketplacePartnersMapQuery();
113-
114-
const { data: typesMap, isLoading: isTypesLoading } =
115-
useAllMarketplaceTypesMapQuery();
116-
117-
React.useEffect(() => {
118-
const shouldFetchMore =
119-
!isFetchingNextPage &&
120-
products.length > 0 &&
121-
displayCount >= products.length &&
122-
products.length < category.products_count;
123-
124-
if (shouldFetchMore) {
125-
fetchNextPage();
126-
}
127-
}, [
128-
isFetchingNextPage,
129-
products.length,
130-
displayCount,
131-
category.products_count,
132-
fetchNextPage,
133-
]);
134-
135-
const isLoading = isProductsLoading || isPartnerLoading || isTypesLoading;
33+
const [displayCount, setDisplayCount] = React.useState(PRODUCTS_PER_BATCH);
13634
const productsToDisplay = products.slice(0, displayCount);
137-
const hasMoreProducts = category.products_count > displayCount;
138-
139-
const getLogoUrl = (partnerId: number) => {
140-
const partner = partnersMap?.[partnerId];
141-
if (!partner) return '';
142-
143-
return theme.name === 'light'
144-
? partner.logo_url_light_mode
145-
: partner.logo_url_dark_mode;
146-
};
147-
148-
const getSkeletonCount = () => {
149-
const remaining = category.products_count - displayCount;
150-
return Math.min(remaining, LOAD_MORE_INCREMENT);
151-
};
152-
153-
const handleLoadMore = () => {
154-
const remaining = category.products_count - displayCount;
155-
const increment = Math.min(remaining, LOAD_MORE_INCREMENT);
156-
setDisplayCount(displayCount + increment);
157-
};
35+
const hasMoreProducts = products.length > displayCount;
15836

15937
const handleProductClick = (productId: number) => {
16038
navigate({ to: `/cloud-marketplace/catalog/${productId}` });
16139
};
16240

16341
const cardData: ProductCardItem[] = productsToDisplay.map((product) => ({
164-
companyName: partnersMap?.[product.partner_id]?.name ?? '',
165-
description: product.short_description,
42+
companyName: product.partner.name,
43+
description: product.shortDescription,
16644
id: product.id,
167-
logoUrl: getLogoUrl(product.partner_id),
45+
logoUrl: getLogoUrl(product, theme),
16846
productName: product.name,
169-
productTag: product.tile_tag,
170-
type: typesMap?.[product.type_id]?.name ?? '',
47+
productTag: product.tileTag,
48+
type: product.type.name,
17149
}));
17250

173-
const errorMessage = productsError
174-
? getAPIErrorOrDefault(
175-
productsError,
176-
`Error loading products for category ${category.name}`
177-
)[0].reason
178-
: '';
179-
18051
return (
18152
<CategorySectionView
18253
cardData={cardData}
183-
categoryName={category.name}
184-
displayCount={displayCount}
185-
errorMessage={errorMessage}
54+
categoryName={categoryName}
55+
displayCount={productsToDisplay.length}
56+
errorMessage={''}
18657
hasMoreProducts={hasMoreProducts}
187-
isFetchingNextPage={isFetchingNextPage}
188-
isLoading={isLoading}
189-
onLoadMore={handleLoadMore}
58+
onLoadMore={() => setDisplayCount((prev) => prev + PRODUCTS_PER_BATCH)}
19059
onProductClick={handleProductClick}
191-
skeletonCount={getSkeletonCount()}
19260
/>
19361
);
19462
};

packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.test.tsx

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,9 @@ describe('CategorySectionView', () => {
2323
displayCount: 1,
2424
errorMessage: '',
2525
hasMoreProducts: false,
26-
isFetchingNextPage: false,
2726
isLoading: false,
2827
onLoadMore: vi.fn(),
2928
onProductClick: vi.fn(),
30-
skeletonCount: 1,
3129
};
3230

3331
it('renders Category name', () => {
@@ -49,6 +47,7 @@ describe('CategorySectionView', () => {
4947
const propsWithMoreProducts = {
5048
...mockProps,
5149
hasMoreProducts: true,
50+
isLoading: false,
5251
};
5352

5453
const { getByRole } = renderWithTheme(
@@ -60,35 +59,27 @@ describe('CategorySectionView', () => {
6059
expect(loadMoreButton).toBeVisible();
6160
});
6261

63-
it('does not render the load more button if `hasMoreProducts` is false ', () => {
62+
it('does not render the load more button if `hasMoreProducts` is false or loading', () => {
6463
const propsWithoutMoreProducts = {
6564
...mockProps,
6665
hasMoreProducts: false,
66+
isLoading: false,
6767
};
68-
69-
const { queryByRole } = renderWithTheme(
70-
<CategorySectionView {...propsWithoutMoreProducts} />
71-
);
72-
const loadMoreButton = queryByRole('button', {
73-
name: /Load More.../i,
74-
});
75-
expect(loadMoreButton).toBeNull();
76-
});
77-
78-
it('does not render the load more button while fetching next page', () => {
79-
const propsFetchingNextPage = {
68+
const propsLoading = {
8069
...mockProps,
8170
hasMoreProducts: true,
82-
isFetchingNextPage: true,
71+
isLoading: true,
8372
};
8473

85-
const { queryByRole } = renderWithTheme(
86-
<CategorySectionView {...propsFetchingNextPage} />
74+
const { queryByRole: queryByRoleNoMore } = renderWithTheme(
75+
<CategorySectionView {...propsWithoutMoreProducts} />
8776
);
88-
const loadMoreButtonWhileFetching = queryByRole('button', {
89-
name: /Load More.../i,
90-
});
91-
expect(loadMoreButtonWhileFetching).toBeNull();
77+
expect(queryByRoleNoMore('button', { name: /Load More.../i })).toBeNull();
78+
79+
const { queryByRole: queryByRoleLoading } = renderWithTheme(
80+
<CategorySectionView {...propsLoading} />
81+
);
82+
expect(queryByRoleLoading('button', { name: /Load More.../i })).toBeNull();
9283
});
9384

9485
it('renders the correct product details', () => {
@@ -108,14 +99,13 @@ describe('CategorySectionView', () => {
10899
...mockProps,
109100
cardData: [],
110101
isLoading: true,
111-
skeletonCount: 6,
112102
};
113103

114104
const { getAllByTestId } = renderWithTheme(
115105
<CategorySectionView {...loadingProps} />
116106
);
117107
const items = getAllByTestId('marketplace-skeleton-card');
118-
expect(items).toHaveLength(mockProps.skeletonCount);
108+
expect(items.length).toBeGreaterThan(0);
119109
});
120110

121111
it('renders the error message when there is an error', () => {

0 commit comments

Comments
 (0)