diff --git a/packages/manager/.changeset/pr-10182-upcoming-features-1707859324148.md b/packages/manager/.changeset/pr-10182-upcoming-features-1707859324148.md new file mode 100644 index 00000000000..5754ebaf602 --- /dev/null +++ b/packages/manager/.changeset/pr-10182-upcoming-features-1707859324148.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +List view for Linode Clone and Create from Backup ([#10182](https://github.com/linode/manager/pull/10182)) diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx index 9e4b7c320fb..0c215e5cf97 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx @@ -85,7 +85,8 @@ export const DebouncedSearchTextField = React.memo( ) : ( - clearable && ( + clearable && + textFieldValue && ( setTextFieldValue('')} diff --git a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx index 9df73864fdd..94e0f7d417f 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx @@ -8,6 +8,8 @@ import { StyledActionButton } from 'src/components/Button/StyledActionButton'; interface InlineMenuActionProps { /** Required action text */ actionText: string; + /** Optional height when displayed as a button */ + buttonHeight?: number; /** Optional class names */ className?: string; /** Optional disabled */ @@ -27,6 +29,7 @@ interface InlineMenuActionProps { export const InlineMenuAction = (props: InlineMenuActionProps) => { const { actionText, + buttonHeight, className, disabled, href, @@ -53,6 +56,7 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { disabled={disabled} loading={loading} onClick={onClick} + sx={buttonHeight !== undefined ? { height: buttonHeight } : {}} tooltipAnalyticsEvent={tooltipAnalyticsEvent} tooltipText={tooltip} {...rest} diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index a4ea2f4b3f2..a59d1d61d10 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -14,7 +14,7 @@ const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; const options: { flag: keyof Flags; label: string }[] = [ { flag: 'aclb', label: 'ACLB' }, { flag: 'aclbFullCreateFlow', label: 'ACLB Full Create Flow' }, - { flag: 'linodeCloneUIChanges', label: 'Linode Clone UI Changes' }, + { flag: 'linodeCloneUiChanges', label: 'Linode Clone UI Changes' }, { flag: 'gecko', label: 'Gecko' }, { flag: 'parentChildAccountAccess', label: 'Parent/Child Account' }, { flag: 'selfServeBetas', label: 'Self Serve Betas' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 0a6b6bee49f..f1c127b6430 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -49,7 +49,7 @@ export interface Flags { firewallNodebalancer: boolean; gecko: boolean; ipv6Sharing: boolean; - linodeCloneUIChanges: boolean; + linodeCloneUiChanges: boolean; linodeCreateWithFirewall: boolean; mainContentBanner: MainContentBanner; metadata: boolean; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index 8310c7c9d07..10bcd964208 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -82,7 +82,6 @@ import { validatePassword } from 'src/utilities/validatePassword'; import LinodeCreate from './LinodeCreate'; import { deriveDefaultLabel } from './deriveDefaultLabel'; import { HandleSubmit, Info, LinodeCreateValidation, TypeInfo } from './types'; -import { getRegionIDFromLinodeID } from './utilities'; import type { CreateLinodeRequest, @@ -588,19 +587,13 @@ class LinodeCreateContainer extends React.PureComponent { * since the API does not infer this automatically. */ - /** - * safe to ignore possibility of "undefined" - * null checking happens in CALinodeCreate - */ - const selectedRegionID = getRegionIDFromLinodeID( - this.props.linodesData!, - id - ); this.setState({ selectedBackupID: undefined, selectedDiskSize: diskSize, selectedLinodeID: id, - selectedRegionID, + selectedRegionID: this.props.linodesData?.find( + (linode) => linode.id == id + )?.region, selectedTypeID: undefined, }); } diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel.tsx deleted file mode 100644 index f9cfbafd690..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { Linode } from '@linode/api-v4/lib/linodes'; -import Grid from '@mui/material/Unstable_Grid2'; -import { styled, useTheme } from '@mui/material/styles'; -import * as React from 'react'; - -import { Box } from 'src/components/Box'; -import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { Notice } from 'src/components/Notice/Notice'; -import Paginate from 'src/components/Paginate'; -import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { Paper } from 'src/components/Paper'; -import { RenderGuard } from 'src/components/RenderGuard'; -import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; -import { Stack } from 'src/components/Stack'; -import { Typography } from 'src/components/Typography'; -import { useFlags } from 'src/hooks/useFlags'; - -export interface ExtendedLinode extends Linode { - heading: string; - subHeadings: string[]; -} - -interface Notice { - level: 'error' | 'warning'; // most likely only going to need these two 'warning'; 'warning'; - text: string; -} - -interface Props { - disabled?: boolean; - error?: string; - handleSelection: (id: number, type: null | string, diskSize?: number) => void; - header?: string; - linodes: ExtendedLinode[]; - notices?: Notice[]; - selectedLinodeID?: number; -} - -const SelectLinodePanel = (props: Props) => { - const { - disabled, - error, - handleSelection, - header, - linodes, - notices, - selectedLinodeID, - } = props; - - const flags = useFlags(); - - const theme = useTheme(); - const [userSearchText, setUserSearchText] = React.useState< - string | undefined - >(undefined); - - // Capture the selected linode when this component mounts, - // so it doesn't change when the user selects a different one. - const [preselectedLinodeID] = React.useState( - flags.linodeCloneUIChanges && selectedLinodeID - ); - - const searchText = React.useMemo( - () => - userSearchText !== undefined - ? userSearchText - : linodes.find((linode) => linode.id === preselectedLinodeID)?.label || - '', - [linodes, preselectedLinodeID, userSearchText] - ); - - const filteredLinodes = React.useMemo( - () => - linodes.filter((linode) => - linode.label.toLowerCase().includes(searchText.toLowerCase()) - ), - [linodes, searchText] - ); - - const renderCard = (linode: ExtendedLinode) => { - return ( - { - handleSelection(linode.id, linode.type, linode.specs.disk); - }} - checked={linode.id === Number(selectedLinodeID)} - disabled={disabled} - heading={linode.heading} - key={`selection-card-${linode.id}`} - subheadings={linode.subHeadings} - /> - ); - }; - - return ( - - {({ - count, - data: linodesData, - handlePageChange, - handlePageSizeChange, - page, - pageSize, - }) => { - return ( - <> - - - {error && ( - - )} - {notices && - !disabled && - notices.map((notice, i) => ( - - ))} - - - {!!header ? header : 'Select Linode'} - - {flags.linodeCloneUIChanges && ( - - )} - - - {linodesData.map((linode) => { - return renderCard(linode); - })} - - - - - - ); - }} - - ); -}; - -const StyledBox = styled(Box, { - label: 'StyledBox', -})(({ theme }) => ({ - padding: `${theme.spacing(2)} 0 0`, -})); - -const StyledPaper = styled(Paper, { label: 'StyledPaper' })(({ theme }) => ({ - backgroundColor: theme.color.white, - flexGrow: 1, - width: '100%', -})); - -export default RenderGuard(SelectLinodePanel); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx new file mode 100644 index 00000000000..568554fb3cd --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx @@ -0,0 +1,54 @@ +import { Linode } from '@linode/api-v4'; +import React from 'react'; + +import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; +import { useImageQuery } from 'src/queries/images'; +import { useRegionsQuery } from 'src/queries/regions'; +import { useTypeQuery } from 'src/queries/types'; +import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; +import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; + +interface Props { + disabled?: boolean; + handleSelection: () => void; + linode: Linode; + selected?: boolean; +} + +export const SelectLinodeCard = ({ + disabled, + handleSelection, + linode, + selected, +}: Props) => { + const { data: regions } = useRegionsQuery(); + + const { data: linodeType } = useTypeQuery( + linode?.type ?? '', + Boolean(linode?.type) + ); + + const { data: linodeImage } = useImageQuery( + linode?.image ?? '', + Boolean(linode?.image) + ); + + const type = linodeType ? formatStorageUnits(linodeType?.label) : linode.type; + const image = linodeImage?.label ?? linode.image; + const region = + regions?.find((region) => region.id == linode.region)?.label ?? + linode.region; + + return ( + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx new file mode 100644 index 00000000000..eb372dc8e55 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx @@ -0,0 +1,26 @@ +import Grid from '@mui/material/Unstable_Grid2'; +import React from 'react'; + +import { SelectLinodeCard } from './SelectLinodeCard'; +import { RenderLinodeProps } from './SelectLinodePanel'; + +export const SelectLinodeCards = ({ + disabled, + handleSelection, + orderBy: { data: linodes }, + selectedLinodeId, +}: RenderLinodeProps) => ( + + {linodes.map((linode) => ( + + handleSelection(linode.id, linode.type, linode.specs.disk) + } + disabled={disabled} + key={linode.id} + linode={linode} + selected={linode.id == selectedLinodeId} + /> + ))} + +); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx new file mode 100644 index 00000000000..a423e52f689 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx @@ -0,0 +1,270 @@ +import { fireEvent } from '@testing-library/react'; +import { rest } from 'msw'; +import React from 'react'; + +import { imageFactory, linodeFactory } from 'src/factories'; +import { breakpoints } from 'src/foundations/breakpoints'; +import { server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; +import { resizeScreenSize } from 'src/utilities/testHelpers'; + +import { SelectLinodePanel } from './SelectLinodePanel'; + +const defaultProps = { + handleSelection: vi.fn(), + linodes: linodeFactory.buildList(3), +}; + +const setupMocks = () => { + const image1 = imageFactory.build({ + id: 'linode/debian10', + label: 'Debian 10', + }); + + server.use( + rest.get('*/linode/instances/:linodeId', (req, res, ctx) => { + return res(ctx.json(defaultProps.linodes[0])); + }), + rest.get('*/images/:imageId', (req, res, ctx) => { + return res(ctx.json(image1)); + }) + ); +}; + +describe('SelectLinodePanel (table, desktop)', () => { + beforeAll(() => { + resizeScreenSize(breakpoints.values.lg); + setupMocks(); + }); + + it('renders as a table', async () => { + const { container, findAllByRole, findByRole } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + expect(await findByRole('table')).toBeInTheDocument(); + + expect( + container.querySelector('[data-qa-linode-cards]') + ).not.toBeInTheDocument(); + + expect((await findAllByRole('row')).length).toBe(4); + }); + + it('can be disabled', async () => { + const { findAllByRole } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + for (const radio of await findAllByRole('radio')) { + expect(radio).toBeDisabled(); + } + }); + + it('selects the plan when clicked', async () => { + const mockOnSelect = vi.fn(); + + const { findAllByRole } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + const radioInput = (await findAllByRole('radio'))[0]; + fireEvent.click(radioInput); + + expect(mockOnSelect).toHaveBeenCalledWith( + 0, + defaultProps.linodes[0].type, + defaultProps.linodes[0].specs.disk + ); + }); + + it('allows searching', async () => { + setupMocks(); + + const { findAllByRole, findByRole } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + fireEvent.change(await findByRole('textbox'), { + target: { value: defaultProps.linodes[0].label }, + }); + + await expect((await findAllByRole('row')).length).toBe(2); + + expect((await findAllByRole('row'))[1]).toHaveTextContent( + defaultProps.linodes[0].label + ); + }); + + it('displays the heading, notices and error', () => { + const { getByText } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + expect(getByText('Example error')).toBeInTheDocument(); + expect(getByText('Example header')).toBeInTheDocument(); + expect(getByText('Example notice')).toBeInTheDocument(); + }); + + it('prefills the search box when mounted with a selected linode', async () => { + setupMocks(); + + const { container, findAllByRole } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + expect( + container.querySelector('[data-qa-linode-search] input') + ).toHaveValue(defaultProps.linodes[0].label); + + expect((await findAllByRole('row')).length).toBe(2); + + expect((await findAllByRole('row'))[1]).toHaveTextContent( + defaultProps.linodes[0].label + ); + }); +}); + +describe('SelectLinodePanel (cards, mobile)', () => { + beforeAll(() => { + resizeScreenSize(breakpoints.values.sm); + setupMocks(); + }); + + it('renders as cards', async () => { + const { container, queryByRole } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + expect(queryByRole('table')).not.toBeInTheDocument(); + + expect(container.querySelectorAll('[data-qa-selection-card]').length).toBe( + 3 + ); + }); + + it('can be disabled', async () => { + const { container } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + container + .querySelectorAll('[data-qa-selection-card]') + .forEach((card) => expect(card).toBeDisabled()); + }); + + it('selects the plan when clicked', async () => { + const mockOnSelect = vi.fn(); + + const { container } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + const selectionCard = container.querySelectorAll( + '[data-qa-selection-card]' + )[0]; + fireEvent.click(selectionCard); + + expect(mockOnSelect).toHaveBeenCalledWith( + 0, + defaultProps.linodes[0].type, + defaultProps.linodes[0].specs.disk + ); + }); + + it('allows searching', async () => { + setupMocks(); + + const { container, findByRole } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + fireEvent.change(await findByRole('textbox'), { + target: { value: defaultProps.linodes[0].label }, + }); + + await expect( + container.querySelectorAll('[data-qa-selection-card]').length + ).toBe(1); + + expect( + container.querySelectorAll('[data-qa-selection-card]')[0] + ).toHaveTextContent(defaultProps.linodes[0].label); + }); + + it('displays the heading, notices and error', () => { + const { getByText } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + expect(getByText('Example error')).toBeInTheDocument(); + expect(getByText('Example header')).toBeInTheDocument(); + expect(getByText('Example notice')).toBeInTheDocument(); + }); + + it('prefills the search box when mounted with a selected linode', async () => { + setupMocks(); + + const { container } = renderWithTheme( + , + { + flags: { linodeCloneUiChanges: true }, + } + ); + + expect( + container.querySelector('[data-qa-linode-search] input') + ).toHaveValue(defaultProps.linodes[0].label); + + expect( + container.querySelector('[data-qa-selection-card]') + ).toBeInTheDocument(); + + expect( + container.querySelector('[data-qa-selection-card]') + ).toHaveTextContent(defaultProps.linodes[0].label); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx new file mode 100644 index 00000000000..9d4b440720f --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx @@ -0,0 +1,228 @@ +import { Linode } from '@linode/api-v4/lib/linodes'; +import { useMediaQuery } from '@mui/material'; +import { styled, useTheme } from '@mui/material/styles'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { List } from 'src/components/List'; +import { ListItem } from 'src/components/ListItem'; +import { Notice } from 'src/components/Notice/Notice'; +import { OrderByProps, sortData } from 'src/components/OrderBy'; +import Paginate from 'src/components/Paginate'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; +import { useOrder } from 'src/hooks/useOrder'; + +import { PowerActionsDialog } from '../../PowerActionsDialogOrDrawer'; +import { SelectLinodeCards } from './SelectLinodeCards'; +import { SelectLinodeTable } from './SelectLinodeTable'; + +interface Props { + disabled?: boolean; + error?: string; + handleSelection: (id: number, type: null | string, diskSize?: number) => void; + header?: string; + linodes: Linode[]; + notices?: string[]; + selectedLinodeID?: number; + showPowerActions?: boolean; +} + +export const SelectLinodePanel = (props: Props) => { + const { + disabled, + error, + handleSelection, + header, + linodes, + notices, + selectedLinodeID, + showPowerActions, + } = props; + + const { handleOrderChange, order, orderBy } = useOrder( + { order: 'asc', orderBy: 'label' }, + 'create-select-linode' + ); + + const orderedLinodes = sortData(orderBy, order)(linodes); + + const flags = useFlags(); + const theme = useTheme(); + const matchesMdUp = useMediaQuery(theme.breakpoints.up('md')); + + const [userSearchText, setUserSearchText] = React.useState< + string | undefined + >(undefined); + + const [powerOffLinode, setPowerOffLinode] = React.useState< + { linodeId: number } | false + >(false); + + // Capture the selected linode when this component mounts, + // so it doesn't change when the user selects a different one. + const [preselectedLinodeID] = React.useState( + flags.linodeCloneUiChanges && selectedLinodeID + ); + + const searchText = React.useMemo( + () => + userSearchText !== undefined + ? userSearchText + : linodes.find((linode) => linode.id === preselectedLinodeID)?.label || + '', + [linodes, preselectedLinodeID, userSearchText] + ); + + const filteredLinodes = React.useMemo( + () => + orderedLinodes.filter((linode) => + linode.label.toLowerCase().includes(searchText.toLowerCase()) + ), + [orderedLinodes, searchText] + ); + + const SelectComponent = + matchesMdUp && flags.linodeCloneUiChanges + ? SelectLinodeTable + : SelectLinodeCards; + + return ( + <> + + {({ + count, + data: linodesData, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => { + return ( + <> + + + {error && ( + + )} + {notices && !disabled && notices.length > 0 && ( + + ({ + '& > li': { + display: 'list-item', + lineHeight: theme.spacing(3), + padding: 0, + pl: 0, + }, + listStyle: 'disc', + ml: theme.spacing(2), + })} + > + {notices.map((notice, i) => ( + {notice} + ))} + + + )} + + + {!!header ? header : 'Select Linode'} + + {flags.linodeCloneUiChanges && ( + + )} + + + setPowerOffLinode({ linodeId }) + } + orderBy={{ + data: linodesData, + handleOrderChange, + order, + orderBy, + }} + disabled={disabled ?? false} + handleSelection={handleSelection} + selectedLinodeId={selectedLinodeID} + showPowerActions={showPowerActions ?? false} + /> + + + + + ); + }} + + {powerOffLinode && ( + setPowerOffLinode(false)} + /> + )} + + ); +}; + +export interface RenderLinodeProps { + disabled: boolean; + handlePowerOff: (linodeId: number) => void; + handleSelection: Props['handleSelection']; + orderBy: OrderByProps; + selectedLinodeId: number | undefined; + showPowerActions: boolean; +} + +const StyledBox = styled(Box, { + label: 'StyledBox', +})(({ theme }) => ({ + padding: `${theme.spacing(2)} 0 0`, +})); + +const StyledPaper = styled(Paper, { label: 'StyledPaper' })(({ theme }) => ({ + backgroundColor: theme.color.white, + flexGrow: 1, + width: '100%', +})); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.test.tsx new file mode 100644 index 00000000000..a651175ff47 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.test.tsx @@ -0,0 +1,159 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { imageFactory } from 'src/factories'; +import { linodeFactory } from 'src/factories/linodes'; +import { rest, server } from 'src/mocks/testServer'; +import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { SelectLinodeRow } from './SelectLinodeRow'; + +const loadingTestId = 'circle-progress'; + +describe('SelectLinodeRow', () => { + const handlePowerOff = vi.fn(); + const handleSelection = vi.fn(); + + it('should display linode label, status, image, plan and region', async () => { + const linode1 = linodeFactory.build({ id: 1, label: 'linode-1' }); + const image1 = imageFactory.build({ + id: 'linode/debian10', + label: 'Debian 10', + }); + + server.use( + rest.get('*/linode/instances/:linodeId', (req, res, ctx) => { + return res(ctx.json(linode1)); + }), + rest.get('*/images/:imageId', (req, res, ctx) => { + return res(ctx.json(image1)); + }) + ); + + const { + findByText, + getAllByRole, + getByTestId, + getByText, + } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + getByText(linode1.label); + getByText('Running'); + await findByText('Debian 10'); + await findByText('Linode 1 GB'); + await findByText('Newark, NJ'); + + const selectButton = getAllByRole('button')[0]; + fireEvent.click(selectButton); + expect(handleSelection).toHaveBeenCalled(); + + const powerOffButton = getByText('Power Off'); + fireEvent.click(powerOffButton); + expect(handlePowerOff).toHaveBeenCalled(); + }); + + it('should not display power off linode button if linode is not running', async () => { + const linode1 = linodeFactory.build({ + id: 1, + label: 'linode-1', + status: 'offline', + }); + const image1 = imageFactory.build({ + id: 'linode/debian10', + label: 'Debian 10', + }); + server.use( + rest.get('*/linode/instances/:linodeId', (req, res, ctx) => { + return res(ctx.json(linode1)); + }), + rest.get('*/images/:imageId', (req, res, ctx) => { + return res(ctx.json(image1)); + }) + ); + + const { findByText, getByTestId, getByText, queryByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + getByText(linode1.label); + getByText('Offline'); + await findByText('Debian 10'); + await findByText('Linode 1 GB'); + await findByText('Newark, NJ'); + + expect(queryByText('Power Off')).not.toBeInTheDocument(); + }); + + it('should not display power off linode button if not enabled', async () => { + const linode1 = linodeFactory.build({ + id: 1, + label: 'linode-1', + }); + const image1 = imageFactory.build({ + id: 'linode/debian10', + label: 'Debian 10', + }); + server.use( + rest.get('*/linode/instances/:linodeId', (req, res, ctx) => { + return res(ctx.json(linode1)); + }), + rest.get('*/images/:imageId', (req, res, ctx) => { + return res(ctx.json(image1)); + }) + ); + + const { findByText, getByTestId, getByText, queryByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + getByText(linode1.label); + getByText('Running'); + await findByText('Debian 10'); + await findByText('Linode 1 GB'); + await findByText('Newark, NJ'); + + expect(queryByText('Power Off')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.tsx new file mode 100644 index 00000000000..5e4bf419a29 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.tsx @@ -0,0 +1,196 @@ +import ErrorOutline from '@mui/icons-material/ErrorOutline'; +import { useTheme } from '@mui/material'; +import * as React from 'react'; +import { useQueryClient } from 'react-query'; + +import { Box } from 'src/components/Box'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { Link } from 'src/components/Link'; +import { OrderByProps } from 'src/components/OrderBy'; +import { Radio } from 'src/components/Radio/Radio'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TableCell, TableCellProps } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { Typography } from 'src/components/Typography'; +import { getLinodeIconStatus } from 'src/features/Linodes/LinodesLanding/utils'; +import { useImageQuery } from 'src/queries/images'; +import { + queryKey as linodesQueryKey, + useLinodeQuery, +} from 'src/queries/linodes/linodes'; +import { useTypeQuery } from 'src/queries/types'; +import { capitalizeAllWords } from 'src/utilities/capitalize'; +import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; + +import { RegionIndicator } from '../../LinodesLanding/RegionIndicator'; + +interface Props { + disabled?: boolean; + handlePowerOff: () => void; + handleSelection: () => void; + linodeId: number; + selected: boolean; + showPowerActions: boolean; +} + +export const SelectLinodeRow = (props: Props) => { + const queryClient = useQueryClient(); + const { + disabled, + handlePowerOff, + handleSelection, + linodeId, + selected, + showPowerActions, + } = props; + + const theme = useTheme(); + + const { + data: linode, + error: linodeError, + isLoading: linodeLoading, + } = useLinodeQuery(linodeId); + + const { data: linodeType } = useTypeQuery( + linode?.type ?? '', + Boolean(linode?.type) + ); + + const { data: linodeImage } = useImageQuery( + linode?.image ?? '', + Boolean(linode?.image) + ); + + // If the Linode's status is running, we want to check if its interfaces associated with this subnet have become active so + // that we can determine if it needs a reboot or not. So, we need to invalidate the linode configs query to get the most up to date information. + React.useEffect(() => { + if (linode && linode.status === 'running') { + queryClient.invalidateQueries([ + linodesQueryKey, + 'linode', + linodeId, + 'configs', + ]); + } + }, [linode, linodeId, queryClient]); + + if (linodeLoading || !linode) { + return ( + + + + + + ); + } + + if (linodeError) { + return ( + + + + ({ color: theme.color.red, marginRight: 1 })} + /> + + There was an error loading{' '} + Linode {linodeId} + + + + + ); + } + + const iconStatus = getLinodeIconStatus(linode.status); + const isRunning = linode.status == 'running'; + + return ( + + + + + {linode.label} + + + {capitalizeAllWords(linode.status.replace('_', ' '))} + + {linodeImage?.label ?? linode.image ?? 'Unknown'} + + {linodeType ? formatStorageUnits(linodeType?.label) : linode.type} + + + + + {showPowerActions && ( + + {isRunning && selected && ( + + )} + + )} + + ); +}; + +// Keep up to date with number of columns +export const numCols = 7; + +export const SelectLinodeTableRowHead = (props: { + orderBy: Omit, 'data'>; + showPowerActions: boolean; +}) => { + const { orderBy, showPowerActions } = props; + const CustomSortCell = ( + props: TableCellProps & { + label: string; + } + ) => ( + + ); + + return ( + + + + Linode + + + Status + + + Image + + + Plan + + + Region + + {showPowerActions && } + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeTable.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeTable.tsx new file mode 100644 index 00000000000..953ca9b1740 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeTable.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableHead } from 'src/components/TableHead'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; + +import { RenderLinodeProps } from './SelectLinodePanel'; +import { + SelectLinodeRow, + SelectLinodeTableRowHead, + numCols, +} from './SelectLinodeRow'; + +export const SelectLinodeTable = ({ + disabled, + handlePowerOff, + handleSelection, + orderBy, + selectedLinodeId, + showPowerActions, +}: RenderLinodeProps) => ( + + + + + + {orderBy.data.length > 0 ? ( + orderBy.data.map((linode) => ( + + handleSelection(linode.id, linode.type, linode.specs.disk) + } + disabled={disabled} + handlePowerOff={() => handlePowerOff(linode.id)} + key={linode.id} + linodeId={linode.id} + selected={Number(selectedLinodeId) === linode.id} + showPowerActions={showPowerActions} + /> + )) + ) : ( + + )} + +
+); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx index 9e921edf8d4..144ec864983 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx @@ -3,25 +3,22 @@ import { LinodeBackupsResponse, getLinodeBackups, } from '@linode/api-v4/lib/linodes'; -import { compose as ramdaCompose } from 'ramda'; import * as React from 'react'; import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; import { Paper } from 'src/components/Paper'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { reportException } from 'src/exceptionReporting'; -import { extendType } from 'src/utilities/extendType'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { SelectBackupPanel } from '../SelectBackupPanel'; -import SelectLinodePanel from '../SelectLinodePanel'; +import { SelectLinodePanel } from '../SelectLinodePanel/SelectLinodePanel'; import { BackupFormStateHandlers, Info, ReduxStateProps, WithLinodesTypesRegionsAndImages, } from '../types'; -import { extendLinodes, getRegionIDFromLinodeID } from '../utilities'; import { StyledGrid } from './CommonTabbedContent.styles'; export interface LinodeWithBackups extends Linode { @@ -76,17 +73,12 @@ export class FromBackupsContent extends React.Component { const { disabled, errors, - imagesData, linodesData, - regionsData, selectedBackupID, selectedLinodeID, setBackupID, - typesData, } = this.props; - const extendedTypes = typesData?.map(extendType); - const hasErrorFor = getAPIErrorFor(errorResources, errors); const userHasBackups = linodesData.some( @@ -110,30 +102,16 @@ export class FromBackupsContent extends React.Component { ) : ( - extendLinodes( - linodes, - imagesData, - extendedTypes, - regionsData - ), - filterLinodesWithBackups - )(linodesData)} notices={[ - { - level: 'warning', - text: `This newly created Linode will be created with - the same password and SSH Keys (if any) as the original Linode. - Also note that this Linode will need to be manually booted after it finishes - provisioning.`, - }, + `This newly created Linode will be created with + the same password and SSH Keys (if any) as the original Linode.`, + 'This Linode will need to be manually booted after it finishes provisioning.', ]} disabled={disabled} error={hasErrorFor('linode_id')} handleSelection={this.handleLinodeSelect} + linodes={filterLinodesWithBackups(linodesData)} selectedLinodeID={selectedLinodeID} - updateFor={[selectedLinodeID, errors]} /> { }); throw new Error('selectedLinodeID is not a number'); } - const regionID = getRegionIDFromLinodeID( - this.props.linodesData, - selectedLinodeID - ); + const regionID = this.props.linodesData.find( + (linode) => linode.id == selectedLinodeID + )?.region; this.props.updateRegionID(regionID || ''); } @@ -198,8 +175,7 @@ export class FromBackupsContent extends React.Component { this.setState({ isGettingBackups: false, selectedLinodeWithBackups }); }) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .catch((err) => { + .catch(() => { this.setState({ backupsError: 'Error retrieving backups for this Linode.', isGettingBackups: false, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx index a62df47226d..91fd233eea3 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx @@ -9,13 +9,12 @@ import { useFlags } from 'src/hooks/useFlags'; import { extendType } from 'src/utilities/extendType'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; -import SelectLinodePanel from '../SelectLinodePanel'; +import { SelectLinodePanel } from '../SelectLinodePanel/SelectLinodePanel'; import { CloneFormStateHandlers, ReduxStateProps, WithLinodesTypesRegionsAndImages, } from '../types'; -import { extendLinodes } from '../utilities'; import { StyledGrid } from './CommonTabbedContent.styles'; const errorResources = { @@ -32,7 +31,6 @@ export type CombinedProps = CloneFormStateHandlers & export const FromLinodeContent = (props: CombinedProps) => { const { errors, - imagesData, linodesData, regionsData, selectedLinodeID, @@ -55,9 +53,9 @@ export const FromLinodeContent = (props: CombinedProps) => { }; /** Set the Linode ID and the disk size and reset the plan selection */ - const handleSelectLinode = (linodeID: number, type: null | string) => { + const handleSelectLinode = (linodeId: number) => { const linode = props.linodesData.find( - (eachLinode) => eachLinode.id === linodeID + (eachLinode) => eachLinode.id === linodeId ); if (linode) { @@ -96,25 +94,11 @@ export const FromLinodeContent = (props: CombinedProps) => { ) : ( { error={hasErrorFor('linode_id')} handleSelection={handleSelectLinode} header={'Select Linode to Clone From'} + linodes={linodesData} selectedLinodeID={selectedLinodeID} - updateFor={[selectedLinodeID, errors]} + showPowerActions /> )} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/types.ts b/packages/manager/src/features/Linodes/LinodesCreate/types.ts index 0907d08a61c..c59f5dc799e 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/types.ts +++ b/packages/manager/src/features/Linodes/LinodesCreate/types.ts @@ -11,11 +11,6 @@ import { APIError } from '@linode/api-v4/lib/types'; import { Tag } from 'src/components/TagsInput/TagsInput'; import { ExtendedType } from 'src/utilities/extendType'; -export interface ExtendedLinode extends Linode { - heading: string; - subHeadings: string[]; -} - export type TypeInfo = | { details: string; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts index 9cb78582600..98d96209048 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts @@ -1,14 +1,9 @@ -import { extendedTypes } from 'src/__data__/ExtendedType'; -import { linode1, linode2 } from 'src/__data__/linodes'; import { imageFactory, normalizeEntities } from 'src/factories'; import { stackScriptFactory } from 'src/factories/stackscripts'; import { - extendLinodes, filterOneClickApps, - formatLinodeSubheading, getMonthlyAndHourlyNodePricing, - getRegionIDFromLinodeID, handleAppLabel, trimOneClickFromLabel, } from './utilities'; @@ -24,45 +19,6 @@ const linodeImage = imageFactory.build({ const images = normalizeEntities(imageFactory.buildList(10)); images['linode/debian10'] = linodeImage; -describe('Extend Linode', () => { - it('should create an array of Extended Linodes from an array of Linodes', () => { - const extendedLinodes = extendLinodes( - [ - { - ...linode1, - image: 'linode/debian10', - }, - ], - images, - extendedTypes - ); - expect(extendedLinodes[0].heading).toBe('test'); - expect(extendedLinodes[0].subHeadings).toEqual(['Nanode 1 GB, Debian 10']); - }); - - it('should concat image, type data, and region data separated by a comma', () => { - const withImage = formatLinodeSubheading('linode', 'image'); - const withoutImage = formatLinodeSubheading('linode'); - const withImageAndRegion = formatLinodeSubheading( - 'linode', - 'image', - 'region' - ); - - expect(withImage).toEqual(['linode, image']); - expect(withoutImage).toEqual(['linode']); - expect(withImageAndRegion).toEqual(['linode, image, region']); - }); -}); - -describe('getRegionIDFromLinodeID', () => { - it('returns the regionID from the given Linodes and Linode ID', () => { - expect(getRegionIDFromLinodeID([linode1, linode2], 2020425)).toBe( - 'us-east' - ); - }); -}); - describe('Marketplace cluster pricing', () => { it('should return the monthly and hourly price multipled by the number of nodes', () => { expect(getMonthlyAndHourlyNodePricing(30, 0.045, 3)).toEqual({ diff --git a/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx b/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx index 0a1f0645903..73b5d99dba4 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx @@ -1,91 +1,6 @@ import { decode } from 'he'; -import * as React from 'react'; -import { Link } from 'src/components/Link'; -import { Typography } from 'src/components/Typography'; -import { displayType } from 'src/features/Linodes/presentation'; -import { ExtendedType } from 'src/utilities/extendType'; - -import { ExtendedLinode } from './types'; - -import type { Image, Linode, Region, StackScript } from '@linode/api-v4/lib'; - -/** - * adds a heading and subheading key to the Linode - */ -export const extendLinodes = ( - linodes: Linode[], - imagesData: Record = {}, - typesData: ExtendedType[] = [], - regionsData: Region[] = [] -): ExtendedLinode[] => { - return linodes.map((linode) => { - /** get image data based on the Linode's image key */ - const linodeImageMetaData = imagesData[linode.image || '']; - - return { - ...linode, - heading: linode.label, - subHeadings: formatLinodeSubheading( - displayType(linode.type, typesData), - linodeImageMetaData ? linodeImageMetaData.label : '', - regionsData.find((region) => region.id === linode.region)?.label ?? - undefined - ), - }; - }); -}; - -export const formatLinodeSubheading = ( - typeLabel: string, - imageLabel?: string, - regionLabel?: string -) => { - if (imageLabel && regionLabel) { - return [`${typeLabel}, ${imageLabel}, ${regionLabel}`]; - } - if (imageLabel) { - return [`${typeLabel}, ${imageLabel}`]; - } - return [typeLabel]; -}; - -export const getRegionIDFromLinodeID = ( - linodes: Linode[], - id: number -): string | undefined => { - const thisLinode = linodes.find((linode) => linode.id === id); - return thisLinode ? thisLinode.region : undefined; -}; - -export const gpuPlanText = (useTypography?: boolean): JSX.Element => { - const gpuPlanTextSegments = [ - 'Linode GPU plans have limited availability and may not be available at the time of your request. Some additional verification may be required to access these services. ', - 'with information on getting started.', - ]; - - if (useTypography) { - return ( - - {gpuPlanTextSegments[0]} - - Here is a guide - {' '} - {gpuPlanTextSegments[1]} - - ); - } - - return ( - <> - {gpuPlanTextSegments[0]} - - {` `}Here is a guide - {' '} - {gpuPlanTextSegments[1]} - - ); -}; +import type { Region, StackScript } from '@linode/api-v4/lib'; export const getMonthlyAndHourlyNodePricing = ( monthlyPrice: null | number | undefined, diff --git a/packages/manager/src/queries/types.ts b/packages/manager/src/queries/types.ts index 9cbc2efa816..d659396747e 100644 --- a/packages/manager/src/queries/types.ts +++ b/packages/manager/src/queries/types.ts @@ -38,12 +38,14 @@ const specificTypesQueryKey = (type: string) => [queryKey, 'detail', type]; /** * Some Linodes may have types that aren't returned by the /types and /types-legacy endpoints. This * hook may be useful in fetching these "shadow plans". + * + * Always returns an array of the same length of the `types` argument. */ export const useSpecificTypes = (types: string[], enabled = true) => { const queryClient = useQueryClient(); return useQueries( types.map>((type) => ({ - enabled, + enabled: Boolean(type) && enabled, queryFn: () => getSingleType(type, queryClient), queryKey: specificTypesQueryKey(type), ...queryPresets.oneTimeFetch, @@ -52,10 +54,5 @@ export const useSpecificTypes = (types: string[], enabled = true) => { }; export const useTypeQuery = (type: string, enabled = true) => { - return useQuery({ - queryFn: () => getType(type), - queryKey: specificTypesQueryKey(type), - ...queryPresets.oneTimeFetch, - enabled: enabled && Boolean(type), - }); + return useSpecificTypes([type], enabled)[0]; };