From b897726631ff01231c6fb5dad8a207cae57a206a Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Fri, 26 Jan 2024 16:58:11 -0500 Subject: [PATCH 01/12] feat: [M3-7644] - Add VPC IPv4 address and range to Linode IP Address Table (#10108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description πŸ“ Add support for displaying VPC IPv4 addresses and ranges in the Linode Details -> Network tab -> "IP Addresses" table ## Changes πŸ”„ List any change relevant to the reviewer. - Add Support for VPC IPv4 addresses and ranges to the Linode IP Address table - If there is a VPC interface with 1:1 NAT, hide the Public IPv4 IP address row - Improve mobile text display of the Type column ## How to test πŸ§ͺ ### Prerequisites (How to setup test environment) - Have the `vpc-extra-beta` tag on your account and a VPC with at least one Subnet - Assign a Linode to the Subnet and check both the checkboxes (Auto-assign a VPC IPv4 address & Assign a public IPv4 address) - To add `ip_ranges`, check out https://github.com/linode/manager/pull/10089 ### Verification steps (How to verify changes) - Go to the Linode Details -> Network tab of the assigned Linode and check the IP Addresses table - You should see IP Address rows for `IPv4 – VPC`, `VPC IPv4 – NAT`, and `IPv4 – VPC – Range` for each range - You should _not_ see an IP address row for the type `IPv4 - Public` ``` yarn test LinodeIPAddressRow ``` --- .../pr-10108-added-1706218083195.md | 5 ++ .../factories/linodeConfigInterfaceFactory.ts | 2 +- .../LinodeIPAddressRow.test.tsx | 44 ++++++++++++- .../LinodeNetworking/LinodeIPAddressRow.tsx | 6 +- .../LinodeNetworking/LinodeIPAddresses.tsx | 62 +++++++++++++++++-- .../LinodeNetworkingActionMenu.tsx | 13 ++-- .../LinodesDetail/LinodeNetworking/types.ts | 5 +- 7 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 packages/manager/.changeset/pr-10108-added-1706218083195.md diff --git a/packages/manager/.changeset/pr-10108-added-1706218083195.md b/packages/manager/.changeset/pr-10108-added-1706218083195.md new file mode 100644 index 00000000000..b4cdd37e2d0 --- /dev/null +++ b/packages/manager/.changeset/pr-10108-added-1706218083195.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +VPC IPv4 address and range to Linode IP Address Table ([#10108](https://github.com/linode/manager/pull/10108)) 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/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 fa3a1b83735..39002b778c5 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'; From f35b24930026c7e3538bcba9f40e64f8c9b0e685 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Fri, 26 Jan 2024 16:58:31 -0500 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20[M3-7641]=20=E2=80=93=20Support?= =?UTF-8?q?=20IPv4=20ranges=20in=20VPC=20"Assign=20Linodes"=20drawer=20(#1?= =?UTF-8?q?0089)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pr-10089-added-1706135120889.md | 5 + .../MultipleIPInput/MultipleIPInput.tsx | 38 +++- .../src/components/PrimaryNav/PrimaryNav.tsx | 2 +- .../RemovableSelectionsList.style.ts | 7 + .../RemovableSelectionsListTable.tsx | 133 ++++++++++++ .../Linodes/LinodeSelect/LinodeSelect.tsx | 11 + .../VPCs/VPCDetail/AssignIPRanges.test.tsx | 54 +++++ .../VPCs/VPCDetail/AssignIPRanges.tsx | 55 +++++ .../SubnetAssignLinodesDrawer.test.tsx | 65 ++++-- .../VPCDetail/SubnetAssignLinodesDrawer.tsx | 204 ++++++++++++------ .../src/features/VPCs/VPCDetail/VPCDetail.tsx | 4 +- .../features/VPCs/VPCLanding/VPCLanding.tsx | 4 +- .../manager/src/features/VPCs/constants.ts | 5 + .../noneSingleOrMultipleWithChip.test.tsx | 22 ++ .../noneSingleOrMultipleWithChip.tsx | 42 ++++ .../pr-10089-changed-1706135447361.md | 5 + packages/validation/src/linodes.schema.ts | 2 +- 17 files changed, 556 insertions(+), 102 deletions(-) create mode 100644 packages/manager/.changeset/pr-10089-added-1706135120889.md create mode 100644 packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx create mode 100644 packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.test.tsx create mode 100644 packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx create mode 100644 packages/manager/src/utilities/noneSingleOrMultipleWithChip.test.tsx create mode 100644 packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx create mode 100644 packages/validation/.changeset/pr-10089-changed-1706135447361.md diff --git a/packages/manager/.changeset/pr-10089-added-1706135120889.md b/packages/manager/.changeset/pr-10089-added-1706135120889.md new file mode 100644 index 00000000000..308323c4d71 --- /dev/null +++ b/packages/manager/.changeset/pr-10089-added-1706135120889.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Support for IPv4 Ranges in VPC 'Assign Linodes to subnet' drawer ([#10089](https://github.com/linode/manager/pull/10089)) diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 9d92323b8e4..3f9b0446a09 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 ? ( @@ -177,27 +198,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/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 354c427f634..4382a4ad74c 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -195,7 +195,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !showVPCs, href: '/vpcs', icon: , - isBeta: true, + isBeta: flags.vpc, // @TODO VPC: after VPC enters GA, remove this property entirely }, { display: 'Firewalls', 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..afb213e5801 --- /dev/null +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx @@ -0,0 +1,133 @@ +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" + > + + + )} + + + )) + ); + + return ( + <> + {headerText} + + + + {tableHeaders.map((thisHeader) => ( + + {thisHeader} + + ))} + + + + {selectedOptionsJSX} +
+ + ); +}; 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/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..2c806dd507a --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx @@ -0,0 +1,55 @@ +import { styled } from '@mui/material/styles'; +import * as React from 'react'; + +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 { Typography } from 'src/components/Typography'; +import { + 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; + ipRanges: ExtendedIP[]; + ipRangesError?: string; + sx?: SxProps; +} + +export const AssignIPRanges = (props: Props) => { + const { handleIPRangeChange, ipRanges, ipRangesError, sx } = props; + + return ( + <> + + {ipRangesError && } + ({ fontFamily: theme.font.bold })}> + {ASSIGN_IPV4_RANGES_TITLE} + + + {ASSIGN_IPV4_RANGES_DESCRIPTION} + + Learn more + + . + + + + ); +}; + +const StyledDescription = styled('span')(() => ({ + marginRight: '5px', +})); 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 1e9b3d35fa3..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,10 +13,11 @@ 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'; +import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { defaultPublicInterface } from 'src/features/Linodes/LinodesCreate/LinodeCreate'; import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP, @@ -27,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 { @@ -34,19 +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'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; // @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 { data: vpc, error, isLoading } = useVPCQuery(+vpcId); const { data: regions } = useRegionsQuery(); + const flags = useFlags(); const [editVPCDrawerOpen, setEditVPCDrawerOpen] = React.useState(false); const [deleteVPCDialogOpen, setDeleteVPCDialogOpen] = React.useState(false); @@ -112,7 +114,7 @@ const VPCDetail = () => { labelOptions: { noCap: true }, pathname: `/vpcs/${vpc.label}`, }} - betaFeedbackLink={VPC_FEEDBACK_FORM_URL} + betaFeedbackLink={flags.vpc ? VPC_FEEDBACK_FORM_URL : undefined} // @TODO VPC: remove this once VPC goes into GA docsLabel="Docs" docsLink={VPC_DOCS_LINK} /> diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx index f32fef1af9c..c1f108a6920 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx @@ -18,6 +18,7 @@ import { VPC_FEEDBACK_FORM_URL, VPC_LABEL, } from 'src/features/VPCs/constants'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useVPCsQuery } from 'src/queries/vpcs'; @@ -55,6 +56,7 @@ const VPCLanding = () => { ); const history = useHistory(); + const flags = useFlags(); const [selectedVPC, setSelectedVPC] = React.useState(); @@ -96,7 +98,7 @@ const VPCLanding = () => { return ( <> { + 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/validation/.changeset/pr-10089-changed-1706135447361.md b/packages/validation/.changeset/pr-10089-changed-1706135447361.md new file mode 100644 index 00000000000..1ac75811631 --- /dev/null +++ b/packages/validation/.changeset/pr-10089-changed-1706135447361.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +ip_ranges field in LinodeInterfaceSchema no longer limited to 1 element ([#10089](https://github.com/linode/manager/pull/10089)) 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'), From d1649a581d87914237d1a12bc7e7239760b892d3 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Mon, 29 Jan 2024 10:18:48 -0500 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20[M3-7642]=20=E2=80=93=20Support?= =?UTF-8?q?=20IPv4=20Ranges=20data=20in=20"Unassign=20Linodes"=20drawer=20?= =?UTF-8?q?(#10114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description πŸ“ Support IPv4 Ranges data in "Unassign Linodes" drawer. ## Changes πŸ”„ - Use `RemovableSelectionsListTable` in Unassign drawer to display VPC IPv4 and VPC IPv4 Ranges data - Added IPv4 VPC and ranges to CSV. Simplified out data structure to mimic subnet assign drawer ## How to test πŸ§ͺ ### Verification steps Have the `vpc-extra-beta` tag on your account and a VPC containing at least one subnet with a linode assigned to it. Confirm that: - You see a table in the "Linodes to be Unassigned from Subnet" section - When you select a linode, the table is populated with its data - If you select one with no IPv4 Ranges, you should see "None" in that cell - Clicking the "X" for that table row removes it (and clicking "X" in the LinodeSelect should clear all selections from the table) - Ensure both assign/unassign download CSV data contains IPv4 VPC address and it's ranges (if you added any) >[!note] When adding ranges, you'll want to narrow things to `/32`, IE: `10.0.4.1/32` --------- Co-authored-by: Dajahi Wiley Co-authored-by: Jaalah Ramos --- .../pr-10114-added-1706289133795.md | 5 ++ .../VPCDetail/SubnetUnassignLinodesDrawer.tsx | 57 ++++++++++++++++--- packages/manager/src/utilities/subnets.ts | 2 + 3 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 packages/manager/.changeset/pr-10114-added-1706289133795.md diff --git a/packages/manager/.changeset/pr-10114-added-1706289133795.md b/packages/manager/.changeset/pr-10114-added-1706289133795.md new file mode 100644 index 00000000000..2ce5f72c95a --- /dev/null +++ b/packages/manager/.changeset/pr-10114-added-1706289133795.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Support for VPC IPv4 Ranges data in Unassign Linodes drawer ([#10114](https://github.com/linode/manager/pull/10114)) diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index 6f3dca58717..c4fef9e7282 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 && ( Date: Mon, 29 Jan 2024 11:12:56 -0500 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20[M3-7643]=20=E2=80=93=20Support?= =?UTF-8?q?=20VPC=20IPv4=20Ranges=20in=20Linode=20Create=20flow=20(#10116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description πŸ“ Add support for VPC IPv4 Ranges in the Linode Create flow. - "Add additional IPv4 ranges" section added in VPC panel - Logic to include `ip_ranges` in creation payload The inclusion of a "VPC IPv4 Ranges" column in the VPC Detail > Subnets inner table as per the mockups slipped through the cracks during ticket creation, but I noticed it and included it in this PR (to try to keep things more consolidated). ## How to test πŸ§ͺ ### Verification steps With the `vpc-extra-beta` tag on your account, enter the Linode Create flow and confirm: - The "Add additional IPv4 ranges" section appears in the VPC panel after a subnet is selected - You can add and remove IP input fields using the "Add IPv4 Range" and the "X" buttons, respectively - When you click "Create Linode," the POST request contains the IP ranges you specified in the form in the `ip_ranges` property - Once the linode is created, go to the detail page of the VPC you assigned the linode to. Expand the inner subnet table and observe the "VPC IPv4 Ranges" column is populated with what you provided (or "None" if you didn't provide any). --------- Co-authored-by: Dajahi Wiley Co-authored-by: Jaalah Ramos Co-authored-by: Hana Xu --- .../pr-10116-added-1706293067938.md | 5 + .../MultipleIPInput/MultipleIPInput.tsx | 1 + .../Linodes/LinodesCreate/LinodeCreate.tsx | 8 + .../LinodesCreate/LinodeCreateContainer.tsx | 10 +- .../Linodes/LinodesCreate/VPCPanel.test.tsx | 11 +- .../Linodes/LinodesCreate/VPCPanel.tsx | 182 ++++++++++-------- .../LinodeSettings/InterfaceSelect.tsx | 2 + .../VPCs/VPCDetail/SubnetLinodeRow.test.tsx | 10 +- .../VPCs/VPCDetail/SubnetLinodeRow.tsx | 38 ++++ 9 files changed, 183 insertions(+), 84 deletions(-) create mode 100644 packages/manager/.changeset/pr-10116-added-1706293067938.md diff --git a/packages/manager/.changeset/pr-10116-added-1706293067938.md b/packages/manager/.changeset/pr-10116-added-1706293067938.md new file mode 100644 index 00000000000..b95ebe9a78c --- /dev/null +++ b/packages/manager/.changeset/pr-10116-added-1706293067938.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +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)) diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 3f9b0446a09..63642a7184e 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -177,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} > diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 1f7b58a7e4b..cad641822ed 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -99,8 +99,10 @@ import { } from './types'; import type { Tab } from 'src/components/Tabs/TabLinkList'; +import { ExtendedIP } from 'src/utilities/ipUtils'; export interface LinodeCreateProps { + additionalIPv4RangesForVPC: ExtendedIP[]; assignPublicIPv4Address: boolean; autoassignIPv4WithinVPC: boolean; checkValidation: LinodeCreateValidation; @@ -108,6 +110,7 @@ export interface LinodeCreateProps { firewallId?: number; handleAgreementChange: () => void; handleFirewallChange: (firewallId: number) => void; + handleIPv4RangesForVPC: (ranges: ExtendedIP[]) => void; handleShowApiAwarenessModal: () => void; handleSubmitForm: HandleSubmit; handleSubnetChange: (subnetId: number) => void; @@ -641,9 +644,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} @@ -822,6 +827,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 c666a2d414f..3e46c43e8ac 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -69,6 +69,7 @@ import { } from 'src/utilities/formatRegion'; import { isEURegion } from 'src/utilities/formatRegion'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { validatePassword } from 'src/utilities/validatePassword'; @@ -85,11 +86,12 @@ import type { LinodeTypeClass, PriceObject, } from '@linode/api-v4/lib/linodes'; -import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; +import { ExtendedIP } from 'src/utilities/ipUtils'; const DEFAULT_IMAGE = 'linode/debian11'; interface State { + additionalIPv4RangesForVPC: ExtendedIP[]; assignPublicIPv4Address: boolean; attachedVLANLabel: null | string; authorized_users: string[]; @@ -140,6 +142,7 @@ type CombinedProps = WithSnackbarProps & WithAccountSettingsProps; const defaultState: State = { + additionalIPv4RangesForVPC: [], assignPublicIPv4Address: false, attachedVLANLabel: '', authorized_users: [], @@ -277,6 +280,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} @@ -515,6 +519,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..31dafd60d7d 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; @@ -50,9 +54,11 @@ const ERROR_GROUP_STRING = 'vpc-errors'; export const VPCPanel = (props: VPCPanelProps) => { const { + additionalIPv4RangesForVPC, assignPublicIPv4Address, autoassignIPv4WithinVPC, from, + handleIPv4RangeChange, handleSelectVPC, handleSubnetChange, handleVPCIPv4Change, @@ -247,87 +253,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/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index 6a299c39682..c0f768ba5c1 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -386,9 +386,11 @@ export const InterfaceSelect = (props: CombinedProps) => { toggleAutoassignIPv4WithinVPCEnabled={() => setAutoAssignVPCIPv4((autoAssignVPCIPv4) => !autoAssignVPCIPv4) } + additionalIPv4RangesForVPC={[]} // @TODO VPC: temporary placeholder to before M3-7645 is worked on to prevent errors assignPublicIPv4Address={autoAssignLinodeIPv4} autoassignIPv4WithinVPC={autoAssignVPCIPv4} from="linodeConfig" + handleIPv4RangeChange={() => null} // @TODO VPC: temporary placeholder to before M3-7645 is worked on to prevent errors handleSelectVPC={handleVPCLabelChange} handleSubnetChange={handleSubnetChange} handleVPCIPv4Change={handleVPCIPv4Input} diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx index 279ed791858..5d2c103b148 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx @@ -55,7 +55,7 @@ describe('SubnetLinodeRow', () => { 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 From 7c26b523d087073bee73952bea99ae6825512692 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos Date: Mon, 29 Jan 2024 12:29:12 -0500 Subject: [PATCH 05/12] Bump versions and changelog --- .../manager/.changeset/pr-10089-added-1706135120889.md | 5 ----- .../manager/.changeset/pr-10108-added-1706218083195.md | 5 ----- .../manager/.changeset/pr-10114-added-1706289133795.md | 5 ----- .../manager/.changeset/pr-10116-added-1706293067938.md | 5 ----- packages/manager/CHANGELOG.md | 10 ++++++++++ packages/manager/package.json | 2 +- .../.changeset/pr-10089-changed-1706135447361.md | 5 ----- packages/validation/CHANGELOG.md | 7 +++++++ packages/validation/package.json | 2 +- 9 files changed, 19 insertions(+), 27 deletions(-) delete mode 100644 packages/manager/.changeset/pr-10089-added-1706135120889.md delete mode 100644 packages/manager/.changeset/pr-10108-added-1706218083195.md delete mode 100644 packages/manager/.changeset/pr-10114-added-1706289133795.md delete mode 100644 packages/manager/.changeset/pr-10116-added-1706293067938.md delete mode 100644 packages/validation/.changeset/pr-10089-changed-1706135447361.md diff --git a/packages/manager/.changeset/pr-10089-added-1706135120889.md b/packages/manager/.changeset/pr-10089-added-1706135120889.md deleted file mode 100644 index 308323c4d71..00000000000 --- a/packages/manager/.changeset/pr-10089-added-1706135120889.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Support for IPv4 Ranges in VPC 'Assign Linodes to subnet' drawer ([#10089](https://github.com/linode/manager/pull/10089)) diff --git a/packages/manager/.changeset/pr-10108-added-1706218083195.md b/packages/manager/.changeset/pr-10108-added-1706218083195.md deleted file mode 100644 index b4cdd37e2d0..00000000000 --- a/packages/manager/.changeset/pr-10108-added-1706218083195.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -VPC IPv4 address and range to Linode IP Address Table ([#10108](https://github.com/linode/manager/pull/10108)) diff --git a/packages/manager/.changeset/pr-10114-added-1706289133795.md b/packages/manager/.changeset/pr-10114-added-1706289133795.md deleted file mode 100644 index 2ce5f72c95a..00000000000 --- a/packages/manager/.changeset/pr-10114-added-1706289133795.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Support for VPC IPv4 Ranges data in Unassign Linodes drawer ([#10114](https://github.com/linode/manager/pull/10114)) diff --git a/packages/manager/.changeset/pr-10116-added-1706293067938.md b/packages/manager/.changeset/pr-10116-added-1706293067938.md deleted file mode 100644 index b95ebe9a78c..00000000000 --- a/packages/manager/.changeset/pr-10116-added-1706293067938.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -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)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 4c688dd1b16..dc7db6ff50f 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,16 @@ 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-01-31] - v1.111.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)) + ## [2024-01-22] - v1.110.0 diff --git a/packages/manager/package.json b/packages/manager/package.json index 17f03b88f38..d6002c0423a 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.110.0", + "version": "1.111.0", "private": true, "type": "module", "bugs": { diff --git a/packages/validation/.changeset/pr-10089-changed-1706135447361.md b/packages/validation/.changeset/pr-10089-changed-1706135447361.md deleted file mode 100644 index 1ac75811631..00000000000 --- a/packages/validation/.changeset/pr-10089-changed-1706135447361.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Changed ---- - -ip_ranges field in LinodeInterfaceSchema no longer limited to 1 element ([#10089](https://github.com/linode/manager/pull/10089)) diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index ec3caf79aa4..ec7fdca23f5 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2024-01-31] - v0.39.0 + + +### Changed: + +- ip_ranges field in LinodeInterfaceSchema no longer limited to 1 element ([#10089](https://github.com/linode/manager/pull/10089)) + ## [2024-01-22] - v0.38.0 diff --git a/packages/validation/package.json b/packages/validation/package.json index 06383f7e063..26b568958f3 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.38.0", + "version": "0.39.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", From 37e2a4a25761de9d90167e607cde6a895ceb827f Mon Sep 17 00:00:00 2001 From: Joe D'Amore Date: Mon, 29 Jan 2024 13:46:36 -0500 Subject: [PATCH 06/12] Update test text field label from "Linodes" to "Linode" --- .../manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0836de3659f..70dd130e395 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) From 6bd2f42ab04730ddcc74893fd5600af53538dd26 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos Date: Mon, 29 Jan 2024 13:52:27 -0500 Subject: [PATCH 07/12] Fix IP ranges table spacing and unassign CSV download wording --- .../RemovableSelectionsListTable.tsx | 22 ++++++++++++------- .../VPCDetail/SubnetUnassignLinodesDrawer.tsx | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx index afb213e5801..64ee4b8712b 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx @@ -112,19 +112,25 @@ export const RemovableSelectionsListTable = ( )) ); + const tableHeadersJSX = tableHeaders.map((thisHeader, idx) => { + const lastHeader = idx === tableHeaders.length - 1; + + return ( + + {thisHeader} + + ); + }); + return ( <> {headerText} - - {tableHeaders.map((thisHeader) => ( - - {thisHeader} - - ))} - - + {tableHeadersJSX} {selectedOptionsJSX}
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index c4fef9e7282..952ad1efdf0 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -346,7 +346,7 @@ export const SubnetUnassignLinodesDrawer = React.memo( filename={`linodes-unassigned-${formattedDate}.csv`} headers={SUBNET_LINODE_CSV_HEADERS} onClick={downloadCSV} - text={'Download List of Unassigned Linodes (.csv)'} + text={'Download List of Linodes to be Unassigned (.csv)'} /> )} Date: Mon, 12 Feb 2024 12:02:46 -0500 Subject: [PATCH 08/12] Update version numbers --- packages/manager/CHANGELOG.md | 2 +- packages/validation/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index dc7db6ff50f..697d8a3aac5 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,7 +4,7 @@ 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-01-31] - v1.111.0 +## [2024-02-12] - v1.112.0 ### Added: diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index ec7fdca23f5..4ff5fedb19d 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,4 +1,4 @@ -## [2024-01-31] - v0.39.0 +## [2024-02-12] - v0.40.0 ### Changed: From 9d5fad53e9479d97651d836a1f649d4e1e782b24 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Mon, 12 Feb 2024 12:30:49 -0500 Subject: [PATCH 09/12] fix: [M3-7739, M3-7741, M3-7746, M3-7747] - Cherry pick zero price fixes for release (#10177) * fix: [M3-7741] - Hide error notices for $0 regions in Resize Pool and Add a Node Pool drawers (#10157) * Allow -zsh LKE prices without error notices in Resize Pool and Add Pool drawers * Fix loading spinner displaying above what was supposed to be loading * Fix conditional to render notice if either price is invalid * Add test coverage * Added changeset: Hide error notices for /bin/sh regions for LKE Resize and Add Node Pools * Fix changeset wording * Address feedback: use invalid price util * fix: [M3-7746] - Fix $0 region price error in "Enable All Backups" drawer (#10161) * Remove error indicator for Linodes in $0 regions * Fix $0 total price display issue * Cover $0 pricing cases in Cypress backup tests * Add BackupLinodeRow tests to account for error states and $0 regions * Add unit tests for BackupDrawer component * fix: [M3-7747] - Fix Linode Migration dialog hidden $0 price (#10166) * Add unit tests for MigrationPricing component * Accounting for $0 prices in MigrationPricing component * fix: [M3-7739] - Fix error when enabling backups for Linodes in regions with $0 price (#10153) * Fix error when enabling backups for Linodes in regions with $0 price * Add unit tests for EnableBackupsDialog --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Replace "toBeDisabled" with "toHaveAttribute" assertion --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- .../pr-10153-fixed-1707249479775.md | 5 + .../pr-10157-fixed-1707328749030.md | 5 + .../pr-10161-fixed-1707341493849.md | 5 + .../pr-10166-fixed-1707414781493.md | 5 + .../e2e/core/kubernetes/lke-update.spec.ts | 225 ++++++++++++++++++ .../e2e/core/linodes/backup-linode.spec.ts | 19 +- .../support/constants/dc-specific-pricing.ts | 10 +- packages/manager/src/factories/index.ts | 1 + .../features/Backups/BackupDrawer.test.tsx | 172 +++++++++++++ .../src/features/Backups/BackupDrawer.tsx | 4 +- .../features/Backups/BackupLinodeRow.test.tsx | 67 ++++++ .../src/features/Backups/BackupLinodeRow.tsx | 7 +- .../NodePoolsDisplay/AddNodePoolDrawer.tsx | 10 +- .../ResizeNodePoolDrawer.test.tsx | 13 +- .../NodePoolsDisplay/ResizeNodePoolDrawer.tsx | 124 +++++----- .../NodePoolsDisplay/utils.test.ts | 19 ++ .../NodePoolsDisplay/utils.ts | 13 + .../LinodeBackup/EnableBackupsDialog.test.tsx | 138 +++++++++++ .../LinodeBackup/EnableBackupsDialog.tsx | 7 +- .../MigrateLinode/MigrationPricing.test.tsx | 169 +++++++++++++ .../MigrateLinode/MigrationPricing.tsx | 18 +- .../src/utilities/pricing/backups.test.tsx | 33 +++ .../manager/src/utilities/pricing/backups.ts | 11 +- 23 files changed, 990 insertions(+), 90 deletions(-) create mode 100644 packages/manager/.changeset/pr-10153-fixed-1707249479775.md create mode 100644 packages/manager/.changeset/pr-10157-fixed-1707328749030.md create mode 100644 packages/manager/.changeset/pr-10161-fixed-1707341493849.md create mode 100644 packages/manager/.changeset/pr-10166-fixed-1707414781493.md create mode 100644 packages/manager/src/features/Backups/BackupDrawer.test.tsx create mode 100644 packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts create mode 100644 packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx create mode 100644 packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.test.tsx diff --git a/packages/manager/.changeset/pr-10153-fixed-1707249479775.md b/packages/manager/.changeset/pr-10153-fixed-1707249479775.md new file mode 100644 index 00000000000..4de7dd8fc46 --- /dev/null +++ b/packages/manager/.changeset/pr-10153-fixed-1707249479775.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Error when enabling backups for Linodes in regions with $0 pricing ([#10153](https://github.com/linode/manager/pull/10153)) diff --git a/packages/manager/.changeset/pr-10157-fixed-1707328749030.md b/packages/manager/.changeset/pr-10157-fixed-1707328749030.md new file mode 100644 index 00000000000..3495d7c8dd3 --- /dev/null +++ b/packages/manager/.changeset/pr-10157-fixed-1707328749030.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Error notices for $0 regions in LKE Resize and Add Node Pools drawers ([#10157](https://github.com/linode/manager/pull/10157)) diff --git a/packages/manager/.changeset/pr-10161-fixed-1707341493849.md b/packages/manager/.changeset/pr-10161-fixed-1707341493849.md new file mode 100644 index 00000000000..156c095a6cc --- /dev/null +++ b/packages/manager/.changeset/pr-10161-fixed-1707341493849.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Error in Enable All Backups drawer when one or more Linode is in a $0 region ([#10161](https://github.com/linode/manager/pull/10161)) diff --git a/packages/manager/.changeset/pr-10166-fixed-1707414781493.md b/packages/manager/.changeset/pr-10166-fixed-1707414781493.md new file mode 100644 index 00000000000..ff63281d847 --- /dev/null +++ b/packages/manager/.changeset/pr-10166-fixed-1707414781493.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Display $0.00 prices in Linode Migration dialog ([#10166](https://github.com/linode/manager/pull/10166)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index 258b469209c..bc5b2e91c21 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1011,4 +1011,229 @@ describe('LKE cluster updates for DC-specific prices', () => { // Confirm total price updates in Kube Specs: $14.40/mo existing pool + $28.80/mo new pool. cy.findByText('$43.20/month').should('be.visible'); }); + + /* + * - Confirms node pool resize UI flow using mocked API responses. + * - Confirms that pool size can be changed. + * - Confirms that drawer reflects $0 pricing. + * - Confirms that details page still shows $0 pricing after resizing. + */ + it('can resize pools with region prices of $0', () => { + const dcSpecificPricingRegion = getRegionById('us-southeast'); + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, + control_plane: { + high_availability: false, + }, + }); + + const mockNodePoolResized = nodePoolFactory.build({ + count: 3, + type: dcPricingMockLinodeTypes[2].id, + nodes: kubeLinodeFactory.buildList(3), + }); + + const mockNodePoolInitial = { + ...mockNodePoolResized, + count: 1, + nodes: [mockNodePoolResized.nodes[0]], + }; + + const mockLinodes: Linode[] = mockNodePoolResized.nodes.map( + (node: PoolNodeResponse): Linode => { + return linodeFactory.build({ + id: node.instance_id ?? undefined, + ipv4: [randomIp()], + region: dcSpecificPricingRegion.id, + type: dcPricingMockLinodeTypes[2].id, + }); + } + ); + + const mockNodePoolDrawerTitle = 'Resize Pool: Linode 2 GB Plan'; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( + 'getNodePools' + ); + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType'); + mockGetKubernetesVersions().as('getVersions'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getLinodes', + '@getVersions', + '@getLinodeType', + ]); + + // Confirm that nodes are visible. + mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { + cy.get(`tr[data-qa-node-row="${node.id}"]`) + .should('be.visible') + .within(() => { + const nodeLinode = mockLinodes.find( + (linode: Linode) => linode.id === node.instance_id + ); + if (nodeLinode) { + cy.findByText(nodeLinode.label).should('be.visible'); + } + }); + }); + + // Confirm total price is listed in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + + // Click "Resize Pool" and increase size to 4 nodes. + ui.button + .findByTitle('Resize Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as( + 'resizeNodePool' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as( + 'getNodePools' + ); + + ui.drawer + .findByTitle(mockNodePoolDrawerTitle) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.disabled'); + + cy.findByText('Current pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + cy.findByText('Resized pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + + cy.findByLabelText('Add 1') + .should('be.visible') + .should('be.enabled') + .click() + .click() + .click(); + + cy.findByLabelText('Edit Quantity').should('have.value', '4'); + cy.findByText('Current pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + cy.findByText('Resized pool: $0/month (4 nodes at $0/month)').should( + 'be.visible' + ); + + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@resizeNodePool', '@getNodePools']); + + // Confirm total price is still $0 in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + }); + + /* + * - Confirms UI flow when adding node pools using mocked API responses. + * - Confirms that drawer reflects $0 prices. + * - Confirms that details page still shows $0 pricing after adding node pool. + */ + it('can add node pools with region prices of $0', () => { + const dcSpecificPricingRegion = getRegionById('us-southeast'); + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, + control_plane: { + high_availability: false, + }, + }); + + const mockNewNodePool = nodePoolFactory.build({ + count: 2, + type: dcPricingMockLinodeTypes[2].id, + nodes: kubeLinodeFactory.buildList(2), + }); + + const mockNodePool = nodePoolFactory.build({ + count: 1, + type: dcPricingMockLinodeTypes[2].id, + nodes: kubeLinodeFactory.buildList(1), + }); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); + mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType'); + mockGetLinodeTypes(dcPricingMockLinodeTypes); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']); + + // Assert that initial node pool is shown on the page. + cy.findByText('Linode 2 GB', { selector: 'h2' }).should('be.visible'); + + // Confirm total price of $0 is listed in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + + // Add a new node pool, select plan, submit form in drawer. + ui.button + .findByTitle('Add a Node Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as( + 'getNodePools' + ); + + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Linode 2 GB') + .should('be.visible') + .closest('tr') + .within(() => { + // Assert that $0 prices are displayed the plan table, then add a node pool with 2 linodes. + cy.findAllByText('$0').should('have.length', 2); + cy.findByLabelText('Add 1').should('be.visible').click().click(); + }); + + // Assert that $0 prices are displayed as helper text. + cy.contains( + 'This pool will add $0/month (2 nodes at $0/month) to this cluster.' + ).should('be.visible'); + + ui.button + .findByTitle('Add pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for API responses. + cy.wait(['@addNodePool', '@getNodePools']); + + // Confirm total price is still $0 in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts index 68d5c24693e..01694c1fa4a 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -270,16 +270,22 @@ describe('"Enable Linode Backups" banner', () => { // See `dcPricingMockLinodeTypes` exported from `support/constants/dc-specific-pricing.ts`. linodeFactory.build({ label: randomLabel(), - region: 'us-east', + region: 'us-ord', backups: { enabled: false }, type: dcPricingMockLinodeTypesForBackups[0].id, }), linodeFactory.build({ label: randomLabel(), - region: 'us-west', + region: 'us-east', backups: { enabled: false }, type: dcPricingMockLinodeTypesForBackups[1].id, }), + linodeFactory.build({ + label: randomLabel(), + region: 'us-west', + backups: { enabled: false }, + type: dcPricingMockLinodeTypesForBackups[2].id, + }), linodeFactory.build({ label: randomLabel(), region: 'us-central', @@ -317,6 +323,7 @@ describe('"Enable Linode Backups" banner', () => { // The expected backup price for each Linode, as shown in backups drawer table. const expectedPrices = [ + '$0.00/mo', // us-ord mocked price. '$3.57/mo', // us-east mocked price. '$4.17/mo', // us-west mocked price. '$2.00/mo', // regular price. @@ -358,7 +365,7 @@ describe('"Enable Linode Backups" banner', () => { ); // Confirm that expected total cost is shown. - cy.contains(`Total for 3 Linodes: ${expectedTotal}`).should( + cy.contains(`Total for 4 Linodes: ${expectedTotal}`).should( 'be.visible' ); @@ -377,6 +384,10 @@ describe('"Enable Linode Backups" banner', () => { .closest('tr') .within(() => { cy.findByText(expectedPrice).should('be.visible'); + // Confirm no error indicator appears for $0.00 prices. + cy.findByLabelText( + 'There was an error loading the price.' + ).should('not.exist'); }); }); @@ -398,7 +409,7 @@ describe('"Enable Linode Backups" banner', () => { cy.wait([...enableBackupAliases, '@updateAccountSettings']); ui.toast.assertMessage( - '3 Linodes have been enrolled in automatic backups, and all new Linodes will automatically be backed up.' + '4 Linodes have been enrolled in automatic backups, and all new Linodes will automatically be backed up.' ); }); }); diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index 9fb1445e7a0..3843a35aceb 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -75,9 +75,10 @@ export const dcPricingMockLinodeTypes = linodeTypeFactory.buildList(3, { monthly: 12.2, }, { - hourly: 0.006, + // Mock a DC with $0 region prices, which is possible in some circumstances (e.g. Limited Availability). + hourly: 0.0, id: 'us-southeast', - monthly: 4.67, + monthly: 0.0, }, ], }); @@ -92,6 +93,11 @@ export const dcPricingMockLinodeTypesForBackups = linodeTypeFactory.buildList( monthly: 2.0, }, region_prices: [ + { + hourly: 0, + id: 'us-ord', + monthly: 0, + }, { hourly: 0.0048, id: 'us-east', diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index 3b6507ec78f..95233470184 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -41,6 +41,7 @@ export * from './statusPage'; export * from './subnets'; export * from './support'; export * from './tags'; +export * from './types'; export * from './volume'; export * from './vlans'; export * from './vpcs'; diff --git a/packages/manager/src/features/Backups/BackupDrawer.test.tsx b/packages/manager/src/features/Backups/BackupDrawer.test.tsx new file mode 100644 index 00000000000..26eeecebc10 --- /dev/null +++ b/packages/manager/src/features/Backups/BackupDrawer.test.tsx @@ -0,0 +1,172 @@ +import * as React from 'react'; +import { + accountSettingsFactory, + linodeFactory, + typeFactory, +} from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { BackupDrawer } from './BackupDrawer'; + +const queryMocks = vi.hoisted(() => ({ + useAllLinodesQuery: vi.fn().mockReturnValue({ + data: undefined, + }), + useAllTypes: vi.fn().mockReturnValue({ + data: undefined, + }), + useTypeQuery: vi.fn().mockReturnValue({ + data: undefined, + }), + useAccountSettings: vi.fn().mockReturnValue({ + data: undefined, + }), +})); + +vi.mock('src/queries/linodes/linodes', async () => { + const actual = await vi.importActual('src/queries/linodes/linodes'); + return { + ...actual, + useAllLinodesQuery: queryMocks.useAllLinodesQuery, + }; +}); + +vi.mock('src/queries/types', async () => { + const actual = await vi.importActual('src/queries/types'); + return { + ...actual, + useAllTypes: queryMocks.useAllTypes, + useTypeQuery: queryMocks.useTypeQuery, + }; +}); + +vi.mock('src/queries/accountSettings', async () => { + const actual = await vi.importActual('src/queries/accountSettings'); + return { + ...actual, + useAccountSettings: queryMocks.useAccountSettings, + }; +}); + +describe('BackupDrawer', () => { + beforeEach(() => { + const mockType = typeFactory.build({ + id: 'mock-linode-type', + label: 'Mock Linode Type', + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'es-mad', + monthly: 0, + }, + ], + }, + }, + }); + queryMocks.useAccountSettings.mockReturnValue({ + data: accountSettingsFactory.build({ + backups_enabled: false, + }), + }); + queryMocks.useAllTypes.mockReturnValue({ + data: [mockType], + }); + queryMocks.useTypeQuery.mockReturnValue({ + data: mockType, + }); + }); + + describe('Total price display', () => { + it('displays total backup price', async () => { + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [ + linodeFactory.build({ + region: 'es-mad', + type: 'mock-linode-type', + backups: { enabled: false }, + }), + ...linodeFactory.buildList(5, { + region: 'us-east', + type: 'mock-linode-type', + backups: { enabled: false }, + }), + ], + }); + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('Total for 6 Linodes:')).toBeVisible(); + expect(await findByText('$12.50')).toBeVisible(); + }); + + it('displays total backup price when total is $0', async () => { + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [ + linodeFactory.build({ + region: 'es-mad', + type: 'mock-linode-type', + backups: { enabled: false }, + }), + ], + }); + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('Total for 1 Linode:')).toBeVisible(); + expect(await findByText('$0.00')).toBeVisible(); + }); + + it('displays placeholder when total backup price cannot be determined', async () => { + queryMocks.useAllTypes.mockReturnValue({ + data: undefined, + }); + + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [linodeFactory.build({ backups: { enabled: false } })], + }); + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('Total for 1 Linode:')).toBeVisible(); + expect(await findByText('$--.--')).toBeVisible(); + }); + }); + + describe('Linode list', () => { + it('Only lists Linodes that do not have backups enabled', async () => { + const mockLinodesWithBackups = linodeFactory.buildList(3, { + backups: { enabled: true }, + }); + + const mockLinodesWithoutBackups = linodeFactory.buildList(3, { + backups: { enabled: false }, + }); + + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [...mockLinodesWithBackups, ...mockLinodesWithoutBackups], + }); + + const { findByText, queryByText } = renderWithTheme( + + ); + // Confirm that Linodes without backups are listed in table. + /* eslint-disable no-await-in-loop */ + for (const mockLinode of mockLinodesWithoutBackups) { + expect(await findByText(mockLinode.label)).toBeVisible(); + } + // Confirm that Linodes with backups are not listed in table. + for (const mockLinode of mockLinodesWithBackups) { + expect(queryByText(mockLinode.label)).toBeNull(); + } + }); + }); +}); diff --git a/packages/manager/src/features/Backups/BackupDrawer.tsx b/packages/manager/src/features/Backups/BackupDrawer.tsx index 952ebe1aa6c..67265b2c07e 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.tsx @@ -181,9 +181,7 @@ all new Linodes will automatically be backed up.`   diff --git a/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx b/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx index 68bce89c5dc..cfeb5e3cb2a 100644 --- a/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx +++ b/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx @@ -78,4 +78,71 @@ describe('BackupLinodeRow', () => { expect(await findByText('Jakarta, ID')).toBeVisible(); expect(await findByText('$3.57/mo')).toBeVisible(); }); + + it('should render error indicator when price cannot be determined', async () => { + server.use( + rest.get('*/linode/types/linode-type-test', (req, res, ctx) => { + return res.networkError('A hypothetical network error has occurred!'); + }) + ); + + const linode = linodeFactory.build({ + label: 'my-dc-pricing-linode-to-back-up', + region: 'id-cgk', + type: 'linode-type-test', + }); + + const { findByText, findByLabelText } = renderWithTheme( + wrapWithTableBody() + ); + + expect(await findByText('$--.--/mo')).toBeVisible(); + expect( + await findByLabelText('There was an error loading the price.') + ).toBeVisible(); + }); + + it('should not render error indicator for $0 price', async () => { + server.use( + rest.get('*/linode/types/linode-type-test', (req, res, ctx) => { + return res( + ctx.json( + linodeTypeFactory.build({ + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'id-cgk', + monthly: 0, + }, + ], + }, + }, + label: 'Linode Test Type', + }) + ) + ); + }) + ); + + const linode = linodeFactory.build({ + label: 'my-dc-pricing-linode-to-back-up', + region: 'id-cgk', + type: 'linode-type-test', + }); + + const { findByText, queryByLabelText } = renderWithTheme( + wrapWithTableBody() + ); + + expect(await findByText('$0.00/mo')).toBeVisible(); + expect( + queryByLabelText('There was an error loading the price.') + ).toBeNull(); + }); }); diff --git a/packages/manager/src/features/Backups/BackupLinodeRow.tsx b/packages/manager/src/features/Backups/BackupLinodeRow.tsx index 47d29c22619..213bea721cc 100644 --- a/packages/manager/src/features/Backups/BackupLinodeRow.tsx +++ b/packages/manager/src/features/Backups/BackupLinodeRow.tsx @@ -32,6 +32,9 @@ export const BackupLinodeRow = (props: Props) => { const regionLabel = regions?.find((r) => r.id === linode.region)?.label ?? linode.region; + const hasInvalidPrice = + backupsMonthlyPrice === null || backupsMonthlyPrice === undefined; + return ( @@ -53,8 +56,8 @@ export const BackupLinodeRow = (props: Props) => { {regionLabel ?? 'Unknown'} {`$${backupsMonthlyPrice?.toFixed(2) ?? UNKNOWN_PRICE}/mo`} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index f8914146720..f34cc4228c9 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -1,4 +1,5 @@ import { Theme } from '@mui/material/styles'; +import { isNumber } from 'lodash'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -20,6 +21,7 @@ import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { KubernetesPlansPanel } from '../../KubernetesPlansPanel/KubernetesPlansPanel'; import { nodeWarning } from '../../kubeUtils'; +import { hasInvalidNodePoolPrice } from './utils'; import type { Region } from '@linode/api-v4'; @@ -102,10 +104,12 @@ export const AddNodePoolDrawer = (props: Props) => { ?.monthly; const totalPrice = - selectedTypeInfo && pricePerNode + selectedTypeInfo && isNumber(pricePerNode) ? selectedTypeInfo.count * pricePerNode : undefined; + const hasInvalidPrice = hasInvalidNodePoolPrice(pricePerNode, totalPrice); + React.useEffect(() => { if (open) { resetDrawer(); @@ -199,7 +203,7 @@ export const AddNodePoolDrawer = (props: Props) => { /> )} - {selectedTypeInfo && !totalPrice && !pricePerNode && ( + {selectedTypeInfo && hasInvalidPrice && ( { )} { await findByText(/linode 1 GB/i); }); - it('should display a warning if the user tries to resize a node pool to < 3 nodes', () => { - const { getByText } = renderWithTheme( + it('should display a warning if the user tries to resize a node pool to < 3 nodes', async () => { + const { findByText } = renderWithTheme( ); - expect(getByText(/minimum of 3 nodes/i)); + expect(await findByText(/minimum of 3 nodes/i)); }); - it('should display a warning if the user tries to resize to a smaller node count', () => { - const { getByTestId, getByText } = renderWithTheme( + it('should display a warning if the user tries to resize to a smaller node count', async () => { + const { findByTestId, getByText } = renderWithTheme( ); - const decrement = getByTestId('decrement-button'); + + const decrement = await findByTestId('decrement-button'); fireEvent.click(decrement); expect(getByText(/resizing to fewer nodes/i)); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx index 5c77d3ee9a9..5f44f7681cd 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx @@ -1,7 +1,7 @@ import { KubeNodePoolResponse, Region } from '@linode/api-v4'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { CircleProgress } from 'src/components/CircleProgress'; @@ -19,6 +19,8 @@ import { getKubernetesMonthlyPrice } from 'src/utilities/pricing/kubernetes'; import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { nodeWarning } from '../../kubeUtils'; +import { hasInvalidNodePoolPrice } from './utils'; +import { isNumber } from 'lodash'; const useStyles = makeStyles()((theme: Theme) => ({ helperText: { @@ -107,85 +109,89 @@ export const ResizeNodePoolDrawer = (props: Props) => { types: planType ? [planType] : [], }); + const hasInvalidPrice = hasInvalidNodePoolPrice( + pricePerNode, + totalMonthlyPrice + ); + return ( - {isLoadingTypes && } -
) => { - e.preventDefault(); - handleSubmit(); - }} - > -
- {totalMonthlyPrice && ( + {isLoadingTypes ? ( + + ) : ( + ) => { + e.preventDefault(); + handleSubmit(); + }} + > +
Current pool: $ - {renderMonthlyPriceToCorrectDecimalPlace(totalMonthlyPrice)}/month{' '} - ({pluralize('node', 'nodes', nodePool.count)} at $ + {renderMonthlyPriceToCorrectDecimalPlace(totalMonthlyPrice)} + /month ({pluralize('node', 'nodes', nodePool.count)} at $ {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} /month) - )} -
- - {error && } - -
- - Enter the number of nodes you'd like in this pool: - - -
+
+ + {error && } -
- {/* Renders total pool price/month for N nodes at price per node/month. */} - {pricePerNode && ( +
+ + Enter the number of nodes you'd like in this pool: + + +
+ +
+ {/* Renders total pool price/month for N nodes at price per node/month. */} {`Resized pool: $${renderMonthlyPriceToCorrectDecimalPlace( - updatedCount * pricePerNode + isNumber(pricePerNode) ? updatedCount * pricePerNode : undefined )}/month`}{' '} ({pluralize('node', 'nodes', updatedCount)} at $ {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} /month) +
+ + {updatedCount < nodePool.count && ( + + )} + + {updatedCount < 3 && ( + )} -
- - {updatedCount < nodePool.count && ( - - )} - - {updatedCount < 3 && ( - - )} - - {nodePool.count && (!pricePerNode || !totalMonthlyPrice) && ( - + )} + + - )} - - - + + )}
); }; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts new file mode 100644 index 00000000000..ac3166ae4f3 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts @@ -0,0 +1,19 @@ +import { hasInvalidNodePoolPrice } from './utils'; + +describe('hasInvalidNodePoolPrice', () => { + it('returns false if the prices are both zero, which is valid', () => { + expect(hasInvalidNodePoolPrice(0, 0)).toBe(false); + }); + + it('returns true if at least one of the prices is undefined', () => { + expect(hasInvalidNodePoolPrice(0, undefined)).toBe(true); + expect(hasInvalidNodePoolPrice(undefined, 0)).toBe(true); + expect(hasInvalidNodePoolPrice(undefined, undefined)).toBe(true); + }); + + it('returns true if at least one of the prices is null', () => { + expect(hasInvalidNodePoolPrice(0, null)).toBe(true); + expect(hasInvalidNodePoolPrice(null, 0)).toBe(true); + expect(hasInvalidNodePoolPrice(null, null)).toBe(true); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts new file mode 100644 index 00000000000..3b52451b27d --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts @@ -0,0 +1,13 @@ +/** + * Checks whether prices are valid - 0 is valid, but undefined and null prices are invalid. + * @returns true if either value is null or undefined + */ +export const hasInvalidNodePoolPrice = ( + pricePerNode: null | number | undefined, + totalPrice: null | number | undefined +) => { + const isInvalidPricePerNode = !pricePerNode && pricePerNode !== 0; + const isInvalidTotalPrice = !totalPrice && totalPrice !== 0; + + return isInvalidPricePerNode || isInvalidTotalPrice; +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx new file mode 100644 index 00000000000..29ce79397e6 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; +import { EnableBackupsDialog } from './EnableBackupsDialog'; +import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; +import { typeFactory } from 'src/factories/types'; +import { linodeFactory } from 'src/factories'; + +const queryMocks = vi.hoisted(() => ({ + useLinodeQuery: vi.fn().mockReturnValue({ + data: undefined, + }), + useTypeQuery: vi.fn().mockReturnValue({ + data: undefined, + }), +})); + +vi.mock('src/queries/linodes/linodes', async () => { + const actual = await vi.importActual('src/queries/linodes/linodes'); + return { + ...actual, + useLinodeQuery: queryMocks.useLinodeQuery, + }; +}); + +vi.mock('src/queries/types', async () => { + const actual = await vi.importActual('src/queries/types'); + return { + ...actual, + useTypeQuery: queryMocks.useTypeQuery, + }; +}); + +describe('EnableBackupsDialog component', () => { + beforeEach(() => { + queryMocks.useTypeQuery.mockReturnValue({ + data: typeFactory.build({ + id: 'mock-linode-type', + label: 'Mock Linode Type', + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'es-mad', + monthly: 0, + }, + ], + }, + }, + }), + }); + }); + + it('Displays the monthly backup price', async () => { + queryMocks.useLinodeQuery.mockReturnValue({ + data: linodeFactory.build({ + id: 1, + label: 'Mock Linode', + type: 'mock-linode-type', + region: 'us-east', + }), + }); + + const { findByText } = renderWithTheme( + + ); + + // Confirm that the user is warned that they will be billed, and that the correct + // price is displayed. + expect( + await findByText( + /Are you sure you want to enable backups on this Linode\?.*/ + ) + ).toHaveTextContent(/This will add .* to your monthly bill/); + expect(await findByText('$2.50')).toBeVisible(); + }); + + it('Displays the monthly backup price when the price is $0', async () => { + queryMocks.useLinodeQuery.mockReturnValue({ + data: linodeFactory.build({ + id: 1, + label: 'Mock Linode', + type: 'mock-linode-type', + region: 'es-mad', + }), + }); + + const { getByTestId, findByText, queryByText } = renderWithTheme( + + ); + + // Confirm that the user is warned that they will be billed, and that $0.00 + // is shown. + expect( + await findByText( + /Are you sure you want to enable backups on this Linode\?.*/ + ) + ).toHaveTextContent(/This will add .* to your monthly bill/); + expect(await findByText('$0.00')).toBeVisible(); + + // Confirm that error message is not present. + expect(queryByText(PRICES_RELOAD_ERROR_NOTICE_TEXT)).toBeNull(); + + // Confirm that "Enable Backups" button is enabled. + expect(getByTestId('confirm-enable-backups')).toBeEnabled(); + }); + + it('Displays an error when backup price cannot be determined', async () => { + queryMocks.useTypeQuery.mockReturnValue({ + data: undefined, + }); + + queryMocks.useLinodeQuery.mockReturnValue({ + data: linodeFactory.build({ + id: 1, + label: 'Mock Linode', + type: 'mock-linode-type', + region: 'es-mad', + }), + }); + + const { getByTestId, findByText } = renderWithTheme( + + ); + + // Confirm that error message is not present. + expect(await findByText(PRICES_RELOAD_ERROR_NOTICE_TEXT)).toBeVisible(); + + // Confirm that "Enable Backups" button is disabled. + expect(getByTestId('confirm-enable-backups')).toHaveAttribute( + 'aria-disabled' + ); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx index 327aa57580e..9c28b43666f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx @@ -47,6 +47,9 @@ export const EnableBackupsDialog = (props: Props) => { type, }); + const hasBackupsMonthlyPriceError = + !backupsMonthlyPrice && backupsMonthlyPrice !== 0; + const { enqueueSnackbar } = useSnackbar(); const { checkForNewEvents } = useEventsPollingActions(); @@ -70,7 +73,7 @@ export const EnableBackupsDialog = (props: Props) => { { open={open} title="Enable backups?" > - {backupsMonthlyPrice ? ( + {!hasBackupsMonthlyPriceError ? ( Are you sure you want to enable backups on this Linode?{` `} This will add diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.test.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.test.tsx new file mode 100644 index 00000000000..c632327861d --- /dev/null +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.test.tsx @@ -0,0 +1,169 @@ +import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { MigrationPricing } from './MigrationPricing'; + +const backupPricesNull = { + monthly: null, + hourly: null, +}; + +const backupPricesZero = { + monthly: 0, + hourly: 0, +}; + +const backupPricesRegular = { + hourly: 0.004, + monthly: 2.5, +}; + +describe('MigrationPricing component', () => { + describe('render condition', () => { + it('does not render when prices are not specified', async () => { + // Some combinations of props that should prevent component rendering. + const propCombinations = [ + { backups: 'disabled' as const, hourly: undefined, monthly: 0 }, + { backups: backupPricesNull, hourly: null, monthly: 0.1 }, + { backups: backupPricesRegular, hourly: null, monthly: null }, + { backups: backupPricesZero, hourly: undefined, monthly: 1 }, + { backups: undefined, hourly: 0, monthly: 0 }, + { backups: undefined, hourly: 1, monthly: undefined }, + { backups: undefined, hourly: null, monthly: null }, + ]; + + propCombinations.forEach((props) => { + const { queryByTestId, unmount } = renderWithTheme( + + ); + expect(queryByTestId('migration-pricing')).toBeNull(); + unmount(); + }); + }); + + it('renders when prices are specified', async () => { + const props = { + backups: 'disabled' as const, + hourly: 0.004, + monthly: 2.5, + }; + + const { findByTestId } = renderWithTheme( + + ); + expect(await findByTestId('migration-pricing')).not.toBeNull(); + }); + + it('renders when $0 prices are specified', async () => { + const props = { + backups: 'disabled' as const, + hourly: 0, + monthly: 0, + }; + + const { findByTestId } = renderWithTheme( + + ); + expect(await findByTestId('migration-pricing')).not.toBeNull(); + }); + }); + + describe('price display', () => { + it('displays prices', async () => { + const props = { + backups: 'disabled' as const, + hourly: 0.004, + monthly: 2.5, + }; + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('$0.004')).toBeVisible(); + expect(await findByText('$2.50')).toBeVisible(); + }); + + it('displays $0 prices', async () => { + const props = { + backups: 'disabled' as const, + hourly: 0, + monthly: 0, + }; + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('$0.000')).toBeVisible(); + expect(await findByText('$0.00')).toBeVisible(); + }); + }); + + describe('backup price display', () => { + it('shows backup prices', async () => { + const props = { + backups: backupPricesRegular, + hourly: 0.001, + monthly: 1.5, + }; + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('| Backups', { exact: false })).toBeVisible(); + expect(await findByText('$2.50')).toBeVisible(); + }); + + it('shows $0 backup prices', async () => { + const props = { + backups: backupPricesZero, + hourly: 0.001, + monthly: 1.5, + }; + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('| Backups', { exact: false })).toBeVisible(); + expect(await findByText('$0.00')).toBeVisible(); + }); + + it('hides backup prices when backups are disabled', () => { + const props = { + backups: 'disabled' as const, + hourly: 0.001, + monthly: 1.5, + }; + + const { queryByText } = renderWithTheme( + + ); + expect(queryByText('| Backups', { exact: false })).toBeNull(); + }); + + it('hides backup prices when backups are undefined', () => { + const props = { + backups: undefined, + hourly: 0.001, + monthly: 1.5, + }; + + const { queryByText } = renderWithTheme( + + ); + expect(queryByText('| Backups', { exact: false })).toBeNull(); + }); + + it('hides backup prices when backup prices are null', () => { + const props = { + backups: backupPricesNull, + hourly: 0.001, + monthly: 1.5, + }; + + const { queryByText } = renderWithTheme( + + ); + expect(queryByText('| Backups', { exact: false })).toBeNull(); + }); + }); +}); diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx index fbc7480a4ab..8a6a8172858 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx @@ -1,6 +1,7 @@ import { PriceObject } from '@linode/api-v4'; import { styled } from '@mui/material/styles'; import { useTheme } from '@mui/material'; +import { isNumber } from 'lodash'; import * as React from 'react'; import { Box } from 'src/components/Box'; @@ -24,8 +25,17 @@ export const MigrationPricing = (props: MigrationPricingProps) => { const theme = useTheme(); const priceFontSize = `${theme.typography.body1.fontSize}`; - return monthly && hourly && backups ? ( - + const shouldShowPrice = + isNumber(monthly) && isNumber(hourly) && backups !== undefined; + + const shouldShowBackupsPrice = + backups && backups !== 'disabled' && backups.monthly !== null; + + return shouldShowPrice ? ( + {currentPanel ? 'Current' : 'New'} Price { interval="hour" price={hourly} /> - {backups !== 'disabled' && backups?.monthly && ( + {shouldShowBackupsPrice && ( <>   @@ -53,7 +63,7 @@ export const MigrationPricing = (props: MigrationPricingProps) => { )} diff --git a/packages/manager/src/utilities/pricing/backups.test.tsx b/packages/manager/src/utilities/pricing/backups.test.tsx index a2d0d90b7f1..a3ef5db57af 100644 --- a/packages/manager/src/utilities/pricing/backups.test.tsx +++ b/packages/manager/src/utilities/pricing/backups.test.tsx @@ -100,4 +100,37 @@ describe('getTotalBackupsPrice', () => { }) ).toBe(8.57); }); + + it('correctly calculates the total price with $0 DC-specific pricing for Linode backups', () => { + const basePriceLinodes = linodeFactory.buildList(2, { type: 'my-type' }); + const zeroPriceLinode = linodeFactory.build({ + region: 'es-mad', + type: 'my-type', + }); + const linodes = [...basePriceLinodes, zeroPriceLinode]; + const types = linodeTypeFactory.buildList(1, { + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'es-mad', + monthly: 0, + }, + ], + }, + }, + id: 'my-type', + }); + expect( + getTotalBackupsPrice({ + linodes, + types, + }) + ).toBe(5); + }); }); diff --git a/packages/manager/src/utilities/pricing/backups.ts b/packages/manager/src/utilities/pricing/backups.ts index 6fe31cafe5a..f6337e2a44f 100644 --- a/packages/manager/src/utilities/pricing/backups.ts +++ b/packages/manager/src/utilities/pricing/backups.ts @@ -75,11 +75,12 @@ export const getTotalBackupsPrice = ({ return undefined; } - const backupsMonthlyPrice: PriceObject['monthly'] | undefined = - getMonthlyBackupsPrice({ - region: linode.region, - type, - }) || undefined; + const backupsMonthlyPrice: + | PriceObject['monthly'] + | undefined = getMonthlyBackupsPrice({ + region: linode.region, + type, + }); if (backupsMonthlyPrice === null || backupsMonthlyPrice === undefined) { return undefined; From 5510eedd397fec929eb6b7cce05b304e1c031241 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Mon, 12 Feb 2024 11:51:19 -0600 Subject: [PATCH 10/12] Cloud version 1.112.0 --- .../manager/.changeset/pr-10153-fixed-1707249479775.md | 5 ----- .../manager/.changeset/pr-10157-fixed-1707328749030.md | 5 ----- .../manager/.changeset/pr-10161-fixed-1707341493849.md | 5 ----- .../manager/.changeset/pr-10166-fixed-1707414781493.md | 5 ----- packages/manager/CHANGELOG.md | 7 +++++++ packages/manager/package.json | 4 ++-- 6 files changed, 9 insertions(+), 22 deletions(-) delete mode 100644 packages/manager/.changeset/pr-10153-fixed-1707249479775.md delete mode 100644 packages/manager/.changeset/pr-10157-fixed-1707328749030.md delete mode 100644 packages/manager/.changeset/pr-10161-fixed-1707341493849.md delete mode 100644 packages/manager/.changeset/pr-10166-fixed-1707414781493.md diff --git a/packages/manager/.changeset/pr-10153-fixed-1707249479775.md b/packages/manager/.changeset/pr-10153-fixed-1707249479775.md deleted file mode 100644 index 4de7dd8fc46..00000000000 --- a/packages/manager/.changeset/pr-10153-fixed-1707249479775.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Error when enabling backups for Linodes in regions with $0 pricing ([#10153](https://github.com/linode/manager/pull/10153)) diff --git a/packages/manager/.changeset/pr-10157-fixed-1707328749030.md b/packages/manager/.changeset/pr-10157-fixed-1707328749030.md deleted file mode 100644 index 3495d7c8dd3..00000000000 --- a/packages/manager/.changeset/pr-10157-fixed-1707328749030.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Error notices for $0 regions in LKE Resize and Add Node Pools drawers ([#10157](https://github.com/linode/manager/pull/10157)) diff --git a/packages/manager/.changeset/pr-10161-fixed-1707341493849.md b/packages/manager/.changeset/pr-10161-fixed-1707341493849.md deleted file mode 100644 index 156c095a6cc..00000000000 --- a/packages/manager/.changeset/pr-10161-fixed-1707341493849.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Error in Enable All Backups drawer when one or more Linode is in a $0 region ([#10161](https://github.com/linode/manager/pull/10161)) diff --git a/packages/manager/.changeset/pr-10166-fixed-1707414781493.md b/packages/manager/.changeset/pr-10166-fixed-1707414781493.md deleted file mode 100644 index ff63281d847..00000000000 --- a/packages/manager/.changeset/pr-10166-fixed-1707414781493.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Display $0.00 prices in Linode Migration dialog ([#10166](https://github.com/linode/manager/pull/10166)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index dda94eebf9b..65155c879ba 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -7,6 +7,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2024-02-12] - v1.112.0 +### 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)) + ### Added: - Support for IPv4 Ranges in VPC 'Assign Linodes to subnet' drawer ([#10089](https://github.com/linode/manager/pull/10089)) diff --git a/packages/manager/package.json b/packages/manager/package.json index f0f3e13b000..619861d2364 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": { @@ -215,4 +215,4 @@ "Firefox ESR", "not ie < 9" ] -} \ No newline at end of file +} From 9178a45cba49ab1b3a379b3aca6b7291e63e25cd Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Mon, 12 Feb 2024 13:56:19 -0500 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20[M3-7645]=20=E2=80=93=20Support?= =?UTF-8?q?=20VPC=20IPv4=20Ranges=20in=20Add/Edit=20Linode=20Config=20dial?= =?UTF-8?q?og=20(#10170)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/manager/CHANGELOG.md | 14 +-- .../Linodes/LinodesCreate/LinodeCreate.tsx | 2 +- .../Linodes/LinodesCreate/VPCPanel.tsx | 6 +- .../LinodeConfigs/LinodeConfigDialog.tsx | 96 ++++++++++--------- .../LinodeSettings/InterfaceSelect.tsx | 29 +++++- .../VPCs/VPCDetail/AssignIPRanges.tsx | 59 +++++++++--- .../manager/src/features/VPCs/constants.ts | 3 + 7 files changed, 137 insertions(+), 72 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 65155c879ba..db4c67420e5 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2024-02-12] - 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)) ### Fixed: @@ -14,13 +21,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - 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)) -### 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)) - ## [2024-02-05] - v1.111.0 ### Changed: diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index cad641822ed..b5ac6c725c0 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -62,6 +62,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'; @@ -99,7 +100,6 @@ import { } from './types'; import type { Tab } from 'src/components/Tabs/TabLinkList'; -import { ExtendedIP } from 'src/utilities/ipUtils'; export interface LinodeCreateProps { additionalIPv4RangesForVPC: ExtendedIP[]; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx index 31dafd60d7d..e140925ec52 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx @@ -45,6 +45,7 @@ export interface VPCPanelProps { subnetError?: string; toggleAssignPublicIPv4Address: () => void; toggleAutoassignIPv4WithinVPCEnabled: () => void; + vpcIPRangesError?: string; vpcIPv4AddressOfLinode: string | undefined; vpcIPv4Error?: string; vpcIdError?: string; @@ -69,6 +70,7 @@ export const VPCPanel = (props: VPCPanelProps) => { subnetError, toggleAssignPublicIPv4Address, toggleAutoassignIPv4WithinVPCEnabled, + vpcIPRangesError, vpcIPv4AddressOfLinode, vpcIPv4Error, vpcIdError, @@ -347,9 +349,9 @@ export const VPCPanel = (props: VPCPanelProps) => { )} )} 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/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index c0f768ba5c1..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,11 +410,11 @@ export const InterfaceSelect = (props: CombinedProps) => { toggleAutoassignIPv4WithinVPCEnabled={() => setAutoAssignVPCIPv4((autoAssignVPCIPv4) => !autoAssignVPCIPv4) } - additionalIPv4RangesForVPC={[]} // @TODO VPC: temporary placeholder to before M3-7645 is worked on to prevent errors + additionalIPv4RangesForVPC={additionalIPv4RangesForVPC ?? []} assignPublicIPv4Address={autoAssignLinodeIPv4} autoassignIPv4WithinVPC={autoAssignVPCIPv4} from="linodeConfig" - handleIPv4RangeChange={() => null} // @TODO VPC: temporary placeholder to before M3-7645 is worked on to prevent errors + handleIPv4RangeChange={handleIPv4RangeChange} handleSelectVPC={handleVPCLabelChange} handleSubnetChange={handleSubnetChange} handleVPCIPv4Change={handleVPCIPv4Input} @@ -399,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.tsx b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx index 2c806dd507a..5e79f07e3a2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx @@ -1,14 +1,17 @@ -import { styled } from '@mui/material/styles'; +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_IPV4_RANGES_DESCRIPTION, ASSIGN_IPV4_RANGES_TITLE, + UNDERSTANDING_IP_ADDRESSES_LINK, } from 'src/features/VPCs/constants'; import { ExtendedIP } from 'src/utilities/ipUtils'; @@ -16,35 +19,56 @@ 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, ipRanges, ipRangesError, sx } = props; + const { + handleIPRangeChange, + includeDescriptionInTooltip, + ipRanges, + ipRangesError, + sx, + } = props; + + const theme = useTheme(); return ( <> - + {ipRangesError && } - ({ fontFamily: theme.font.bold })}> - {ASSIGN_IPV4_RANGES_TITLE} - - - {ASSIGN_IPV4_RANGES_DESCRIPTION} - - Learn more - - . - + + + {ASSIGN_IPV4_RANGES_TITLE} + + {includeDescriptionInTooltip ? ( + + ) : ( + {IPv4RangesDescriptionJSX} + )} + ); @@ -53,3 +77,10 @@ export const AssignIPRanges = (props: Props) => { const StyledDescription = styled('span')(() => ({ marginRight: '5px', })); + +const IPv4RangesDescriptionJSX = ( + <> + {ASSIGN_IPV4_RANGES_DESCRIPTION} + Learn more. + +); diff --git a/packages/manager/src/features/VPCs/constants.ts b/packages/manager/src/features/VPCs/constants.ts index a077c38a2ef..28ff126414c 100644 --- a/packages/manager/src/features/VPCs/constants.ts +++ b/packages/manager/src/features/VPCs/constants.ts @@ -70,3 +70,6 @@ export const VPC_GETTING_STARTED_LINK = export const VPC_MULTIPLE_CONFIGURATIONS_LEARN_MORE_LINK = 'https://www.linode.com/docs/products/compute/compute-instances/guides/configuration-profiles/'; + +export const UNDERSTANDING_IP_ADDRESSES_LINK = + 'https://www.linode.com/docs/guides/how-to-understand-ip-addresses/'; From 58346c27ae591db40e50b2e2fbe093f6dd202d8b Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Mon, 12 Feb 2024 13:31:15 -0600 Subject: [PATCH 12/12] Bump validation/package.json --- packages/validation/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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