Skip to content
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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d37bfa0
initial tab handling
bnussman Mar 12, 2024
da993ce
add some code comments
bnussman Mar 12, 2024
ff22162
add distribution tab
bnussman Mar 13, 2024
b3e8cdf
clean up
bnussman Mar 13, 2024
63268ec
fix console error
bnussman Mar 13, 2024
a907562
make styles match existing component
bnussman Mar 13, 2024
2efb996
wip
bnussman Mar 13, 2024
956820b
fix: extra rendering due to incorrect watch call
bnussman Mar 13, 2024
fdb1100
test and document
bnussman Mar 13, 2024
ff58cae
add a way to test with hook form context
bnussman Mar 13, 2024
638803f
Added changeset: Linode Create Refactor - Part 2 - Images and Distrib…
bnussman Mar 14, 2024
1fe6b29
clean up a lot and test more
bnussman Mar 14, 2024
4dfb984
move `font-logos` css import in hopes to defer loading till the css i…
bnussman Mar 14, 2024
4a3e081
use `history.push`
bnussman Mar 19, 2024
c2a5ab2
add basic testing for new select
bnussman Mar 19, 2024
42d6b24
Merge branch 'develop' into linode-create-v2-part-2
bnussman Mar 19, 2024
1bf9aa5
add a unit test for `ImageSelectv2`
bnussman Mar 19, 2024
5981137
fix icon size in chrome
bnussman Mar 19, 2024
556b950
follow react-hook-forms reccomendation of not using undefined
bnussman Mar 19, 2024
330c80e
Added changeset: Make `image` nullable in `CreateLinodeSchema`
bnussman Mar 19, 2024
3e48701
make `image` nullable
bnussman Mar 19, 2024
a9a25ba
allow `image` to be `null` in `CreateLinodeRequest`
bnussman Mar 19, 2024
ce23b06
Added changeset: Allow `image` to be `null` in `CreateLinodeRequest`
bnussman Mar 19, 2024
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/api-v4": Changed
---

Allow `image` to be `null` in `CreateLinodeRequest` ([#10281](https://github.com/linode/manager/pull/10281))
2 changes: 1 addition & 1 deletion packages/api-v4/src/linodes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ export interface CreateLinodeRequest {
stackscript_id?: number;
backup_id?: number;
swap_size?: number;
image?: string;
image?: string | null;
root_pass?: string;
authorized_keys?: string[];
backups_enabled?: boolean;
Expand Down
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))
28 changes: 28 additions & 0 deletions packages/manager/src/components/DistributionIcon.stories.tsx
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;
29 changes: 29 additions & 0 deletions packages/manager/src/components/DistributionIcon.test.tsx
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');
});
});
50 changes: 50 additions & 0 deletions packages/manager/src/components/DistributionIcon.tsx
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
@@ -1,8 +1,8 @@
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
import { Theme } from '@mui/material/styles';
import { makeStyles } from 'tss-react/mui';
import * as React from 'react';
import { OptionProps } from 'react-select';
import { makeStyles } from 'tss-react/mui';
Copy link
Contributor

Choose a reason for hiding this comment

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

?

Copy link
Member Author

Choose a reason for hiding this comment

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

perfectionist/sort-imports 🀷


import { Box } from 'src/components/Box';
import { Item } from 'src/components/EnhancedSelect';
Expand Down
7 changes: 5 additions & 2 deletions packages/manager/src/components/ImageSelect/ImageSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -137,6 +137,9 @@ const isMemo = (prevProps: ImageSelectProps, nextProps: ImageSelectProps) => {
);
};

/**
* @deprecated Start using ImageSelectv2 when possible
*/
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice πŸ‘

export const ImageSelect = React.memo((props: ImageSelectProps) => {
const {
classNames,
Expand Down
15 changes: 0 additions & 15 deletions packages/manager/src/components/ImageSelect/icons.ts

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();
});
});
42 changes: 42 additions & 0 deletions packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The 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);
});
});
Loading
Loading