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} -
+ + {errors.root?.message && ( + + )} VPC ({ marginTop: theme.spacing(2.5) })}> - + { disabled: userCannotAddVPC, label: 'Create VPC', loading: isLoadingCreateVPC, - onClick: onCreateVPC, + onClick: handleSubmit(onCreateVPC), + type: 'submit', }} />
- +
); }; 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} -
- - + + + {errors.root?.message ? ( + + ) : null} + + + + + - - - { - onCreateVPC(); - }, - }} - secondaryButtonProps={{ - 'data-testid': 'cancel', - label: 'Cancel', - onClick: onClose, - }} - style={{ marginTop: theme.spacing(3) }} - /> - -
+ + +
); }; 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,