From e777479acd34c66142de41c5e44c6e5669f6b96d Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Mon, 16 Oct 2023 10:32:48 -0400 Subject: [PATCH] feat: [M3-7121 & M3-7122] - Support VPC in Linode Add/Edit Config dialog (#9709) --- ...pr-9709-upcoming-features-1697141846298.md | 5 + .../Linodes/LinodesCreate/AttachVLAN.tsx | 8 +- .../Linodes/LinodesCreate/LinodeCreate.tsx | 1 + .../Linodes/LinodesCreate/VLANAccordion.tsx | 6 +- .../Linodes/LinodesCreate/VPCPanel.test.tsx | 3 +- .../Linodes/LinodesCreate/VPCPanel.tsx | 107 +++-- .../LinodesDetail/LinodeConfigs/ConfigRow.tsx | 30 +- .../LinodeConfigs/InterfaceListItem.test.tsx | 79 ++++ .../LinodeConfigs/InterfaceListItem.tsx | 44 +++ .../LinodeConfigs/LinodeConfigDialog.test.tsx | 54 +++ .../LinodeConfigs/LinodeConfigDialog.tsx | 204 ++++++++-- .../LinodeSettings/InterfaceSelect.tsx | 364 ++++++++++++++---- .../VPCDetail/SubnetAssignLinodesDrawer.tsx | 1 + packages/manager/src/queries/vpcs.ts | 5 +- .../manager/src/utilities/formikErrorUtils.ts | 2 +- .../pr-9709-changed-1697141933133.md | 5 + packages/validation/src/linodes.schema.ts | 13 +- 17 files changed, 750 insertions(+), 181 deletions(-) create mode 100644 packages/manager/.changeset/pr-9709-upcoming-features-1697141846298.md create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/InterfaceListItem.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/InterfaceListItem.tsx create mode 100644 packages/validation/.changeset/pr-9709-changed-1697141933133.md diff --git a/packages/manager/.changeset/pr-9709-upcoming-features-1697141846298.md b/packages/manager/.changeset/pr-9709-upcoming-features-1697141846298.md new file mode 100644 index 00000000000..95bcf2498ee --- /dev/null +++ b/packages/manager/.changeset/pr-9709-upcoming-features-1697141846298.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Support VPCs in Add/Edit Linode Config dialog ([#9709](https://github.com/linode/manager/pull/9709)) diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AttachVLAN.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AttachVLAN.tsx index d2004576c00..736d9889bc4 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AttachVLAN.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AttachVLAN.tsx @@ -18,7 +18,7 @@ import { import { InterfaceSelect } from '../LinodesDetail/LinodeSettings/InterfaceSelect'; -// @TODO Delete this file when VPC is released +// @TODO VPC: Delete this file when VPC is released interface Props { handleVLANChange: (updatedInterface: Interface) => void; @@ -105,14 +105,16 @@ export const AttachVLAN = React.memo((props: Props) => { . handleVLANChange(newInterface) } fromAddonsPanel ipamAddress={ipamAddress} - ipamError={ipamError} label={vlanLabel} - labelError={labelError} purpose="vlan" readOnly={readOnly || !regionSupportsVLANs || false} region={region} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 6d8d4afee30..d77ef974ba9 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -612,6 +612,7 @@ export class LinodeCreate extends React.PureComponent< } assignPublicIPv4Address={this.props.assignPublicIPv4Address} autoassignIPv4WithinVPC={this.props.autoassignIPv4WithinVPC} + from="linodeCreate" handleSelectVPC={this.props.setSelectedVPC} handleSubnetChange={this.props.handleSubnetChange} handleVPCIPv4Change={this.props.handleVPCIPv4Change} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx index 37488a5839e..393f81ef7e7 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx @@ -108,14 +108,16 @@ export const VLANAccordion = React.memo((props: Props) => { . handleVLANChange(newInterface) } fromAddonsPanel ipamAddress={ipamAddress} - ipamError={ipamError} label={vlanLabel} - labelError={labelError} purpose="vlan" readOnly={readOnly || !regionSupportsVLANs || false} region={region} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx index e9d17c68b4f..6adead72adf 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx @@ -7,7 +7,7 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { rest, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; -import { VPCPanel } from './VPCPanel'; +import { VPCPanel, VPCPanelProps } from './VPCPanel'; const queryClient = new QueryClient(); @@ -19,6 +19,7 @@ afterEach(() => { const props = { assignPublicIPv4Address: false, autoassignIPv4WithinVPC: true, + from: 'linodeCreate' as VPCPanelProps['from'], handleSelectVPC: jest.fn(), handleSubnetChange: jest.fn(), handleVPCIPv4Change: jest.fn(), diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx index 14cd14e9f27..26bacff7a69 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx @@ -1,4 +1,6 @@ import Stack from '@mui/material/Stack'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; import { Box } from 'src/components/Box'; @@ -23,20 +25,23 @@ import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; import { StyledCreateLink } from './LinodeCreate.styles'; import { REGION_CAVEAT_HELPER_TEXT } from './constants'; -interface VPCPanelProps { +export interface VPCPanelProps { assignPublicIPv4Address: boolean; autoassignIPv4WithinVPC: boolean; + from: 'linodeConfig' | 'linodeCreate'; handleSelectVPC: (vpcId: number) => void; handleSubnetChange: (subnetId: number) => void; handleVPCIPv4Change: (IPv4: string) => void; + publicIPv4Error?: string; region: string | undefined; - selectedSubnetId: number | undefined; - selectedVPCId: number | undefined; + selectedSubnetId: null | number | undefined; + selectedVPCId: null | number | undefined; subnetError?: string; toggleAssignPublicIPv4Address: () => void; toggleAutoassignIPv4WithinVPCEnabled: () => void; vpcIPv4AddressOfLinode: string | undefined; vpcIPv4Error?: string; + vpcIdError?: string; } const ERROR_GROUP_STRING = 'vpc-errors'; @@ -45,9 +50,11 @@ export const VPCPanel = (props: VPCPanelProps) => { const { assignPublicIPv4Address, autoassignIPv4WithinVPC, + from, handleSelectVPC, handleSubnetChange, handleVPCIPv4Change, + publicIPv4Error, region, selectedSubnetId, selectedVPCId, @@ -56,8 +63,12 @@ export const VPCPanel = (props: VPCPanelProps) => { toggleAutoassignIPv4WithinVPCEnabled, vpcIPv4AddressOfLinode, vpcIPv4Error, + vpcIdError, } = props; + const theme = useTheme(); + const isSmallBp = useMediaQuery(theme.breakpoints.down('sm')); + const flags = useFlags(); const { account } = useAccountManagement(); @@ -104,10 +115,15 @@ export const VPCPanel = (props: VPCPanelProps) => { : accumulator; }, []); - vpcDropdownOptions.unshift({ - label: 'None', - value: -1, - }); + const fromLinodeCreate = from === 'linodeCreate'; + const fromLinodeConfig = from === 'linodeConfig'; + + if (fromLinodeCreate) { + vpcDropdownOptions.unshift({ + label: 'None', + value: -1, + }); + } const subnetDropdownOptions: Item[] = vpcs @@ -121,27 +137,46 @@ export const VPCPanel = (props: VPCPanelProps) => { ? getAPIErrorOrDefault(error, 'Unable to load VPCs')[0].reason : undefined; - const mainCopyVPC = - vpcDropdownOptions.length <= 1 - ? 'Allow Linode to communicate in an isolated environment.' - : 'Assign this Linode to an existing VPC.'; + const getMainCopyVPC = () => { + if (fromLinodeConfig) { + return null; + } + + const copy = + vpcDropdownOptions.length <= 1 + ? 'Allow Linode to communicate in an isolated environment.' + : 'Assign this Linode to an existing VPC.'; + + return ( + <> + {/* @TODO VPC: Update link */} + {copy} Learn more. + + ); + }; return ( ({ + ...(fromLinodeCreate && { + marginTop: theme.spacing(3), + }), + ...(fromLinodeConfig && { + padding: 0, + }), + })} data-testid="vpc-panel" - sx={(theme) => ({ marginTop: theme.spacing(3) })} > - ({ marginBottom: theme.spacing(2) })} - variant="h2" - > - VPC - - - - {/* @TODO VPC: Update link */} - {mainCopyVPC} Learn more. + {fromLinodeCreate && ( + ({ marginBottom: theme.spacing(2) })} + variant="h2" + > + VPC + )} + + {getMainCopyVPC()} + + + )} {values.interfaces.map((thisInterface, idx) => { return ( handleInterfaceChange(idx, newInterface) } - ipamError={ - formik.errors[`interfaces[${idx}].ipam_address`] - } ipamAddress={thisInterface.ipam_address} key={`eth${idx}-interface`} label={thisInterface.label} - labelError={formik.errors[`interfaces[${idx}].label`]} purpose={thisInterface.purpose} readOnly={isReadOnly} region={linode?.region} slotNumber={idx} + subnetId={thisInterface.subnet_id} + vpcIPv4={thisInterface.ipv4?.vpc} + vpcId={thisInterface.vpc_id} /> ); })} @@ -1023,7 +1159,7 @@ interface ConfigFormProps { loading: boolean; } -const DialogContent: React.FC = (props) => { +const DialogContent = (props: ConfigFormProps) => { const { errors, loading } = props; if (loading) { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index 360c0e16d86..1e7bb13c0db 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -2,6 +2,7 @@ import { InterfacePayload, InterfacePurpose, } from '@linode/api-v4/lib/linodes/types'; +import Stack from '@mui/material/Stack'; import Grid from '@mui/material/Unstable_Grid2'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; @@ -10,61 +11,80 @@ import * as React from 'react'; import { Divider } from 'src/components/Divider'; import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { TextField } from 'src/components/TextField'; +import { VPCPanel } from 'src/features/Linodes/LinodesCreate/VPCPanel'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAccount } from 'src/queries/account'; import { useVlansQuery } from 'src/queries/vlans'; +import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics'; export interface Props { fromAddonsPanel?: boolean; handleChange: (updatedInterface: ExtendedInterface) => void; - ipamAddress: null | string; - ipamError?: string; - label: null | string; - labelError?: string; + ipamAddress?: null | string; + label?: null | string; purpose: ExtendedPurpose; readOnly: boolean; region?: string; slotNumber: number; } +interface VPCStateErrors { + ipamError?: string; + labelError?: string; + publicIPv4Error?: string; + subnetError?: string; + vpcError?: string; + vpcIPv4Error?: string; +} + +interface VPCState { + errors: VPCStateErrors; + subnetId?: null | number; + vpcIPv4?: string; + vpcId?: null | number; +} + // To allow for empty slots, which the API doesn't account for export type ExtendedPurpose = 'none' | InterfacePurpose; -export interface ExtendedInterface extends Omit { +export interface ExtendedInterface + extends Partial> { purpose: ExtendedPurpose; } -export const InterfaceSelect = (props: Props) => { - const theme = useTheme(); - const isSmallBp = useMediaQuery(theme.breakpoints.down('sm')); +type CombinedProps = Props & VPCState; +export const InterfaceSelect = (props: CombinedProps) => { const { + errors, fromAddonsPanel, handleChange, ipamAddress, - ipamError, label, - labelError, purpose, readOnly, region, slotNumber, + subnetId, + vpcIPv4, + vpcId, } = props; - const [newVlan, setNewVlan] = React.useState(''); + const theme = useTheme(); + const isSmallBp = useMediaQuery( + theme.breakpoints.down(fromAddonsPanel ? 'sm' : 1015) + ); + const flags = useFlags(); + const { data: account } = useAccount(); - const purposeOptions: Item[] = [ - { - label: 'Public Internet', - value: 'public', - }, - { - label: 'VLAN', - value: 'vlan', - }, - { - label: 'None', - value: 'none', - }, - ]; + const showVPCs = isFeatureEnabled( + 'VPCs', + Boolean(flags.vpc), + account?.capabilities ?? [] + ); + + const [newVlan, setNewVlan] = React.useState(''); + const purposeOptions = getPurposeOptions(showVPCs); const { data: vlans, isLoading } = useVlansQuery(); const vlanOptions = @@ -82,6 +102,9 @@ export const InterfaceSelect = (props: Props) => { vlanOptions.push({ label: newVlan, value: newVlan }); } + const [autoAssignVPCIPv4, setAutoAssignVPCIPv4] = React.useState(true); + const [autoAssignLinodeIPv4, setAutoAssignLinodeIPv4] = React.useState(false); + const handlePurposeChange = (selected: Item) => { const purpose = selected.value; handleChange({ @@ -101,6 +124,109 @@ export const InterfaceSelect = (props: Props) => { purpose, }); + const handleVPCLabelChange = (selectedVPCId: number) => + handleChange({ + ipam_address: null, + ipv4: { + vpc: autoAssignVPCIPv4 ? undefined : vpcIPv4, + }, + label: null, + purpose, + subnet_id: undefined, + vpc_id: selectedVPCId, + }); + + const handleSubnetChange = (selectedSubnetId: number) => + handleChange({ + ipam_address: null, + ipv4: { + vpc: autoAssignVPCIPv4 ? undefined : vpcIPv4, + }, + label: null, + purpose, + subnet_id: selectedSubnetId, + vpc_id: vpcId, + }); + + const handleVPCIPv4Input = (vpcIPv4Input: string) => { + const changeObj = { + ipam_address: null, + label: null, + purpose, + subnet_id: subnetId, + vpc_id: vpcId, + }; + if (autoAssignLinodeIPv4) { + handleChange({ + ...changeObj, + ipv4: { + nat_1_1: 'any', + vpc: vpcIPv4Input, + }, + }); + } else { + handleChange({ + ...changeObj, + ipv4: { + vpc: vpcIPv4Input, + }, + }); + } + }; + + React.useEffect(() => { + if (purpose !== 'vpc') { + return handleChange({ + ipam_address: ipamAddress, + label, + purpose, + }); + } + + const changeObj = { + ipam_address: null, + label: null, + purpose, + subnet_id: subnetId, + vpc_id: vpcId, + }; + + /** + * If a user checks the "Auto-assign a VPC IPv4 address" box, then we send the user inputted address, otherwise we send nothing/undefined. + * If a user checks the "Assign a public IPv4" address box, then we send nat_1_1: 'any' to the API for auto assignment. + */ + if (!autoAssignVPCIPv4 && autoAssignLinodeIPv4) { + handleChange({ + ...changeObj, + ipv4: { + nat_1_1: 'any', + vpc: vpcIPv4, + }, + }); + } else if ( + (autoAssignVPCIPv4 && autoAssignLinodeIPv4) || + autoAssignLinodeIPv4 + ) { + handleChange({ + ...changeObj, + ipv4: { + nat_1_1: 'any', + }, + }); + } else if (autoAssignVPCIPv4 && !autoAssignLinodeIPv4) { + handleChange({ + ...changeObj, + }); + } else if (!autoAssignLinodeIPv4 && !autoAssignVPCIPv4) { + handleChange({ + ...changeObj, + ipv4: { + vpc: vpcIPv4, + }, + }); + } + }, [autoAssignVPCIPv4, autoAssignLinodeIPv4, purpose]); + const handleCreateOption = (_newVlan: string) => { setNewVlan(_newVlan); handleChange({ @@ -110,10 +236,85 @@ export const InterfaceSelect = (props: Props) => { }); }; + const jsxSelectVLAN = ( + { /> )} - {purpose === 'vlan' ? ( - - - -