Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
---

Add reusable Product Selection Card component for Marketplace ([#13247](https://github.com/linode/manager/pull/13247))
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

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

import { ProductSelectionCard } from './ProductSelectionCard';

describe('ProductSelectionCard', () => {
const baseData = {
companyName: 'Test Company',

Check warning on line 11 in packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.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":11,"column":18,"nodeType":"Literal","endLine":11,"endColumn":32}
description: 'This is a test product description',

Check warning on line 12 in packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Define a constant instead of duplicating this literal 3 times. Raw Output: {"ruleId":"sonarjs/no-duplicate-string","severity":1,"message":"Define a constant instead of duplicating this literal 3 times.","line":12,"column":18,"nodeType":"Literal","endLine":12,"endColumn":54}
logoUrl: '/test-logo.png',
productName: 'Test Product',

Check warning on line 14 in packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Define a constant instead of duplicating this literal 3 times. Raw Output: {"ruleId":"sonarjs/no-duplicate-string","severity":1,"message":"Define a constant instead of duplicating this literal 3 times.","line":14,"column":18,"nodeType":"Literal","endLine":14,"endColumn":32}
type: 'SaaS & APIs',

Check warning on line 15 in packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.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":15,"column":11,"nodeType":"Literal","endLine":15,"endColumn":24}
};

it('renders all - logo image, product name, company name, description and type chip', () => {
const { getByAltText, getByText } = renderWithTheme(
<ProductSelectionCard
data={{
...baseData,
}}
onClick={() => {}}
/>
);

const logo = getByAltText('Test Product logo');
expect(logo).toBeVisible();
expect(logo).toHaveAttribute('src', '/test-logo.png');

expect(getByText('Test Product')).toBeVisible();
expect(getByText('Test Company')).toBeVisible();
expect(getByText('This is a test product description')).toBeVisible();
expect(getByText('SaaS & APIs')).toBeVisible();
});

it('truncates long descriptions and appends an ellipsis', () => {
const longDescription = Array(300).fill('word').join(' ');
const { getByText } = renderWithTheme(
<ProductSelectionCard
data={{
...baseData,
description: longDescription,
}}
onClick={() => {}}
/>
);

const displayedText = getByText(/word/);
// Truncate adds "..." so length should be less than original
expect(displayedText.textContent?.length).toBeLessThan(
longDescription.length
);
expect(displayedText.textContent).toContain('...');
});

it('renders product tag chip when provided', () => {
const { getByText } = renderWithTheme(
<ProductSelectionCard
data={{
...baseData,
productTag: 'New',
}}
onClick={() => {}}
/>
);

expect(getByText('New')).toBeVisible();
});

it('calls onClick when card is clicked', async () => {
const handleClick = vi.fn();
const { getByText } = renderWithTheme(
<ProductSelectionCard data={{ ...baseData }} onClick={handleClick} />
);

await userEvent.click(getByText('Test Product'));

expect(handleClick).toHaveBeenCalledTimes(1);
});

it('renders disabled state correctly', () => {
const { getByTestId } = renderWithTheme(
<ProductSelectionCard
data={{ ...baseData }}
disabled
onClick={() => {}}
/>
);

expect(getByTestId('selection-card')).toBeDisabled();
});

it('renders all elements together', () => {
const { getByText, getByAltText } = renderWithTheme(
<ProductSelectionCard
data={{
...baseData,
description: 'Full product description',
logoUrl: '/logo.png',
productName: 'Complete Product',
productTag: 'New',
}}
onClick={() => {}}
/>
);

expect(getByText('Complete Product')).toBeVisible();
expect(getByText('Test Company')).toBeVisible();
expect(getByText('Full product description')).toBeVisible();
expect(getByText('New')).toBeVisible();
expect(getByText('SaaS & APIs')).toBeVisible();
expect(getByAltText('Complete Product logo')).toBeVisible();
});

it('does not render optional elements when not provided', () => {
const { getByText, queryByText } = renderWithTheme(
<ProductSelectionCard
data={{
...baseData,
productName: 'Minimal Product',
}}
onClick={() => {}}
/>
);

expect(getByText('Minimal Product')).toBeVisible();
expect(getByText('Test Company')).toBeVisible();
expect(getByText('This is a test product description')).toBeVisible();
expect(getByText('SaaS & APIs')).toBeVisible();

// optional elements should not be in the document
expect(queryByText('New')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { Box, Chip, Typography } from '@linode/ui';
import { truncate } from '@linode/utilities';
import { styled } from '@mui/material/styles';
import React from 'react';

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

export interface ProductCardData {
/**
* Company name displayed below the product name
*/
companyName: string;
/**
* Product description text
*/
description: string;
/**
* URL or path to the product logo image
*/
logoUrl: string;
/**
* Product name/title
*/
productName: string;
/**
* Product tag chip displayed in top right corner (e.g., "New", "30 days free trial")
*/
productTag?: string;
/**
* Bottom left type chip label (e.g., "SaaS and APIs")
*/
type: string;
}

export interface ProductSelectionCardProps {
/**
* Product data to display
*/
data: ProductCardData;
/**
* If true, the card will be disabled
* @default false
*/
disabled?: boolean;
/**
* Callback fired when the card is clicked
*/
onClick?: () => void;
}

/**
* A reusable product selection card component for displaying marketplace products.
* Built on top of SelectionCard for consistency.
*/
export const ProductSelectionCard = React.memo(
(props: ProductSelectionCardProps) => {
const { data, disabled = false, onClick } = props;
const { type, companyName, description, logoUrl, productName, productTag } =
data;

const subheadings = React.useMemo(
() => [
// Company name as first subheading
<Typography
key="company"
sx={(theme) => ({
color: theme.tokens.alias.Content.Text.Secondary.Default,
font: theme.font.semibold,
fontSize: theme.tokens.font.FontSize.Xxxs, // Must come after font
})}
>
{companyName}
</Typography>,
// Description
<Typography
key="description"
sx={(theme) => ({
color: theme.tokens.alias.Content.Text.Primary.Default,
fontSize: theme.tokens.font.FontSize.Xs,
marginTop: theme.spacingFunction(12),
paddingBottom: theme.spacingFunction(36), // Always space for type chip at bottom
})}
variant="body1"
>
{truncate(description, 200)}
</Typography>,
// Type chip (as last element with absolute positioning at bottom)
<Box
key="category"
sx={(theme) => ({
bottom: theme.spacingFunction(16),
left: theme.spacingFunction(20),
position: 'absolute',
})}
>
<Chip
label={type}
size="small"
sx={(theme) => ({
backgroundColor: theme.tokens.alias.Background.Informativesubtle,
})}
/>
</Box>,
],
[companyName, description, type]
);

// Render header row with logo and optional Product tag chip
const renderHeader = React.useCallback(() => {
return (
<Box
sx={{
alignItems: 'flex-start',

Check warning on line 113 in packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.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":113,"column":25,"nodeType":"Literal","endLine":113,"endColumn":37}
display: 'flex',
justifyContent: 'space-between',
width: '100%',
}}
>
{/* Logo */}
{logoUrl && (
<StyledLogoBox>
<img
alt={`${productName} logo`}
src={logoUrl}
style={{
display: 'block',
height: '100%',
objectFit: 'contain',
width: '100%',
}}
/>
</StyledLogoBox>
)}

{/* Product Tag Chip */}
{productTag && (
<Chip
label={productTag}
sx={(theme) => ({
'& .MuiChip-label': {
font: theme.font.bold,
fontSize: theme.tokens.font.FontSize.Xxxs, // Must come after font
padding: `${theme.spacingFunction(4)} ${theme.spacingFunction(6)}`,
},
backgroundColor:
theme.tokens.component.Badge.Positive.Subtle.Background,
color: theme.tokens.component.Badge.Positive.Subtle.Text,
flexShrink: 0,
})}
/>
)}
</Box>
);
}, [logoUrl, productName, productTag]);

return (
<SelectionCard
disabled={disabled}
heading={productName}
onClick={onClick}
renderIcon={renderHeader}
subheadings={subheadings}
sxCardBase={(theme) => ({
alignItems: 'flex-start',
flexDirection: 'column',
minHeight: '280px',
Copy link
Contributor

Choose a reason for hiding this comment

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

I believe the card has a fixed dimension of 400 x 280 px. Could we set the Width too?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think SelectionCard is already configured to work properly on different screen sizes when used in grids. Adding a fixed width could break its responsiveness. Since this card is used the same way in other parts of CM, it should behave consistently throughout so I think we can keep it as is.

Copy link
Contributor

Choose a reason for hiding this comment

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

A confirmation with UX would be better

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We already have Figma UX mocks available for tablet and mobile views with different responsive widths, so I think we're good to go. cc @davyd-akamai

padding: `${theme.spacingFunction(16)} ${theme.spacingFunction(20)}`,
position: 'relative',
gap: theme.spacingFunction(12),
})}
sxCardBaseIcon={{
alignItems: 'flex-start',
justifyContent: 'flex-start',
width: '100%',
}}
/>
);
}
);

const StyledLogoBox = styled(Box)({
height: '48px',
maxWidth: '96px',
overflow: 'hidden',
width: 'auto',
});
6 changes: 3 additions & 3 deletions packages/manager/src/routes/marketplace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ export const marketplaceCatlogRoute = createRoute({
getParentRoute: () => marketplaceRoute,
path: '/catalog',
}).lazy(() =>
import('src/features/Marketplace/marketplaceLazyRoute').then(
(m) => m.marketplaceLazyRoute
)
import(
'src/features/Marketplace/MarketplaceLanding/marketplaceLazyRoute'
).then((m) => m.marketplaceLazyRoute)
);

export const marketplaceRouteTree = marketplaceRoute.addChildren([
Expand Down