diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index e62aa45b79e..35ff819b993 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-02-13] - v1.112.0 + +### Added: + +- Support for IPv4 Ranges in VPC 'Assign Linodes to subnet' drawer ([#10089](https://github.com/linode/manager/pull/10089)) +- VPC IPv4 address and range to Linode IP Address Table ([#10108](https://github.com/linode/manager/pull/10108)) +- Support for VPC IPv4 Ranges data in Unassign Linodes drawer ([#10114](https://github.com/linode/manager/pull/10114)) +- Support for VPC IPv4 Ranges in Linode Create flow and 'VPC IPv4 Ranges' column to inner Subnets table on VPC Detail page ([#10116](https://github.com/linode/manager/pull/10116)) +- Support VPC IPv4 Ranges in Add/Edit Linode Config dialog ([#10170](https://github.com/linode/manager/pull/10170)) + +### Changed: + +- "Learn more" docs link for IPv4 ranges in Add/Edit Linode Config dialog, Linode Create flow, and VPC "Assign Linodes" drawer + +### Fixed: + +- Error when enabling backups for Linodes in regions with $0 pricing ([#10153](https://github.com/linode/manager/pull/10153)) +- Error notices for $0 regions in LKE Resize and Add Node Pools drawers ([#10157](https://github.com/linode/manager/pull/10157)) +- Error in Enable All Backups drawer when one or more Linode is in a $0 region ([#10161](https://github.com/linode/manager/pull/10161)) +- Display $0.00 prices in Linode Migration dialog ([#10166](https://github.com/linode/manager/pull/10166)) + ## [2024-02-05] - v1.111.0 ### Changed: diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts index ec272ddb620..43da412e2f0 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -153,7 +153,7 @@ describe('VPC assign/unassign flows', () => { mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as( 'getLinodeConfigs' ); - cy.findByLabelText('Linodes') + cy.findByLabelText('Linode') .should('be.visible') .click() .type(mockLinode.label) diff --git a/packages/manager/package.json b/packages/manager/package.json index ff8e4d79de7..9856aaecb0c 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.111.0", + "version": "1.112.0", "private": true, "type": "module", "bugs": { diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 9d92323b8e4..63642a7184e 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -7,7 +7,9 @@ import { makeStyles } from 'tss-react/mui'; import { Button } from 'src/components/Button/Button'; import { InputLabel } from 'src/components/InputLabel'; +import { LinkButton } from 'src/components/LinkButton'; import { Notice } from 'src/components/Notice/Notice'; +import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; @@ -56,9 +58,11 @@ const useStyles = makeStyles()((theme: Theme) => ({ })); interface Props { + buttonText?: string; className?: string; error?: string; forDatabaseAccessControls?: boolean; + forVPCIPv4Ranges?: boolean; helperText?: string; inputProps?: InputBaseProps; ips: ExtendedIP[]; @@ -72,9 +76,11 @@ interface Props { export const MultipleIPInput = React.memo((props: Props) => { const { + buttonText, className, error, forDatabaseAccessControls, + forVPCIPv4Ranges, helperText, ips, onBlur, @@ -122,6 +128,21 @@ export const MultipleIPInput = React.memo((props: Props) => { return null; } + const addIPButton = forVPCIPv4Ranges ? ( + + {buttonText} + + ) : ( + + ); + return (
{tooltip ? ( @@ -156,6 +177,7 @@ export const MultipleIPInput = React.memo((props: Props) => { direction="row" justifyContent="center" key={`domain-transfer-ip-${idx}`} + maxWidth={forVPCIPv4Ranges ? '415px' : undefined} spacing={2} > @@ -177,27 +199,22 @@ export const MultipleIPInput = React.memo((props: Props) => { value={thisIP.address} /> - {/** Don't show the button for the first input since it won't do anything, unless this component is used in DBaaS */} + {/** Don't show the button for the first input since it won't do anything, unless this component is + * used in DBaaS or for Linode VPC interfaces + */} - {idx > 0 || forDatabaseAccessControls ? ( + {(idx > 0 || forDatabaseAccessControls || forVPCIPv4Ranges) && ( - ) : null} + )} ))} - + {addIPButton}
); }); diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts index 392807ae9d1..c49bf85f512 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts @@ -88,3 +88,10 @@ export const StyledScrollBox = styled(Box, { maxWidth: `${maxWidth}px`, overflow: 'auto', })); + +export const StyledItemWithPlusChip = styled('span', { + label: 'ItemWithPlusChip', +})({ + alignItems: 'center', + display: 'inline-flex', +}); diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx new file mode 100644 index 00000000000..64ee4b8712b --- /dev/null +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx @@ -0,0 +1,139 @@ +import Close from '@mui/icons-material/Close'; +import * as React from 'react'; + +import { IconButton } from 'src/components/IconButton'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { determineNoneSingleOrMultipleWithChip } from 'src/utilities/noneSingleOrMultipleWithChip'; + +import { TableRowEmpty } from '../TableRowEmpty/TableRowEmpty'; +import { + SelectedOptionsHeader, + StyledLabel, +} from './RemovableSelectionsList.style'; + +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 }; + +export interface RemovableSelectionsListTableProps { + /** + * The descriptive text to display above the list + */ + headerText: string; + /** + * If false, hide the remove button + */ + isRemovable?: boolean; + /** + * 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[]; + /** + * Headers for the table containing the list of selected options + */ + tableHeaders: string[]; +} + +export const RemovableSelectionsListTable = ( + props: RemovableSelectionsListTableProps +) => { + const { + headerText, + isRemovable = true, + noDataText, + onRemove, + preferredDataLabel, + selectionData, + tableHeaders, + } = props; + + const handleOnClick = (selection: RemovableItem) => { + onRemove(selection); + }; + + const selectedOptionsJSX = + selectionData.length === 0 ? ( + + ) : ( + selectionData.map((selection) => ( + + + + {preferredDataLabel + ? selection[preferredDataLabel] + : selection.label} + + + {selection.interfaceData?.ipv4?.vpc ?? null} + + {determineNoneSingleOrMultipleWithChip( + selection.interfaceData?.ip_ranges ?? [] + )} + + + {isRemovable && ( + handleOnClick(selection)} + size="medium" + > + + + )} + + + )) + ); + + const tableHeadersJSX = tableHeaders.map((thisHeader, idx) => { + const lastHeader = idx === tableHeaders.length - 1; + + return ( + + {thisHeader} + + ); + }); + + return ( + <> + {headerText} + + + {tableHeadersJSX} + + {selectedOptionsJSX} +
+ + ); +}; diff --git a/packages/manager/src/factories/linodeConfigInterfaceFactory.ts b/packages/manager/src/factories/linodeConfigInterfaceFactory.ts index 7d1a0301f8e..914551a8fa6 100644 --- a/packages/manager/src/factories/linodeConfigInterfaceFactory.ts +++ b/packages/manager/src/factories/linodeConfigInterfaceFactory.ts @@ -15,7 +15,7 @@ export const LinodeConfigInterfaceFactoryWithVPC = Factory.Sync.makeFactory i), - ip_ranges: ['192.0.2.0/24'], + ip_ranges: ['192.0.2.0/24', '192.0.3.0/24'], ipam_address: '10.0.0.1/24', ipv4: { nat_1_1: 'some nat', diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index f2a3690ebe4..fcaac5bab0f 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -10,6 +10,11 @@ import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { mapIdsToDevices } from 'src/utilities/mapIdsToDevices'; interface LinodeSelectProps { + /** Determine whether isOptionEqualToValue prop should be defined for Autocomplete + * component (to avoid "The value provided to Autocomplete is invalid [...]" console + * errors). See https://github.com/linode/manager/pull/10089 for context & discussion. + */ + checkIsOptionEqualToValue?: boolean; /** Whether to display the clear icon. Defaults to `true`. */ clearable?: boolean; /** Disable editing the input value. */ @@ -73,6 +78,7 @@ export const LinodeSelect = ( props: LinodeMultiSelectProps | LinodeSingleSelectProps ) => { const { + checkIsOptionEqualToValue, clearable = true, disabled, errorText, @@ -116,6 +122,11 @@ export const LinodeSelect = ( getOptionLabel={(linode: Linode) => renderOptionLabel ? renderOptionLabel(linode) : linode.label } + isOptionEqualToValue={ + checkIsOptionEqualToValue + ? (option, value) => option.id === value.id + : undefined + } noOptionsText={ noOptionsMessage ?? getDefaultNoOptionsMessage(error, isLoading) } diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 95cea371bd4..e310e7ca1cc 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -64,6 +64,7 @@ import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature import { getErrorMap } from 'src/utilities/errorUtils'; import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; +import { ExtendedIP } from 'src/utilities/ipUtils'; import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; @@ -103,6 +104,7 @@ import { import type { Tab } from 'src/components/Tabs/TabLinkList'; export interface LinodeCreateProps { + additionalIPv4RangesForVPC: ExtendedIP[]; assignPublicIPv4Address: boolean; autoassignIPv4WithinVPC: boolean; checkValidation: LinodeCreateValidation; @@ -110,6 +112,7 @@ export interface LinodeCreateProps { firewallId?: number; handleAgreementChange: () => void; handleFirewallChange: (firewallId: number) => void; + handleIPv4RangesForVPC: (ranges: ExtendedIP[]) => void; handleShowApiAwarenessModal: () => void; handleSubmitForm: HandleSubmit; handleSubnetChange: (subnetId: number) => void; @@ -677,9 +680,11 @@ export class LinodeCreate extends React.PureComponent< toggleAutoassignIPv4WithinVPCEnabled={ this.props.toggleAutoassignIPv4WithinVPCEnabled } + additionalIPv4RangesForVPC={this.props.additionalIPv4RangesForVPC} assignPublicIPv4Address={this.props.assignPublicIPv4Address} autoassignIPv4WithinVPC={this.props.autoassignIPv4WithinVPC} from="linodeCreate" + handleIPv4RangeChange={this.props.handleIPv4RangesForVPC} handleSelectVPC={this.props.setSelectedVPC} handleSubnetChange={this.props.handleSubnetChange} handleVPCIPv4Change={this.props.handleVPCIPv4Change} @@ -858,6 +863,9 @@ export class LinodeCreate extends React.PureComponent< this.props.selectedVPCId !== -1 ) { const vpcInterfaceData: InterfacePayload = { + ip_ranges: this.props.additionalIPv4RangesForVPC + .map((ipRange) => ipRange.address) + .filter((ipRange) => ipRange !== ''), ipam_address: null, ipv4: { nat_1_1: this.props.assignPublicIPv4Address ? 'any' : undefined, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index daa6cc2c3d4..fa39eab131d 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -68,11 +68,12 @@ import { } from 'src/utilities/analytics'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { ExtendedType, extendType } from 'src/utilities/extendType'; -import { isEURegion } from 'src/utilities/formatRegion'; import { getGDPRDetails, getSelectedRegionGroup, } from 'src/utilities/formatRegion'; +import { isEURegion } from 'src/utilities/formatRegion'; +import { ExtendedIP } from 'src/utilities/ipUtils'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; @@ -95,6 +96,7 @@ import type { const DEFAULT_IMAGE = 'linode/debian11'; interface State { + additionalIPv4RangesForVPC: ExtendedIP[]; assignPublicIPv4Address: boolean; attachedVLANLabel: null | string; authorized_users: string[]; @@ -147,6 +149,7 @@ type CombinedProps = WithSnackbarProps & WithEventsPollingActionProps; const defaultState: State = { + additionalIPv4RangesForVPC: [], assignPublicIPv4Address: false, attachedVLANLabel: '', authorized_users: [], @@ -285,6 +288,7 @@ class LinodeCreateContainer extends React.PureComponent { firewallId={this.state.selectedfirewallId} handleAgreementChange={this.handleAgreementChange} handleFirewallChange={this.handleFirewallChange} + handleIPv4RangesForVPC={this.handleVPCIPv4RangesChange} handleSelectUDFs={this.setUDFs} handleShowApiAwarenessModal={this.handleShowApiAwarenessModal} handleSubmitForm={this.submitForm} @@ -524,6 +528,10 @@ class LinodeCreateContainer extends React.PureComponent { this.setState({ vpcIPv4AddressOfLinode: IPv4 }); }; + handleVPCIPv4RangesChange = (ranges: ExtendedIP[]) => { + this.setState({ additionalIPv4RangesForVPC: ranges }); + }; + params = getQueryParamsFromQueryString(this.props.location.search) as Record< string, string diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx index a203e30fee4..e5018a0679e 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx @@ -17,9 +17,11 @@ afterEach(() => { }); const props = { + additionalIPv4RangesForVPC: [], assignPublicIPv4Address: false, autoassignIPv4WithinVPC: true, from: 'linodeCreate' as VPCPanelProps['from'], + handleIPv4RangeChange: vi.fn(), handleSelectVPC: vi.fn(), handleSubnetChange: vi.fn(), handleVPCIPv4Change: vi.fn(), @@ -105,7 +107,12 @@ describe('VPCPanel', () => { }); it('should have the VPC IPv4 auto-assign checkbox checked by default', async () => { - const _props = { ...props, region: 'us-east', selectedVPCId: 5 }; + const _props = { + ...props, + region: 'us-east', + selectedSubnetId: 2, + selectedVPCId: 5, + }; server.use( rest.get('*/regions', (req, res, ctx) => { @@ -234,6 +241,7 @@ describe('VPCPanel', () => { ...props, autoassignIPv4WithinVPC: false, region: 'us-east', + selectedSubnetId: 2, selectedVPCId: 5, vpcIPv4AddressOfLinode: '10.0.4.3', }; @@ -269,6 +277,7 @@ describe('VPCPanel', () => { ...props, assignPublicIPv4Address: true, region: 'us-east', + selectedSubnetId: 2, selectedVPCId: 5, }; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx index a960f2c2bff..e140925ec52 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx @@ -14,6 +14,7 @@ import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; +import { AssignIPRanges } from 'src/features/VPCs/VPCDetail/AssignIPRanges'; import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP } from 'src/features/VPCs/constants'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; @@ -22,15 +23,18 @@ import { useVPCsQuery } from 'src/queries/vpcs'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { ExtendedIP } from 'src/utilities/ipUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { VPCCreateDrawer } from './VPCCreateDrawer'; import { REGION_CAVEAT_HELPER_TEXT } from './constants'; export interface VPCPanelProps { + additionalIPv4RangesForVPC: ExtendedIP[]; assignPublicIPv4Address: boolean; autoassignIPv4WithinVPC: boolean; from: 'linodeConfig' | 'linodeCreate'; + handleIPv4RangeChange: (ranges: ExtendedIP[]) => void; handleSelectVPC: (vpcId: number) => void; handleSubnetChange: (subnetId: number) => void; handleVPCIPv4Change: (IPv4: string) => void; @@ -41,6 +45,7 @@ export interface VPCPanelProps { subnetError?: string; toggleAssignPublicIPv4Address: () => void; toggleAutoassignIPv4WithinVPCEnabled: () => void; + vpcIPRangesError?: string; vpcIPv4AddressOfLinode: string | undefined; vpcIPv4Error?: string; vpcIdError?: string; @@ -50,9 +55,11 @@ const ERROR_GROUP_STRING = 'vpc-errors'; export const VPCPanel = (props: VPCPanelProps) => { const { + additionalIPv4RangesForVPC, assignPublicIPv4Address, autoassignIPv4WithinVPC, from, + handleIPv4RangeChange, handleSelectVPC, handleSubnetChange, handleVPCIPv4Change, @@ -63,6 +70,7 @@ export const VPCPanel = (props: VPCPanelProps) => { subnetError, toggleAssignPublicIPv4Address, toggleAutoassignIPv4WithinVPCEnabled, + vpcIPRangesError, vpcIPv4AddressOfLinode, vpcIPv4Error, vpcIdError, @@ -247,87 +255,105 @@ export const VPCPanel = (props: VPCPanelProps) => { options={subnetDropdownOptions} placeholder="Select Subnet" /> - ({ - marginLeft: '2px', - paddingTop: theme.spacing(), - })} - alignItems="center" - display="flex" - flexDirection="row" - > - + ({ + marginLeft: '2px', + paddingTop: theme.spacing(), + })} + alignItems="center" + display="flex" + flexDirection="row" + > + + } + label={ + + + Auto-assign a VPC IPv4 address for this Linode in + the VPC + + + + } + data-testid="vpc-ipv4-checkbox" /> - } - label={ - - - Auto-assign a VPC IPv4 address for this Linode in the - VPC - - - - } - data-testid="vpc-ipv4-checkbox" - /> - - {!autoassignIPv4WithinVPC && ( - handleVPCIPv4Change(e.target.value)} - required={!autoassignIPv4WithinVPC} - value={vpcIPv4AddressOfLinode} - /> - )} - ({ - marginLeft: '2px', - marginTop: !autoassignIPv4WithinVPC ? theme.spacing() : 0, - })} - alignItems="center" - display="flex" - > - + {!autoassignIPv4WithinVPC && ( + handleVPCIPv4Change(e.target.value)} + required={!autoassignIPv4WithinVPC} + value={vpcIPv4AddressOfLinode} /> - } - label={ - - - Assign a public IPv4 address for this Linode - - - - } - /> - - {assignPublicIPv4Address && publicIPv4Error && ( - ({ - color: theme.color.red, - })} - > - {publicIPv4Error} - + )} + ({ + marginLeft: '2px', + marginTop: !autoassignIPv4WithinVPC ? theme.spacing() : 0, + })} + alignItems="center" + display="flex" + > + + } + label={ + + + Assign a public IPv4 address for this Linode + + + + } + /> + + {assignPublicIPv4Address && publicIPv4Error && ( + ({ + color: theme.color.red, + })} + > + {publicIPv4Error} + + )} + + )} )} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index fe5447fe110..072c1bfeeba 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -38,8 +38,6 @@ import { NATTED_PUBLIC_IP_HELPER_TEXT, NOT_NATTED_HELPER_TEXT, } from 'src/features/VPCs/constants'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; import { useLinodeConfigCreateMutation, useLinodeConfigUpdateMutation, @@ -53,7 +51,6 @@ import { useRegionsQuery } from 'src/queries/regions'; import { queryKey as vlansQueryKey } from 'src/queries/vlans'; import { useAllVolumesQuery } from 'src/queries/volumes'; import { vpcQueryKey } from 'src/queries/vpcs'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { DevicesAsStrings, createDevicesFromStrings, @@ -64,6 +61,7 @@ import { handleGeneralErrors, } from 'src/utilities/formikErrorUtils'; import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; +import { ExtendedIP } from 'src/utilities/ipUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { @@ -116,6 +114,7 @@ interface Props { } const defaultInterface = { + ip_ranges: [], ipam_address: '', label: '', purpose: 'none', @@ -183,6 +182,7 @@ const interfacesToState = (interfaces?: Interface[]) => { const interfacesPayload = interfaces.map( ({ id, + ip_ranges, ipam_address, ipv4, label, @@ -192,6 +192,7 @@ const interfacesToState = (interfaces?: Interface[]) => { vpc_id, }) => ({ id, + ip_ranges, ipam_address, ipv4, label, @@ -262,8 +263,6 @@ export const LinodeConfigDialog = (props: Props) => { ); const theme = useTheme(); - const flags = useFlags(); - const { account } = useAccountManagement(); const regions = useRegionsQuery().data ?? []; @@ -291,13 +290,6 @@ export const LinodeConfigDialog = (props: Props) => { thisRegion.capabilities.includes('VPCs') ); - // @TODO VPC: remove once VPC is fully rolled out - const vpcEnabled = isFeatureEnabled( - 'VPCs', - Boolean(flags.vpc), - account?.capabilities ?? [] - ); - const { resetForm, setFieldValue, values, ...formik } = useFormik({ initialValues: defaultFieldsValues, onSubmit: (values) => onSubmit(values), @@ -493,7 +485,7 @@ export const LinodeConfigDialog = (props: Props) => { (_interface) => _interface.primary === true ); - if (vpcEnabled && indexOfExistingPrimaryInterface !== -1) { + if (indexOfExistingPrimaryInterface !== -1) { setPrimaryInterfaceIndex(indexOfExistingPrimaryInterface); } @@ -523,7 +515,7 @@ export const LinodeConfigDialog = (props: Props) => { setPrimaryInterfaceIndex(0); } } - }, [open, config, initrdFromConfig, resetForm, queryClient, vpcEnabled]); + }, [open, config, initrdFromConfig, resetForm, queryClient]); const generalError = formik.status?.generalError; @@ -667,12 +659,9 @@ export const LinodeConfigDialog = (props: Props) => { const networkInterfacesHelperText = ( - Configure the network that a selected interface will connect to ( - {vpcEnabled - ? '"Public Internet", VLAN, or VPC' - : 'either "Public Internet" or a VLAN'} - ) . Each Linode can have up to three Network Interfaces. For more - information, see our{' '} + Configure the network that a selected interface will connect to + "Public Internet", VLAN, or VPC. Each Linode can have up to + three Network Interfaces. For more information, see our{' '} Network Interfaces guide @@ -951,9 +940,7 @@ export const LinodeConfigDialog = (props: Props) => { - - {vpcEnabled ? 'Networking' : 'Network Interfaces'} - + Networking { variant="error" /> )} - {vpcEnabled && ( - <> - + + {values.interfaces.map((thisInterface, idx) => { + const thisInterfaceIPRanges: ExtendedIP[] = ( + thisInterface.ip_ranges ?? [] + ).map((ip_range, index) => { + // Display a more user-friendly error to the user as opposed to, for example, "interfaces[1].ip_ranges[1] is invalid" + const errorString: string = formik.errors[ + `interfaces[${idx}].ip_ranges[${index}]` + ]?.includes('is invalid') + ? 'Invalid IP range' + : formik.errors[`interfaces[${idx}].ip_ranges[${index}]`]; + + return { + address: ip_range, + error: errorString, + }; + }); + return ( {unrecommendedConfigNoticeSelector({ @@ -1005,6 +1006,8 @@ export const LinodeConfigDialog = (props: Props) => { })} { handleChange={(newInterface: Interface) => handleInterfaceChange(idx, newInterface) } + additionalIPv4RangesForVPC={thisInterfaceIPRanges} ipamAddress={thisInterface.ipam_address} key={`eth${idx}-interface`} label={thisInterface.label} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx index 312889cd1d5..45cb4a2f974 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx @@ -1,8 +1,12 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; +import { LinodeConfigInterfaceFactoryWithVPC } from 'src/factories/linodeConfigInterfaceFactory'; import { linodeIPFactory } from 'src/factories/linodes'; -import { ipResponseToDisplayRows } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses'; +import { + ipResponseToDisplayRows, + vpcConfigInterfaceToDisplayRows, +} from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses'; import { PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; @@ -10,6 +14,9 @@ import { IPAddressRowHandlers, LinodeIPAddressRow } from './LinodeIPAddressRow'; const ips = linodeIPFactory.build(); const ipDisplay = ipResponseToDisplayRows(ips)[0]; +const ipDisplayVPC = vpcConfigInterfaceToDisplayRows( + LinodeConfigInterfaceFactoryWithVPC.build() +)[0]; const handlers: IPAddressRowHandlers = { handleOpenEditRDNS: vi.fn(), @@ -42,6 +49,25 @@ describe('LinodeIPAddressRow', () => { getAllByText('Delete'); getAllByText('Edit RDNS'); }); + it('should render a VPC IP Address row', () => { + const { getAllByText, queryByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + getAllByText(ipDisplayVPC.address); + getAllByText(ipDisplayVPC.type); + // No actions should be rendered + expect(queryByText('Delete')).not.toBeInTheDocument(); + expect(queryByText('Edit RDNS')).not.toBeInTheDocument(); + }); it('should disable the row if disabled is true and display a tooltip', async () => { const { findByRole, getByTestId } = renderWithTheme( @@ -96,3 +122,19 @@ describe('LinodeIPAddressRow', () => { expect(editRDNSBtn).not.toHaveAttribute('aria-disabled', 'true'); }); }); + +describe('ipResponseToDisplayRows', () => { + it('should not return a Public IPv4 row if there is a VPC interface with 1:1 NAT', () => { + const ipDisplays = ipResponseToDisplayRows( + ips, + LinodeConfigInterfaceFactoryWithVPC.build() + ); + + expect( + ipDisplays.find((ipDisplay) => ipDisplay.type === 'IPv4 – Public') + ).toBeUndefined(); + expect( + ipDisplays.find((ipDisplay) => ipDisplay.type === 'VPC IPv4 – NAT') + ).toBeDefined(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx index 853e6e1ffe6..5d768cda4b9 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx @@ -72,7 +72,11 @@ export const LinodeIPAddressRow = (props: CombinedProps) => { {!isVPCOnlyLinode && } - + {type} {gateway} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx index fb2e6612e11..12fb232a546 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx @@ -1,4 +1,4 @@ -import { LinodeIPsResponse } from '@linode/api-v4/lib/linodes'; +import { Interface, LinodeIPsResponse } from '@linode/api-v4/lib/linodes'; import { IPAddress, IPRange } from '@linode/api-v4/lib/networking'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; @@ -52,7 +52,9 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { const { data: ips, error, isLoading } = useLinodeIPsQuery(linodeID); const readOnly = getPermissionsForLinode(grants, linodeID) === 'read_only'; - const { isVPCOnlyLinode } = useVPCConfigInterface(linodeID); + const { configInterfaceWithVPC, isVPCOnlyLinode } = useVPCConfigInterface( + linodeID + ); const [selectedIP, setSelectedIP] = React.useState(); const [selectedRange, setSelectedRange] = React.useState(); @@ -119,7 +121,7 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { } const renderIPTable = () => { - const ipDisplay = ipResponseToDisplayRows(ips); + const ipDisplay = ipResponseToDisplayRows(ips, configInterfaceWithVPC); return (
@@ -192,7 +194,7 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { isVPCOnlyLinode={ isVPCOnlyLinode && ipDisplay.type === 'IPv4 – Public' } - key={ipDisplay.address} + key={`${ipDisplay.address}-${ipDisplay.type}`} linodeId={linodeID} readOnly={readOnly} /> @@ -286,9 +288,51 @@ export interface IPDisplay { type: IPTypes; } +export const vpcConfigInterfaceToDisplayRows = ( + configInterfaceWithVPC: Interface +) => { + const ipDisplay: IPDisplay[] = []; + + const { ip_ranges, ipv4 } = configInterfaceWithVPC; + const emptyProps = { + gateway: '', + rdns: '', + subnetMask: '', + }; + + if (ipv4?.vpc) { + ipDisplay.push({ + address: ipv4.vpc, + type: 'IPv4 – VPC', + ...emptyProps, + }); + } + + if (ipv4?.nat_1_1) { + ipDisplay.push({ + address: ipv4.nat_1_1, + type: 'VPC IPv4 – NAT', + ...emptyProps, + }); + } + + if (ip_ranges) { + ip_ranges.forEach((ip_range) => { + ipDisplay.push({ + address: ip_range, + type: 'IPv4 – VPC – Range', + ...emptyProps, + }); + }); + } + + return ipDisplay; +}; + // Takes an IP Response object and returns high-level IP display rows. export const ipResponseToDisplayRows = ( - ipResponse?: LinodeIPsResponse + ipResponse?: LinodeIPsResponse, + configInterfaceWithVPC?: Interface ): IPDisplay[] => { if (!ipResponse) { return []; @@ -311,6 +355,14 @@ export const ipResponseToDisplayRows = ( ipDisplay.push(ipToDisplay(ipv6?.link_local, 'Link Local')); } + if (configInterfaceWithVPC) { + if (configInterfaceWithVPC.ipv4?.nat_1_1) { + // If there is a VPC interface with 1:1 NAT, hide the Public IPv4 IP address row + ipDisplay.shift(); + } + ipDisplay.push(...vpcConfigInterfaceToDisplayRows(configInterfaceWithVPC)); + } + // IPv6 ranges and pools to display in the networking table ipDisplay.push( ...[...(ipv6 ? ipv6.global : [])].map((thisIP) => { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx index efc84beeeef..b9be41758e6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx @@ -37,11 +37,14 @@ export const LinodeNetworkingActionMenu = (props: Props) => { readOnly, } = props; - const showEdit = - ipType !== 'IPv4 – Private' && - ipType !== 'IPv6 – Link Local' && - ipType !== 'IPv4 – Reserved (public)' && - ipType !== 'IPv4 – Reserved (private)'; + const showEdit = ![ + 'IPv4 – Private', + 'IPv4 – Reserved (private)', + 'IPv4 – Reserved (public)', + 'IPv4 – VPC', + 'IPv6 – Link Local', + 'VPC IPv4 – NAT', + ].includes(ipType); const deletableIPTypes = ['IPv4 – Public', 'IPv4 – Private', 'IPv6 – Range']; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts index e10a2366d15..f12c35c7c2a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts @@ -4,6 +4,9 @@ export type IPTypes = | 'IPv4 – Reserved (private)' | 'IPv4 – Reserved (public)' | 'IPv4 – Shared' + | 'IPv4 – VPC – Range' + | 'IPv4 – VPC' | 'IPv6 – Link Local' | 'IPv6 – Range' - | 'IPv6 – SLAAC'; + | 'IPv6 – SLAAC' + | 'VPC IPv4 – NAT'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index 6a299c39682..988041f6b8e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -18,8 +18,10 @@ import { useAccount } from 'src/queries/account'; import { useVlansQuery } from 'src/queries/vlans'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics'; +import { ExtendedIP } from 'src/utilities/ipUtils'; interface Props { + additionalIPv4RangesForVPC?: ExtendedIP[]; errors: VPCInterfaceErrors & OtherInterfaceErrors; fromAddonsPanel?: boolean; handleChange: (updatedInterface: ExtendedInterface) => void; @@ -33,6 +35,7 @@ interface Props { slotNumber: number; } interface VPCInterfaceErrors { + ipRangeError?: string; labelError?: string; publicIPv4Error?: string; subnetError?: string; @@ -63,6 +66,7 @@ type CombinedProps = Props & VPCState; export const InterfaceSelect = (props: CombinedProps) => { const { + additionalIPv4RangesForVPC, errors, fromAddonsPanel, handleChange, @@ -118,6 +122,9 @@ export const InterfaceSelect = (props: CombinedProps) => { const [autoAssignLinodeIPv4, setAutoAssignLinodeIPv4] = React.useState( Boolean(nattedIPv4Address) ); + const _additionalIPv4RangesForVPC = additionalIPv4RangesForVPC?.map( + (ip_range) => ip_range.address + ); const handlePurposeChange = (selected: Item) => { const purpose = selected.value; @@ -142,6 +149,7 @@ export const InterfaceSelect = (props: CombinedProps) => { // Only clear VPC related fields if VPC selection changes if (selectedVPCId !== vpcId) { handleChange({ + ip_ranges: _additionalIPv4RangesForVPC, ipam_address: null, ipv4: { nat_1_1: autoAssignLinodeIPv4 ? 'any' : undefined, @@ -155,8 +163,22 @@ export const InterfaceSelect = (props: CombinedProps) => { } }; + const handleIPv4RangeChange = (ipv4Ranges: ExtendedIP[]) => { + const changeObj = { + ip_ranges: ipv4Ranges.map((ip_range) => ip_range.address), + ipam_address: null, + label: null, + purpose, + subnet_id: subnetId, + vpc_id: vpcId, + }; + + handleChange(changeObj); + }; + const handleSubnetChange = (selectedSubnetId: number) => handleChange({ + ip_ranges: _additionalIPv4RangesForVPC, ipam_address: null, ipv4: { nat_1_1: autoAssignLinodeIPv4 ? 'any' : undefined, @@ -170,6 +192,7 @@ export const InterfaceSelect = (props: CombinedProps) => { const handleVPCIPv4Input = (vpcIPv4Input: string) => { const changeObj = { + ip_ranges: _additionalIPv4RangesForVPC, ipam_address: null, label: null, purpose, @@ -204,6 +227,7 @@ export const InterfaceSelect = (props: CombinedProps) => { } const changeObj = { + ip_ranges: _additionalIPv4RangesForVPC, ipam_address: null, label: null, purpose, @@ -386,9 +410,11 @@ export const InterfaceSelect = (props: CombinedProps) => { toggleAutoassignIPv4WithinVPCEnabled={() => setAutoAssignVPCIPv4((autoAssignVPCIPv4) => !autoAssignVPCIPv4) } + additionalIPv4RangesForVPC={additionalIPv4RangesForVPC ?? []} assignPublicIPv4Address={autoAssignLinodeIPv4} autoassignIPv4WithinVPC={autoAssignVPCIPv4} from="linodeConfig" + handleIPv4RangeChange={handleIPv4RangeChange} handleSelectVPC={handleVPCLabelChange} handleSubnetChange={handleSubnetChange} handleVPCIPv4Change={handleVPCIPv4Input} @@ -397,6 +423,7 @@ export const InterfaceSelect = (props: CombinedProps) => { selectedSubnetId={subnetId} selectedVPCId={vpcId} subnetError={errors.subnetError} + vpcIPRangesError={errors.ipRangeError} vpcIPv4AddressOfLinode={vpcIPv4} vpcIPv4Error={errors.vpcIPv4Error} vpcIdError={errors.vpcError} diff --git a/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.test.tsx new file mode 100644 index 00000000000..ca8c7f472c1 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.test.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; + +import { + ASSIGN_IPV4_RANGES_DESCRIPTION, + ASSIGN_IPV4_RANGES_TITLE, +} from 'src/features/VPCs/constants'; +import { ExtendedIP } from 'src/utilities/ipUtils'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AssignIPRanges } from './AssignIPRanges'; + +describe('AssignIPRanges', () => { + const handleIPRangeChangeMock = vi.fn(); + const ipRanges: ExtendedIP[] = []; + const ipRangesError = 'Error message'; + + afterEach(() => { + handleIPRangeChangeMock.mockClear(); + }); + + it('renders component with title and description', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText(ASSIGN_IPV4_RANGES_TITLE)).toBeInTheDocument(); + expect(getByText(ASSIGN_IPV4_RANGES_DESCRIPTION)).toBeInTheDocument(); + }); + + it('renders error notice if ipRangesError is provided', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText('Error message')).toBeInTheDocument(); + }); + + it('calls handleIPRangeChange when input value changes', async () => { + const { getByText } = renderWithTheme( + + ); + + const button = getByText('Add IPv4 Range'); + expect(button).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx new file mode 100644 index 00000000000..946302aa5db --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx @@ -0,0 +1,86 @@ +import { styled, useTheme } from '@mui/material/styles'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { Divider } from 'src/components/Divider'; +import { Link } from 'src/components/Link'; +import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; +import { Notice } from 'src/components/Notice/Notice'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; +import { + ASSIGN_COMPUTE_INSTANCE_TO_VPC_LINK, + ASSIGN_IPV4_RANGES_DESCRIPTION, + ASSIGN_IPV4_RANGES_TITLE, +} from 'src/features/VPCs/constants'; +import { ExtendedIP } from 'src/utilities/ipUtils'; + +import type { SxProps } from '@mui/material/styles'; + +interface Props { + handleIPRangeChange: (ips: ExtendedIP[]) => void; + includeDescriptionInTooltip?: boolean; + ipRanges: ExtendedIP[]; + ipRangesError?: string; + sx?: SxProps; +} + +export const AssignIPRanges = (props: Props) => { + const { + handleIPRangeChange, + includeDescriptionInTooltip, + ipRanges, + ipRangesError, + sx, + } = props; + + const theme = useTheme(); + + return ( + <> + + {ipRangesError && } + + + {ASSIGN_IPV4_RANGES_TITLE} + + {includeDescriptionInTooltip ? ( + + ) : ( + {IPv4RangesDescriptionJSX} + )} + + + + ); +}; + +const StyledDescription = styled('span')(() => ({ + marginRight: '5px', +})); + +const IPv4RangesDescriptionJSX = ( + <> + {ASSIGN_IPV4_RANGES_DESCRIPTION} + Learn more. + +); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx index 2250290b5df..e4174419378 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx @@ -1,11 +1,22 @@ import { Subnet } from '@linode/api-v4'; -import { fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import * as React from 'react'; +import { QueryClient } from 'react-query'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { linodeFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import { SubnetAssignLinodesDrawer } from './SubnetAssignLinodesDrawer'; +const queryClient = new QueryClient(); + +beforeAll(() => mockMatchMedia()); +afterEach(() => { + queryClient.clear(); +}); + const props = { onClose: vi.fn(), open: true, @@ -15,13 +26,27 @@ const props = { label: 'subnet-1', } as Subnet, vpcId: 1, - vpcRegion: '', + vpcRegion: 'us-east', }; describe('Subnet Assign Linodes Drawer', () => { + const linode = linodeFactory.build({ + label: 'this-linode', + region: props.vpcRegion, + }); + + server.use( + rest.get('*/linode/instances', (req, res, ctx) => { + return res(ctx.json(makeResourcePage([linode]))); + }) + ); + it('should render a subnet assign linodes drawer', () => { - const { getByText, queryByText } = renderWithTheme( - + const { getByText, queryAllByText } = renderWithTheme( + , + { + queryClient, + } ); const header = getByText( @@ -36,14 +61,9 @@ describe('Subnet Assign Linodes Drawer', () => { `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'); + const linodeSelect = queryAllByText('Linode')[0]; 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)'); @@ -52,19 +72,26 @@ describe('Subnet Assign Linodes Drawer', () => { expect(doneButton).toBeVisible(); }); - it('should show the IPv4 textbox when the checkmark is clicked', () => { - const { getByText } = renderWithTheme( - + it.skip('should show the IPv4 textbox when the checkmark is clicked', async () => { + const { findByText, getByLabelText } = renderWithTheme( + , + { + queryClient, + } ); - const checkbox = getByText( + const selectField = getByLabelText('Linode'); + fireEvent.change(selectField, { target: { value: 'this-linode' } }); + + const checkbox = await findByText( 'Auto-assign a VPC IPv4 address for this Linode' ); - expect(checkbox).toBeVisible(); + + await waitFor(() => expect(checkbox).toBeVisible()); fireEvent.click(checkbox); - const ipv4Textbox = getByText('VPC IPv4'); - expect(ipv4Textbox).toBeVisible(); + const ipv4Textbox = await findByText('VPC IPv4'); + await waitFor(() => expect(ipv4Textbox).toBeVisible()); }); it('should close the drawer', () => { diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 153094a6a23..16a2eacc7b9 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -1,4 +1,5 @@ import { appendConfigInterface } from '@linode/api-v4'; +import { useTheme } from '@mui/material/styles'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -12,7 +13,7 @@ import { FormControlLabel } from 'src/components/FormControlLabel'; 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 { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable'; import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; @@ -28,6 +29,7 @@ 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 { ExtendedIP } from 'src/utilities/ipUtils'; import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; import { @@ -35,18 +37,20 @@ import { MULTIPLE_CONFIGURATIONS_MESSAGE, REGIONAL_LINODE_MESSAGE, } from '../constants'; +import { AssignIPRanges } from './AssignIPRanges'; import { StyledButtonBox } from './SubnetAssignLinodesDrawer.styles'; import type { APIError, Config, + Interface, 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 +// putting this in a common file and naming it something like SubnetActionMenuItemProps or something interface SubnetAssignLinodesDrawerProps { onClose: () => void; open: boolean; @@ -57,7 +61,7 @@ interface SubnetAssignLinodesDrawerProps { type LinodeAndConfigData = Linode & { configId: number; - interfaceId: number; + interfaceData: Interface | undefined; linodeConfigLabel: string; }; @@ -72,9 +76,10 @@ export const SubnetAssignLinodesDrawer = ( unassignLinodesErrors, } = useUnassignLinode(); const csvRef = React.useRef(); - const newInterfaceId = React.useRef(-1); + const newInterface = React.useRef(); const removedLinodeId = React.useRef(-1); const formattedDate = useFormattedDate(); + const theme = useTheme(); const [assignLinodesErrors, setAssignLinodesErrors] = React.useState< Record @@ -128,7 +133,7 @@ export const SubnetAssignLinodesDrawer = ( // 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] + // care of the MUI invalid value warning that was occurring before in the Linodes autocomplete [M3-6752] React.useEffect(() => { if (linodes) { setLinodeOptionsToAssign(findUnassignedLinodes() ?? []); @@ -145,7 +150,7 @@ export const SubnetAssignLinodesDrawer = ( } const handleAssignLinode = async () => { - const { chosenIP, selectedConfig, selectedLinode } = values; + const { chosenIP, ipRanges, selectedConfig, selectedLinode } = values; const configId = getConfigId(linodeConfigs, selectedConfig); @@ -154,6 +159,9 @@ export const SubnetAssignLinodesDrawer = ( ); const interfacePayload: InterfacePayload = { + ip_ranges: ipRanges + .map((ipRange) => ipRange.address) + .filter((ipRange) => ipRange !== ''), ipam_address: null, ipv4: { nat_1_1: 'any', // 'any' in all cases here to help the user towards a functional configuration & hide complexity per stakeholder feedback @@ -177,7 +185,7 @@ export const SubnetAssignLinodesDrawer = ( ); } - const newInterface = await appendConfigInterface( + const _newInterface = await appendConfigInterface( selectedLinode?.id ?? -1, configId, interfacePayload @@ -185,8 +193,8 @@ export const SubnetAssignLinodesDrawer = ( // 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; + // interface data without causing a re-render + newInterface.current = _newInterface; await invalidateQueries({ configId, @@ -195,7 +203,32 @@ export const SubnetAssignLinodesDrawer = ( vpcId, }); } catch (errors) { - const errorMap = getErrorMap(['ipv4.vpc'], errors); + const fieldsOfIPRangesErrors = errors.reduce( + (accum: any, _err: { field: string }) => { + if (_err.field && _err.field.includes('ip_ranges[')) { + return [...accum, _err.field]; + } else { + return [...accum]; + } + }, + [] + ); + + const errorMap = getErrorMap( + [...fieldsOfIPRangesErrors, 'ipv4.vpc', 'ip_ranges'], + errors + ); + + const ipRangesWithErrors = ipRanges.map((ipRange, idx) => { + const errorForThisIdx = errorMap[`ip_ranges[${idx}]`]; + return { + address: ipRange.address, + error: errorForThisIdx, + }; + }); + + setFieldValue('ipRanges', ipRangesWithErrors); + const errorMessage = determineErrorMessage(configId, errorMap); setAssignLinodesErrors({ ...errorMap, none: errorMessage }); @@ -203,12 +236,12 @@ export const SubnetAssignLinodesDrawer = ( }; const handleUnassignLinode = async (data: LinodeAndConfigData) => { - const { configId, id: linodeId, interfaceId } = data; + const { configId, id: linodeId, interfaceData } = data; removedLinodeId.current = linodeId; try { await unassignLinode({ configId, - interfaceId, + interfaceId: interfaceData?.id ?? -1, linodeId, subnetId: subnet?.id ?? -1, vpcId, @@ -244,6 +277,7 @@ export const SubnetAssignLinodesDrawer = ( enableReinitialize: true, initialValues: { chosenIP: '', + ipRanges: [] as ExtendedIP[], selectedConfig: null as Config | null, selectedLinode: null as Linode | null, }, @@ -252,6 +286,13 @@ export const SubnetAssignLinodesDrawer = ( validateOnChange: false, }); + const handleIPRangeChange = React.useCallback( + (_ipRanges: ExtendedIP[]) => { + setFieldValue('ipRanges', _ipRanges); + }, + [setFieldValue] + ); + React.useEffect(() => { // Return early if no Linode is selected if (!values.selectedLinode) { @@ -270,7 +311,7 @@ export const SubnetAssignLinodesDrawer = ( const newLinodeData = { ...values.selectedLinode, configId, - interfaceId: newInterfaceId.current, + interfaceData: newInterface?.current, // Create a label that combines Linode label and configuration label (if available) linodeConfigLabel: `${values.selectedLinode.label}${ values.selectedConfig?.label @@ -290,6 +331,7 @@ export const SubnetAssignLinodesDrawer = ( setLinodeConfigs([]); setValues({ chosenIP: '', + ipRanges: [], selectedConfig: null, selectedLinode: null, }); @@ -297,6 +339,7 @@ export const SubnetAssignLinodesDrawer = ( }, [ subnet, assignedLinodesAndConfigData, + values.ipRanges, values.selectedLinode, values.selectedConfig, linodeConfigs, @@ -390,66 +433,90 @@ export const SubnetAssignLinodesDrawer = ( setFieldValue('selectedLinode', selected); setAssignLinodesErrors({}); }} + checkIsOptionEqualToValue disabled={userCannotAssignLinodes} - label={'Linodes'} + label="Linode" // 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" + placeholder="Select Linode or type to search" sx={{ marginBottom: '8px' }} value={values.selectedLinode?.id || null} /> - - - } - label={ - - Auto-assign a VPC IPv4 address for this Linode - - } - data-testid="vpc-ipv4-checkbox" - disabled={userCannotAssignLinodes} - sx={{ marginRight: 0 }} - /> - - - {!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 && ( + {values.selectedLinode?.id && ( <> - - {MULTIPLE_CONFIGURATIONS_MESSAGE}{' '} - - Learn more - - . - - { - setFieldValue('selectedConfig', value); - setAssignLinodesErrors({}); - }} - disabled={userCannotAssignLinodes} - label={'Configuration profile'} - options={linodeConfigs} - placeholder="Select a configuration profile" - value={values.selectedConfig || null} - /> + + + } + label={ + + Auto-assign a VPC IPv4 address for this Linode + + } + data-testid="vpc-ipv4-checkbox" + disabled={userCannotAssignLinodes} + sx={{ marginRight: 0 }} + /> + + + {!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}{' '} + + Learn more + + . + + { + setFieldValue('selectedConfig', value); + setAssignLinodesErrors({}); + }} + disabled={userCannotAssignLinodes} + label={'Configuration profile'} + options={linodeConfigs} + placeholder="Select a configuration profile" + value={values.selectedConfig || null} + /> + + )} + {/* Display the 'Assign additional IPv4 ranges' section if + the Configuration Profile section has been populated, or + if it doesn't display b/c the linode has a single config + */} + {((linodeConfigs.length > 1 && values.selectedConfig) || + linodeConfigs.length === 1) && ( + 1 + ? theme.spacing(2) + : theme.spacing(), + }} + handleIPRangeChange={handleIPRangeChange} + ipRanges={values.ipRanges} + ipRangesError={assignLinodesErrors['ip_ranges']} + /> + )} )} @@ -477,7 +544,7 @@ export const SubnetAssignLinodesDrawer = ( /> )) : null} - { handleUnassignLinode(data as LinodeAndConfigData); setUnassignLinodesErrors([]); @@ -486,6 +553,7 @@ export const SubnetAssignLinodesDrawer = ( noDataText={'No Linodes have been assigned.'} preferredDataLabel="linodeConfigLabel" selectionData={assignedLinodesAndConfigData} + tableHeaders={['Linode', 'VPC IPv4', 'VPC IPv4 Ranges']} /> {assignedLinodesAndConfigData.length > 0 && ( { const handleUnassignLinode = vi.fn(); - it('should display linode label, reboot status, VPC IPv4 address, associated firewalls, and Reboot and Unassign buttons', async () => { + it('should display linode label, reboot status, VPC IPv4 address, associated firewalls, IPv4 chip, and Reboot and Unassign buttons', async () => { const linodeFactory1 = linodeFactory.build({ id: 1, label: 'linode-1' }); server.use( rest.get('*/instances/*/configs', async (req, res, ctx) => { @@ -100,11 +100,15 @@ describe('SubnetLinodeRow', () => { getAllByText('10.0.0.0'); getByText(mockFirewall0); - const rebootLinodeButton = getAllByRole('button')[1]; + const plusChipButton = getAllByRole('button')[1]; + expect(plusChipButton).toHaveTextContent('+1'); + + const rebootLinodeButton = getAllByRole('button')[2]; expect(rebootLinodeButton).toHaveTextContent('Reboot'); fireEvent.click(rebootLinodeButton); expect(handlePowerActionsLinode).toHaveBeenCalled(); - const unassignLinodeButton = getAllByRole('button')[2]; + + const unassignLinodeButton = getAllByRole('button')[3]; expect(unassignLinodeButton).toHaveTextContent('Unassign Linode'); fireEvent.click(unassignLinodeButton); expect(handleUnassignLinode).toHaveBeenCalled(); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index 43977566633..be9474d2da9 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -22,6 +22,7 @@ import { useLinodeQuery, } from 'src/queries/linodes/linodes'; import { capitalizeAllWords } from 'src/utilities/capitalize'; +import { determineNoneSingleOrMultipleWithChip } from 'src/utilities/noneSingleOrMultipleWithChip'; import { NETWORK_INTERFACES_GUIDE_URL, @@ -204,6 +205,16 @@ export const SubnetLinodeRow = (props: Props) => { )} + + + {getIPRangesCellContents( + configs ?? [], + configsLoading, + subnetId, + configsError ?? undefined + )} + + {getFirewallsCellString( @@ -294,6 +305,30 @@ const getIPv4Link = (configInterface: Interface | undefined): JSX.Element => { ); }; +const getIPRangesCellContents = ( + configs: Config[], + loading: boolean, + subnetId: number, + error?: APIError[] +): JSX.Element | string => { + if (loading) { + return 'Loading...'; + } + + if (error) { + return 'Error retrieving VPC IPv4s'; + } + + if (configs.length === 0) { + return 'None'; + } + + const configInterface = getSubnetInterfaceFromConfigs(configs, subnetId); + return determineNoneSingleOrMultipleWithChip( + configInterface?.ip_ranges ?? [] + ); +}; + const getFirewallLinks = (data: Firewall[]): JSX.Element => { const firstThreeFirewalls = data.slice(0, 3); return ( @@ -325,6 +360,9 @@ export const SubnetLinodeTableRowHead = ( VPC IPv4 + + VPC IPv4 Ranges + Firewalls diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index 6f3dca58717..952ad1efdf0 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -10,7 +10,7 @@ import { Box } from 'src/components/Box'; import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; -import { RemovableSelectionsList } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; +import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable'; import { SUBNET_UNASSIGN_LINODES_WARNING } from 'src/features/VPCs/constants'; import { useFormattedDate } from 'src/hooks/useFormattedDate'; import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; @@ -25,6 +25,7 @@ import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; import type { APIError, DeleteLinodeConfigInterfacePayload, + Interface, Linode, UpdateConfigInterfacePayload, } from '@linode/api-v4'; @@ -37,6 +38,12 @@ interface Props { vpcId: number; } +interface ConfigInterfaceAndLinodeData extends Linode { + configId: number; + interfaceData: Interface; + interfaceId: number; +} + export const SubnetUnassignLinodesDrawer = React.memo( ({ onClose, open, singleLinodeToBeUnassigned, subnet, vpcId }: Props) => { const { data: profile } = useProfile(); @@ -53,9 +60,15 @@ export const SubnetUnassignLinodesDrawer = React.memo( const csvRef = React.useRef(); const formattedDate = useFormattedDate(); + const [selectedLinodes, setSelectedLinodes] = React.useState( singleLinodeToBeUnassigned ? [singleLinodeToBeUnassigned] : [] ); + const [ + selectedLinodesAndConfigData, + setSelectedLinodesAndConfigData, + ] = React.useState([]); + const hasError = React.useRef(false); // This flag is used to prevent the drawer from closing if an error occurs. const [ @@ -126,9 +139,11 @@ export const SubnetUnassignLinodesDrawer = React.memo( } return { + ...linode, configId: configWithVpcInterface.id, + + interfaceData: vpcInterface, interfaceId: vpcInterface.id, - linodeId: linode.id, }; } return null; @@ -136,12 +151,26 @@ export const SubnetUnassignLinodesDrawer = React.memo( ); // Filter out any null values and ensure item conforms to type using `is` type guard. - const filteredConfigInterfaces = updatedConfigInterfaces.filter( + const _selectedLinodesAndConfigData = updatedConfigInterfaces.filter( + (item): item is ConfigInterfaceAndLinodeData => item !== null + ); + + // Remove interface property for the DeleteLinodeConfigInterfacePayload data + const _updatedConfigInterfaces = updatedConfigInterfaces.map( + (item) => ({ + configId: item?.configId, + interfaceId: item?.interfaceId, + linodeId: item?.id, + }) + ); + + const filteredConfigInterfaces = _updatedConfigInterfaces.filter( (item): item is DeleteLinodeConfigInterfacePayload => item !== null ); // Update the state with the new data setConfigInterfacesToDelete([...filteredConfigInterfaces]); + setSelectedLinodesAndConfigData([..._selectedLinodesAndConfigData]); } catch (error) { // Capture errors if the promise.all fails hasError.current = true; @@ -168,15 +197,24 @@ export const SubnetUnassignLinodesDrawer = React.memo( csvRef.current.link.click(); }; - const handleRemoveLinode = (optionToRemove: Linode) => { + const handleRemoveLinode = ( + optionToRemove: ConfigInterfaceAndLinodeData + ) => { setSelectedLinodes((prevSelectedLinodes) => prevSelectedLinodes.filter((option) => option.id !== optionToRemove.id) ); + setConfigInterfacesToDelete((prevInterfacesToDelete) => prevInterfacesToDelete.filter( (option) => option.linodeId !== optionToRemove.id ) ); + + setSelectedLinodesAndConfigData((prevSelectedLinodesAndConfigData) => + prevSelectedLinodesAndConfigData.filter( + (option) => option.id !== optionToRemove.id + ) + ); }; const processUnassignLinodes = async () => { @@ -284,15 +322,16 @@ export const SubnetUnassignLinodesDrawer = React.memo( /> )} ({ marginTop: theme.spacing(2) })}> - - {selectedLinodes.length > 0 && ( + {selectedLinodesAndConfigData.length > 0 && ( )} { + it('should return None for empty arrays', () => { + expect(determineNoneSingleOrMultipleWithChip([])).toEqual('None'); + }); + + it('should return the element if the array only consists of one element', () => { + const array = ['Test']; + + expect(determineNoneSingleOrMultipleWithChip(array)).toEqual(array[0]); + }); + + it('should not return "None" nor equal the first element of the array if the array contains multiple elements', () => { + const array = ['Test', 'Test 2', 'Test 3', 'Test 4']; + + const returned = determineNoneSingleOrMultipleWithChip(array); + + expect(returned).not.toEqual('None'); + expect(returned).not.toEqual('Test'); + }); +}); diff --git a/packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx b/packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx new file mode 100644 index 00000000000..67b58fb2dcc --- /dev/null +++ b/packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; + +import { Chip } from 'src/components/Chip'; +import { StyledItemWithPlusChip } from 'src/components/RemovableSelectionsList/RemovableSelectionsList.style'; +import { Tooltip } from 'src/components/Tooltip'; + +export const remainingDataLengthChip = 'remaining-data-length-chip'; + +export const determineNoneSingleOrMultipleWithChip = ( + dataArray: string[] +): JSX.Element | string => { + if (dataArray.length === 0) { + return 'None'; + } + + if (dataArray.length === 1) { + return dataArray[0]; + } + + const allDataExceptFirstElement = dataArray.slice(1); + + const remainingData = allDataExceptFirstElement.map((datum) => ( + <> + {datum} +
+ + )); + + return ( + + {dataArray[0]}{' '} + + + + + ); +}; diff --git a/packages/manager/src/utilities/subnets.ts b/packages/manager/src/utilities/subnets.ts index 44ad6d4449a..cc14a84eb0c 100644 --- a/packages/manager/src/utilities/subnets.ts +++ b/packages/manager/src/utilities/subnets.ts @@ -7,6 +7,8 @@ export const SUBNET_LINODE_CSV_HEADERS = [ { key: 'label', label: 'Linode Label' }, { key: 'id', label: 'Linode ID' }, { key: 'ipv4', label: 'IPv4' }, + { key: 'interfaceData.ipv4.vpc', label: 'IPv4 VPC' }, + { key: 'interfaceData.ip_ranges', label: 'IPv4 VPC Ranges' }, ]; // @TODO VPC: added ipv6 related fields here, but they will not be used until VPCs support ipv6 diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 25bb28ea05b..9ba366e402f 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2024-02-13] - v0.40.0 + + +### Changed: + +- ip_ranges field in LinodeInterfaceSchema no longer limited to 1 element ([#10089](https://github.com/linode/manager/pull/10089)) + ## [2024-02-05] - v0.39.0 @@ -5,6 +12,7 @@ - Add `path_regex` as a valid ACLB rule match field ([#10126](https://github.com/linode/manager/pull/10126)) + ## [2024-01-22] - v0.38.0 diff --git a/packages/validation/package.json b/packages/validation/package.json index 26b568958f3..3d5d2f5e275 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.39.0", + "version": "0.40.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", @@ -68,4 +68,4 @@ }, "author": "Linode LLC", "license": "Apache-2.0" -} +} \ No newline at end of file diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 40834ed8052..2c9948e9679 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -195,7 +195,7 @@ export const LinodeInterfaceSchema = object().shape({ .of(string()) .when('purpose', { is: 'vpc', - then: array().of(string().test(validateIP)).max(1).notRequired(), + then: array().of(string().test(validateIP)).notRequired(), otherwise: array().test({ name: testnameDisallowedBasedOnPurpose('VPC'), message: testmessageDisallowedBasedOnPurpose('vpc', 'ip_ranges'),