diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 557504d6341..bf83834138a 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -201,7 +201,23 @@ export interface Interface { ip_ranges?: string[]; } -export type InterfacePayload = Omit; +export interface InterfacePayload { + /** + * Required to specify a VLAN + */ + label?: string | null; + purpose: InterfacePurpose; + /** + * Used for VLAN, but is optional + */ + ipam_address?: string | null; + primary?: boolean; + subnet_id?: number | null; + vpc_id?: number | null; + ipv4?: ConfigInterfaceIPv4; + ipv6?: ConfigInterfaceIPv6; + ip_ranges?: string[] | null; +} export interface ConfigInterfaceOrderPayload { ids: number[]; @@ -527,7 +543,7 @@ export interface CreateLinodeRequest { * This is used to set the swap disk size for the newly-created Linode. * @default 512 */ - swap_size?: number; + swap_size?: number | null; /** * An Image ID to deploy the Linode Disk from. */ @@ -540,7 +556,7 @@ export interface CreateLinodeRequest { * A list of public SSH keys that will be automatically appended to the root user’s * `~/.ssh/authorized_keys`file when deploying from an Image. */ - authorized_keys?: string[]; + authorized_keys?: string[] | null; /** * If this field is set to true, the created Linode will automatically be enrolled in the Linode Backup service. * This will incur an additional charge. The cost for the Backup service is dependent on the Type of Linode deployed. @@ -549,7 +565,7 @@ export interface CreateLinodeRequest { * * @default false */ - backups_enabled?: boolean; + backups_enabled?: boolean | null; /** * This field is required only if the StackScript being deployed requires input data from the User for successful completion */ @@ -560,29 +576,29 @@ export interface CreateLinodeRequest { * @default true if the Linode is created with an Image or from a Backup. * @default false if using new Linode Interfaces and no interfaces are defined */ - booted?: boolean; + booted?: boolean | null; /** * The Linode’s label is for display purposes only. * If no label is provided for a Linode, a default will be assigned. */ - label?: string; + label?: string | null; /** * An array of tags applied to this object. * * Tags are for organizational purposes only. */ - tags?: string[]; + tags?: string[] | null; /** * If true, the created Linode will have private networking enabled and assigned a private IPv4 address. * @default false */ - private_ip?: boolean; + private_ip?: boolean | null; /** * A list of usernames. If the usernames have associated SSH keys, * the keys will be appended to the root users `~/.ssh/authorized_keys` * file automatically when deploying from an Image. */ - authorized_users?: string[]; + authorized_users?: string[] | null; /** * An array of Network Interfaces to add to this Linode’s Configuration Profile. */ @@ -598,7 +614,7 @@ export interface CreateLinodeRequest { * * Default value on depends on interfaces_for_new_linodes field in AccountSettings object. */ - interface_generation?: InterfaceGenerationType; + interface_generation?: InterfaceGenerationType | null; /** * Default value mirrors network_helper in AccountSettings object. Should only be * present when using Linode Interfaces. @@ -612,7 +628,7 @@ export interface CreateLinodeRequest { /** * An object containing user-defined data relevant to the creation of Linodes. */ - metadata?: UserData; + metadata?: UserData | null; /** * The `id` of the Firewall to attach this Linode to upon creation. */ @@ -620,12 +636,12 @@ export interface CreateLinodeRequest { /** * An object that assigns this the Linode to a placement group upon creation. */ - placement_group?: CreateLinodePlacementGroupPayload; + placement_group?: CreateLinodePlacementGroupPayload | null; /** * A property with a string literal type indicating whether the Linode is encrypted or unencrypted. * @default 'enabled' (if the region supports LDE) */ - disk_encryption?: EncryptionStatus; + disk_encryption?: EncryptionStatus | null; } export interface MigrateLinodeRequest { diff --git a/packages/manager/.changeset/pr-11847-tech-stories-1742223197152.md b/packages/manager/.changeset/pr-11847-tech-stories-1742223197152.md new file mode 100644 index 00000000000..cd1a49a0272 --- /dev/null +++ b/packages/manager/.changeset/pr-11847-tech-stories-1742223197152.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Improved type-safety of Linode Create flow form ([#11847](https://github.com/linode/manager/pull/11847)) diff --git a/packages/manager/.changeset/pr-11847-upcoming-features-1742223153659.md b/packages/manager/.changeset/pr-11847-upcoming-features-1742223153659.md new file mode 100644 index 00000000000..b39573e6062 --- /dev/null +++ b/packages/manager/.changeset/pr-11847-upcoming-features-1742223153659.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Initial support for VPCs using Linode Interfaces on the Linode create flow ([#11847](https://github.com/linode/manager/pull/11847)) diff --git a/packages/manager/src/components/VLANSelect.tsx b/packages/manager/src/components/VLANSelect.tsx index f593e3011b1..00da2fbff28 100644 --- a/packages/manager/src/components/VLANSelect.tsx +++ b/packages/manager/src/components/VLANSelect.tsx @@ -20,6 +20,10 @@ interface Props { * Default API filter */ filter?: Filter; + /** + * Helper text that will show below the select + */ + helperText?: string; /** * Called when the field is blurred */ @@ -45,7 +49,16 @@ interface Props { * - Allows VLAN creation */ export const VLANSelect = (props: Props) => { - const { disabled, errorText, filter, onBlur, onChange, sx, value } = props; + const { + disabled, + errorText, + filter, + helperText, + onBlur, + onChange, + sx, + value, + } = props; const [open, setOpen] = React.useState(false); const [inputValue, setInputValue] = useState(''); @@ -133,6 +146,7 @@ export const VLANSelect = (props: Props) => { }} disabled={disabled} errorText={errorText ?? error?.[0].reason} + helperText={helperText} inputValue={selectedVLAN ? selectedVLAN.label : inputValue} label="VLAN" loading={isFetching} diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/Addons/utilities.ts index fccad7aa0c7..a6a97f31560 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/utilities.ts @@ -1,7 +1,9 @@ +import type { LinodeCreateFormValues } from '../utilities'; + interface BackupsEnabledOptions { accountBackupsEnabled: boolean | undefined; isDistributedRegion: boolean; - value: boolean | undefined; + value: LinodeCreateFormValues['backups_enabled']; } export const getBackupsEnabledValue = (options: BackupsEnabledOptions) => { @@ -13,7 +15,7 @@ export const getBackupsEnabledValue = (options: BackupsEnabledOptions) => { return true; } - if (options.value === undefined) { + if (options.value === undefined || options.value === null) { return false; } diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/LinodeInterface.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/LinodeInterface.tsx index 56e88793685..88ddc67987d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/LinodeInterface.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/LinodeInterface.tsx @@ -5,6 +5,7 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { InterfaceFirewall } from './InterfaceFirewall'; import { InterfaceType } from './InterfaceType'; import { VLAN } from './VLAN'; +import { VPC } from './VPC'; import type { LinodeCreateFormValues } from '../utilities'; @@ -40,14 +41,21 @@ export const LinodeInterface = ({ index, onRemove }: Props) => { Interface eth{index} {index !== 0 && } - {errors.interfaces?.[index]?.purpose?.message && ( + {errors.linodeInterfaces?.[index]?.message && ( + )} + {errors.linodeInterfaces?.[index]?.purpose?.message && ( + )} {interfaceType === 'vlan' && } + {interfaceType === 'vpc' && } {interfaceGeneration === 'linode' && } ); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/Networking.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/Networking.tsx index d0e94b66752..9ecbf859fd3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/Networking.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/Networking.tsx @@ -1,13 +1,14 @@ import { Button, Divider, + Notice, Paper, PlusSignIcon, Stack, Typography, } from '@linode/ui'; import React from 'react'; -import { useFieldArray, useWatch } from 'react-hook-form'; +import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; import { Firewall } from './Firewall'; import { InterfaceGeneration } from './InterfaceGeneration'; @@ -16,17 +17,20 @@ import { LinodeInterface } from './LinodeInterface'; import type { LinodeCreateFormValues } from '../utilities'; export const Networking = () => { - const { append, fields, remove } = useFieldArray< - LinodeCreateFormValues, - 'linodeInterfaces' - >({ + const { + control, + formState: { errors }, + } = useFormContext(); + + const { append, fields, remove } = useFieldArray({ + control, name: 'linodeInterfaces', }); - const interfaceGeneration = useWatch< - LinodeCreateFormValues, - 'interface_generation' - >({ name: 'interface_generation' }); + const interfaceGeneration = useWatch({ + control, + name: 'interface_generation', + }); return ( @@ -54,6 +58,9 @@ export const Networking = () => { Add Another Interface + {errors.linodeInterfaces?.message && ( + + )} {fields.map((field, index) => ( { const regionId = useWatch({ control, name: 'region' }); + const { data: selectedRegion } = useRegionQuery(regionId); + + const regionSupportsVLANs = + selectedRegion?.capabilities.includes('Vlans') ?? false; + return ( - - ( - - )} - control={control} - name={`linodeInterfaces.${index}.vlan.vlan_label`} - /> - ( - - )} - control={control} - name={`linodeInterfaces.${index}.vlan.ipam_address`} - /> + + {!regionId && ( + + )} + {selectedRegion && !regionSupportsVLANs && ( + + )} + + ( + + )} + control={control} + name={`linodeInterfaces.${index}.vlan.vlan_label`} + /> + ( + + )} + control={control} + name={`linodeInterfaces.${index}.vlan.ipam_address`} + /> + ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/VPC.tsx new file mode 100644 index 00000000000..fe7c5d3ee6b --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/VPC.tsx @@ -0,0 +1,146 @@ +import { useAllVPCsQuery, useRegionQuery } from '@linode/queries'; +import { Autocomplete, Box, Notice, Stack } from '@linode/ui'; +import React, { useState } from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import { LinkButton } from 'src/components/LinkButton'; +import { REGION_CAVEAT_HELPER_TEXT } from 'src/features/VPCs/constants'; +import { VPCCreateDrawer } from 'src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer'; + +import type { LinodeCreateFormValues } from '../utilities'; + +interface Props { + index: number; +} + +export const VPC = ({ index }: Props) => { + const { + control, + setValue, + resetField, + } = useFormContext(); + const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false); + + const [regionId, selectedVPCId] = useWatch({ + control, + name: ['region', `linodeInterfaces.${index}.vpc.vpc_id`], + }); + + const { data: selectedRegion } = useRegionQuery(regionId); + + const regionSupportsVPCs = + selectedRegion?.capabilities.includes('VPCs') ?? false; + + const { data: vpcs, error, isLoading } = useAllVPCsQuery({ + enabled: regionSupportsVPCs, + filter: { region: regionId }, + }); + + const selectedVPC = vpcs?.find((vpc) => vpc.id === selectedVPCId); + + return ( + + + {!regionId && ( + + )} + {selectedRegion && !regionSupportsVPCs && ( + + )} + ( + { + field.onChange(vpc?.id ?? null); + + if (vpc && vpc.subnets.length === 1) { + // If the user selectes a VPC and the VPC only has one subnet, + // preselect that subnet for the user. + setValue( + `linodeInterfaces.${index}.vpc.subnet_id`, + vpc.subnets[0].id, + { shouldValidate: true } + ); + } else { + // Otherwise, just clear the selected subnet + resetField(`linodeInterfaces.${index}.vpc.subnet_id`); + } + }} + textFieldProps={{ + tooltipText: REGION_CAVEAT_HELPER_TEXT, + }} + disabled={!regionSupportsVPCs} + errorText={error?.[0].reason ?? fieldState.error?.message} + label="VPC" + loading={isLoading} + noMarginTop + onBlur={field.onBlur} + options={vpcs ?? []} + placeholder="None" + value={selectedVPC ?? null} + /> + )} + control={control} + name={`linodeInterfaces.${index}.vpc.vpc_id`} + /> + {regionId && regionSupportsVPCs && ( + + setIsCreateDrawerOpen(true)}> + Create VPC + + + )} + ( + subnet.id === field.value + ) ?? null + } + disabled={!regionSupportsVPCs} + errorText={fieldState.error?.message} + getOptionLabel={(subnet) => `${subnet.label} (${subnet.ipv4})`} + label="Subnet" + noMarginTop + onBlur={field.onBlur} + onChange={(e, subnet) => field.onChange(subnet?.id ?? null)} + options={selectedVPC?.subnets ?? []} + placeholder="Select Subnet" + /> + )} + control={control} + name={`linodeInterfaces.${index}.vpc.subnet_id`} + /> + + { + setValue(`linodeInterfaces.${index}.vpc.vpc_id`, vpc.id, { + shouldValidate: true, + }); + + if (vpc.subnets.length === 1) { + // If the user creates a VPC with just one subnet, + // preselect it for them + setValue( + `linodeInterfaces.${index}.vpc.subnet_id`, + vpc.subnets[0].id, + { + shouldValidate: true, + } + ); + } + }} + onClose={() => setIsCreateDrawerOpen(false)} + open={isCreateDrawerOpen} + selectedRegion={regionId} + /> + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreate/Networking/utilities.test.ts index ef7a4dbec6c..acb500b8970 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/utilities.test.ts @@ -1,6 +1,10 @@ import { linodeInterfaceFactoryPublic } from '@linode/utilities'; -import { getLinodeInterfacePayload } from './utilities'; +import { + transformLegacyInterfaceErrorsToLinodeInterfaceErrors, + getLinodeInterfacePayload, +} from './utilities'; +import { APIError } from '@linode/api-v4'; describe('Linode Create Networking Utilities', () => { describe('getLinodeInterfacesPayload', () => { @@ -48,4 +52,18 @@ describe('Linode Create Networking Utilities', () => { }); }); }); + + describe('getLinodeInterfaceErrorsFromLegacyInterfaceErrors', () => { + it('transforms a legacy error into a linodeInterface error', () => { + const error: APIError[] = [ + { field: 'interfaces[1].subnet_id', reason: 'Fake message' }, + ]; + + expect( + transformLegacyInterfaceErrorsToLinodeInterfaceErrors(error) + ).toStrictEqual([ + { field: 'linodeInterfaces[1].vpc.subnet_id', reason: 'Fake message' }, + ]); + }); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/Networking/utilities.ts index 1db01ac0ccc..734857a706d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/utilities.ts @@ -1,4 +1,5 @@ import type { + APIError, CreateLinodeInterfacePayload, InterfacePayload, InterfacePurpose, @@ -52,19 +53,57 @@ export const getLegacyInterfaceFromLinodeInterface = ( ): InterfacePayload => { const purpose = linodeInterface.purpose; - return { - ip_ranges: linodeInterface.vpc?.ipv4?.ranges?.map(({ range }) => range), - ipam_address: linodeInterface.vlan?.ipam_address ?? null, - ipv4: - purpose === 'vpc' - ? { - nat_1_1: linodeInterface.vpc?.ipv4?.addresses?.[0].nat_1_1_address, - vpc: linodeInterface.vpc?.ipv4?.addresses?.[0].address, - } - : undefined, - label: linodeInterface.vlan?.vlan_label ?? null, - purpose, - subnet_id: linodeInterface.vpc?.subnet_id, - vpc_id: linodeInterface.vpc?.vpc_id, - }; + if (purpose === 'vlan') { + return { + ipam_address: linodeInterface.vlan?.ipam_address, + label: linodeInterface.vlan?.vlan_label, + purpose, + }; + } + + if (purpose === 'vpc') { + return { + ip_ranges: linodeInterface.vpc?.ipv4?.ranges?.map(({ range }) => range), + ipv4: { + nat_1_1: linodeInterface.vpc?.ipv4?.addresses?.[0].nat_1_1_address, + vpc: linodeInterface.vpc?.ipv4?.addresses?.[0].address, + }, + purpose, + subnet_id: linodeInterface.vpc?.subnet_id, + vpc_id: linodeInterface.vpc?.vpc_id, + }; + } + + return { purpose: 'public' }; +}; + +const legacyFieldToNewFieldMap = { + '].label': '].vlan.vlan_lanel', + '].subnet_id': '].vpc.subnet_id', +}; + +/** + * Our form's state stores interfaces in the new "Linode Interfaces" shape. + * If the user selects legacy interfaces, we tranform the new interface into legacy interfaces. + * + * If the user selects legacy interfaces and the API returns API errors in the shape of legacy interface, + * we need to map the errors to the new Linode Interfaces shape so they surface correctly in the UI. + */ +export const transformLegacyInterfaceErrorsToLinodeInterfaceErrors = ( + errors: APIError[] +) => { + for (const error of errors) { + for (const key in legacyFieldToNewFieldMap) { + if (error.field && error.field.includes(key)) { + error.field = error.field.replace( + key, + legacyFieldToNewFieldMap[key as keyof typeof legacyFieldToNewFieldMap] + ); + } + if (error.field && error.field.startsWith('interfaces')) { + error.field = error.field.replace('interfaces', 'linodeInterfaces'); + } + } + } + return errors; }; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx index 6414b6e9cb4..ce5c061e547 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx @@ -41,6 +41,7 @@ import { EUAgreement } from './EUAgreement'; import { Firewall } from './Firewall'; import { FirewallAuthorization } from './FirewallAuthorization'; import { Networking } from './Networking/Networking'; +import { transformLegacyInterfaceErrorsToLinodeInterfaceErrors } from './Networking/utilities'; import { Plan } from './Plan'; import { getLinodeCreateResolver } from './resolvers'; import { Security } from './Security'; @@ -158,17 +159,11 @@ export const LinodeCreate = () => { }); } } catch (errors) { + if (isLinodeInterfacesEnabled) { + transformLegacyInterfaceErrorsToLinodeInterfaceErrors(errors); + } for (const error of errors) { if (error.field) { - if ( - isLinodeInterfacesEnabled && - error.field.startsWith('interfaces') - ) { - form.setError( - error.field.replace('interfaces', 'linodeInterfaces'), - { message: error.reason } - ); - } form.setError(error.field, { message: error.reason }); } else { form.setError('root', { message: error.reason }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts b/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts index da29e8602b7..b4095fd6905 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts @@ -1,16 +1,17 @@ import { yupResolver } from '@hookform/resolvers/yup'; +import { accountQueries, regionQueries } from '@linode/queries'; import { isNullOrUndefined } from '@linode/utilities'; -import { CreateLinodeSchema } from '@linode/validation'; -import { accountQueries, regionQueries } from '@linode/queries'; import { getRegionCountryGroup, isEURegion } from 'src/utilities/formatRegion'; +import { getLinodeInterfacePayload } from './Networking/utilities'; import { CreateLinodeFromBackupSchema, CreateLinodeFromMarketplaceAppSchema, CreateLinodeFromStackScriptSchema, + CreateLinodeSchema, } from './schemas'; -import { getLinodeCreatePayload } from './utilities'; +import { getInterfacesPayload } from './utilities'; import type { LinodeCreateType } from './types'; import type { @@ -19,24 +20,41 @@ import type { } from './utilities'; import type { QueryClient } from '@tanstack/react-query'; import type { FieldErrors, Resolver } from 'react-hook-form'; -import type { ObjectSchema } from 'yup'; export const getLinodeCreateResolver = ( tab: LinodeCreateType | undefined, queryClient: QueryClient ): Resolver => { const schema = linodeCreateResolvers[tab ?? 'OS']; - return async (values, context, options) => { - const transformedValues = getLinodeCreatePayload( - structuredClone(values), - context?.isLinodeInterfacesEnabled ?? false - ); + return async (rawValues, context, options) => { + const values = structuredClone(rawValues); + + // Because `interfaces` are so complex, we need to perform some transformations before + // we even try to valiate them with our vaidation schema. + if (context?.isLinodeInterfacesEnabled) { + values.interfaces = []; + values.linodeInterfaces = values.linodeInterfaces.map( + getLinodeInterfacePayload + ); + } else { + values.linodeInterfaces = []; + values.interfaces = + getInterfacesPayload(values.interfaces, values.private_ip) ?? []; + } + + if (!values.placement_group?.id) { + values.placement_group = undefined; + } + + if (!values.metadata?.user_data) { + values.metadata = undefined; + } - const { errors } = await yupResolver( - schema as ObjectSchema, + const { errors } = await yupResolver( + schema, {}, { mode: 'async', raw: true } - )(transformedValues as LinodeCreateFormValues, context, options); + )(values, context, options); if (tab === 'Clone Linode' && !values.linode) { (errors as FieldErrors)['linode'] = { @@ -82,10 +100,10 @@ export const getLinodeCreateResolver = ( } if (errors) { - return { errors, values }; + return { errors, values: rawValues }; } - return { errors: {}, values }; + return { errors: {}, values: rawValues }; }; }; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts b/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts index cd403930683..899526f791a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts @@ -1,5 +1,33 @@ -import { CreateLinodeSchema } from '@linode/validation'; -import { number, object } from 'yup'; +import { + CreateLinodeSchema as BaseCreateLinodeSchema, + ConfigProfileInterfaceSchema, +} from '@linode/validation'; +import { array, boolean, number, object, string } from 'yup'; + +import { CreateLinodeInterfaceFormSchema } from '../LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities'; + +import type { LinodeCreateFormValues } from './utilities'; +import type { ObjectSchema } from 'yup'; + +/** + * Extends pure `CreateLinodeSchema` because the Lindoe Create form + * has extra fields that we want to validate. + * In theory, this schema should align with the `LinodeCreateFormValues` type. + */ +export const CreateLinodeSchema: ObjectSchema = BaseCreateLinodeSchema.concat( + object({ + firewallOverride: boolean(), + hasSignedEUAgreement: boolean(), + interfaces: array(ConfigProfileInterfaceSchema).required(), + linode: object({ + id: number().defined(), + label: string().defined(), + region: string().defined(), + type: string().defined().nullable(), + }).notRequired(), + linodeInterfaces: array(CreateLinodeInterfaceFormSchema).required(), + }) +); /** * Extends the Linode Create schema to make backup_id required for the backups tab diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index 75381fa4bd4..8c796415db0 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -211,7 +211,7 @@ export const getLinodeCreatePayload = ( */ export const getInterfacesPayload = ( interfaces: InterfacePayload[] | undefined, - hasPrivateIP: boolean | undefined + hasPrivateIP: LinodeCreateFormValues['backups_enabled'] ): InterfacePayload[] | undefined => { if (!interfaces) { return undefined; @@ -306,7 +306,12 @@ export interface LinodeCreateFormValues extends CreateLinodeRequest { /** * The currently selected Linode (used for the Backups and Clone tabs) */ - linode?: Linode | null; + linode?: { + id: number; + label: string; + region: string; + type: string | null; + } | null; /** * Form state for the new Linode interface */ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts index 606c4fa8bb1..8ef16196c63 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts @@ -12,7 +12,7 @@ export const CreateLinodeInterfaceFormSchema = CreateLinodeInterfaceSchema.conca .oneOf(['vpc', 'vlan', 'public']) .required('You must selected an Interface type.'), vpc: CreateVPCInterfaceSchema.concat( - object({ vpc_id: number().required() }) + object({ vpc_id: number().required('VPC is required.') }) ) .optional() .nullable() diff --git a/packages/validation/.changeset/pr-11847-changed-1742223102140.md b/packages/validation/.changeset/pr-11847-changed-1742223102140.md new file mode 100644 index 00000000000..b65a43127c3 --- /dev/null +++ b/packages/validation/.changeset/pr-11847-changed-1742223102140.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Improved accuracy of schemas related to Linode creation ([#11847](https://github.com/linode/manager/pull/11847)) diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 79d16b53235..abfb12d6c65 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -133,10 +133,13 @@ const ipv6ConfigInterface = object().when('purpose', { // This is the validation schema for legacy interfaces attached to configuration profiles // For new interfaces, denoted as Linode Interfaces, see CreateLinodeInterfaceSchema or ModifyLinodeInterfaceSchema export const ConfigProfileInterfaceSchema = object().shape({ - purpose: mixed().oneOf( - ['public', 'vlan', 'vpc'], - 'Purpose must be public, vlan, or vpc.' - ), + purpose: string() + .oneOf( + ['public', 'vlan', 'vpc'] as const, + 'Purpose must be public, vlan, or vpc.' + ) + .defined() + .required(), label: string().when('purpose', { is: 'vlan', then: (schema) => @@ -193,7 +196,7 @@ export const ConfigProfileInterfaceSchema = object().shape({ return !isVLANandIsSetToPrimary; } ) - .notRequired(), + .optional(), subnet_id: number().when('purpose', { is: 'vpc', then: (schema) => @@ -226,7 +229,7 @@ export const ConfigProfileInterfaceSchema = object().shape({ ipv4: ipv4ConfigInterface, ipv6: ipv6ConfigInterface, ip_ranges: array() - .of(string()) + .of(string().defined()) .notRequired() .nullable() .when('purpose', { @@ -307,17 +310,16 @@ export const UpdateLinodePasswordSchema = object({ }); const MetadataSchema = object({ - user_data: string().notRequired().nullable(), + user_data: string().nullable().defined(), }); const PlacementGroupPayloadSchema = object({ - id: number().notRequired().nullable(), + id: number().required(), }); const DiskEncryptionSchema = string() .oneOf(['enabled', 'disabled']) - .notRequired() - .nullable(); + .notRequired(); const alerts = object({ cpu: number() @@ -553,7 +555,7 @@ const CreateVlanInterfaceSchema = object({ }); export const CreateVPCInterfaceSchema = object({ - subnet_id: number(), + subnet_id: number().required('Subnet is required.'), ipv4: object({ addresses: array().of(CreateVPCInterfaceIpv4AddressSchema), ranges: array().of(VPCInterfaceIPv4RangeSchema), @@ -649,7 +651,7 @@ export const CreateLinodeSchema = object({ then: (schema) => schema.ensure().required('Image is required.'), otherwise: (schema) => schema.nullable().notRequired(), }), - authorized_keys: array().of(string()).notRequired(), + authorized_keys: array().of(string().defined()).notRequired(), backups_enabled: boolean().notRequired(), stackscript_data, booted: boolean().notRequired(), @@ -658,9 +660,9 @@ export const CreateLinodeSchema = object({ .notRequired() .min(3, LINODE_LABEL_CHAR_REQUIREMENT) .max(64, LINODE_LABEL_CHAR_REQUIREMENT), - tags: array().of(string()).notRequired(), + tags: array().of(string().defined()).notRequired(), private_ip: boolean().notRequired(), - authorized_users: array().of(string()).notRequired(), + authorized_users: array().of(string().defined()).notRequired(), root_pass: string().when('image', { is: (value: any) => Boolean(value), then: (schema) => @@ -679,10 +681,12 @@ export const CreateLinodeSchema = object({ return ConfigProfileInterfacesSchema; } ), - interface_generation: string().oneOf(['legacy_config', 'linode']), + interface_generation: string() + .oneOf(['legacy_config', 'linode']) + .notRequired(), network_helper: boolean(), ipv4: array() - .of(string()) + .of(string().defined()) .when('interface_generation', { is: 'linode', then: (schema) => @@ -696,8 +700,8 @@ export const CreateLinodeSchema = object({ test: (value) => !value || value.length === 0, }), }), - metadata: MetadataSchema, + metadata: MetadataSchema.notRequired().default(undefined), firewall_id: number().nullable().notRequired(), - placement_group: PlacementGroupPayloadSchema, + placement_group: PlacementGroupPayloadSchema.notRequired().default(undefined), disk_encryption: DiskEncryptionSchema, });