diff --git a/packages/manager/.changeset/pr-11357-tech-stories-1733778960338.md b/packages/manager/.changeset/pr-11357-tech-stories-1733778960338.md
new file mode 100644
index 00000000000..f7d9d2977ee
--- /dev/null
+++ b/packages/manager/.changeset/pr-11357-tech-stories-1733778960338.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tech Stories
+---
+
+Refactor VPC Create to use `react-hook-form` instead of `formik` ([#11357](https://github.com/linode/manager/pull/11357))
diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts
index 86601275d26..31c9522f730 100644
--- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts
+++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts
@@ -71,7 +71,8 @@ describe('VPC create flow', () => {
subnets: mockSubnets,
});
- const ipValidationErrorMessage = 'The IPv4 range must be in CIDR format';
+ const ipValidationErrorMessage1 = 'A subnet must have an IPv4 range.';
+ const ipValidationErrorMessage2 = 'The IPv4 range must be in CIDR format.';
const vpcCreationErrorMessage = 'An unknown error has occurred.';
const totalSubnetUniqueLinodes = getUniqueLinodesFromSubnets(mockSubnets);
@@ -111,7 +112,7 @@ describe('VPC create flow', () => {
.should('be.enabled')
.click();
- cy.findByText(ipValidationErrorMessage).should('be.visible');
+ cy.findByText(ipValidationErrorMessage1).should('be.visible');
// Enter a random non-IP address string to further test client side validation.
cy.findByText('Subnet IP Address Range')
@@ -126,7 +127,7 @@ describe('VPC create flow', () => {
.should('be.enabled')
.click();
- cy.findByText(ipValidationErrorMessage).should('be.visible');
+ cy.findByText(ipValidationErrorMessage2).should('be.visible');
// Enter a valid IP address with an invalid network prefix to further test client side validation.
cy.findByText('Subnet IP Address Range')
@@ -141,7 +142,7 @@ describe('VPC create flow', () => {
.should('be.enabled')
.click();
- cy.findByText(ipValidationErrorMessage).should('be.visible');
+ cy.findByText(ipValidationErrorMessage2).should('be.visible');
// Replace invalid IP address range with valid range.
cy.findByText('Subnet IP Address Range')
@@ -180,10 +181,12 @@ describe('VPC create flow', () => {
getSubnetNodeSection(1)
.should('be.visible')
.within(() => {
- cy.findByText('Label is required').should('be.visible');
+ cy.findByText('Label must be between 1 and 64 characters.').should(
+ 'be.visible'
+ );
// Delete subnet.
- cy.findByLabelText('Remove Subnet')
+ cy.findByLabelText('Remove Subnet 1')
.should('be.visible')
.should('be.enabled')
.click();
@@ -300,7 +303,7 @@ describe('VPC create flow', () => {
getSubnetNodeSection(0)
.should('be.visible')
.within(() => {
- cy.findByLabelText('Remove Subnet')
+ cy.findByLabelText('Remove Subnet 0')
.should('be.visible')
.should('be.enabled')
.click();
diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.test.tsx
index 2fcb98fee30..f24a05f98ce 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.test.tsx
@@ -1,24 +1,22 @@
import * as React from 'react';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';
import { SubnetContent } from './SubnetContent';
-const props = {
- disabled: false,
- onChangeField: vi.fn(),
- subnets: [
- {
- ip: { ipv4: '', ipv4Error: '' },
- label: '',
- labelError: '',
- },
- ],
-};
-
describe('Subnet form content', () => {
it('renders the subnet content correctly', () => {
- const { getByText } = renderWithTheme();
+ const { getByText } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: {
+ defaultValues: {
+ description: '',
+ label: '',
+ region: '',
+ subnets: [{ ipv4: '', label: '' }],
+ },
+ },
+ });
getByText('Subnets');
getByText('Subnet Label');
diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx
index 0e514bec10c..33a250e5d7f 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx
@@ -1,5 +1,6 @@
import { Notice } from '@linode/ui';
import * as React from 'react';
+import { useFormContext } from 'react-hook-form';
import { useLocation } from 'react-router-dom';
import { Link } from 'src/components/Link';
@@ -13,21 +14,17 @@ import {
StyledHeaderTypography,
} from './VPCCreateForm.styles';
-import type { APIError } from '@linode/api-v4';
+import type { CreateVPCPayload } from '@linode/api-v4';
import type { LinodeCreateType } from 'src/features/Linodes/LinodeCreate/types';
import type { LinodeCreateQueryParams } from 'src/features/Linodes/types';
-import type { SubnetFieldState } from 'src/utilities/subnets';
interface Props {
disabled?: boolean;
isDrawer?: boolean;
- onChangeField: (field: string, value: SubnetFieldState[]) => void;
- subnetErrors?: APIError[];
- subnets: SubnetFieldState[];
}
export const SubnetContent = (props: Props) => {
- const { disabled, isDrawer, onChangeField, subnetErrors, subnets } = props;
+ const { disabled, isDrawer } = props;
const location = useLocation();
const isFromLinodeCreate = location.pathname.includes('/linodes/create');
@@ -35,6 +32,10 @@ export const SubnetContent = (props: Props) => {
location.search
);
+ const {
+ formState: { errors },
+ } = useFormContext();
+
return (
<>
@@ -59,22 +60,21 @@ export const SubnetContent = (props: Props) => {
.
- {subnetErrors
- ? subnetErrors.map((apiError: APIError) => (
-
- ))
- : null}
- onChangeField('subnets', subnets)}
- subnets={subnets}
- />
+ {errors.root?.subnetLabel && (
+
+ )}
+ {errors.root?.subnetIPv4 && (
+
+ )}
+
>
);
};
diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx
index 996d429a640..9de5fd147fa 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx
@@ -1,19 +1,26 @@
import * as React from 'react';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';
import { VPCTopSectionContent } from './VPCTopSectionContent';
const props = {
- errors: {},
- onChangeField: vi.fn(),
regions: [],
- values: { description: '', label: '', region: '', subnets: [] },
};
describe('VPC Top Section form content', () => {
it('renders the vpc top section form content correctly', () => {
- const { getByText } = renderWithTheme();
+ const { getByText } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: {
+ defaultValues: {
+ description: '',
+ label: '',
+ region: '',
+ subnets: [],
+ },
+ },
+ });
getByText('Region');
getByText('VPC Label');
diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx
index 0befdae844f..5eae5db43f5 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx
@@ -1,5 +1,6 @@
import { TextField } from '@linode/ui';
import * as React from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
import { useLocation } from 'react-router-dom';
import { Link } from 'src/components/Link';
@@ -10,29 +11,27 @@ import { getQueryParamsFromQueryString } from 'src/utilities/queryParams';
import { VPC_CREATE_FORM_VPC_HELPER_TEXT } from '../../constants';
import { StyledBodyTypography } from './VPCCreateForm.styles';
+import type { CreateVPCPayload } from '@linode/api-v4';
import type { Region } from '@linode/api-v4';
-import type { FormikErrors } from 'formik';
import type { LinodeCreateType } from 'src/features/Linodes/LinodeCreate/types';
import type { LinodeCreateQueryParams } from 'src/features/Linodes/types';
-import type { CreateVPCFieldState } from 'src/hooks/useCreateVPC';
interface Props {
disabled?: boolean;
- errors: FormikErrors;
isDrawer?: boolean;
- onChangeField: (field: string, value: string) => void;
regions: Region[];
- values: CreateVPCFieldState;
}
export const VPCTopSectionContent = (props: Props) => {
- const { disabled, errors, isDrawer, onChangeField, regions, values } = props;
+ const { disabled, isDrawer, regions } = props;
const location = useLocation();
const isFromLinodeCreate = location.pathname.includes('/linodes/create');
const queryParams = getQueryParamsFromQueryString(
location.search
);
+ const { control } = useFormContext();
+
return (
<>
@@ -53,36 +52,53 @@ export const VPCTopSectionContent = (props: Props) => {
.
- onChangeField('region', region?.id ?? '')}
- regions={regions}
- value={values.region}
+ (
+ field.onChange(region?.id ?? '')}
+ regions={regions}
+ value={field.value}
+ />
+ )}
+ control={control}
+ name="region"
/>
- ) =>
- onChangeField('label', e.target.value)
- }
- aria-label="Enter a label"
- disabled={disabled}
- errorText={errors.label}
- label="VPC Label"
- value={values.label}
+ (
+
+ )}
+ control={control}
+ name="label"
/>
- ) =>
- onChangeField('description', e.target.value)
- }
- disabled={disabled}
- errorText={errors.description}
- label="Description"
- maxRows={1}
- multiline
- optional
- value={values.description}
+ (
+
+ )}
+ control={control}
+ name="description"
/>
>
);
diff --git a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx
index 0babd9bf0f7..9eac79771e0 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx
@@ -1,82 +1,115 @@
-import { fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import * as React from 'react';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';
import { MultipleSubnetInput } from './MultipleSubnetInput';
const props = {
- onChange: vi.fn(),
- subnets: [
- {
- ip: { ipv4: '', ipv4Error: '' },
- label: 'subnet 1',
- labelError: '',
- },
- {
- ip: { ipv4: '', ipv4Error: '' },
- label: 'subnet 2',
- labelError: '',
- },
- {
- ip: { ipv4: '', ipv4Error: '' },
- label: 'subnet 3',
- labelError: '',
- },
- ],
+ disabled: false,
+ isDrawer: false,
+};
+
+const formOptions = {
+ defaultValues: {
+ description: '',
+ label: '',
+ region: '',
+ subnets: [
+ {
+ ipv4: '',
+ label: 'subnet 0',
+ },
+ {
+ ipv4: '',
+ label: 'subnet 1',
+ },
+ {
+ ipv4: '',
+ label: 'subnet 2',
+ },
+ ],
+ },
};
describe('MultipleSubnetInput', () => {
it('should render a subnet node for each of the given subnets', () => {
- const { getAllByText, getByDisplayValue } = renderWithTheme(
-
- );
+ const {
+ getAllByText,
+ getByDisplayValue,
+ } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: formOptions,
+ });
expect(getAllByText('Subnet Label')).toHaveLength(3);
expect(getAllByText('Subnet IP Address Range')).toHaveLength(3);
+ getByDisplayValue('subnet 0');
getByDisplayValue('subnet 1');
getByDisplayValue('subnet 2');
- getByDisplayValue('subnet 3');
});
- it('should add a subnet to the array when the Add Subnet button is clicked', () => {
- const { getByText } = renderWithTheme();
- const addButton = getByText('Add another Subnet');
- fireEvent.click(addButton);
- expect(props.onChange).toHaveBeenCalledWith([
- ...props.subnets,
- {
- ip: { availIPv4s: 256, ipv4: '10.0.1.0/24', ipv4Error: '' },
- label: '',
- labelError: '',
+ it('should display "Add a Subnet" if there are no subnets', () => {
+ const { getByText } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: {
+ defaultValues: {
+ description: '',
+ label: '',
+ region: '',
+ subnets: [],
+ },
},
- ]);
+ });
+
+ getByText('Add a Subnet');
});
- it('all inputs should have a close button (X)', () => {
- const { queryAllByTestId } = renderWithTheme(
-
+ it('should add a subnet to the array when the Add Subnet button is clicked', async () => {
+ const { getByDisplayValue, getByText } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: formOptions,
+ });
+ const addButton = getByText('Add another Subnet');
+ await userEvent.click(addButton);
+
+ expect(getByDisplayValue('10.0.1.0/24')).toBeVisible();
+ });
+
+ it('all subnets should have a delete button (X) if not in a drawer', () => {
+ const { queryAllByTestId } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: formOptions,
+ });
+ expect(queryAllByTestId(/delete-subnet/)).toHaveLength(
+ formOptions.defaultValues.subnets.length
);
+ });
+
+ it('the first does not have a delete button if in a drawer', () => {
+ const {
+ queryAllByTestId,
+ queryByTestId,
+ } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: formOptions,
+ });
expect(queryAllByTestId(/delete-subnet/)).toHaveLength(
- props.subnets.length
+ formOptions.defaultValues.subnets.length - 1
);
+ expect(queryByTestId('delete-subnet-0')).toBeNull();
});
- it('should remove an element from the array based on its index when the X is clicked', () => {
- const { getByTestId } = renderWithTheme();
+ it('should remove an element from the array based on its index when the X is clicked', async () => {
+ const {
+ getByTestId,
+ queryByDisplayValue,
+ } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: formOptions,
+ });
const closeButton = getByTestId('delete-subnet-1');
- fireEvent.click(closeButton);
- expect(props.onChange).toHaveBeenCalledWith([
- {
- ip: { ipv4: '', ipv4Error: '' },
- label: 'subnet 1',
- labelError: '',
- },
- {
- ip: { ipv4: '', ipv4Error: '' },
- label: 'subnet 3',
- labelError: '',
- },
- ]);
+ await userEvent.click(closeButton);
+ expect(queryByDisplayValue('subnet-1')).toBeNull();
});
});
diff --git a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx
index d7a690f0119..9a42b960d53 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx
@@ -1,6 +1,7 @@
import { Button, Divider } from '@linode/ui';
import Grid from '@mui/material/Unstable_Grid2';
import * as React from 'react';
+import { useFieldArray, useFormContext } from 'react-hook-form';
import {
DEFAULT_SUBNET_IPV4_VALUE,
@@ -9,78 +10,63 @@ import {
import { SubnetNode } from './SubnetNode';
-import type { SubnetFieldState } from 'src/utilities/subnets';
+import type { CreateVPCPayload } from '@linode/api-v4';
interface Props {
disabled?: boolean;
isDrawer?: boolean;
- onChange: (subnets: SubnetFieldState[]) => void;
- subnets: SubnetFieldState[];
}
export const MultipleSubnetInput = (props: Props) => {
- const { disabled, isDrawer, onChange, subnets } = props;
+ const { disabled, isDrawer } = props;
+
+ const { control } = useFormContext();
+
+ const { append, fields, remove } = useFieldArray({
+ control,
+ name: 'subnets',
+ });
const [lastRecommendedIPv4, setLastRecommendedIPv4] = React.useState(
DEFAULT_SUBNET_IPV4_VALUE
);
- const addSubnet = () => {
+ const handleAddSubnet = () => {
const recommendedIPv4 = getRecommendedSubnetIPv4(
lastRecommendedIPv4,
- subnets.map((subnet) => subnet.ip.ipv4 ?? '')
+ fields.map((subnet) => subnet.ipv4 ?? '')
);
setLastRecommendedIPv4(recommendedIPv4);
- onChange([
- ...subnets,
- {
- ip: { availIPv4s: 256, ipv4: recommendedIPv4, ipv4Error: '' },
- label: '',
- labelError: '',
- },
- ]);
- };
-
- const handleSubnetChange = (
- subnet: SubnetFieldState,
- subnetIdx: number,
- removable: boolean
- ) => {
- const newSubnets = [...subnets];
- if (removable) {
- newSubnets.splice(subnetIdx, 1);
- } else {
- newSubnets[subnetIdx] = subnet;
- }
- onChange(newSubnets);
+ append({
+ ipv4: recommendedIPv4,
+ label: '',
+ });
};
return (
- {subnets.map((subnet, subnetIdx) => (
-
- {subnetIdx !== 0 && (
- ({ marginTop: theme.spacing(2.5) })} />
- )}
-
- handleSubnetChange(subnet, subnetIdx ?? 0, !!removable)
- }
- disabled={disabled}
- idx={subnetIdx}
- isCreateVPCDrawer={isDrawer}
- isRemovable={true}
- subnet={subnet}
- />
-
- ))}
+ {fields.map((subnet, subnetIdx) => {
+ return (
+
+ {subnetIdx !== 0 && (
+ ({ marginTop: theme.spacing(2.5) })} />
+ )}
+
+
+ );
+ })}
);
diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx
index 538b2beab9b..338a924f924 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx
@@ -1,77 +1,98 @@
-import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
import * as React from 'react';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';
import { SubnetNode } from './SubnetNode';
+const props = {
+ idx: 0,
+ isCreateVPCDrawer: false,
+ remove: vi.fn(),
+};
+
+const formOptions = {
+ defaultValues: {
+ description: '',
+ label: '',
+ region: '',
+ subnets: [
+ {
+ ipv4: '10.0.0.0',
+ label: 'subnet 0',
+ },
+ ],
+ },
+};
+
describe('SubnetNode', () => {
- // state that keeps track of available IPv4s has been moved out of this component,
- // due to React key issues vs maintaining state, so this test will now fail
- it.skip('should calculate the correct subnet mask', async () => {
- renderWithTheme(
- {}}
- subnet={{ ip: { ipv4: '' }, label: '' }}
- />
- );
- const subnetAddress = screen.getAllByTestId('textfield-input');
- expect(subnetAddress[1]).toBeInTheDocument();
- await userEvent.type(subnetAddress[1], '192.0.0.0/24', { delay: 1 });
+ it('should show the correct subnet mask', async () => {
+ const { getByDisplayValue, getByText } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: {
+ defaultValues: {
+ ...formOptions.defaultValues,
+ subnets: [{ ipv4: '10.0.0.0/24', label: 'subnet 0' }],
+ },
+ },
+ });
- expect(subnetAddress[1]).toHaveValue('192.0.0.0/24');
- const availIps = screen.getByText('Number of Available IP Addresses: 252');
- expect(availIps).toBeInTheDocument();
+ getByDisplayValue('10.0.0.0/24');
+ getByText('Number of Available IP Addresses: 252');
});
it('should not show a subnet mask for an ip without a mask', async () => {
- renderWithTheme(
- {}}
- subnet={{ ip: { ipv4: '' }, label: '' }}
- />
- );
- const subnetAddress = screen.getAllByTestId('textfield-input');
- expect(subnetAddress[1]).toBeInTheDocument();
- await userEvent.type(subnetAddress[1], '192.0.0.0', { delay: 1 });
+ const {
+ getByDisplayValue,
+ queryByText,
+ } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: formOptions,
+ });
- expect(subnetAddress[1]).toHaveValue('192.0.0.0');
- const availIps = screen.queryByText('Number of Available IP Addresses:');
- expect(availIps).not.toBeInTheDocument();
+ getByDisplayValue('10.0.0.0');
+ expect(queryByText('Number of Available IP Addresses:')).toBeNull();
});
it('should show a label and ip textfield inputs at minimum', () => {
- renderWithTheme(
- {}}
- subnet={{ ip: { ipv4: '' }, label: '' }}
- />
- );
+ const { getByText } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: {
+ defaultValues: {
+ ...formOptions.defaultValues,
+ subnets: [{ ipv4: '10.0.0.0/24', label: 'subnet 0' }],
+ },
+ },
+ });
+
+ getByText('Subnet Label');
+ getByText('Subnet IP Address Range');
+ });
+
+ it('should show a removable button if not a drawer', () => {
+ const { getByTestId } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: {
+ defaultValues: {
+ ...formOptions.defaultValues,
+ subnets: [{ ipv4: '10.0.0.0/24', label: 'subnet 0' }],
+ },
+ },
+ });
- const label = screen.getByText('Subnet Label');
- expect(label).toBeInTheDocument();
- const ipAddress = screen.getByText('Subnet IP Address Range');
- expect(ipAddress).toBeInTheDocument();
+ expect(getByTestId('delete-subnet-0')).toBeInTheDocument();
});
- it('should show a removable button if isRemovable is true', () => {
- renderWithTheme(
- {}}
- subnet={{ ip: { ipv4: '' }, label: '' }}
- />
- );
+ it('should not show a removable button if a drawer for the first subnet', () => {
+ const { queryByTestId } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: {
+ defaultValues: {
+ ...formOptions.defaultValues,
+ subnets: [{ ipv4: '10.0.0.0/24', label: 'subnet 0' }],
+ },
+ },
+ });
- const removableButton = screen.getByTestId('delete-subnet-1');
- expect(removableButton).toBeInTheDocument();
+ expect(queryByTestId('delete-subnet-0')).toBeNull();
});
});
diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx
index fde1a471489..1b19eee28ec 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx
@@ -1,67 +1,43 @@
-import { Button, FormHelperText, Stack, TextField } from '@linode/ui';
+import { Button, TextField } from '@linode/ui';
import Close from '@mui/icons-material/Close';
import { styled } from '@mui/material/styles';
import Grid from '@mui/material/Unstable_Grid2';
import * as React from 'react';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
import {
RESERVED_IP_NUMBER,
calculateAvailableIPv4sRFC1918,
} from 'src/utilities/subnets';
-import type { SubnetFieldState } from 'src/utilities/subnets';
+import type { CreateVPCPayload } from '@linode/api-v4';
interface Props {
disabled?: boolean;
- // extra props enable SubnetNode to be an independent component or be part of MultipleSubnetInput
- // potential refactor - isRemoveable, and subnetIdx & remove in onChange prop
- idx?: number;
+ idx: number;
isCreateVPCDrawer?: boolean;
- isRemovable?: boolean;
- onChange: (
- subnet: SubnetFieldState,
- subnetIdx?: number,
- remove?: boolean
- ) => void;
- subnet: SubnetFieldState;
+ remove: (index?: number | number[]) => void;
}
// @TODO VPC: currently only supports IPv4, must update when/if IPv6 is also supported
export const SubnetNode = (props: Props) => {
- const {
- disabled,
- idx,
- isCreateVPCDrawer,
- isRemovable,
- onChange,
- subnet,
- } = props;
+ const { disabled, idx, isCreateVPCDrawer, remove } = props;
- const onLabelChange = (e: React.ChangeEvent) => {
- const newSubnet = {
- ...subnet,
- label: e.target.value,
- labelError: '',
- };
- onChange(newSubnet, idx);
- };
+ const { control } = useFormContext();
- const onIpv4Change = (e: React.ChangeEvent) => {
- const availIPs = calculateAvailableIPv4sRFC1918(e.target.value);
- const newSubnet = {
- ...subnet,
- ip: { availIPv4s: availIPs, ipv4: e.target.value },
- };
- onChange(newSubnet, idx);
- };
+ const { ipv4, label } = useWatch({ control, name: `subnets.${idx}` });
- const removeSubnet = () => {
- onChange(subnet, idx, isRemovable);
- };
+ const numberOfAvailIPs = calculateAvailableIPv4sRFC1918(ipv4 ?? '');
- const showRemoveButton = isCreateVPCDrawer
- ? idx !== 0 && isRemovable
- : isRemovable;
+ const availableIPHelperText = numberOfAvailIPs
+ ? `Number of Available IP Addresses: ${
+ numberOfAvailIPs > 4
+ ? (numberOfAvailIPs - RESERVED_IP_NUMBER).toLocaleString()
+ : 0
+ }`
+ : undefined;
+
+ const showRemoveButton = !(isCreateVPCDrawer && idx === 0);
return (
@@ -70,39 +46,47 @@ export const SubnetNode = (props: Props) => {
sx={{ ...(!showRemoveButton && { width: '100%' }), flexGrow: 1 }}
xs={showRemoveButton ? 11 : 12}
>
-
-
-
- {subnet.ip.availIPv4s && (
-
- Number of Available IP Addresses:{' '}
- {subnet.ip.availIPv4s > 4
- ? (subnet.ip.availIPv4s - RESERVED_IP_NUMBER).toLocaleString()
- : 0}
-
+ (
+
+ )}
+ control={control}
+ name={`subnets.${idx}.label`}
+ />
+ (
+
)}
-
+ control={control}
+ name={`subnets.${idx}.ipv4`}
+ />
{showRemoveButton && (
-
+ remove(idx)}
+ >
diff --git a/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx
index b67ce7af200..d810571b71d 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx
@@ -1,7 +1,6 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
-import { act } from 'react-dom/test-utils';
import { renderWithTheme } from 'src/utilities/testHelpers';
@@ -29,37 +28,11 @@ describe('VPC create page', () => {
getAllByText('Create VPC');
});
- // test fails due to new default value for subnet ip addresses - if we remove default text and just
- // have a placeholder, we can put this test back in
- it.skip('should require vpc labels and region and ignore subnets that are blank', async () => {
- const {
- getAllByTestId,
- getByText,
- getAllByText,
- queryByText,
- } = renderWithTheme();
- const createVPCButton = getByText('Create VPC');
- const subnetIP = getAllByTestId('textfield-input');
- expect(createVPCButton).toBeInTheDocument();
- expect(subnetIP[3]).toBeInTheDocument();
- await act(async () => {
- await userEvent.click(createVPCButton);
- });
- const regionError = getByText('Region is required');
- expect(regionError).toBeInTheDocument();
- const labelErrors = getAllByText('Label is required');
- expect(labelErrors).toHaveLength(1);
- const badSubnetIP = queryByText('The IPv4 range must be in CIDR format');
- expect(badSubnetIP).not.toBeInTheDocument();
- });
-
it('should add and delete subnets correctly', async () => {
renderWithTheme();
const addSubnet = screen.getByText('Add another Subnet');
expect(addSubnet).toBeInTheDocument();
- await act(async () => {
- await userEvent.click(addSubnet);
- });
+ await userEvent.click(addSubnet);
const subnetLabels = screen.getAllByText('Subnet Label');
const subnetIps = screen.getAllByText('Subnet IP Address Range');
@@ -68,9 +41,7 @@ describe('VPC create page', () => {
const deleteSubnet = screen.getByTestId('delete-subnet-1');
expect(deleteSubnet).toBeInTheDocument();
- await act(async () => {
- await userEvent.click(deleteSubnet);
- });
+ await userEvent.click(deleteSubnet);
const subnetLabelAfter = screen.getAllByText('Subnet Label');
const subnetIpsAfter = screen.getAllByText('Subnet IP Address Range');
@@ -80,24 +51,21 @@ describe('VPC create page', () => {
it('should display that a subnet ip is invalid and require a subnet label if a user adds an invalid subnet ip', async () => {
renderWithTheme();
- const subnetLabel = screen.getByText('Subnet Label');
- expect(subnetLabel).toBeInTheDocument();
const subnetIp = screen.getByText('Subnet IP Address Range');
expect(subnetIp).toBeInTheDocument();
const createVPCButton = screen.getByText('Create VPC');
expect(createVPCButton).toBeInTheDocument();
- await act(async () => {
- await userEvent.type(subnetIp, 'bad');
- await userEvent.click(createVPCButton);
- });
+ await userEvent.type(subnetIp, 'bad');
+ await userEvent.click(createVPCButton);
const badSubnetIP = screen.getByText(
- 'The IPv4 range must be in CIDR format'
+ 'The IPv4 range must be in CIDR format.'
);
expect(badSubnetIP).toBeInTheDocument();
- const badLabels = screen.getAllByText('Label is required');
- expect(badLabels).toHaveLength(2);
+ expect(
+ screen.getAllByText('Label must be between 1 and 64 characters.')
+ ).toHaveLength(2);
});
it('should have a default value for the subnet ip address', () => {
diff --git a/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.tsx b/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.tsx
index 161425ee5c4..7dbbba8d96b 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.tsx
@@ -3,6 +3,7 @@ import { styled } from '@mui/material/styles';
import Grid from '@mui/material/Unstable_Grid2';
import { createLazyRoute } from '@tanstack/react-router';
import * as React from 'react';
+import { FormProvider } from 'react-hook-form';
import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
@@ -17,20 +18,20 @@ import { VPCTopSectionContent } from './FormComponents/VPCTopSectionContent';
const VPCCreate = () => {
const {
- formik,
- generalAPIError,
- generalSubnetErrorsFromAPI,
+ form,
isLoadingCreateVPC,
- onChangeField,
onCreateVPC,
regionsData,
userCannotAddVPC,
} = useCreateVPC({ pushToVPCPage: true });
- const { errors, handleSubmit, setFieldValue, values } = formik;
+ const {
+ formState: { errors },
+ handleSubmit,
+ } = form;
return (
- <>
+
{
/>
{userCannotAddVPC && CannotCreateVPCNotice}
- {generalAPIError ? (
-
- ) : null}
-
- >
+
);
};
diff --git a/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx
index 434decf1b5b..b18c26b67e3 100644
--- a/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx
@@ -1,7 +1,7 @@
-import { fireEvent, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import * as React from 'react';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';
import { VPCCreateDrawer } from './VPCCreateDrawer';
@@ -11,11 +11,27 @@ const props = {
open: true,
};
+const formOptions = {
+ defaultValues: {
+ description: '',
+ label: '',
+ region: '',
+ subnets: [
+ {
+ ipv4: '',
+ label: 'subnet 0',
+ },
+ ],
+ },
+};
+
describe('VPC Create Drawer', () => {
it('should render the vpc and subnet sections', () => {
- const { getAllByText } = renderWithTheme();
+ const { getAllByText } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: formOptions,
+ });
- getAllByText('Region');
getAllByText('VPC Label');
getAllByText('Region');
getAllByText('Description');
@@ -28,16 +44,21 @@ describe('VPC Create Drawer', () => {
});
it('should not be able to remove the first subnet', () => {
- renderWithTheme();
+ const { queryByTestId } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: formOptions,
+ });
- const deleteSubnet = screen.queryByTestId('delete-subnet-0');
- expect(deleteSubnet).not.toBeInTheDocument();
+ expect(queryByTestId('delete-subnet-0')).toBeNull();
});
- it('should close the drawer', () => {
- renderWithTheme();
- const cancelButton = screen.getByText('Cancel');
- fireEvent.click(cancelButton);
+ it('should close the drawer', async () => {
+ const { getByText } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: formOptions,
+ });
+ const cancelButton = getByText('Cancel');
+ await userEvent.click(cancelButton);
expect(props.onClose).toHaveBeenCalled();
});
});
diff --git a/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.tsx b/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.tsx
index d477be0dadb..28d77e166ee 100644
--- a/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.tsx
@@ -2,6 +2,7 @@ import { Box, Notice } from '@linode/ui';
import { useTheme } from '@mui/material/styles';
import Grid from '@mui/material/Unstable_Grid2';
import * as React from 'react';
+import { FormProvider } from 'react-hook-form';
import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
import { Drawer } from 'src/components/Drawer';
@@ -27,15 +28,10 @@ export const VPCCreateDrawer = (props: Props) => {
const { onClose, onSuccess, open, selectedRegion } = props;
const {
- formik,
- generalAPIError,
- generalSubnetErrorsFromAPI,
+ form,
isLoadingCreateVPC,
- onChangeField,
onCreateVPC,
regionsData,
- setGeneralAPIError,
- setGeneralSubnetErrorsFromAPI,
userCannotAddVPC,
} = useCreateVPC({
handleSelectVPC: onSuccess,
@@ -43,60 +39,52 @@ export const VPCCreateDrawer = (props: Props) => {
selectedRegion,
});
- const { errors, handleSubmit, resetForm, setFieldValue, values } = formik;
+ const {
+ formState: { errors },
+ handleSubmit,
+ reset,
+ } = form;
- React.useEffect(() => {
- if (open) {
- resetForm();
- setGeneralSubnetErrorsFromAPI([]);
- setGeneralAPIError(undefined);
- }
- }, [open, resetForm, setGeneralAPIError, setGeneralSubnetErrorsFromAPI]);
+ const handleDrawerClose = () => {
+ onClose();
+ reset();
+ };
return (
-
+
{userCannotAddVPC && CannotCreateVPCNotice}
-
- {generalAPIError ? (
-
- ) : null}
-
-
+
+
+
);
};
diff --git a/packages/manager/src/hooks/useCreateVPC.ts b/packages/manager/src/hooks/useCreateVPC.ts
index becb96d697c..e8bddfd357c 100644
--- a/packages/manager/src/hooks/useCreateVPC.ts
+++ b/packages/manager/src/hooks/useCreateVPC.ts
@@ -1,36 +1,22 @@
+import { yupResolver } from '@hookform/resolvers/yup';
+import { isEmpty } from '@linode/api-v4';
import { createVPCSchema } from '@linode/validation';
-import { useFormik } from 'formik';
import * as React from 'react';
+import { useForm } from 'react-hook-form';
import { useHistory, useLocation } from 'react-router-dom';
import { useGrants, useProfile } from 'src/queries/profile/profile';
import { useRegionsQuery } from 'src/queries/regions/regions';
import { useCreateVPCMutation } from 'src/queries/vpcs/vpcs';
import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics';
-import { handleVPCAndSubnetErrors } from 'src/utilities/formikErrorUtils';
import { getQueryParamsFromQueryString } from 'src/utilities/queryParams';
import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView';
import { DEFAULT_SUBNET_IPV4_VALUE } from 'src/utilities/subnets';
-import type {
- APIError,
- CreateSubnetPayload,
- CreateVPCPayload,
- VPC,
-} from '@linode/api-v4';
+import type { CreateVPCPayload, VPC } from '@linode/api-v4';
import type { LinodeCreateType } from 'src/features/Linodes/LinodeCreate/types';
-import type { SubnetError } from 'src/utilities/formikErrorUtils';
-import type { SubnetFieldState } from 'src/utilities/subnets';
// Custom hook to consolidate shared logic between VPCCreate.tsx and VPCCreateDrawer.tsx
-
-export interface CreateVPCFieldState {
- description: string;
- label: string;
- region: string;
- subnets: SubnetFieldState[];
-}
-
export interface UseCreateVPCInputs {
handleSelectVPC?: (vpc: VPC) => void;
onDrawerClose?: () => void;
@@ -46,6 +32,8 @@ export const useCreateVPC = (inputs: UseCreateVPCInputs) => {
selectedRegion,
} = inputs;
+ const previousSubmitCount = React.useRef(0);
+
const history = useHistory();
const { data: profile } = useProfile();
const { data: grants } = useGrants();
@@ -58,91 +46,21 @@ export const useCreateVPC = (inputs: UseCreateVPCInputs) => {
const { data: regions } = useRegionsQuery();
const regionsData = regions ?? [];
- const [
- generalSubnetErrorsFromAPI,
- setGeneralSubnetErrorsFromAPI,
- ] = React.useState();
- const [generalAPIError, setGeneralAPIError] = React.useState<
- string | undefined
- >();
-
const {
isPending: isLoadingCreateVPC,
mutateAsync: createVPC,
} = useCreateVPCMutation();
- // When creating the subnet payloads, we also create a mapping of the indexes of the subnets that appear on
- // the UI to the indexes of the subnets that the API will receive. This enables users to leave subnets blank
- // on the UI and still have any errors returned by the API correspond to the correct subnet
- const createSubnetsPayloadAndMapping = () => {
- const subnetsPayload: CreateSubnetPayload[] = [];
- const subnetIdxMapping: Record = {};
- let apiSubnetIdx = 0;
-
- for (let i = 0; i < formik.values.subnets.length; i++) {
- const { ip, label } = formik.values.subnets[i];
- // if we are inside the VPCCreateDrawer, we force the first subnet to always be included in the payload,
- // even if its fields are empty. This is for validation purposes - so that errors can be surfaced on the
- // first subnet's label and ipv4 field if applicable.
- if ((onDrawerClose && i === 0) || ip.ipv4 || label) {
- subnetsPayload.push({ ipv4: ip.ipv4, label });
- subnetIdxMapping[i] = apiSubnetIdx;
- apiSubnetIdx++;
- }
- }
-
- return {
- subnetsPayload,
- visualToAPISubnetMapping: subnetIdxMapping,
- };
- };
-
- const combineErrorsAndSubnets = (
- errors: Record,
- visualToAPISubnetMapping: Record
- ) => {
- return formik.values.subnets.map((subnet, idx) => {
- const apiSubnetIdx: number | undefined = visualToAPISubnetMapping[idx];
- // If the subnet has errors associated with it, include those errors in its state
- if ((apiSubnetIdx || apiSubnetIdx === 0) && errors[apiSubnetIdx]) {
- const errorData: SubnetError = errors[apiSubnetIdx];
- return {
- ...subnet,
- // @TODO VPC: IPv6 error handling
- ip: {
- ...subnet.ip,
- ipv4Error: errorData.ipv4 ?? '',
- },
- labelError: errorData.label ?? '',
- };
- } else {
- return subnet;
- }
- });
- };
-
- const onCreateVPC = async () => {
- formik.setSubmitting(true);
- setGeneralAPIError(undefined);
-
- const {
- subnetsPayload,
- visualToAPISubnetMapping,
- } = createSubnetsPayloadAndMapping();
-
- const createVPCPayload: CreateVPCPayload = {
- ...formik.values,
- subnets: subnetsPayload,
- };
-
+ const onCreateVPC = async (values: CreateVPCPayload) => {
try {
- const vpc = await createVPC(createVPCPayload);
+ const vpc = await createVPC(values);
if (pushToVPCPage) {
history.push(`/vpcs/${vpc.id}`);
} else {
if (handleSelectVPC && onDrawerClose) {
handleSelectVPC(vpc);
onDrawerClose();
+ form.reset();
}
}
@@ -156,86 +74,51 @@ export const useCreateVPC = (inputs: UseCreateVPCInputs) => {
});
}
} catch (errors) {
- const generalSubnetErrors = errors.filter(
- (error: APIError) =>
- // Both general and specific subnet errors include 'subnets' in their error field.
- // General subnet errors come in as { field: subnets.some_field, ...}, whereas
- // specific subnet errors come in as { field: subnets[some_index].some_field, ...}. So,
- // to avoid specific subnet errors, we filter out errors with a field that includes '['
- error.field &&
- error.field.includes('subnets') &&
- !error.field.includes('[')
- );
-
- if (generalSubnetErrors) {
- setGeneralSubnetErrorsFromAPI(generalSubnetErrors);
+ for (const error of errors) {
+ if (error?.field === 'subnets.label') {
+ form.setError('root.subnetLabel', { message: error.reason });
+ } else if (error?.field === 'subnets.ipv4') {
+ form.setError('root.subnetIPv4', { message: error.reason });
+ } else {
+ form.setError(error?.field ?? 'root', { message: error.reason });
+ }
}
- const indivSubnetErrors = handleVPCAndSubnetErrors(
- errors.filter(
- // ignore general subnet errors: !(the logic of filtering for only general subnet errors)
- (error: APIError) =>
- !error.field?.includes('subnets') ||
- !error.field ||
- error.field.includes('[')
- ),
- formik.setFieldError,
- setGeneralAPIError
- );
-
- // must combine errors and subnet data to avoid indexing weirdness when deleting a subnet
- const subnetsAndErrors = combineErrorsAndSubnets(
- indivSubnetErrors,
- visualToAPISubnetMapping
- );
- formik.setFieldValue('subnets', subnetsAndErrors);
-
- scrollErrorIntoView();
}
+ };
- formik.setSubmitting(false);
+ const defaultValues = {
+ description: '',
+ label: '',
+ region: selectedRegion ?? '',
+ subnets: [
+ {
+ ipv4: DEFAULT_SUBNET_IPV4_VALUE,
+ label: '',
+ },
+ ],
};
- const formik = useFormik({
- enableReinitialize: true,
- initialValues: {
- description: '',
- label: '',
- region: selectedRegion ?? '',
- subnets: [
- {
- ip: {
- availIPv4s: 256,
- ipv4: DEFAULT_SUBNET_IPV4_VALUE,
- ipv4Error: '',
- },
- label: '',
- labelError: '',
- },
- ] as SubnetFieldState[],
- } as CreateVPCFieldState,
- onSubmit: onCreateVPC,
- validateOnChange: false,
- validationSchema: createVPCSchema,
+ const form = useForm({
+ defaultValues,
+ mode: 'onBlur',
+ resolver: yupResolver(createVPCSchema),
+ values: { ...defaultValues },
});
- // Helper method to set a field's value and clear existing errors
- const onChangeField = (field: string, value: string) => {
- formik.setFieldValue(field, value);
- if (formik.errors[field as keyof CreateVPCFieldState]) {
- formik.setFieldError(field, undefined);
+ const { errors, submitCount } = form.formState;
+
+ React.useEffect(() => {
+ if (!isEmpty(errors) && submitCount > previousSubmitCount.current) {
+ scrollErrorIntoView(undefined, { behavior: 'smooth' });
}
- };
+ previousSubmitCount.current = submitCount;
+ }, [errors, submitCount]);
return {
- formik,
- generalAPIError,
- generalSubnetErrorsFromAPI,
+ form,
isLoadingCreateVPC,
- onChangeField,
onCreateVPC,
regionsData,
- setGeneralAPIError,
- setGeneralSubnetErrorsFromAPI,
userCannotAddVPC,
};
};
diff --git a/packages/manager/src/utilities/formikErrorUtils.test.ts b/packages/manager/src/utilities/formikErrorUtils.test.ts
index 48adf78a959..11bc89e5d45 100644
--- a/packages/manager/src/utilities/formikErrorUtils.test.ts
+++ b/packages/manager/src/utilities/formikErrorUtils.test.ts
@@ -1,7 +1,6 @@
import {
getFormikErrorsFromAPIErrors,
handleAPIErrors,
- handleVPCAndSubnetErrors,
set,
} from './formikErrorUtils';
@@ -34,100 +33,6 @@ describe('handleAPIErrors', () => {
});
});
-const subnetMultipleErrorsPerField = [
- {
- field: 'subnets[0].label',
- reason: 'not expected error for label',
- },
- {
- field: 'subnets[0].label',
- reason: 'expected error for label',
- },
- {
- field: 'subnets[3].label',
- reason: 'not expected error for label',
- },
- {
- field: 'subnets[3].label',
- reason: 'expected error for label',
- },
- {
- field: 'subnets[3].ipv4',
- reason: 'not expected error for ipv4',
- },
- {
- field: 'subnets[3].ipv4',
- reason: 'expected error for ipv4',
- },
-];
-
-const subnetErrors = [
- {
- field: 'subnets[1].label',
- reason: 'Label required',
- },
- {
- field: 'subnets[2].label',
- reason: 'bad label',
- },
- {
- field: 'subnets[2].ipv4',
- reason: 'cidr ipv4',
- },
- {
- field: 'subnets[4].ipv4',
- reason: 'needs an ip',
- },
- {
- field: 'subnets[4].ipv6',
- reason: 'needs an ipv6',
- },
-];
-
-describe('handleVpcAndConvertSubnetErrors', () => {
- it('converts API errors for subnets into a map of subnet index to SubnetErrors', () => {
- const errors = handleVPCAndSubnetErrors(
- subnetErrors,
- setFieldError,
- setError
- );
- expect(Object.keys(errors)).toHaveLength(3);
- expect(Object.keys(errors)).toEqual(['1', '2', '4']);
- expect(errors[1]).toEqual({ label: 'Label required' });
- expect(errors[2]).toEqual({ ipv4: 'cidr ipv4', label: 'bad label' });
- expect(errors[4]).toEqual({ ipv4: 'needs an ip', ipv6: 'needs an ipv6' });
- });
-
- it('takes the last error to display if a subnet field has multiple errors associated with it', () => {
- const errors = handleVPCAndSubnetErrors(
- subnetMultipleErrorsPerField,
- setFieldError,
- setError
- );
- expect(Object.keys(errors)).toHaveLength(2);
- expect(errors[0]).toEqual({ label: 'expected error for label' });
- expect(errors[3]).toEqual({
- ipv4: 'expected error for ipv4',
- label: 'expected error for label',
- });
- });
-
- it('passes errors without the subnet field to handleApiErrors', () => {
- const errors = handleVPCAndSubnetErrors(
- errorWithField,
- setFieldError,
- setError
- );
- expect(Object.keys(errors)).toHaveLength(0);
- expect(errors).toEqual({});
- expect(setFieldError).toHaveBeenCalledWith(
- 'card_number',
- errorWithField[0].reason
- );
- expect(setError).not.toHaveBeenCalled();
- });
-});
-
describe('getFormikErrorsFromAPIErrors', () => {
it('should convert APIError[] to errors in the shape formik expects', () => {
const testCases = [
diff --git a/packages/manager/src/utilities/formikErrorUtils.ts b/packages/manager/src/utilities/formikErrorUtils.ts
index 290c75b9638..2ae6cbcb776 100644
--- a/packages/manager/src/utilities/formikErrorUtils.ts
+++ b/packages/manager/src/utilities/formikErrorUtils.ts
@@ -145,57 +145,3 @@ export const handleAPIErrors = (
}
});
};
-
-export interface SubnetError {
- ipv4?: string;
- ipv6?: string;
- label?: string;
-}
-
-/**
- * Handles given API errors and converts any specific subnet related errors into a usable format;
- * Returns a map of subnets' indexes to their @interface SubnetError
- * Example: errors = [{ reason: 'error1', field: 'subnets[1].label' },
- * { reason: 'error2', field: 'subnets[1].ipv4' },
- * { reason: 'not a subnet error so will not appear in return obj', field: 'label'},
- * { reason: 'error3', field: 'subnets[4].ipv4' }]
- * returns: {
- * 1: { label: 'error1', ipv4: 'error2' },
- * 4: { ipv4: 'error3'}
- * }
- *
- * @param errors the errors from the API
- * @param setFieldError function to set non-subnet related field errors
- * @param setError function to set (non-subnet related) general API errors
- */
-export const handleVPCAndSubnetErrors = (
- errors: APIError[],
- setFieldError: (field: string, message: string) => void,
- setError?: (message: string) => void
-) => {
- const subnetErrors: Record = {};
- const nonSubnetErrors: APIError[] = [];
-
- errors.forEach((error) => {
- if (error.field && error.field.includes('subnets[')) {
- const [subnetIdx, field] = error.field.split('.');
- const idx = parseInt(
- subnetIdx.substring(subnetIdx.indexOf('[') + 1, subnetIdx.indexOf(']')),
- 10
- );
-
- // if there already exists some previous error for the subnet at index idx, we
- // just add the current error. Otherwise, we create a new entry for the subnet.
- if (subnetErrors[idx]) {
- subnetErrors[idx] = { ...subnetErrors[idx], [field]: error.reason };
- } else {
- subnetErrors[idx] = { [field]: error.reason };
- }
- } else {
- nonSubnetErrors.push(error);
- }
- });
-
- handleAPIErrors(nonSubnetErrors, setFieldError, setError);
- return subnetErrors;
-};
diff --git a/packages/manager/src/utilities/subnets.ts b/packages/manager/src/utilities/subnets.ts
index 31564f72f8f..93ff787506d 100644
--- a/packages/manager/src/utilities/subnets.ts
+++ b/packages/manager/src/utilities/subnets.ts
@@ -11,23 +11,6 @@ export const SUBNET_LINODE_CSV_HEADERS = [
{ key: 'interfaceData.ip_ranges', label: 'IPv4 VPC Ranges' },
];
-// @TODO VPC: added ipv6 related fields here, but they will not be used until VPCs support ipv6
-export interface SubnetIPState {
- availIPv4s?: number;
- ipv4?: string;
- ipv4Error?: string;
- ipv6?: string;
- ipv6Error?: string;
-}
-
-export interface SubnetFieldState {
- ip: SubnetIPState;
- label: string;
- labelError?: string;
-}
-
-export type SubnetIPType = 'ipv4' | 'ipv6';
-
/**
* Maps subnet mask length to number of theoretically available IPs.
* - To get usable IPs, subtract 2 from the given number, as the first and last
diff --git a/packages/validation/.changeset/pr-11357-changed-1733929595429.md b/packages/validation/.changeset/pr-11357-changed-1733929595429.md
new file mode 100644
index 00000000000..66aec530c3f
--- /dev/null
+++ b/packages/validation/.changeset/pr-11357-changed-1733929595429.md
@@ -0,0 +1,5 @@
+---
+"@linode/validation": Changed
+---
+
+Update VPC validation to temporarily hide mention of IPv6 in UI, fix punctuation ([#11357](https://github.com/linode/manager/pull/11357))
diff --git a/packages/validation/src/vpcs.schema.ts b/packages/validation/src/vpcs.schema.ts
index 4484e08047f..be92dd771e3 100644
--- a/packages/validation/src/vpcs.schema.ts
+++ b/packages/validation/src/vpcs.schema.ts
@@ -13,6 +13,8 @@ const labelTestDetails = {
const IP_EITHER_BOTH_NOT_NEITHER =
'A subnet must have either IPv4 or IPv6, or both, but not neither.';
+// @TODO VPC - remove below constant when IPv6 is added
+const TEMPORARY_IPV4_REQUIRED_MESSAGE = 'A subnet must have an IPv4 range.';
export const determineIPType = (ip: string) => {
try {
@@ -128,9 +130,11 @@ export const createSubnetSchema = object().shape(
is: (value: unknown) =>
value === '' || value === null || value === undefined,
then: (schema) =>
- schema.required(IP_EITHER_BOTH_NOT_NEITHER).test({
+ // @TODO VPC - change required message back to IP_EITHER_BOTH_NOT_NEITHER when IPv6 is supported
+ // Since only IPv4 is currently supported, subnets must have an IPv4
+ schema.required(TEMPORARY_IPV4_REQUIRED_MESSAGE).test({
name: 'IPv4 CIDR format',
- message: 'The IPv4 range must be in CIDR format',
+ message: 'The IPv4 range must be in CIDR format.',
test: (value) =>
vpcsValidateIP({
value,
@@ -147,7 +151,7 @@ export const createSubnetSchema = object().shape(
case 'string':
return schema.notRequired().test({
name: 'IPv4 CIDR format',
- message: 'The IPv4 range must be in CIDR format',
+ message: 'The IPv4 range must be in CIDR format.',
test: (value) =>
vpcsValidateIP({
value,