Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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,175 @@
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 { CategorySectionView } from './CategorySectionView';

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

const INITIAL_DISPLAY_COUNT = 6;
const LOAD_MORE_INCREMENT = 6;

export interface CategorySectionProps extends MarketplaceCategory {
filteredProducts?: MarketplaceProduct[];
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we could simplify this type, and it would be better to include filters instead of filteredProducts. This would help avoid consuming two different product queries at different levels. We could then use these filters in useInfiniteMarketplaceProductsQuery and centralize the initial load and filtering logic within the same query

Suggested change
export interface CategorySectionProps extends MarketplaceCategory {
filteredProducts?: MarketplaceProduct[];
}
export interface CategorySectionProps {
category: MarketplaceCategory;
filters: GlobalFilters;
}

GlobalFilters coould be like

export interface GlobalFilters {
  categoryId?: number;
  // IDs derived from search query matching category/type/partner names
  searchDerivedCategoryIds?: number[];
  searchDerivedPartnerIds?: number[];
  searchDerivedTypeIds?: number[];
  searchQuery: string;
  typeId?: 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.

This does sound plausible but when the filters are enabled it would cause us to send a separate product filtered query from each category rendered on the UI. Using one single category agnostic query when the filters are enabled would reduce the number of total api calls that are being made.

cc @tvijay-akamai

Copy link
Contributor

Choose a reason for hiding this comment

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

@harsh-akamai If products api is called without categories and let's assume if filtered products are more then 500, then api will not return all the products in one go


export interface ProductCardItem {
data: ProductCardData;
id: number;
}

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

const productsQueryEnabled = filteredProducts
? filteredProducts?.length === 0
: true;

const {
data: productsData,
error,
fetchNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteMarketplaceProductsQuery(
{ category: categoryId },
productsQueryEnabled
);

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

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

export const CategorySection = (props: CategorySectionProps) => {
const { name, id, products_count, filteredProducts } = props;
const theme = useTheme();
const navigate = useNavigate();

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

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

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

React.useEffect(() => {
const shouldFetchMore =
!filteredProducts &&
!isFetchingNextPage &&
products.length > 0 &&
displayCount >= products.length &&
products.length < products_count;

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

const isLoading = isProductsLoading || isPartnerLoading || isTypesLoading;
const productsToDisplay = products.slice(0, displayCount);
const hasMoreProducts = 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 = products_count - displayCount;
return Math.min(remaining, LOAD_MORE_INCREMENT);
};

const handleLoadMore = () => {
const remaining = 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 ?? 'Application',
},
}));

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

return (
<CategorySectionView
cardData={cardData}
categoryName={name || 'Category 1'}
displayCount={displayCount}
errorMessage={errorMessage}
hasMoreProducts={hasMoreProducts}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
onLoadMore={handleLoadMore}
onProductClick={handleProductClick}
productsError={!!productsError}
skeletonCount={getSkeletonCount()}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { Box, Button, ErrorState, Stack, Typography } from '@linode/ui';
import { Grid, styled } from '@mui/material';
import * as React from 'react';

import { SelectionCard } from 'src/components/SelectionCard/SelectionCard';
import { Skeleton } from 'src/components/Skeleton';

import { ProductSelectionCard, StyledLogoBox } from './ProductSelectionCard';

import type { ProductCardItem } from './CategorySection';

export interface CategorySectionViewProps {
cardData: ProductCardItem[];
categoryName: string;
displayCount: number;
errorMessage: string;
hasMoreProducts: boolean;
isFetchingNextPage: boolean;
isLoading: boolean;
onLoadMore: () => void;
onProductClick: (productId: number) => void;
productsError: boolean;
skeletonCount: number;
}

const MarketplaceSkeletonGrid = ({
productsDisplayedCount = 6,
}: {
productsDisplayedCount?: number;
}) => {
const renderIcon = React.useCallback(
() => (
<Box
sx={{
alignItems: 'flex-start',

Check warning on line 35 in packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Define a constant instead of duplicating this literal 4 times. Raw Output: {"ruleId":"sonarjs/no-duplicate-string","severity":1,"message":"Define a constant instead of duplicating this literal 4 times.","line":35,"column":23,"nodeType":"Literal","endLine":35,"endColumn":35}

Check warning on line 35 in packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Define a constant instead of duplicating this literal 4 times. Raw Output: {"ruleId":"sonarjs/no-duplicate-string","severity":1,"message":"Define a constant instead of duplicating this literal 4 times.","line":35,"column":23,"nodeType":"Literal","endLine":35,"endColumn":35}
display: 'flex',
justifyContent: 'space-between',
width: '100%',
}}
>
{
<StyledLogoBox>
<Skeleton height="48px" variant="rounded" width="48px" />
</StyledLogoBox>
}
</Box>
),
[productsDisplayedCount]

Check warning on line 48 in packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 React Hook React.useCallback has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array. Raw Output: {"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook React.useCallback has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array.","line":48,"column":5,"nodeType":"ArrayExpression","endLine":48,"endColumn":29,"suggestions":[{"desc":"Update the dependencies array to be: []","fix":{"range":[1281,1305],"text":"[]"}}]}

Check warning on line 48 in packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 React Hook React.useCallback has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array. Raw Output: {"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook React.useCallback has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array.","line":48,"column":5,"nodeType":"ArrayExpression","endLine":48,"endColumn":29,"suggestions":[{"desc":"Update the dependencies array to be: []","fix":{"range":[1281,1305],"text":"[]"}}]}
);

const heading = React.useMemo(
() => <StyledSkeleton width="60%" />,
[productsDisplayedCount]

Check warning on line 53 in packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 React Hook React.useMemo has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array. Raw Output: {"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook React.useMemo has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array.","line":53,"column":5,"nodeType":"ArrayExpression","endLine":53,"endColumn":29,"suggestions":[{"desc":"Update the dependencies array to be: []","fix":{"range":[1391,1415],"text":"[]"}}]}

Check warning on line 53 in packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 React Hook React.useMemo has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array. Raw Output: {"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook React.useMemo has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array.","line":53,"column":5,"nodeType":"ArrayExpression","endLine":53,"endColumn":29,"suggestions":[{"desc":"Update the dependencies array to be: []","fix":{"range":[1391,1415],"text":"[]"}}]}
);

const subHeadings = React.useMemo(
() => [
<StyledSkeleton key="company" width="40%" />,
<Box
key="description"
sx={(theme) => ({
marginTop: theme.spacingFunction(12),
paddingBottom: theme.spacingFunction(36), // Always space for type chip at bottom
})}
>
<StyledSkeleton />
<StyledSkeleton />
<StyledSkeleton width="50%" />
</Box>,
<Box
key="category"
sx={(theme) => ({
bottom: theme.spacingFunction(16),
left: theme.spacingFunction(20),
position: 'absolute',
})}
>
<StyledSkeleton height={20} width={80} />
</Box>,
],
[productsDisplayedCount]

Check warning on line 81 in packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 React Hook React.useMemo has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array. Raw Output: {"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook React.useMemo has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array.","line":81,"column":5,"nodeType":"ArrayExpression","endLine":81,"endColumn":29,"suggestions":[{"desc":"Update the dependencies array to be: []","fix":{"range":[2128,2152],"text":"[]"}}]}

Check warning on line 81 in packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 React Hook React.useMemo has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array. Raw Output: {"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook React.useMemo has an unnecessary dependency: 'productsDisplayedCount'. Either exclude it or remove the dependency array.","line":81,"column":5,"nodeType":"ArrayExpression","endLine":81,"endColumn":29,"suggestions":[{"desc":"Update the dependencies array to be: []","fix":{"range":[2128,2152],"text":"[]"}}]}
);
return (
<Grid container spacing={2}>
{Array.from({ length: productsDisplayedCount }).map((_) => (
<SelectionCard
heading={heading}
key="skeleton card"
renderIcon={renderIcon}
subheadings={subHeadings}
sxCardBase={(theme) => ({
alignItems: 'flex-start',
flexDirection: 'column',
minHeight: '280px',
padding: `${theme.spacingFunction(16)} ${theme.spacingFunction(20)}`,
position: 'relative',
gap: theme.spacingFunction(12),
'&:hover': {
borderColor: theme.borderColors.divider,
},
})}
sxCardBaseHeading={{ width: '100%' }}
sxCardBaseIcon={{
alignItems: 'flex-start',
justifyContent: 'flex-start',
width: '100%',
}}
/>
))}
</Grid>
);
};

const ProductsGrid = ({
cardData,
onProductClick,
}: {
cardData: ProductCardItem[];
onProductClick: (productId: number) => void;
}) => (
<Grid container spacing={2}>
{cardData.map((item) => (
<ProductSelectionCard
data={item.data}
key={item.id}
onClick={() => onProductClick(item.id)}
/>
))}
</Grid>
);

export const CategorySectionView = (props: CategorySectionViewProps) => {
const {
categoryName,
isLoading,
isFetchingNextPage,
hasMoreProducts,
productsError,
displayCount,
cardData,
skeletonCount,
errorMessage,
onLoadMore,
onProductClick,
} = props;

if (productsError) {
return <ErrorState errorText={errorMessage} />;
}

return (
<Stack spacing={2}>
<Typography variant="h2">{categoryName}</Typography>

{isLoading ? (
<MarketplaceSkeletonGrid productsDisplayedCount={displayCount} />
) : (
<ProductsGrid cardData={cardData} onProductClick={onProductClick} />
)}

{isFetchingNextPage && (
<MarketplaceSkeletonGrid productsDisplayedCount={skeletonCount} />
)}

{!isFetchingNextPage && hasMoreProducts && (
<Button
onClick={onLoadMore}
sx={{
justifyContent: 'start',
paddingLeft: 0,
}}
>
Load More...
</Button>
)}
</Stack>
);
};

const StyledSkeleton = styled(Skeleton)({
borderRadius: '4px',
});
Loading