-
Notifications
You must be signed in to change notification settings - Fork 391
upcoming: [UIE-9826] - Add reusable Product Selection Card component for Marketplace #13247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
b0f6990
0560c3f
722eb18
d3dfd9a
72f82a6
d72467b
b7e434e
7cee1d7
49cd269
2b24134
1a775af
a5ac3e6
1a660af
0d774c1
9bdb322
41c7b33
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
|
||
| description: 'This is a test product description', | ||
|
Check warning on line 12 in packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx
|
||
| logoUrl: '/test-logo.png', | ||
| productName: 'Test Product', | ||
|
Check warning on line 14 in packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx
|
||
| type: 'SaaS & APIs', | ||
|
Check warning on line 15 in packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx
|
||
| }; | ||
|
|
||
| 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
|
||
| 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', | ||
| 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', | ||
| }); | ||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think
SelectionCardis 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.There was a problem hiding this comment.
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
There was a problem hiding this comment.
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