Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Implement the main product grid with category grouping ([#13267](https://github.com/linode/manager/pull/13267))
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export interface SelectionCardProps {
* The heading of the card.
* @example Linode 1GB
*/
heading: string;
heading: JSX.Element | string;
/**
* An optional decoration to display next to the heading.
* @example (Current)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {
useAllMarketplacePartnersMapQuery,
useAllMarketplaceTypesMapQuery,
useInfiniteMarketplaceProductsQuery,
} from '@linode/queries';
import { useTheme } from '@linode/ui';
import { useNavigate } from '@tanstack/react-router';
import * as React from 'react';

import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';

import { useIsMarketplaceV2Enabled } from '../utils';
import { CategorySectionView } from './CategorySectionView';

import type { ProductCardData } from './ProductSelectionCard';
import type { Filter, MarketplaceCategory } from '@linode/api-v4';

const INITIAL_DISPLAY_COUNT = 6;
const LOAD_MORE_INCREMENT = 6;

export interface GlobalFilters {
categoryId?: number;
// IDs derived from search query matching category/type/partner names
searchDerivedPartnerIds?: number[];
searchDerivedTypeIds?: number[];
searchQuery?: string;
typeId?: number;
}

export interface CategorySectionProps {
category: MarketplaceCategory;
filters: GlobalFilters;
}

export interface ProductCardItem extends ProductCardData {
id: number;
}

const useProductsDisplay = (
categoryId: number,
productsCount: number,
filters: GlobalFilters
) => {
const [displayCount, setDisplayCount] = React.useState(
Math.min(productsCount, INITIAL_DISPLAY_COUNT)
);

const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled();

const apiFilter: Filter = {
category_id: categoryId,
...(filters.searchQuery
? {
'+or': [
{ name: { '+contains': filters.searchQuery } },
{ short_description: { '+contains': filters.searchQuery } },
// Include search-derived IDs in the OR condition (excluding duplicates)
...(filters.searchDerivedTypeIds?.map((id) => ({ type_id: id })) ??
[]),
...(filters.searchDerivedPartnerIds?.map((id) => ({
partner_id: id,
})) ?? []),
],
}
: {}),
...(filters.typeId ? { type_id: filters.typeId } : {}),
};

const {
data: productsData,
error,
fetchNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteMarketplaceProductsQuery(
apiFilter,
isMarketplaceV2FeatureEnabled ?? false
);

const products = React.useMemo(
() => productsData?.pages.flatMap((page) => page.data) ?? [],
[productsData]
);

return {
products,
displayCount,
setDisplayCount,
error,
fetchNextPage,
isFetchingNextPage,
isLoading,
};
};

export const CategorySection = (props: CategorySectionProps) => {
const { category, filters } = props;
const theme = useTheme();
const navigate = useNavigate();

const {
products,
displayCount,
setDisplayCount,
error: productsError,
fetchNextPage,
isFetchingNextPage,
isLoading: isProductsLoading,
} = useProductsDisplay(category.id, category.products_count, filters);

const { data: partnersMap, isLoading: isPartnerLoading } =
useAllMarketplacePartnersMapQuery();

const { data: typesMap, isLoading: isTypesLoading } =
useAllMarketplaceTypesMapQuery();

React.useEffect(() => {
const shouldFetchMore =
!isFetchingNextPage &&
products.length > 0 &&
displayCount >= products.length &&
products.length < category.products_count;
Comment on lines +118 to +122
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering if we should also add a hasNextPage check from useInfiniteMarketplaceProductsQuery

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

products.length < category.products_count;
This check covers the hasNextPage check


if (shouldFetchMore) {
fetchNextPage();
}
}, [
isFetchingNextPage,
products.length,
displayCount,
category.products_count,
fetchNextPage,
]);

const isLoading = isProductsLoading || isPartnerLoading || isTypesLoading;
const productsToDisplay = products.slice(0, displayCount);
const hasMoreProducts = category.products_count > displayCount;

const getLogoUrl = (partnerId: number) => {
const partner = partnersMap?.[partnerId];
if (!partner) return '';

return theme.name === 'light'
? partner.logo_url_light_mode
: partner.logo_url_dark_mode;
};

const getSkeletonCount = () => {
const remaining = category.products_count - displayCount;
return Math.min(remaining, LOAD_MORE_INCREMENT);
};

const handleLoadMore = () => {
const remaining = category.products_count - displayCount;
const increment = Math.min(remaining, LOAD_MORE_INCREMENT);
setDisplayCount(displayCount + increment);
};

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

const cardData: ProductCardItem[] = productsToDisplay.map((product) => ({
companyName: partnersMap?.[product.partner_id]?.name || '',
description: product.short_description,
id: product.id,
logoUrl: getLogoUrl(product.partner_id),
productName: product.name,
productTag: product.tile_tag,
type: typesMap?.[product.type_id]?.name ?? '',
}));

const errorMessage = productsError
? getAPIErrorOrDefault(
productsError,
`Error loading products for category ${category.name}`
)[0].reason
: '';

return (
<CategorySectionView
cardData={cardData}
categoryName={category.name}
displayCount={displayCount}
errorMessage={errorMessage}
hasMoreProducts={hasMoreProducts}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
onLoadMore={handleLoadMore}
onProductClick={handleProductClick}
skeletonCount={getSkeletonCount()}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React from 'react';
import '@testing-library/jest-dom';

import { renderWithTheme } from 'src/utilities/testHelpers';

import { CategorySectionView } from './CategorySectionView';

describe('CategorySectionView', () => {
const mockCardData = [
{
companyName: 'Akamai Technologies',
description:
'Akamai is a global content delivery network (CDN) and cloud service provider that offers solutions for web performance, security, and media delivery.',
logoUrl: 'https://www.akamai.com/site/akamai-logo-v5.svg',
productName: 'Akamai Compute',
type: 'Saas & APIs',
id: 1,
},
];
const mockProps = {
cardData: mockCardData,
categoryName: 'Test Category',
displayCount: 1,
errorMessage: '',
hasMoreProducts: false,
isFetchingNextPage: false,
isLoading: false,
onLoadMore: vi.fn(),
onProductClick: vi.fn(),
skeletonCount: 1,
};

it('renders Category name', () => {
const { getByText } = renderWithTheme(
<CategorySectionView {...mockProps} />
);
expect(getByText('Test Category')).toBeVisible();
});

it('displays the correct number of products', () => {
const { getAllByTestId } = renderWithTheme(
<CategorySectionView {...mockProps} />
);
const items = getAllByTestId('selection-card');
expect(items).toHaveLength(mockProps.cardData.length);
});

it('renders the load more button if `hasMoreProducts` is true', () => {
const propsWithMoreProducts = {
...mockProps,
hasMoreProducts: true,
};

const { getByRole } = renderWithTheme(
<CategorySectionView {...propsWithMoreProducts} />
);
const loadMoreButton = getByRole('button', {
name: /Load More.../i,
});
expect(loadMoreButton).toBeVisible();
});

it('does not render the load more button if `hasMoreProducts` is false ', () => {
const propsWithoutMoreProducts = {
...mockProps,
hasMoreProducts: false,
};

const { queryByRole } = renderWithTheme(
<CategorySectionView {...propsWithoutMoreProducts} />
);
const loadMoreButton = queryByRole('button', {
name: /Load More.../i,
});
expect(loadMoreButton).toBeNull();
});

it('does not render the load more button while fetching next page', () => {
const propsFetchingNextPage = {
...mockProps,
hasMoreProducts: true,
isFetchingNextPage: true,
};

const { queryByRole } = renderWithTheme(
<CategorySectionView {...propsFetchingNextPage} />
);
const loadMoreButtonWhileFetching = queryByRole('button', {
name: /Load More.../i,
});
expect(loadMoreButtonWhileFetching).toBeNull();
});

it('renders the correct product details', () => {
const { getByText } = renderWithTheme(
<CategorySectionView {...mockProps} />
);
mockProps.cardData.forEach((item) => {
expect(getByText(item.productName)).toBeVisible();
expect(getByText(item.companyName)).toBeVisible();
expect(getByText(item.description)).toBeVisible();
expect(getByText(item.type)).toBeVisible();
});
});

it('renders the product skeleton while loading', () => {
const loadingProps = {
...mockProps,
cardData: [],
isLoading: true,
skeletonCount: 6,
};

const { getAllByTestId } = renderWithTheme(
<CategorySectionView {...loadingProps} />
);
const items = getAllByTestId('marketplace-skeleton-card');
expect(items).toHaveLength(mockProps.skeletonCount);
});

it('renders the error message when there is an error', () => {
const errorProps = {
...mockProps,
errorMessage: 'Failed to load products.',
};
const { getByText } = renderWithTheme(
<CategorySectionView {...errorProps} />
);
expect(getByText('Failed to load products.')).toBeVisible();
});
});
Loading