Skip to content

Commit

Permalink
upcoming: [M3-7944] - Linode Create Refactor - User Data - Part 7 (li…
Browse files Browse the repository at this point in the history
…node#10331)

* initial work

* unit testing

* clean up

* fix placement of `.`

* Added changeset: Linode Create Refactor v2 - User Data - Part 7

* Update packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx

Co-authored-by: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com>

* add some bais testing to encode and decode

* improve spacing

* use flex spacing insted of margin

* stop propogation of tooltip icon button click

* add a transform step in the onSubmit

---------

Co-authored-by: Banks Nussman <banks@nussman.us>
Co-authored-by: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com>
  • Loading branch information
3 people committed Apr 4, 2024
1 parent 221d01d commit e7a1d14
Show file tree
Hide file tree
Showing 10 changed files with 359 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Linode Create Refactor v2 - User Data - Part 7 ([#10331](https://github.com/linode/manager/pull/10331))
11 changes: 10 additions & 1 deletion packages/manager/src/components/TooltipIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,16 @@ export const TooltipIcon = (props: TooltipIconProps) => {
title={text}
width={width}
>
<IconButton data-qa-help-button size="large" sx={sxTooltipIcon}>
<IconButton
onClick={(e) => {
// This prevents unwanted behavior when clicking a tooltip icon.
// See https://github.com/linode/manager/pull/10331#pullrequestreview-1971338778
e.stopPropagation();
}}
data-qa-help-button
size="large"
sx={sxTooltipIcon}
>
{renderIcon}
</IconButton>
</StyledTooltip>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { fireEvent } from '@testing-library/react';
import * as React from 'react';

import { imageFactory, regionFactory } from 'src/factories';
import { makeResourcePage } from 'src/mocks/serverHandlers';
import { HttpResponse, http, server } from 'src/mocks/testServer';
import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';

import { UserData } from './UserData';

describe('Linode Create v2 UserData', () => {
it('should render if the selected image supports cloud-init and the region supports metadata', async () => {
const image = imageFactory.build({ capabilities: ['cloud-init'] });
const region = regionFactory.build({ capabilities: ['Metadata'] });

server.use(
http.get('*/v4/images/*', () => {
return HttpResponse.json(image);
}),
http.get('*/v4/regions', () => {
return HttpResponse.json(makeResourcePage([region]));
})
);

const { findByText } = renderWithThemeAndHookFormContext({
component: <UserData />,
useFormOptions: { defaultValues: { image: image.id, region: region.id } },
});

const userDataHeading = await findByText('Add User Data');

expect(userDataHeading).toBeVisible();
expect(userDataHeading.tagName).toBe('H2');
});

it('should display a warning message if the user data is not in an accepted format', async () => {
const image = imageFactory.build({ capabilities: ['cloud-init'] });
const region = regionFactory.build({ capabilities: ['Metadata'] });

server.use(
http.get('*/v4/images/*', () => {
return HttpResponse.json(image);
}),
http.get('*/v4/regions', () => {
return HttpResponse.json(makeResourcePage([region]));
})
);

const inputValue = '#test-string';
const { findByLabelText, getByText } = renderWithThemeAndHookFormContext({
component: <UserData />,
useFormOptions: { defaultValues: { image: image.id, region: region.id } },
});

const input = await findByLabelText('User Data');
fireEvent.change(input, { target: { value: inputValue } });
fireEvent.blur(input); // triggers format check

expect(
getByText('The user data may be formatted incorrectly.')
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React, { useMemo } from 'react';
import { Controller, useFormContext, useWatch } from 'react-hook-form';

import { Accordion } from 'src/components/Accordion';
import { Link } from 'src/components/Link';
import { Notice } from 'src/components/Notice/Notice';
import { TextField } from 'src/components/TextField';
import { Typography } from 'src/components/Typography';
import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
import { useImageQuery } from 'src/queries/images';
import { useRegionsQuery } from 'src/queries/regions/regions';

import { UserDataHeading } from './UserDataHeading';

import type { CreateLinodeRequest } from '@linode/api-v4';

export const UserData = () => {
const { control } = useFormContext<CreateLinodeRequest>();

const regionId = useWatch({ control, name: 'region' });
const imageId = useWatch({ control, name: 'image' });

const [formatWarning, setFormatWarning] = React.useState(false);

const { data: regions } = useRegionsQuery();
const { data: image } = useImageQuery(imageId ?? '', Boolean(imageId));

const checkFormat = ({
hasInputValueChanged,
userData,
}: {
hasInputValueChanged: boolean;
userData: string;
}) => {
const userDataLower = userData.toLowerCase();
const validPrefixes = ['#cloud-config', 'content-type: text/', '#!/bin/'];
const isUserDataValid = validPrefixes.some((prefix) =>
userDataLower.startsWith(prefix)
);
setFormatWarning(
userData.length > 0 && !isUserDataValid && !hasInputValueChanged
);
};

const region = useMemo(() => regions?.find((r) => r.id === regionId), [
regions,
regionId,
]);

const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({
globalGrantType: 'add_linodes',
});

if (!region?.capabilities.includes('Metadata')) {
return null;
}

if (!image?.capabilities.includes('cloud-init')) {
return null;
}

return (
<Accordion heading={<UserDataHeading />} sx={{ m: '0 !important', p: 1 }}>
<Typography>
User data is a feature of the Metadata service that enables you to
perform system configuration tasks (such as adding users and installing
software) by providing custom instructions or scripts to cloud-init. Any
user data should be added at this step and cannot be modified after the
the Linode has been created.{' '}
<Link to="https://www.linode.com/docs/products/compute/compute-instances/guides/metadata/">
Learn more
</Link>
.
</Typography>
{formatWarning && (
<Notice spacingBottom={16} spacingTop={16} variant="warning">
The user data may be formatted incorrectly.
</Notice>
)}
<Controller
render={({ field, fieldState }) => (
<TextField
onBlur={(e) =>
checkFormat({
hasInputValueChanged: false,
userData: e.target.value,
})
}
onChange={(e) => {
checkFormat({
hasInputValueChanged: true,
userData: e.target.value,
});
field.onChange(e);
}}
disabled={isLinodeCreateRestricted}
errorText={fieldState.error?.message}
expand
label="User Data"
labelTooltipText="Compatible formats include cloud-config data and executable scripts."
multiline
rows={1}
value={field.value ?? ''}
/>
)}
control={control}
name="metadata.user_data"
/>
</Accordion>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';

import { renderWithTheme } from 'src/utilities/testHelpers';

import { UserDataHeading } from './UserDataHeading';

describe('UserDataHeading', () => {
it('should display a warning in the header for cloning', () => {
const { getByText } = renderWithTheme(<UserDataHeading />, {
MemoryRouter: {
initialEntries: ['/linodes/create?type=Clone+Linode'],
},
});

expect(
getByText(
'Existing user data is not cloned. You may add new user data now.'
)
).toBeVisible();
});

it('should display a warning in the header for creating from a Linode backup', () => {
const { getByText } = renderWithTheme(<UserDataHeading />, {
MemoryRouter: {
initialEntries: ['/linodes/create?type=Backups'],
},
});

expect(
getByText(
'Existing user data is not accessible when creating a Linode from a backup. You may add new user data now.'
)
).toBeVisible();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';

import { Link } from 'src/components/Link';
import { Notice } from 'src/components/Notice/Notice';
import { Stack } from 'src/components/Stack';
import { TooltipIcon } from 'src/components/TooltipIcon';
import { Typography } from 'src/components/Typography';

import { useLinodeCreateQueryParams } from '../utilities';

import type { LinodeCreateType } from '../../LinodesCreate/types';

export const UserDataHeading = () => {
const { params } = useLinodeCreateQueryParams();

const warningMessageMap: Record<LinodeCreateType, null | string> = {
Backups:
'Existing user data is not accessible when creating a Linode from a backup. You may add new user data now.',
'Clone Linode':
'Existing user data is not cloned. You may add new user data now.',
Distributions: null,
Images: null,
'One-Click': null,
StackScripts: null,
};

const warningMessage = params.type ? warningMessageMap[params.type] : null;

return (
<Stack spacing={1}>
<Stack direction="row" spacing={1}>
<Typography variant="h2">Add User Data</Typography>
<TooltipIcon
text={
<>
User data allows you to provide additional custom data to
cloud-init to further configure your system.{' '}
<Link to="https://www.linode.com/docs/products/compute/compute-instances/guides/metadata/">
Learn more
</Link>
.
</>
}
interactive
status="help"
sxTooltipIcon={{ p: 0 }}
/>
</Stack>
{warningMessage && (
<Notice spacingBottom={0} spacingTop={0} variant="warning">
{warningMessage}
</Notice>
)}
</Stack>
);
};
16 changes: 12 additions & 4 deletions packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ import { Region } from './Region';
import { Summary } from './Summary';
import { Distributions } from './Tabs/Distributions';
import { Images } from './Tabs/Images';
import { getTabIndex, tabs, useLinodeCreateQueryParams } from './utilities';
import { UserData } from './UserData/UserData';
import {
getLinodeCreatePayload,
getTabIndex,
tabs,
useLinodeCreateQueryParams,
} from './utilities';

import type { CreateLinodeRequest } from '@linode/api-v4';
import type { SubmitHandler } from 'react-hook-form';
Expand All @@ -32,10 +38,11 @@ export const LinodeCreatev2 = () => {

const { mutateAsync: createLinode } = useCreateLinodeMutation();

const onSubmit: SubmitHandler<CreateLinodeRequest> = async (data) => {
alert(JSON.stringify(data, null, 2));
const onSubmit: SubmitHandler<CreateLinodeRequest> = async (values) => {
const payload = getLinodeCreatePayload(values);
alert(JSON.stringify(payload, null, 2));
try {
const linode = await createLinode(data);
const linode = await createLinode(payload);

history.push(`/linodes/${linode.id}`);
} catch (errors) {
Expand Down Expand Up @@ -93,6 +100,7 @@ export const LinodeCreatev2 = () => {
<Details />
<Access />
<Firewall />
<UserData />
<Addons />
<Summary />
</Stack>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { getTabIndex } from './utilities';
import { createLinodeRequestFactory } from 'src/factories';

import { base64UserData, userData } from '../LinodesCreate/utilities.test';
import { getLinodeCreatePayload, getTabIndex } from './utilities';

describe('getTabIndex', () => {
it('should return 0 when there is no value specifying the tab', () => {
Expand All @@ -12,3 +15,22 @@ describe('getTabIndex', () => {
expect(getTabIndex('Images')).toBe(3);
});
});

describe('getLinodeCreatePayload', () => {
it('should return a basic payload', () => {
const values = createLinodeRequestFactory.build();

expect(getLinodeCreatePayload(values)).toStrictEqual(values);
});

it('should base64 encode metadata', () => {
const values = createLinodeRequestFactory.build({
metadata: { user_data: userData },
});

expect(getLinodeCreatePayload(values)).toStrictEqual({
...values,
metadata: { user_data: base64UserData },
});
});
});
19 changes: 19 additions & 0 deletions packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useHistory } from 'react-router-dom';
import { getQueryParamsFromQueryString } from 'src/utilities/queryParams';

import type { LinodeCreateType } from '../LinodesCreate/types';
import type { CreateLinodeRequest } from '@linode/api-v4';
import { utoa } from '../LinodesCreate/utilities';

/**
* This interface is used to type the query params on the Linode Create flow.
Expand Down Expand Up @@ -60,3 +62,20 @@ export const tabs: LinodeCreateType[] = [
'Backups',
'Clone Linode',
];

/**
* Performs some transformations to the Linode Create form data so that the data
* is in the correct format for the API. Intended to be used in the "onSubmit" when creating a Linode.
*
* @param payload the initial raw values from the Linode Create form
* @returns final Linode Create payload to be sent to the API
*/
export const getLinodeCreatePayload = (
payload: CreateLinodeRequest
): CreateLinodeRequest => {
if (payload.metadata?.user_data) {
payload.metadata.user_data = utoa(payload.metadata.user_data);
}

return payload;
};
Loading

0 comments on commit e7a1d14

Please sign in to comment.