Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Linode Create Refactor - Validation ([#10374](https://github.com/linode/manager/pull/10374))
1 change: 1 addition & 0 deletions packages/manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => {
},
})}
textFieldProps={{
...props.textFieldProps,
InputProps: {
endAdornment: regionFilter !== 'core' &&
selectedRegion?.site_type === 'edge' && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RegionSelectProps>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To match convention:

Suggested change
RegionSelectProps?: Partial<RegionSelectProps>;
regionSelectProps?: Partial<RegionSelectProps>;

@bnussman-akamai bnussman-akamai Apr 16, 2024 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm. MUI uses the style ChipProps, LabelProps, etc...

Should we do lowercase?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yeah it seems like the convention is a little inconsistent (e.g., textFieldProps). I'm in favor of lowerCase for all props (since I associate UpperCase with components) but I don't feel too strongly about it.

currentCapability: Capabilities;
disabled?: boolean;
error?: string;
Expand All @@ -49,6 +51,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => {
helperText,
selectedId,
selectedLinodeTypeId,
RegionSelectProps,
} = props;

const flags = useFlags();
Expand Down Expand Up @@ -147,6 +150,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => {
regions={regions ?? []}
selectedId={selectedId || null}
showEdgeIconHelperText={showEdgeIconHelperText}
{...RegionSelectProps}
/>
{showClonePriceWarning && (
<Notice
Expand Down
7 changes: 6 additions & 1 deletion packages/manager/src/components/VLANSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ interface Props {
* Default API filter
*/
filter?: Filter;
/**
* Called when the field is blurred
*/
onBlur?: () => void;
/**
* Is called when a VLAN is selected
*/
Expand All @@ -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<string>('');
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? ''}
Comment on lines 32 to 42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a small enhancement, I wonder if it would be possible to update the validation on this field when the selected Image changes (i.e., so the validation error clears if the user deselects the image).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I'll look into this edge case

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export const Details = () => {
<TextField
disabled={isCreateLinodeRestricted}
errorText={fieldState.error?.message}
inputRef={field.ref}
label="Linode Label"
onBlur={field.onBlur}
onChange={field.onChange}
value={field.value ?? ''}
/>
Expand Down
10 changes: 7 additions & 3 deletions packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@ import { Paper } from 'src/components/Paper';
import type { CreateLinodeRequest } from '@linode/api-v4';

export const Error = () => {
const { formState } = useFormContext<CreateLinodeRequest>();
const {
formState: { errors },
} = useFormContext<CreateLinodeRequest>();

if (!formState.errors.root?.message) {
const generalError = errors.root?.message ?? errors.interfaces?.message;

if (!generalError) {
return null;
}

return (
<Paper sx={{ p: 0 }}>
<Notice spacingBottom={0} spacingTop={0} variant="error">
<Typography py={2}>{formState.errors.root.message}</Typography>
<Typography py={2}>{generalError}</Typography>
</Notice>
</Paper>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export const Region = () => {

return (
<SelectRegionPanel
RegionSelectProps={{
textFieldProps: {
inputRef: field.ref,
},
}}
currentCapability="Linodes"
disabled={isLinodeCreateRestricted}
error={formState.errors.region?.message}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const Distributions = () => {
<ImageSelectv2
disabled={isCreateLinodeRestricted}
errorText={fieldState.error?.message}
onBlur={field.onBlur}
onChange={(_, image) => field.onChange(image?.id ?? null)}
value={field.value}
variant="public"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const Images = () => {
<ImageSelectv2
disabled={isCreateLinodeRestricted}
errorText={fieldState.error?.message}
onBlur={field.onBlur}
onChange={(_, image) => field.onChange(image?.id ?? null)}
value={field.value}
variant="private"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,22 +80,24 @@ export const UserData = () => {
<Controller
render={({ field, fieldState }) => (
<TextField
onBlur={(e) =>
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
Expand Down
7 changes: 5 additions & 2 deletions packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
)}
Expand All @@ -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}
Comment thread
bnussman-akamai marked this conversation as resolved.
onChange={field.onChange}
optional
placeholder="192.0.2.0/24"
sx={{ maxWidth: 300 }}
value={field.value ?? ''}
/>
)}
Expand Down
16 changes: 15 additions & 1 deletion packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const VPC = () => {
: undefined
}
textFieldProps={{
inputRef: field.ref,
sx: (theme) => ({
[theme.breakpoints.up('sm')]: { minWidth: inputMaxWidth },
}),
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -182,11 +187,14 @@ export const VPC = () => {
<Controller
render={({ field, fieldState }) => (
<TextField
containerProps={{ sx: { mb: 1, mt: 1 } }}
errorText={fieldState.error?.message}
inputRef={field.ref}
label="VPC IPv4"
noMarginTop
onBlur={field.onBlur}
onChange={field.onChange}
required
sx={{ my: 2 }}
value={field.value}
/>
)}
Expand Down Expand Up @@ -240,6 +248,12 @@ export const VPC = () => {
</Link>
.
</Typography>
{formState.errors.interfaces?.[0]?.ip_ranges?.message && (
<Notice
text={formState.errors.interfaces[0]?.ip_ranges?.message}
variant="error"
/>
)}
<VPCRanges />
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ export const VPCRanges = () => {
<TextField
errorText={fieldState.error?.message}
hideLabel
inputRef={field.ref}
label={`IP Range ${index}`}
onBlur={field.onBlur}
onChange={field.onChange}
placeholder="10.0.0.0/24"
sx={{ minWidth: 290 }}
value={field.value}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
defaultValues,
getLinodeCreatePayload,
getTabIndex,
resolver,
tabs,
useLinodeCreateQueryParams,
} from './utilities';
Expand All @@ -38,7 +39,12 @@ import type { CreateLinodeRequest } from '@linode/api-v4';
import type { SubmitHandler } from 'react-hook-form';

export const LinodeCreatev2 = () => {
const methods = useForm<CreateLinodeRequest>({ defaultValues });
const methods = useForm<CreateLinodeRequest>({
defaultValues,
mode: 'onBlur',
resolver,
});

const history = useHistory();

const { mutateAsync: createLinode } = useCreateLinodeMutation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ 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', () => {
const values = createLinodeRequestFactory.build({
metadata: { user_data: userData },
});

expect(getLinodeCreatePayload(values)).toStrictEqual({
expect(getLinodeCreatePayload(values)).toEqual({
...values,
metadata: { user_data: base64UserData },
});
Expand Down
46 changes: 38 additions & 8 deletions packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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<CreateLinodeRequest> = 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 };
};
Loading