-
Notifications
You must be signed in to change notification settings - Fork 364
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
upcoming: [M3-7869] - Linode Create Refactor - Part 2 - Images and Distributions #10281
Changes from all commits
d37bfa0
da993ce
ff22162
b3e8cdf
63268ec
a907562
2efb996
956820b
fdb1100
ff58cae
638803f
1fe6b29
4dfb984
4a3e081
c2a5ab2
42d6b24
1bf9aa5
5981137
556b950
330c80e
3e48701
a9a25ba
ce23b06
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/api-v4": Changed | ||
--- | ||
|
||
Allow `image` to be `null` in `CreateLinodeRequest` ([#10281](https://github.com/linode/manager/pull/10281)) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@linode/manager": Tech Stories | ||
--- | ||
|
||
Linode Create Refactor - Part 2 - Images and Distributions ([#10281](https://github.com/linode/manager/pull/10281)) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
import * as React from 'react'; | ||
|
||
import { DistributionIcon } from './DistributionIcon'; | ||
|
||
import type { Meta, StoryObj } from '@storybook/react'; | ||
|
||
export const Default: StoryObj<typeof DistributionIcon> = { | ||
render: (args) => <DistributionIcon {...args} />, | ||
}; | ||
|
||
export const Ubuntu: StoryObj<typeof DistributionIcon> = { | ||
render: () => <DistributionIcon distribution="Ubuntu" />, | ||
}; | ||
|
||
export const Debian: StoryObj<typeof DistributionIcon> = { | ||
render: () => <DistributionIcon distribution="Debian" />, | ||
}; | ||
|
||
export const Alpine: StoryObj<typeof DistributionIcon> = { | ||
render: () => <DistributionIcon distribution="Alpine" />, | ||
}; | ||
|
||
const meta: Meta<typeof DistributionIcon> = { | ||
component: DistributionIcon, | ||
title: 'Components/Distribution Icon', | ||
}; | ||
|
||
export default meta; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import React from 'react'; | ||
|
||
import { renderWithTheme } from 'src/utilities/testHelpers'; | ||
|
||
import { DistributionIcon } from './DistributionIcon'; | ||
|
||
describe('DistributionIcon', () => { | ||
it('renders the correct font-logos clasname', () => { | ||
const { getByTestId } = renderWithTheme( | ||
<DistributionIcon distribution="Ubuntu" /> | ||
); | ||
|
||
expect(getByTestId('distro-icon')).toHaveClass('fl-ubuntu'); | ||
}); | ||
it('renders the correct font-logos clasname', () => { | ||
const { getByTestId } = renderWithTheme( | ||
<DistributionIcon distribution="Rocky" /> | ||
); | ||
|
||
expect(getByTestId('distro-icon')).toHaveClass('fl-rocky-linux'); | ||
}); | ||
it('renders a generic "tux" when there is no value in the `distroIcons` map', () => { | ||
const { getByTestId } = renderWithTheme( | ||
<DistributionIcon distribution="hdshkgsvkguihsh" /> | ||
); | ||
|
||
expect(getByTestId('distro-icon')).toHaveClass('fl-tux'); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import 'font-logos/assets/font-logos.css'; | ||
import React from 'react'; | ||
|
||
import { Box } from './Box'; | ||
|
||
import type { BoxProps } from './Box'; | ||
import type { Image } from '@linode/api-v4'; | ||
|
||
interface Props extends BoxProps { | ||
/** | ||
* The Linux distribution name | ||
*/ | ||
distribution: Image['vendor']; | ||
} | ||
|
||
/** | ||
* Linux distribution icon component | ||
* | ||
* Uses https://github.com/Lukas-W/font-logos | ||
*/ | ||
export const DistributionIcon = (props: Props) => { | ||
const { distribution, ...rest } = props; | ||
|
||
const className = distribution | ||
? `fl-${distroIcons[distribution] ?? 'tux'}` | ||
: `fl-tux`; | ||
|
||
return <Box className={className} data-testid="distro-icon" {...rest} />; | ||
}; | ||
|
||
/** | ||
* Maps an Image's `vendor` field to a font-logos className | ||
* | ||
* @see https://github.com/Lukas-W/font-logos | ||
*/ | ||
export const distroIcons = { | ||
AlmaLinux: 'almalinux', | ||
Alpine: 'alpine', | ||
Arch: 'archlinux', | ||
CentOS: 'centos', | ||
CoreOS: 'coreos', | ||
Debian: 'debian', | ||
Fedora: 'fedora', | ||
Gentoo: 'gentoo', | ||
Kali: 'kali-linux', | ||
Rocky: 'rocky-linux', | ||
Slackware: 'slackware', | ||
Ubuntu: 'ubuntu', | ||
openSUSE: 'opensuse', | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,8 +6,8 @@ import { equals, groupBy } from 'ramda'; | |
import * as React from 'react'; | ||
|
||
import Select, { GroupType, Item } from 'src/components/EnhancedSelect'; | ||
import { BaseSelectProps } from 'src/components/EnhancedSelect/Select'; | ||
import { _SingleValue } from 'src/components/EnhancedSelect/components/SingleValue'; | ||
import { BaseSelectProps } from 'src/components/EnhancedSelect/Select'; | ||
import { ImageOption } from 'src/components/ImageSelect/ImageOption'; | ||
import { Paper } from 'src/components/Paper'; | ||
import { Typography } from 'src/components/Typography'; | ||
|
@@ -17,7 +17,7 @@ import { arePropsEqual } from 'src/utilities/arePropsEqual'; | |
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; | ||
import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; | ||
|
||
import { distroIcons } from './icons'; | ||
import { distroIcons } from '../DistributionIcon'; | ||
|
||
export type Variant = 'all' | 'private' | 'public'; | ||
|
||
|
@@ -137,6 +137,9 @@ const isMemo = (prevProps: ImageSelectProps, nextProps: ImageSelectProps) => { | |
); | ||
}; | ||
|
||
/** | ||
* @deprecated Start using ImageSelectv2 when possible | ||
*/ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice π |
||
export const ImageSelect = React.memo((props: ImageSelectProps) => { | ||
const { | ||
classNames, | ||
|
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import React from 'react'; | ||
|
||
import { imageFactory } from 'src/factories'; | ||
import { renderWithTheme } from 'src/utilities/testHelpers'; | ||
|
||
import { ImageOptionv2 } from './ImageOptionv2'; | ||
|
||
describe('ImageOptionv2', () => { | ||
it('renders the image label', () => { | ||
const image = imageFactory.build(); | ||
|
||
const { getByText } = renderWithTheme( | ||
<ImageOptionv2 image={image} isSelected={false} listItemProps={{}} /> | ||
); | ||
|
||
expect(getByText(image.label)).toBeVisible(); | ||
}); | ||
it('renders a distribution icon', () => { | ||
const image = imageFactory.build(); | ||
|
||
const { getByTestId } = renderWithTheme( | ||
<ImageOptionv2 image={image} isSelected={false} listItemProps={{}} /> | ||
); | ||
|
||
expect(getByTestId('distro-icon')).toBeVisible(); | ||
}); | ||
it('renders a metadata (cloud-init) icon if the flag is on and the image supports cloud-init', () => { | ||
const image = imageFactory.build({ capabilities: ['cloud-init'] }); | ||
|
||
const { getByLabelText } = renderWithTheme( | ||
<ImageOptionv2 image={image} isSelected={false} listItemProps={{}} />, | ||
{ flags: { metadata: true } } | ||
); | ||
|
||
expect( | ||
getByLabelText('This image is compatible with cloud-init.') | ||
).toBeVisible(); | ||
}); | ||
}); |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor: can we do "V2" in the directory and file names instead of "v2"? Just makes it easier to notice when looking quickly |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; | ||
import React from 'react'; | ||
|
||
import { useFlags } from 'src/hooks/useFlags'; | ||
|
||
import { SelectedIcon } from '../Autocomplete/Autocomplete.styles'; | ||
import { DistributionIcon } from '../DistributionIcon'; | ||
import { Stack } from '../Stack'; | ||
import { Tooltip } from '../Tooltip'; | ||
import { Typography } from '../Typography'; | ||
|
||
import type { Image } from '@linode/api-v4'; | ||
|
||
interface Props { | ||
image: Image; | ||
isSelected: boolean; | ||
listItemProps: React.HTMLAttributes<HTMLLIElement>; | ||
} | ||
|
||
export const ImageOptionv2 = ({ image, isSelected, listItemProps }: Props) => { | ||
const flags = useFlags(); | ||
|
||
return ( | ||
<li {...listItemProps} style={{ maxHeight: 35 }}> | ||
<Stack alignItems="center" direction="row" flexGrow={1} gap={2}> | ||
<DistributionIcon | ||
distribution={image.vendor} | ||
fontSize="1.8em" | ||
lineHeight="1.8em" | ||
/> | ||
<Typography color="inherit">{image.label}</Typography> | ||
<Stack flexGrow={1} /> | ||
{flags.metadata && image.capabilities.includes('cloud-init') && ( | ||
<Tooltip title="This image is compatible with cloud-init."> | ||
<DescriptionOutlinedIcon /> | ||
</Tooltip> | ||
)} | ||
{isSelected && <SelectedIcon visible />} | ||
</Stack> | ||
</li> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import * as React from 'react'; | ||
|
||
import { ImageSelectv2 } from './ImageSelectv2'; | ||
|
||
import type { Meta, StoryObj } from '@storybook/react'; | ||
|
||
export const Default: StoryObj<typeof ImageSelectv2> = { | ||
render: (args) => <ImageSelectv2 {...args} />, | ||
}; | ||
|
||
const meta: Meta<typeof ImageSelectv2> = { | ||
component: ImageSelectv2, | ||
title: 'Components/Selects/Image Select', | ||
}; | ||
|
||
export default meta; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import userEvent from '@testing-library/user-event'; | ||
import React from 'react'; | ||
|
||
import { imageFactory } from 'src/factories'; | ||
import { makeResourcePage } from 'src/mocks/serverHandlers'; | ||
import { HttpResponse, http, server } from 'src/mocks/testServer'; | ||
import { renderWithTheme } from 'src/utilities/testHelpers'; | ||
|
||
import { ImageSelectv2 } from './ImageSelectv2'; | ||
|
||
describe('ImageSelectv2', () => { | ||
it('should render a default "Images" label', () => { | ||
const { getByLabelText } = renderWithTheme(<ImageSelectv2 value={null} />); | ||
|
||
expect(getByLabelText('Images')).toBeVisible(); | ||
}); | ||
|
||
it('should render default placeholder text', () => { | ||
const { getByPlaceholderText } = renderWithTheme( | ||
<ImageSelectv2 value={null} /> | ||
); | ||
|
||
expect(getByPlaceholderText('Choose an image')).toBeVisible(); | ||
}); | ||
|
||
it('should render items returned by the API', async () => { | ||
const images = imageFactory.buildList(5); | ||
|
||
server.use( | ||
http.get('*/v4/images', () => { | ||
return HttpResponse.json(makeResourcePage(images)); | ||
}) | ||
); | ||
|
||
const { getByPlaceholderText, getByText } = renderWithTheme( | ||
<ImageSelectv2 value={null} /> | ||
); | ||
|
||
await userEvent.click(getByPlaceholderText('Choose an image')); | ||
|
||
for (const image of images) { | ||
expect(getByText(image.label)).toBeVisible(); | ||
} | ||
}); | ||
|
||
it('should call onChange when a value is selected', async () => { | ||
const image = imageFactory.build(); | ||
const onChange = vi.fn(); | ||
|
||
server.use( | ||
http.get('*/v4/images', () => { | ||
return HttpResponse.json(makeResourcePage([image])); | ||
}) | ||
); | ||
|
||
const { getByPlaceholderText, getByText } = renderWithTheme( | ||
<ImageSelectv2 onChange={onChange} value={null} /> | ||
); | ||
|
||
await userEvent.click(getByPlaceholderText('Choose an image')); | ||
|
||
const imageOption = getByText(image.label); | ||
|
||
expect(imageOption).toBeVisible(); | ||
|
||
await userEvent.click(imageOption); | ||
|
||
expect(onChange).toHaveBeenCalledWith( | ||
expect.anything(), | ||
image, | ||
'selectOption', | ||
expect.anything() | ||
); | ||
}); | ||
|
||
it('should correctly initialize with a default value', async () => { | ||
const image = imageFactory.build(); | ||
|
||
server.use( | ||
http.get('*/v4/images', () => { | ||
return HttpResponse.json(makeResourcePage([image])); | ||
}) | ||
); | ||
|
||
const { findByDisplayValue } = renderWithTheme( | ||
<ImageSelectv2 value={image.id} /> | ||
); | ||
|
||
await findByDisplayValue(image.label); | ||
}); | ||
}); |
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.
?
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.
perfectionist/sort-imports
π€·