From 0e0e5a51d1fbb160a66307fa5c853b68c7d250d3 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:31:05 -0400 Subject: [PATCH] upcoming: [M3-7944] - Linode Create Refactor - User Data - Part 7 (#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 Co-authored-by: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> --- ...r-10331-upcoming-features-1711738458511.md | 5 + .../manager/src/components/TooltipIcon.tsx | 11 +- .../LinodeCreatev2/UserData/UserData.test.tsx | 63 ++++++++++ .../LinodeCreatev2/UserData/UserData.tsx | 111 ++++++++++++++++++ .../UserData/UserDataHeading.test.tsx | 35 ++++++ .../UserData/UserDataHeading.tsx | 56 +++++++++ .../features/Linodes/LinodeCreatev2/index.tsx | 16 ++- .../Linodes/LinodeCreatev2/utilities.test.tsx | 24 +++- .../Linodes/LinodeCreatev2/utilities.ts | 19 +++ .../Linodes/LinodesCreate/utilities.test.ts | 25 ++++ 10 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10331-upcoming-features-1711738458511.md create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx diff --git a/packages/manager/.changeset/pr-10331-upcoming-features-1711738458511.md b/packages/manager/.changeset/pr-10331-upcoming-features-1711738458511.md new file mode 100644 index 00000000000..93ea88990cb --- /dev/null +++ b/packages/manager/.changeset/pr-10331-upcoming-features-1711738458511.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Linode Create Refactor v2 - User Data - Part 7 ([#10331](https://github.com/linode/manager/pull/10331)) diff --git a/packages/manager/src/components/TooltipIcon.tsx b/packages/manager/src/components/TooltipIcon.tsx index c33486ee24c..ef6f08c53b7 100644 --- a/packages/manager/src/components/TooltipIcon.tsx +++ b/packages/manager/src/components/TooltipIcon.tsx @@ -165,7 +165,16 @@ export const TooltipIcon = (props: TooltipIconProps) => { title={text} width={width} > - + { + // 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} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.test.tsx new file mode 100644 index 00000000000..90347e60898 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.test.tsx @@ -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: , + 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: , + 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(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx new file mode 100644 index 00000000000..982c57bb7b1 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx @@ -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(); + + 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 ( + } sx={{ m: '0 !important', p: 1 }}> + + 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.{' '} + + Learn more + + . + + {formatWarning && ( + + The user data may be formatted incorrectly. + + )} + ( + + 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" + /> + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.test.tsx new file mode 100644 index 00000000000..b12a7f19351 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.test.tsx @@ -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(, { + 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(, { + 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(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx new file mode 100644 index 00000000000..13dd5e70ab6 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx @@ -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 = { + 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 ( + + + Add User Data + + User data allows you to provide additional custom data to + cloud-init to further configure your system.{' '} + + Learn more + + . + + } + interactive + status="help" + sxTooltipIcon={{ p: 0 }} + /> + + {warningMessage && ( + + {warningMessage} + + )} + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 019012008c4..2bad7033a96 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -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'; @@ -32,10 +38,11 @@ export const LinodeCreatev2 = () => { const { mutateAsync: createLinode } = useCreateLinodeMutation(); - const onSubmit: SubmitHandler = async (data) => { - alert(JSON.stringify(data, null, 2)); + const onSubmit: SubmitHandler = 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) { @@ -93,6 +100,7 @@ export const LinodeCreatev2 = () => {
+ diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx index 67973951ed2..2ad9a7b9357 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx @@ -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', () => { @@ -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 }, + }); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index beaa12c99bb..ef022bb9014 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -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. @@ -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; +}; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts index 98d96209048..0dd7619430b 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts @@ -6,6 +6,7 @@ import { getMonthlyAndHourlyNodePricing, handleAppLabel, trimOneClickFromLabel, + utoa, } from './utilities'; import type { StackScript } from '@linode/api-v4'; @@ -133,3 +134,27 @@ describe('handleAppLabel', () => { expect(result.label).toBe('My StackScript® Cluster '); }); }); + +/** + * This is an example cloud-init config + */ +export const userData = `#cloud-config +package_update: true +package_upgrade: true +packages: +- nginx +- mysql-server +`; + +/** + * This is the base64 encoded version of `userData`. + * It was generated by base64 --break=0 --input=[file name here] + */ +export const base64UserData = + 'I2Nsb3VkLWNvbmZpZwpwYWNrYWdlX3VwZGF0ZTogdHJ1ZQpwYWNrYWdlX3VwZ3JhZGU6IHRydWUKcGFja2FnZXM6Ci0gbmdpbngKLSBteXNxbC1zZXJ2ZXIK'; + +describe('utoa', () => { + it('should produce base64 encoded user data', () => { + expect(utoa(userData)).toBe(base64UserData); + }); +});