Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
5 changes: 3 additions & 2 deletions packages/api-v4/src/marketplace/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ import type {
} from './types';
import type { Filter, ResourcePage as Page, Params } from 'src/types';

export const getMarketplaceProducts = (params?: Params, filters?: Filter) =>
Request<Page<MarketplaceProduct>>(
export const getMarketplaceProducts = (params?: Params, filters?: Filter) => {
return Request<Page<MarketplaceProduct>>(
setURL(`${BETA_API_ROOT}/marketplace/products`),
setMethod('GET'),
setParams(params),
setXFilter(filters),
);
};

export const getMarketplaceProduct = (productId: number) =>
Request<MarketplaceProduct>(
Expand Down
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,198 @@
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 {
categortId?: number;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
categortId?: number;
categoryId?: number;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice catch! Addressed this. I've also added the unit tests πŸš€

// IDs derived from search query matching category/type/partner names
searchDerivedCategoryIds?: number[];
searchDerivedPartnerIds?: number[];
searchDerivedTypeIds?: number[];
searchQuery: string;
typeId?: number;
}

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

export interface ProductCardItem {
data: 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) => ({
id: product.id,
data: {
companyName: partnersMap?.[product.partner_id]?.name || '',
description: product.short_description,
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}
productsError={!!productsError}
skeletonCount={getSkeletonCount()}
/>
);
};
Loading