diff --git a/packages/api-v4/.changeset/pr-9687-changed-1695235363178.md b/packages/api-v4/.changeset/pr-9687-changed-1695235363178.md new file mode 100644 index 00000000000..eba989ffc9c --- /dev/null +++ b/packages/api-v4/.changeset/pr-9687-changed-1695235363178.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add type `DeleteLinodeConfigInterfacePayload` for deleting Linode config interfaces ([#9687](https://github.com/linode/manager/pull/9687)) diff --git a/packages/api-v4/src/linodes/configs.ts b/packages/api-v4/src/linodes/configs.ts index 353ee871da2..9e3525c2887 100644 --- a/packages/api-v4/src/linodes/configs.ts +++ b/packages/api-v4/src/linodes/configs.ts @@ -3,7 +3,7 @@ import { UpdateConfigInterfaceOrderSchema, UpdateConfigInterfaceSchema, UpdateLinodeConfigSchema, - linodeInterfaceSchema, + LinodeInterfaceSchema, } from '@linode/validation/lib/linodes.schema'; import { API_ROOT } from '../constants'; import Request, { @@ -180,7 +180,7 @@ export const appendConfigInterface = ( )}/configs/${encodeURIComponent(configId)}/interfaces` ), setMethod('POST'), - setData(data, linodeInterfaceSchema) + setData(data, LinodeInterfaceSchema) ); /** diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 61489c18d7f..a6d9cba1361 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -399,3 +399,9 @@ export interface ResizeLinodePayload { /** @default true */ allow_auto_disk_resize?: boolean; } + +export interface DeleteLinodeConfigInterfacePayload { + linodeId: number; + configId: number; + interfaceId: number; +} diff --git a/packages/manager/.changeset/pr-9687-upcoming-features-1695074900291.md b/packages/manager/.changeset/pr-9687-upcoming-features-1695074900291.md new file mode 100644 index 00000000000..ac0368595eb --- /dev/null +++ b/packages/manager/.changeset/pr-9687-upcoming-features-1695074900291.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Subnet Assign Linodes Drawer and new list component, `RemovableSelectionsList` ([#9687](https://github.com/linode/manager/pull/9687)) diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx index b171fcff0d7..74b249c8f2f 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx @@ -25,7 +25,7 @@ export const CollapsibleRow = (props: Props) => { setOpen(!open)} size="small" sx={{ padding: 1 }} diff --git a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx index 41f2fdeab4d..81e9d998005 100644 --- a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx +++ b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx @@ -2,12 +2,14 @@ import { SxProps } from '@mui/system'; import * as React from 'react'; import { CSVLink } from 'react-csv'; +import DownloadIcon from 'src/assets/icons/lke-download.svg'; import { Button } from 'src/components/Button/Button'; +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import type { ButtonType } from 'src/components/Button/Button'; interface DownloadCSVProps { - buttonType?: ButtonType; + buttonType?: 'styledLink' | ButtonType; children?: React.ReactNode; className?: string; csvRef?: React.RefObject; @@ -39,14 +41,13 @@ export const DownloadCSV = ({ sx, text = 'Download CSV', }: DownloadCSVProps) => { - return ( - + const renderButton = + buttonType === 'styledLink' ? ( + + + {text} + + ) : ( - + ); + + return ( + <> + + {renderButton} + ); }; diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx new file mode 100644 index 00000000000..50236bca9e7 --- /dev/null +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import * as React from 'react'; + +import { Button } from '../Button/Button'; +import { RemovableSelectionsList } from './RemovableSelectionsList'; + +import type { RemovableItem } from './RemovableSelectionsList'; +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +const defaultListItems = Array.from({ length: 20 }, (_, index) => { + const num = index + 1; + return { id: num, label: `my-linode-${num}` }; +}); + +const diffLabelListItems = Array.from({ length: 5 }, (_, index) => { + const num = index + 1; + return { + id: num, + label: `my-linode-${num}`, + preferredLabel: `my-linode-preferred-${num}`, + }; +}); + +interface Dimensions { + maxHeight?: number; + maxWidth?: number; +} +const DefaultRemovableSelectionsListWrapper = (props: Dimensions) => { + const { maxHeight, maxWidth } = props; + const [data, setData] = React.useState(defaultListItems); + + const handleRemove = (item: RemovableItem) => { + setData([...data].filter((data) => data.id !== item.id)); + }; + + const resetList = () => { + setData([...defaultListItems]); + }; + + return ( + <> + + + + ); +}; + +/** + * Interactable example of a RemovableSelectionsList + */ +export const InteractableDefault: Story = { + render: () => , +}; + +/** + * Example of a RemovableSelectionsList with a specified label option. Data passed in + * has the shape of _{ id: number; label: string; preferredLabel: string; }_, where the + * content in _preferredLabel_ is being rendered for each list item's label here. + */ +export const SpecifiedLabelExample: Story = { + render: () => { + const SpecifiedLabelWrapper = () => { + const [data, setData] = React.useState(diffLabelListItems); + + const handleRemove = (item: RemovableItem) => { + setData([...data].filter((data) => data.id !== item.id)); + }; + + const resetList = () => { + setData([...diffLabelListItems]); + }; + + return ( + <> + + + + ); + }; + + return ; + }, +}; + +/** + * Example of a RemovableSelectionsList with a custom height and width + */ +export const CustomHeightAndWidth: Story = { + render: () => ( + + ), +}; + +/** + * Example of a RemovableSelectionsList with no data to remove + */ +export const NoDataExample: Story = { + args: { + headerText: 'Linodes to remove', + noDataText: 'No Linodes available', + onRemove: (_) => {}, + selectionData: [], + }, + render: (args) => { + return ; + }, +}; + +const meta: Meta = { + component: RemovableSelectionsList, + title: 'Components/RemovableSelectionsList', +}; + +export default meta; diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx new file mode 100644 index 00000000000..916b7c96175 --- /dev/null +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RemovableSelectionsList } from './RemovableSelectionsList'; + +const defaultList = Array.from({ length: 5 }, (_, index) => { + const num = index + 1; + return { + id: num, + label: `my-linode-${num}`, + preferredLabel: `my-linode-preferred-${num}`, + }; +}); + +const diffLabelList = Array.from({ length: 5 }, (_, index) => { + const num = index + 1; + return { + id: num, + label: `my-linode-${num}`, + preferredLabel: `my-linode-preferred-${num}`, + }; +}); + +const props = { + headerText: 'Linodes to remove', + noDataText: 'No Linodes available', + onRemove: jest.fn(), + selectionData: defaultList, +}; + +describe('Removable Selections List', () => { + it('should render the list correctly', () => { + const screen = renderWithTheme(); + const header = screen.getByText('Linodes to remove'); + expect(header).toBeVisible(); + + for (let i = 0; i < 5; i++) { + const data = screen.getByText(`my-linode-${i + 1}`); + expect(data).toBeVisible(); + const removeButton = screen.getByLabelText(`remove my-linode-${i + 1}`); + expect(removeButton).toBeInTheDocument(); + } + }); + + it('should display the no data text if there is no data', () => { + const screen = renderWithTheme( + + ); + const header = screen.getByText('Linodes to remove'); + expect(header).toBeVisible(); + const removable = screen.getByText('No Linodes available'); + expect(removable).toBeVisible(); + }); + + it('should render the preferred label option if that is provided', () => { + const screen = renderWithTheme( + + ); + const header = screen.getByText('Linodes to remove'); + expect(header).toBeVisible(); + + for (let i = 0; i < 5; i++) { + const data = screen.getByText(`my-linode-preferred-${i + 1}`); + expect(data).toBeVisible(); + const removeButton = screen.getByLabelText( + `remove my-linode-preferred-${i + 1}` + ); + expect(removeButton).toBeInTheDocument(); + } + }); + + it('should call the onRemove function', () => { + const screen = renderWithTheme(); + const removeButton = screen.getByLabelText(`remove my-linode-1`); + fireEvent.click(removeButton); + expect(props.onRemove).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx new file mode 100644 index 00000000000..f81c3ef93d7 --- /dev/null +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx @@ -0,0 +1,153 @@ +import Close from '@mui/icons-material/Close'; +import { styled } from '@mui/material/styles'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { IconButton } from 'src/components/IconButton'; +import { List } from 'src/components/List'; +import { ListItem } from 'src/components/ListItem'; + +import { isPropValid } from 'src/utilities/isPropValid'; + +export type RemovableItem = { + id: number; + label: string; + // The remaining key-value pairs must have their values typed + // as 'any' because we do not know what types they could be. + // Trying to type them as 'unknown' led to type errors. +} & { [key: string]: any }; + +interface Props { + /** + * The descriptive text to display above the list + */ + headerText: string; + /** + * The maxHeight of the list component, in px + */ + maxHeight?: number; + /** + * The maxWidth of the list component, in px + */ + maxWidth?: number; + /** + * The text to display if there is no data + */ + noDataText: string; + /** + * The action to perform when a data item is clicked + */ + onRemove: (data: RemovableItem) => void; + /** + * Assumes the passed in prop is a key within the selectionData, and that the + * value of this key is a string. + * Displays the value of this key as the label of the data item, rather than data.label + */ + preferredDataLabel?: string; + /** + * The data to display in the list + */ + selectionData: RemovableItem[]; +} + +export const RemovableSelectionsList = (props: Props) => { + const { + headerText, + maxHeight, + maxWidth, + noDataText, + onRemove, + preferredDataLabel, + selectionData, + } = props; + + const handleOnClick = (selection: RemovableItem) => { + onRemove(selection); + }; + + return ( + <> + {headerText} + {selectionData.length > 0 ? ( + + {selectionData.map((selection) => ( + + + {preferredDataLabel + ? selection[preferredDataLabel] + : selection.label} + + handleOnClick(selection)} + size="medium" + > + + + + ))} + + ) : ( + + {noDataText} + + )} + + ); +}; + +const StyledNoAssignedLinodesBox = styled(Box, { + label: 'StyledNoAssignedLinodesBox', + shouldForwardProp: (prop) => isPropValid(['maxWidth'], prop), +})(({ theme, maxWidth }) => ({ + background: theme.name === 'light' ? theme.bg.main : theme.bg.app, + display: 'flex', + flexDirection: 'column', + height: '52px', + justifyContent: 'center', + maxWidth: maxWidth ? `${maxWidth}px` : '416px', + paddingLeft: theme.spacing(2), + width: '100%', +})); + +const SelectedOptionsHeader = styled('h4', { + label: 'SelectedOptionsHeader', +})(({ theme }) => ({ + color: theme.color.headline, + fontFamily: theme.font.bold, + fontSize: '14px', + textTransform: 'initial', +})); + +const SelectedOptionsList = styled(List, { + label: 'SelectedOptionsList', +})(({ theme }) => ({ + background: theme.name === 'light' ? theme.bg.main : theme.bg.app, + overflow: 'auto', + padding: '5px 0', + width: '100%', +})); + +const SelectedOptionsListItem = styled(ListItem, { + label: 'SelectedOptionsListItem', +})(() => ({ + justifyContent: 'space-between', + paddingBottom: 0, + paddingTop: 0, +})); + +const StyledLabel = styled('span', { label: 'StyledLabel' })(({ theme }) => ({ + color: theme.color.label, + fontFamily: theme.font.semiBold, + fontSize: '14px', +})); diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx index 62ec53d5a48..d5e44efa2ff 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx @@ -23,7 +23,7 @@ describe('SubnetNode', () => { await userEvent.type(subnetAddress[1], '192.0.0.0/24', { delay: 1 }); expect(subnetAddress[1]).toHaveValue('192.0.0.0/24'); - const availIps = screen.getByText('Available IP Addresses: 252'); + const availIps = screen.getByText('Number of Available IP Addresses: 252'); expect(availIps).toBeInTheDocument(); }); @@ -41,7 +41,7 @@ describe('SubnetNode', () => { await userEvent.type(subnetAddress[1], '192.0.0.0', { delay: 1 }); expect(subnetAddress[1]).toHaveValue('192.0.0.0'); - const availIps = screen.queryByText('Available IP Addresses:'); + const availIps = screen.queryByText('Number of Available IP Addresses:'); expect(availIps).not.toBeInTheDocument(); }); diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx index 6d8618f6378..6a4863c586f 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx @@ -88,7 +88,7 @@ export const SubnetNode = (props: Props) => { /> {subnet.ip.availIPv4s && ( - Available IP Addresses:{' '} + Number of Available IP Addresses:{' '} {subnet.ip.availIPv4s > 4 ? subnet.ip.availIPv4s - RESERVED_IP_NUMBER : 0} diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx index 8826a5c0a46..4dbb523fdbb 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx @@ -11,6 +11,7 @@ afterEach(() => { }); const props = { + handleAssignLinodes: jest.fn(), handleDelete: jest.fn(), handleEdit: jest.fn(), numLinodes: 1, @@ -23,8 +24,8 @@ describe('SubnetActionMenu', () => { const screen = renderWithTheme(); const actionMenu = screen.getByLabelText(`Action menu for Subnet subnet-1`); fireEvent.click(actionMenu); - screen.getByText('Assign Linode'); - screen.getByText('Unassign Linode'); + screen.getByText('Assign Linodes'); + screen.getByText('Unassign Linodes'); screen.getByText('Edit'); screen.getByText('Delete'); }); @@ -70,4 +71,14 @@ describe('SubnetActionMenu', () => { fireEvent.click(editButton); expect(props.handleEdit).toHaveBeenCalled(); }); + + it('should allow the Assign Linodes button to be clicked', () => { + const screen = renderWithTheme(); + const actionMenu = screen.getByLabelText(`Action menu for Subnet subnet-1`); + fireEvent.click(actionMenu); + + const assignButton = screen.getByText('Assign Linodes'); + fireEvent.click(assignButton); + expect(props.handleAssignLinodes).toHaveBeenCalled(); + }); }); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx index 99cb90baf13..9be680a89aa 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { Action, ActionMenu } from 'src/components/ActionMenu'; interface SubnetActionHandlers { + handleAssignLinodes: (subnet: Subnet) => void; handleDelete: (subnet: Subnet) => void; handleEdit: (subnet: Subnet) => void; } @@ -16,24 +17,28 @@ interface Props extends SubnetActionHandlers { } export const SubnetActionMenu = (props: Props) => { - const { handleDelete, handleEdit, numLinodes, subnet } = props; - - const handleAssignLinode = () => {}; + const { + handleAssignLinodes, + handleDelete, + handleEdit, + numLinodes, + subnet, + } = props; const handleUnassignLinode = () => {}; const actions: Action[] = [ { onClick: () => { - handleAssignLinode(); + handleAssignLinodes(subnet); }, - title: 'Assign Linode', + title: 'Assign Linodes', }, { onClick: () => { handleUnassignLinode(); }, - title: 'Unassign Linode', + title: 'Unassign Linodes', }, { onClick: () => { diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.styles.ts b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.styles.ts new file mode 100644 index 00000000000..8bb12531112 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.styles.ts @@ -0,0 +1,10 @@ +import { styled } from '@mui/material/styles'; + +import { Box } from 'src/components/Box'; +export const StyledButtonBox = styled(Box, { label: 'StyledButtonBox' })( + ({ theme }) => ({ + display: 'flex', + justifyContent: 'flex-end', + margin: `${theme.spacing(3)} 0px`, + }) +); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx new file mode 100644 index 00000000000..916aa29e126 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx @@ -0,0 +1,80 @@ +import { Subnet } from '@linode/api-v4'; +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { SubnetAssignLinodesDrawer } from './SubnetAssignLinodesDrawer'; + +const props = { + onClose: jest.fn(), + open: true, + subnet: { + id: 1, + ipv4: '10.0.0.0/24', + label: 'subnet-1', + } as Subnet, + vpcId: 1, + vpcRegion: '', +}; + +describe('Subnet Assign Linodes Drawer', () => { + it('should render a subnet assign linodes drawer', () => { + const { getByText, queryByText } = renderWithTheme( + + ); + + const header = getByText( + 'Assign Linodes to subnet: subnet-1 (10.0.0.0/24)' + ); + expect(header).toBeVisible(); + const notice = getByText( + 'Assigning a Linode to a subnet requires you to reboot the Linode to update its configuration.' + ); + expect(notice).toBeVisible(); + const helperText = getByText( + `Select the Linodes you would like to assign to this subnet. Only Linodes in this VPC's region are displayed.` + ); + expect(helperText).toBeVisible(); + const linodeSelect = getByText('Linodes'); + expect(linodeSelect).toBeVisible(); + const checkbox = getByText( + 'Auto-assign a VPC IPv4 address for this Linode' + ); + expect(checkbox).toBeVisible(); + const ipv4Textbox = queryByText('VPC IPv4'); + expect(ipv4Textbox).toBeNull(); + const assignButton = getByText('Assign Linode'); + expect(assignButton).toBeVisible(); + const alreadyAssigned = getByText('Linodes Assigned to Subnet (0)'); + expect(alreadyAssigned).toBeVisible(); + const doneButton = getByText('Done'); + expect(doneButton).toBeVisible(); + }); + + it('should show the IPv4 textbox when the checkmark is clicked', () => { + const { getByText } = renderWithTheme( + + ); + + const checkbox = getByText( + 'Auto-assign a VPC IPv4 address for this Linode' + ); + expect(checkbox).toBeVisible(); + fireEvent.click(checkbox); + + const ipv4Textbox = getByText('VPC IPv4'); + expect(ipv4Textbox).toBeVisible(); + }); + + it('should close the drawer', () => { + const { getByText } = renderWithTheme( + + ); + + const doneButton = getByText('Done'); + expect(doneButton).toBeVisible(); + fireEvent.click(doneButton); + expect(props.onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx new file mode 100644 index 00000000000..5e92576d87a --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -0,0 +1,475 @@ +import { appendConfigInterface } from '@linode/api-v4'; +import { useFormik } from 'formik'; +import * as React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Button } from 'src/components/Button/Button'; +import { Checkbox } from 'src/components/Checkbox'; +import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV'; +import { Drawer } from 'src/components/Drawer'; +import { FormHelperText } from 'src/components/FormHelperText'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { RemovableSelectionsList } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; +import { TextField } from 'src/components/TextField'; +import { useFormattedDate } from 'src/hooks/useFormattedDate'; +import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; +import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import { getAllLinodeConfigs } from 'src/queries/linodes/requests'; +import { useGrants, useProfile } from 'src/queries/profile'; +import { getErrorMap } from 'src/utilities/errorUtils'; + +import { + ASSIGN_LINODES_DRAWER_REBOOT_MESSAGE, + MULTIPLE_CONFIGURATIONS_MESSAGE, + REGIONAL_LINODE_MESSAGE, +} from '../constants'; +import { StyledButtonBox } from './SubnetAssignLinodesDrawer.styles'; + +import type { + APIError, + Config, + InterfacePayload, + Linode, + Subnet, +} from '@linode/api-v4'; + +// @TODO VPC: if all subnet action menu item related components use (most of) this as their props, might be worth +// putting this in a common file and naming it something like SubnetActionMenuItemProps or somthing +interface SubnetAssignLinodesDrawerProps { + onClose: () => void; + open: boolean; + subnet?: Subnet; + vpcId: number; + vpcRegion: string; +} + +type LinodeAndConfigData = Linode & { + configId: number; + interfaceId: number; + linodeConfigLabel: string; +}; + +export const SubnetAssignLinodesDrawer = ( + props: SubnetAssignLinodesDrawerProps +) => { + const { onClose, open, subnet, vpcId, vpcRegion } = props; + const { + invalidateQueries, + setUnassignLinodesErrors, + unassignLinode, + unassignLinodesErrors, + } = useUnassignLinode(); + const csvRef = React.useRef(); + const newInterfaceId = React.useRef(-1); + const removedLinodeId = React.useRef(-1); + const formattedDate = useFormattedDate(); + + const [assignLinodesErrors, setAssignLinodesErrors] = React.useState< + Record + >({}); + + // While the drawer is open, we maintain a local list of assigned Linodes. + // This is distinct from the subnet's global list of assigned Linodes, which encompasses all assignments. + // The local list resets to empty when the drawer is closed and reopened. + const [ + assignedLinodesAndConfigData, + setAssignedLinodesAndConfigData, + ] = React.useState([]); + const [linodeConfigs, setLinodeConfigs] = React.useState([]); + const [autoAssignIPv4, setAutoAssignIPv4] = React.useState(true); + + const { data: profile } = useProfile(); + const { data: grants } = useGrants(); + const vpcPermissions = grants?.vpc.find((v) => v.id === vpcId); + + // @TODO VPC: this logic for vpc grants/perms appears a lot - commenting a todo here in case we want to move this logic to a parent component + // there isn't a 'view VPC/Subnet' grant that does anything, so all VPCs get returned even for restricted users + // with permissions set to 'None'. Therefore, we're treating those as read_only as well + const userCannotAssignLinodes = + Boolean(profile?.restricted) && + (vpcPermissions?.permissions === 'read_only' || grants?.vpc.length === 0); + + const csvHeaders = [ + { key: 'label', label: 'Linode Label' }, + { key: 'id', label: 'Linode ID' }, + { key: 'ipv4', label: 'IPv4' }, + ]; + + const downloadCSV = async () => { + await getCSVData(); + csvRef.current.link.click(); + }; + + // We only want the linodes from the same region as the VPC + const { data: linodes, refetch: getCSVData } = useAllLinodesQuery( + {}, + { + region: vpcRegion, + } + ); + + // We need to filter to the linodes from this region that are not already + // assigned to this subnet + const findUnassignedLinodes = React.useCallback(() => { + return linodes?.filter((linode) => { + return !subnet?.linodes.includes(linode.id); + }); + }, [subnet, linodes]); + + const [linodeOptionsToAssign, setLinodeOptionsToAssign] = React.useState< + Linode[] + >([]); + + // Moved the list of linodes that are currently assignable to a subnet into a state variable (linodeOptionsToAssign) + // and update that list whenever this subnet or the list of all linodes in this subnet's region changes. This takes + // care of the MUI invalid value warning that was occuring before in the Linodes autocomplete [M3-6752] + React.useEffect(() => { + if (linodes) { + setLinodeOptionsToAssign(findUnassignedLinodes() ?? []); + } + }, [linodes, setLinodeOptionsToAssign, findUnassignedLinodes]); + + // Determine the configId based on the number of configurations + function getConfigId(linodeConfigs: Config[], selectedConfig: Config | null) { + return ( + (linodeConfigs.length > 1 + ? selectedConfig?.id // Use selected configuration's id if available + : linodeConfigs[0]?.id) ?? -1 // Use the first configuration's id or -1 if no configurations + ); + } + + const handleAssignLinode = async () => { + const { chosenIP, selectedConfig, selectedLinode } = values; + + const configId = getConfigId(linodeConfigs, selectedConfig); + + const interfacePayload: InterfacePayload = { + ipam_address: null, + label: null, + purpose: 'vpc', + subnet_id: subnet?.id, + ...(!autoAssignIPv4 && { ipv4: { vpc: chosenIP } }), + }; + + try { + const newInterface = await appendConfigInterface( + selectedLinode?.id ?? -1, + configId, + interfacePayload + ); + + // We're storing this in a ref to access this later in order + // to update `assignedLinodesAndConfigData` with the new + // interfaceId without causing a re-render + newInterfaceId.current = newInterface.id; + + await invalidateQueries({ + configId, + linodeId: selectedLinode?.id ?? -1, + subnetId: subnet?.id ?? -1, + vpcId, + }); + } catch (errors) { + const errorMap = getErrorMap(['ipv4.vpc'], errors); + const errorMessage = determineErrorMessage(configId, errorMap); + + setAssignLinodesErrors({ ...errorMap, none: errorMessage }); + } + }; + + const handleUnassignLinode = async (data: LinodeAndConfigData) => { + const { configId, id: linodeId, interfaceId } = data; + removedLinodeId.current = linodeId; + try { + await unassignLinode({ + configId, + interfaceId, + linodeId, + subnetId: subnet?.id ?? -1, + vpcId, + }); + } catch (errors) { + setUnassignLinodesErrors(errors as APIError[]); + } + }; + + const handleAutoAssignIPv4Change = () => { + setAutoAssignIPv4(!autoAssignIPv4); + }; + + // Helper function to determine the error message based on the configId + const determineErrorMessage = ( + configId: number, + errorMap: Record + ) => { + if (configId === -1) { + return 'Selected Linode must have at least one configuration profile'; + } + return errorMap.none; + }; + + const { + dirty, + handleSubmit, + resetForm, + setFieldValue, + setValues, + values, + } = useFormik({ + enableReinitialize: true, + initialValues: { + chosenIP: '', + selectedConfig: null as Config | null, + selectedLinode: null as Linode | null, + }, + onSubmit: handleAssignLinode, + validateOnBlur: false, + validateOnChange: false, + }); + + React.useEffect(() => { + // Return early if no Linode is selected + if (!values.selectedLinode) { + return; + } + // Check if the selected Linode is already assigned to the subnet + if ( + values.selectedLinode && + subnet?.linodes.includes(values.selectedLinode.id) + ) { + const configId = getConfigId(linodeConfigs, values.selectedConfig); + + // Construct a new Linode data object with additional properties + const newLinodeData = { + ...values.selectedLinode, + configId, + interfaceId: newInterfaceId.current, + // Create a label that combines Linode label and configuration label (if available) + linodeConfigLabel: `${values.selectedLinode.label}${ + values.selectedConfig?.label + ? ` (${values.selectedConfig.label})` + : '' + }`, + }; + + // Add the new Linode data to the list of assigned Linodes and configurations + setAssignedLinodesAndConfigData([ + ...assignedLinodesAndConfigData, + newLinodeData, + ]); + + // Reset the form, clear its values, and remove any previously selected Linode configurations when a Linode is chosen + resetForm(); + setLinodeConfigs([]); + setValues({ + chosenIP: '', + selectedConfig: null, + selectedLinode: null, + }); + } + }, [ + subnet, + assignedLinodesAndConfigData, + values.selectedLinode, + values.selectedConfig, + linodeConfigs, + resetForm, + setLinodeConfigs, + setValues, + ]); + + React.useEffect(() => { + // if a linode is not assigned to the subnet but is in assignedLinodesAndConfigData, + // we want to remove it from assignedLinodesAndConfigData + const isLinodeToRemoveValid = + removedLinodeId.current !== -1 && + !subnet?.linodes.includes(removedLinodeId.current) && + !!assignedLinodesAndConfigData.find( + (data) => data.id === removedLinodeId.current + ); + + if (isLinodeToRemoveValid) { + setAssignedLinodesAndConfigData( + [...assignedLinodesAndConfigData].filter( + (linode) => linode.id !== removedLinodeId.current + ) + ); + } + }, [subnet, assignedLinodesAndConfigData]); + + const getLinodeConfigData = React.useCallback( + async (linode: Linode | null) => { + if (linode) { + try { + const data = await getAllLinodeConfigs(linode.id); + setLinodeConfigs(data); + } catch (errors) { + // force error to appear at top of drawer + setAssignLinodesErrors({ + none: 'Could not load configurations for selected linode', + }); + } + } else { + setLinodeConfigs([]); + } + }, + [] + ); + + // Every time we select a new linode, we need to get its config data (callback above) + React.useEffect(() => { + getLinodeConfigData(values.selectedLinode); + }, [values.selectedLinode, getLinodeConfigData]); + + const handleOnClose = () => { + onClose(); + resetForm(); + setAssignedLinodesAndConfigData([]); + setLinodeConfigs([]); + setAssignLinodesErrors({}); + setUnassignLinodesErrors([]); + setAutoAssignIPv4(true); + }; + + return ( + + {userCannotAssignLinodes && ( + + )} + {assignLinodesErrors.none && ( + + )} + +
+ {REGIONAL_LINODE_MESSAGE} + { + setFieldValue('selectedLinode', value); + setAssignLinodesErrors({}); + }} + disabled={userCannotAssignLinodes} + inputValue={values.selectedLinode?.label || ''} + label={'Linodes'} + // We only want to be able to assign linodes that were not already assigned to this subnet + options={linodeOptionsToAssign} + placeholder="Select Linodes or type to search" + sx={{ marginBottom: '8px' }} + value={values.selectedLinode || null} + /> + + {!autoAssignIPv4 && ( + { + setFieldValue('chosenIP', e.target.value); + setAssignLinodesErrors({}); + }} + disabled={userCannotAssignLinodes} + errorText={assignLinodesErrors['ipv4.vpc']} + label={'VPC IPv4'} + sx={{ marginBottom: '8px' }} + value={values.chosenIP} + /> + )} + {linodeConfigs.length > 1 && ( + <> + + {MULTIPLE_CONFIGURATIONS_MESSAGE} + {/* @TODO VPC: add docs link */} + Learn more. + + { + setFieldValue('selectedConfig', value); + setAssignLinodesErrors({}); + }} + disabled={userCannotAssignLinodes} + inputValue={values.selectedConfig?.label || ''} + label={'Configuration profile'} + options={linodeConfigs} + placeholder="Select a configuration profile" + value={values.selectedConfig || null} + /> + + )} + + + + + {unassignLinodesErrors + ? unassignLinodesErrors.map((apiError: APIError) => ( + + )) + : null} + { + handleUnassignLinode(data as LinodeAndConfigData); + setUnassignLinodesErrors([]); + }} + headerText={`Linodes Assigned to Subnet (${assignedLinodesAndConfigData.length})`} + noDataText={'No Linodes have been assigned.'} + preferredDataLabel="linodeConfigLabel" + selectionData={assignedLinodesAndConfigData} + /> + + + + +
+ ); +}; diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx index bfe300a56c5..a4ee9e66bdf 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx @@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress/CircleProgress'; +import { DismissibleBanner } from 'src/components/DismissibleBanner'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; @@ -15,6 +16,7 @@ import { truncate } from 'src/utilities/truncate'; import { VPCDeleteDialog } from '../VPCLanding/VPCDeleteDialog'; import { VPCEditDrawer } from '../VPCLanding/VPCEditDrawer'; +import { REBOOT_LINODE_WARNING_VPCDETAILS } from '../constants'; import { getUniqueLinodesFromSubnets } from '../utils'; import { StyledActionButton, @@ -184,7 +186,17 @@ const VPCDetail = () => { > Subnets ({vpc.subnets.length}) - + {numLinodes > 0 && ( + + + {REBOOT_LINODE_WARNING_VPCDETAILS} + + + )} + ); }; diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx index 20f31c48966..cb243706692 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx @@ -34,7 +34,9 @@ describe('VPC Subnets table', () => { getByPlaceholderText, getByTestId, getByText, - } = renderWithTheme(, { queryClient }); + } = renderWithTheme(, { + queryClient, + }); await waitForElementToBeRemoved(getByTestId(loadingTestId)); @@ -53,8 +55,8 @@ describe('VPC Subnets table', () => { const actionMenuButton = getAllByRole('button')[4]; fireEvent.click(actionMenuButton); - getByText('Assign Linode'); - getByText('Unassign Linode'); + getByText('Assign Linodes'); + getByText('Unassign Linodes'); getByText('Edit'); getByText('Delete'); }); @@ -68,7 +70,7 @@ describe('VPC Subnets table', () => { ); const { getAllByRole, getByTestId, getByText } = renderWithTheme( - + ); await waitForElementToBeRemoved(getByTestId(loadingTestId)); @@ -86,7 +88,7 @@ describe('VPC Subnets table', () => { }) ); const { getAllByRole, getByTestId, getByText } = renderWithTheme( - + ); await waitForElementToBeRemoved(getByTestId(loadingTestId)); diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx index a7e05cae750..c59b7df148d 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx @@ -25,6 +25,7 @@ import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useSubnetsQuery } from 'src/queries/vpcs'; +import { SubnetAssignLinodesDrawer } from './SubnetAssignLinodesDrawer'; import { SubnetCreateDrawer } from './SubnetCreateDrawer'; import { SubnetDeleteDialog } from './SubnetDeleteDialog'; import { SubnetEditDrawer } from './SubnetEditDrawer'; @@ -32,12 +33,13 @@ import { SubnetLinodeRow, SubnetLinodeTableRowHead } from './SubnetLinodeRow'; interface Props { vpcId: number; + vpcRegion: string; } const preferenceKey = 'vpc-subnets'; export const VPCSubnetsTable = (props: Props) => { - const { vpcId } = props; + const { vpcId, vpcRegion } = props; const theme = useTheme(); const [subnetsFilterText, setSubnetsFilterText] = React.useState(''); const [selectedSubnet, setSelectedSubnet] = React.useState< @@ -52,6 +54,10 @@ export const VPCSubnetsTable = (props: Props) => { const [subnetCreateDrawerOpen, setSubnetCreateDrawerOpen] = React.useState( false ); + const [ + subnetAssignLinodesDrawerOpen, + setSubnetAssignLinodesDrawerOpen, + ] = React.useState(false); const pagination = usePagination(1, preferenceKey); @@ -110,6 +116,21 @@ export const VPCSubnetsTable = (props: Props) => { setEditSubnetsDrawerOpen(true); }; + const handleSubnetAssignLinodes = (subnet: Subnet) => { + setSelectedSubnet(subnet); + setSubnetAssignLinodesDrawerOpen(true); + }; + + // Ensure that the selected subnet passed to the drawer is up to date + React.useEffect(() => { + if (subnets && selectedSubnet) { + const updatedSubnet = subnets.data.find( + (subnet) => subnet.id === selectedSubnet.id + ); + setSelectedSubnet(updatedSubnet); + } + }, [subnets, selectedSubnet]); + if (isLoading) { return ; } @@ -168,6 +189,7 @@ export const VPCSubnetsTable = (props: Props) => { { page={pagination.page} pageSize={pagination.pageSize} /> + {subnetAssignLinodesDrawerOpen && ( + setSubnetAssignLinodesDrawerOpen(false)} + open={subnetAssignLinodesDrawerOpen} + subnet={selectedSubnet} + vpcId={vpcId} + vpcRegion={vpcRegion} + /> + )} setDeleteSubnetDialogOpen(false)} open={deleteSubnetDialogOpen} diff --git a/packages/manager/src/features/VPCs/constants.ts b/packages/manager/src/features/VPCs/constants.ts new file mode 100644 index 00000000000..fe21791389c --- /dev/null +++ b/packages/manager/src/features/VPCs/constants.ts @@ -0,0 +1,12 @@ +// Various constants for the VPCs package + +export const ASSIGN_LINODES_DRAWER_REBOOT_MESSAGE = + 'Assigning a Linode to a subnet requires you to reboot the Linode to update its configuration.'; + +export const REGIONAL_LINODE_MESSAGE = `Select the Linodes you would like to assign to this subnet. Only Linodes in this VPC's region are displayed.`; + +export const MULTIPLE_CONFIGURATIONS_MESSAGE = + 'This Linode has multiple configurations. Select which configuration you would like added to the subnet.'; + +export const REBOOT_LINODE_WARNING_VPCDETAILS = + 'Assigned or unassigned Linodes will not take affect until the Linodes are rebooted.'; diff --git a/packages/manager/src/hooks/useUnassignLinode.ts b/packages/manager/src/hooks/useUnassignLinode.ts new file mode 100644 index 00000000000..54080f4a1b0 --- /dev/null +++ b/packages/manager/src/hooks/useUnassignLinode.ts @@ -0,0 +1,84 @@ +import { deleteLinodeConfigInterface } from '@linode/api-v4'; +import * as React from 'react'; +import { useQueryClient } from 'react-query'; + +import { configQueryKey, interfaceQueryKey } from 'src/queries/linodes/configs'; +import { queryKey } from 'src/queries/linodes/linodes'; +import { subnetQueryKey, vpcQueryKey } from 'src/queries/vpcs'; + +import type { + APIError, + DeleteLinodeConfigInterfacePayload, +} from '@linode/api-v4'; + +type IdsForUnassignLinode = DeleteLinodeConfigInterfacePayload & { + subnetId: number; + vpcId: number; +}; + +type InvalidateSubnetLinodeConfigQueryIds = Omit< + IdsForUnassignLinode, + 'interfaceId' +>; + +export const useUnassignLinode = () => { + const queryClient = useQueryClient(); + const [unassignLinodesErrors, setUnassignLinodesErrors] = React.useState< + APIError[] + >([]); + + const invalidateQueries = async ({ + configId, + linodeId, + subnetId, + vpcId, + }: InvalidateSubnetLinodeConfigQueryIds) => { + const queryKeys = [ + [vpcQueryKey, 'paginated'], + [vpcQueryKey, 'vpc', vpcId], + [vpcQueryKey, 'vpc', vpcId, subnetQueryKey], + [vpcQueryKey, 'vpc', vpcId, subnetQueryKey, 'subnet', subnetId], + [ + queryKey, + 'linode', + linodeId, + configQueryKey, + 'config', + configId, + interfaceQueryKey, + ], + ]; + await Promise.all( + queryKeys.map((key) => queryClient.invalidateQueries(key)) + ); + }; + + const unassignLinode = async ({ + configId, + interfaceId, + linodeId, + subnetId, + vpcId, + }: IdsForUnassignLinode) => { + await deleteLinodeConfigInterface(linodeId, configId, interfaceId); + invalidateQueries({ configId, linodeId, subnetId, vpcId }); + queryClient.invalidateQueries([ + queryKey, + 'linode', + linodeId, + configQueryKey, + 'config', + configId, + interfaceQueryKey, + 'interface', + interfaceId, + ]); + }; + + return { + invalidateQueries, + unassignLinode, + unassignLinodesErrors, + setUnassignLinodesErrors, + }; +}; diff --git a/packages/manager/src/queries/linodes/configs.ts b/packages/manager/src/queries/linodes/configs.ts index 3eafbee2d1b..4c09ccc06be 100644 --- a/packages/manager/src/queries/linodes/configs.ts +++ b/packages/manager/src/queries/linodes/configs.ts @@ -29,8 +29,8 @@ export const useAllLinodeConfigsQuery = (id: number, enabled = true) => { ); }; -const configQueryKey = 'configs'; -const interfaceQueryKey = 'interfaces'; +export const configQueryKey = 'configs'; +export const interfaceQueryKey = 'interfaces'; // Config queries export const useLinodeConfigDeleteMutation = ( diff --git a/packages/manager/src/queries/linodes/linodes.ts b/packages/manager/src/queries/linodes/linodes.ts index 7e5583bfcd8..c93048e41f5 100644 --- a/packages/manager/src/queries/linodes/linodes.ts +++ b/packages/manager/src/queries/linodes/linodes.ts @@ -38,7 +38,6 @@ import { import { queryKey as accountNotificationsQueryKey } from '../accountNotifications'; import { queryPresets } from '../base'; import { getAllLinodeKernelsRequest, getAllLinodesRequest } from './requests'; - import { queryKey as PROFILE_QUERY_KEY } from '../profile'; export const queryKey = 'linodes'; diff --git a/packages/validation/.changeset/pr-9687-upcoming-features-1695235474879.md b/packages/validation/.changeset/pr-9687-upcoming-features-1695235474879.md new file mode 100644 index 00000000000..37414048814 --- /dev/null +++ b/packages/validation/.changeset/pr-9687-upcoming-features-1695235474879.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Update `LinodeInterfaceSchema` naming convention and add validation for a single interface ([#9687](https://github.com/linode/manager/pull/9687)) diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index bb8b7dcf318..8c7b1194d9b 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -54,7 +54,7 @@ const IPv4 = string() .nullable() .test({ name: 'validateIPv4', - message: 'Must be a valid IPv4 address, e.g. 192.0.2.0.', + message: 'Must be a valid IPv4 address, e.g. 192.0.2.0', test: (value) => test_vpcsValidateIP(value), }); @@ -128,78 +128,75 @@ const ipv6ConfigInterface = object().when('purpose', { }), }); -export const linodeInterfaceSchema = array() - .of( - object().shape({ - purpose: mixed().oneOf( - ['public', 'vlan', 'vpc'], - 'Purpose must be public, vlan, or vpc.' +export const LinodeInterfaceSchema = object().shape({ + purpose: mixed().oneOf( + ['public', 'vlan', 'vpc'], + 'Purpose must be public, vlan, or vpc.' + ), + label: string().when('purpose', { + is: 'vlan', + then: string() + .required('VLAN label is required.') + .min(1, 'VLAN label must be between 1 and 64 characters.') + .max(64, 'VLAN label must be between 1 and 64 characters.') + .matches( + /[a-zA-Z0-9-]+/, + 'Must include only ASCII letters, numbers, and dashes' ), - label: string().when('purpose', { - is: 'vlan', - then: string() - .required('VLAN label is required.') - .min(1, 'VLAN label must be between 1 and 64 characters.') - .max(64, 'VLAN label must be between 1 and 64 characters.') - .matches( - /[a-zA-Z0-9-]+/, - 'Must include only ASCII letters, numbers, and dashes' - ), - otherwise: string().when('label', { - is: null, - then: string().nullable(), - otherwise: string().test({ - name: testnameDisallowedBasedOnPurpose('VLAN'), - message: testmessageDisallowedBasedOnPurpose('vlan', 'label'), - test: (value) => typeof value === 'undefined' || value === '', - }), - }), + otherwise: string().when('label', { + is: null, + then: string().nullable(), + otherwise: string().test({ + name: testnameDisallowedBasedOnPurpose('VLAN'), + message: testmessageDisallowedBasedOnPurpose('vlan', 'label'), + test: (value) => typeof value === 'undefined' || value === '', }), - ipam_address: string().when('purpose', { - is: 'vlan', - then: string().notRequired().test({ - name: 'validateIPAM', - message: 'Must be a valid IPv4 range, e.g. 192.0.2.0/24.', - test: validateIP, - }), - otherwise: string().when('ipam_address', { - is: null, - then: string().nullable(), - otherwise: string().test({ - name: testnameDisallowedBasedOnPurpose('VLAN'), - message: testmessageDisallowedBasedOnPurpose( - 'vlan', - 'ipam_address' - ), - test: (value) => typeof value === 'undefined' || value === '', - }), - }), + }), + }), + ipam_address: string().when('purpose', { + is: 'vlan', + then: string().notRequired().test({ + name: 'validateIPAM', + message: 'Must be a valid IPv4 range, e.g. 192.0.2.0/24', + test: validateIP, + }), + otherwise: string().when('ipam_address', { + is: null, + then: string().nullable(), + otherwise: string().test({ + name: testnameDisallowedBasedOnPurpose('VLAN'), + message: testmessageDisallowedBasedOnPurpose('vlan', 'ipam_address'), + test: (value) => typeof value === 'undefined' || value === '', }), - primary: boolean().notRequired(), - subnet_id: number().when('purpose', { - is: 'vpc', - then: number().required(), - otherwise: number().test({ - name: testnameDisallowedBasedOnPurpose('VPC'), - message: testmessageDisallowedBasedOnPurpose('vpc', 'subnet_id'), - test: (value) => typeof value === 'undefined', - }), + }), + }), + primary: boolean().notRequired(), + subnet_id: number().when('purpose', { + is: 'vpc', + then: number().required(), + otherwise: number().test({ + name: testnameDisallowedBasedOnPurpose('VPC'), + message: testmessageDisallowedBasedOnPurpose('vpc', 'subnet_id'), + test: (value) => typeof value === 'undefined', + }), + }), + ipv4: ipv4ConfigInterface, + ipv6: ipv6ConfigInterface, + ip_ranges: array() + .of(string()) + .when('purpose', { + is: 'vpc', + then: array().of(string().test(validateIP)).max(1).notRequired(), + otherwise: array().test({ + name: testnameDisallowedBasedOnPurpose('VPC'), + message: testmessageDisallowedBasedOnPurpose('vpc', 'ip_ranges'), + test: (value) => typeof value === 'undefined', }), - ipv4: ipv4ConfigInterface, - ipv6: ipv6ConfigInterface, - ip_ranges: array() - .of(string()) - .when('purpose', { - is: 'vpc', - then: array().of(string().test(validateIP)).max(1).notRequired(), - otherwise: array().test({ - name: testnameDisallowedBasedOnPurpose('VPC'), - message: testmessageDisallowedBasedOnPurpose('vpc', 'ip_ranges'), - test: (value) => typeof value === 'undefined', - }), - }), - }) - ) + }), +}); + +export const LinodeInterfacesSchema = array() + .of(LinodeInterfaceSchema) .test( 'unique-public-interface', 'Only one public interface per config is allowed.', @@ -285,7 +282,7 @@ export const CreateLinodeSchema = object({ // .concat(rootPasswordValidation), otherwise: string().notRequired(), }), - interfaces: linodeInterfaceSchema, + interfaces: LinodeInterfacesSchema, metadata: MetadataSchema, firewall_id: number().notRequired(), }); @@ -425,7 +422,7 @@ export const CreateLinodeConfigSchema = object({ virt_mode: mixed().oneOf(['paravirt', 'fullvirt']), helpers, root_device: string(), - interfaces: linodeInterfaceSchema, + interfaces: LinodeInterfacesSchema, }); export const UpdateLinodeConfigSchema = object({ @@ -440,7 +437,7 @@ export const UpdateLinodeConfigSchema = object({ virt_mode: mixed().oneOf(['paravirt', 'fullvirt']), helpers, root_device: string(), - interfaces: linodeInterfaceSchema, + interfaces: LinodeInterfacesSchema, }); export const CreateLinodeDiskSchema = object({