diff --git a/packages/manager/.changeset/pr-10374-upcoming-features-1712936111438.md b/packages/manager/.changeset/pr-10374-upcoming-features-1712936111438.md new file mode 100644 index 00000000000..86db3d4dd48 --- /dev/null +++ b/packages/manager/.changeset/pr-10374-upcoming-features-1712936111438.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Linode Create Refactor - Validation ([#10374](https://github.com/linode/manager/pull/10374)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 95ba61b9112..8a7751e8327 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -16,6 +16,7 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@hookform/resolvers": "2.9.11", "@linode/api-v4": "*", "@linode/validation": "*", "@lukemorales/query-key-factory": "^1.3.4", diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 763c1a83157..55eb20c369c 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -125,6 +125,7 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { }, })} textFieldProps={{ + ...props.textFieldProps, InputProps: { endAdornment: regionFilter !== 'core' && selectedRegion?.site_type === 'edge' && ( diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 0fb480ff6a7..407018317c1 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -26,8 +26,10 @@ import { DocsLink } from '../DocsLink/DocsLink'; import { Link } from '../Link'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; +import { RegionSelectProps } from '../RegionSelect/RegionSelect.types'; interface SelectRegionPanelProps { + RegionSelectProps?: Partial; currentCapability: Capabilities; disabled?: boolean; error?: string; @@ -49,6 +51,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { helperText, selectedId, selectedLinodeTypeId, + RegionSelectProps, } = props; const flags = useFlags(); @@ -147,6 +150,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { regions={regions ?? []} selectedId={selectedId || null} showEdgeIconHelperText={showEdgeIconHelperText} + {...RegionSelectProps} /> {showClonePriceWarning && ( void; /** * Is called when a VLAN is selected */ @@ -42,7 +46,7 @@ interface Props { * - Allows VLAN creation */ export const VLANSelect = (props: Props) => { - const { disabled, errorText, filter, onChange, sx, value } = props; + const { disabled, errorText, filter, onBlur, onChange, sx, value } = props; const [open, setOpen] = React.useState(false); const [inputValue, setInputValue] = useState(''); @@ -124,6 +128,7 @@ export const VLANSelect = (props: Props) => { label="VLAN" loading={isFetching} noOptionsText="You have no VLANs in this region. Type to create one." + onBlur={onBlur} open={open} options={vlans} placeholder="Create or select a VLAN" diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx index 1daa81d6edd..ce033562c43 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx @@ -32,9 +32,11 @@ export const Access = () => { autoComplete="off" disabled={isLinodeCreateRestricted} errorText={fieldState.error?.message} + inputRef={field.ref} label="Root Password" name="password" noMarginTop + onBlur={field.onBlur} onChange={field.onChange} placeholder="Enter a password." value={field.value ?? ''} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx index ed1fc1b205a..06ba195494c 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx @@ -29,7 +29,9 @@ export const Details = () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx index 866fb1a314a..70ae45379a1 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx @@ -8,16 +8,20 @@ import { Paper } from 'src/components/Paper'; import type { CreateLinodeRequest } from '@linode/api-v4'; export const Error = () => { - const { formState } = useFormContext(); + const { + formState: { errors }, + } = useFormContext(); - if (!formState.errors.root?.message) { + const generalError = errors.root?.message ?? errors.interfaces?.message; + + if (!generalError) { return null; } return ( - {formState.errors.root.message} + {generalError} ); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Firewall.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Firewall.tsx index 4188f6581ad..df56b2998b9 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Firewall.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Firewall.tsx @@ -48,6 +48,7 @@ export const Firewall = () => { label="Assign Firewall" loading={isLoading} noMarginTop + onBlur={field.onBlur} onChange={(e, firewall) => field.onChange(firewall?.id ?? null)} options={firewalls ?? []} placeholder="None" diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx index 92090c96788..ee2bc22bbf1 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -17,6 +17,11 @@ export const Region = () => { return ( { field.onChange(image?.id ?? null)} value={field.value} variant="public" diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx index 84ccb844c03..3e766b86341 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx @@ -23,6 +23,7 @@ export const Images = () => { field.onChange(image?.id ?? null)} value={field.value} variant="private" diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx index 982c57bb7b1..e570a4f0013 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx @@ -80,22 +80,24 @@ export const UserData = () => { ( + onBlur={(e) => { + field.onBlur(); checkFormat({ hasInputValueChanged: false, userData: e.target.value, - }) - } + }); + }} onChange={(e) => { + field.onChange(e); checkFormat({ hasInputValueChanged: true, userData: e.target.value, }); - field.onChange(e); }} disabled={isLinodeCreateRestricted} errorText={fieldState.error?.message} expand + inputRef={field.ref} label="User Data" labelTooltipText="Compatible formats include cloud-config data and executable scripts." multiline diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx index 6dc1ee78999..1cae85da5ed 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx @@ -86,8 +86,9 @@ export const VLAN = () => { disabled={disabled} errorText={fieldState.error?.message} filter={{ region: regionId }} + onBlur={field.onBlur} onChange={field.onChange} - sx={{ minWidth: 300 }} + sx={{ width: 300 }} value={field.value ?? null} /> )} @@ -100,13 +101,15 @@ export const VLAN = () => { tooltipText={ 'IPAM address must use IP/netmask format, e.g. 192.0.2.0/24.' } + containerProps={{ maxWidth: 335 }} disabled={disabled} errorText={fieldState.error?.message} + inputRef={field.ref} label="IPAM Address" + onBlur={field.onBlur} onChange={field.onChange} optional placeholder="192.0.2.0/24" - sx={{ maxWidth: 300 }} value={field.value ?? ''} /> )} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx index 48c4f49b85e..4c5a44e4a6f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx @@ -92,6 +92,7 @@ export const VPC = () => { : undefined } textFieldProps={{ + inputRef: field.ref, sx: (theme) => ({ [theme.breakpoints.up('sm')]: { minWidth: inputMaxWidth }, }), @@ -125,6 +126,9 @@ export const VPC = () => { getOptionLabel={(subnet) => `${subnet.label} (${subnet.ipv4})` } + textFieldProps={{ + inputRef: field.ref, + }} value={ selectedVPC?.subnets.find( (subnet) => subnet.id === field.value @@ -133,6 +137,7 @@ export const VPC = () => { errorText={fieldState.error?.message} label="Subnet" noMarginTop + onBlur={field.onBlur} onChange={(e, subnet) => field.onChange(subnet?.id ?? null)} options={selectedVPC?.subnets ?? []} placeholder="Select Subnet" @@ -182,11 +187,14 @@ export const VPC = () => { ( )} @@ -240,6 +248,12 @@ export const VPC = () => { . + {formState.errors.interfaces?.[0]?.ip_ranges?.message && ( + + )} )} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPCRanges.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPCRanges.tsx index 910efbfaecb..fedb35aa619 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPCRanges.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPCRanges.tsx @@ -32,9 +32,12 @@ export const VPCRanges = () => { )} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index c93a9cc0a57..5e6fe165541 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -28,6 +28,7 @@ import { defaultValues, getLinodeCreatePayload, getTabIndex, + resolver, tabs, useLinodeCreateQueryParams, } from './utilities'; @@ -38,7 +39,12 @@ import type { CreateLinodeRequest } from '@linode/api-v4'; import type { SubmitHandler } from 'react-hook-form'; export const LinodeCreatev2 = () => { - const methods = useForm({ defaultValues }); + const methods = useForm({ + defaultValues, + mode: 'onBlur', + resolver, + }); + const history = useHistory(); const { mutateAsync: createLinode } = useCreateLinodeMutation(); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx index 0651af3fde3..fb614917f88 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx @@ -24,7 +24,7 @@ describe('getLinodeCreatePayload', () => { it('should return a basic payload', () => { const values = createLinodeRequestFactory.build(); - expect(getLinodeCreatePayload(values)).toStrictEqual(values); + expect(getLinodeCreatePayload(values)).toEqual(values); }); it('should base64 encode metadata', () => { @@ -32,7 +32,7 @@ describe('getLinodeCreatePayload', () => { metadata: { user_data: userData }, }); - expect(getLinodeCreatePayload(values)).toStrictEqual({ + expect(getLinodeCreatePayload(values)).toEqual({ ...values, metadata: { user_data: base64UserData }, }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 3e1403086af..34cde1fe1e5 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -1,3 +1,5 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { CreateLinodeSchema } from '@linode/validation'; import { useHistory } from 'react-router-dom'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; @@ -6,6 +8,7 @@ import { utoa } from '../LinodesCreate/utilities'; import type { LinodeCreateType } from '../LinodesCreate/types'; import type { CreateLinodeRequest, InterfacePayload } from '@linode/api-v4'; +import type { Resolver } from 'react-hook-form'; /** * This interface is used to type the query params on the Linode Create flow. @@ -74,20 +77,21 @@ export const tabs: LinodeCreateType[] = [ export const getLinodeCreatePayload = ( payload: CreateLinodeRequest ): CreateLinodeRequest => { - if (payload.metadata?.user_data) { - payload.metadata.user_data = utoa(payload.metadata.user_data); + const values = { ...payload }; + if (values.metadata?.user_data) { + values.metadata.user_data = utoa(values.metadata.user_data); } - if (!payload.metadata?.user_data) { - payload.metadata = undefined; + if (!values.metadata?.user_data) { + values.metadata = undefined; } - payload.interfaces = getInterfacesPayload( - payload.interfaces, - Boolean(payload.private_ip) + values.interfaces = getInterfacesPayload( + values.interfaces, + Boolean(values.private_ip) ); - return payload; + return values; }; /** @@ -161,3 +165,29 @@ export const defaultValues: CreateLinodeRequest = { region: '', type: '', }; + +/** + * Provides dynamic validation to the Linode Create form. + * + * Unfortunately, we have to wrap `yupResolver` so that we can transform the payload + * using `getLinodeCreatePayload` before validation happens. + */ +export const resolver: Resolver = async ( + values, + context, + options +) => { + const transformedValues = getLinodeCreatePayload(values); + + const { errors } = await yupResolver( + CreateLinodeSchema, + {}, + { rawValues: true } + )(transformedValues, context, options); + + if (errors) { + return { errors, values }; + } + + return { errors: {}, values }; +}; diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 673c46d6c18..b9a71269c4c 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -173,7 +173,9 @@ export const LinodeInterfaceSchema = object().shape({ primary: boolean().notRequired(), subnet_id: number().when('purpose', { is: 'vpc', - then: number().required('Subnet is required.'), + then: number() + .transform((value) => (isNaN(value) ? undefined : value)) + .required('Subnet is required.'), otherwise: number().test({ name: testnameDisallowedBasedOnPurpose('VPC'), message: testmessageDisallowedBasedOnPurpose('vpc', 'subnet_id'), diff --git a/yarn.lock b/yarn.lock index 878b7763065..30739364956 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1817,6 +1817,11 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== +"@hookform/resolvers@2.9.11": + version "2.9.11" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.9.11.tgz#9ce96e7746625a89239f68ca57c4f654264c17ef" + integrity sha512-bA3aZ79UgcHj7tFV7RlgThzwSSHZgvfbt2wprldRkYBcMopdMvHyO17Wwp/twcJasNFischFfS7oz8Katz8DdQ== + "@humanwhocodes/config-array@^0.11.13": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"