diff --git a/.gitignore b/.gitignore index 734136b05e8..9b96da463da 100644 --- a/.gitignore +++ b/.gitignore @@ -127,12 +127,7 @@ packages/manager/test-report.xml **/manager/cypress/videos/ **/manager/cypress/downloads/ **/manager/cypress/results/ - -# ignore all screenshots except records -# we ignore the png files, not the whole folder recursively -# or the record files are ignored too -**/manager/cypress/screenshots/**/*.png -!**/manager/cypress/screenshots/**/record*.png +**/manager/cypress/screenshots/ packages/manager/cypress/fixtures/example.json @@ -142,4 +137,4 @@ packages/manager/bundle_analyzer_report.html **/manager/src/dev-tools/*.local.* # vitepress -docs/.vitepress/cache \ No newline at end of file +docs/.vitepress/cache diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000000..c799b8b688d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn workspaces run precommit diff --git a/docs/development-guide/05-fetching-data.md b/docs/development-guide/05-fetching-data.md index e1313ed83c4..e6fcd59fc95 100644 --- a/docs/development-guide/05-fetching-data.md +++ b/docs/development-guide/05-fetching-data.md @@ -253,7 +253,7 @@ Note: the legacy `scrollErrorIntoView` is deprecated in favor of `scrollErrorInt Since Cloud Manager uses different ways of handling forms and validation, the `scrollErrorIntoViewV2` util should be implemented using the following patterns to ensure consistency. -##### Formik +##### Formik (deprecated) ```Typescript import * as React from 'react'; diff --git a/docs/development-guide/09-mocking-data.md b/docs/development-guide/09-mocking-data.md index 2d6ad76504b..f75c782ab43 100644 --- a/docs/development-guide/09-mocking-data.md +++ b/docs/development-guide/09-mocking-data.md @@ -41,6 +41,8 @@ const linodeList = linodeFactory.buildList(10, { region: "eu-west" }); // [{ id: 3, label: 'linode-3', region: 'eu-west' }, ...9 more ] ``` +Because our factories are used by our dev tools, unit tests, and end-to-end tests, we should avoid creating factories with random or unpredictable default values (e.g. by using utilities like `pickRandom` to assign property values). + ### Intercepting Requests The [Mock Service Worker](https://mswjs.io/) package intercepts requests at the network level and returns the response you defined in the relevant factory. diff --git a/docs/development-guide/15-composition.md b/docs/development-guide/15-composition.md new file mode 100644 index 00000000000..26a405c9ea5 --- /dev/null +++ b/docs/development-guide/15-composition.md @@ -0,0 +1,54 @@ +# Composition + +## Page Composition +Composing pages in Cloud Manager is a multi-step process that involves several components and patterns. It usually involves using a combination of components already available to the developer, and organized according to the desired layout. + +It is best to avoid one off components for pages, or an excessive amount of custom CSS. It is likely that a component already exists to handle the desired layout or styling. It is often a good idea to spend some time looking through the codebase, at [storybook](https://design.linode.com/) or an existing feature of Cloud Manager before making certain composition decisions. It is also important to compose with markup semanticity in mind, and keep an eye on the render tree to avoid bloating the layout with unnecessary containers, wrappers etc, as well as ensuring that the page is accessible, performant, and has good test coverage. When in doubt, one can also check with the product or UX team to ensure that a component exists for the desired layout or styling. + +### Responsive Design + +While Cloud Manager layout and components are responsive out of the box, some extra handling may be needed for pages that have unique responsive requirements, or because the features has a more complex layout. +A wide array of tools are available to help with responsive design, including media queries, CSS Grid & Flexbox, as well as the `` component, which can be used to hide elements at specific breakpoints. + +Some designs may not feature a mobile layout, and for those cases it is recommended to gather existing examples from the codebase, or from other pages that have a similar layout. + +## Form Composition + +### Formik +Formik is now deprecated. Please use react-hook-form. + +### React Hook Form +The preferred library for building forms in Cloud Manager is [react-hook-form](https://react-hook-form.com/). It is a complete set of tools for building complex forms, and is well documented. +The general way to get started is to use the `useForm` hook, which returns a form context and a set of methods to interact with the form. + +```Typescript +const methods = useForm({ + defaultValues, + mode: 'onBlur', + resolver: myResolvers, + // other methods +}); +``` + +`methods` is an object that contains the form context and a set of methods to interact with the form. +It is passed to the `FormProvider` component, which is a wrapper that provides the form context to the form. + +```Typescript + +
+ {/* form fields */} + +
+
+``` + +It is important to note that react-hook-form does not provide any UI components. It is the responsibility of the developer to provide the form fields and validation, as well as employing semantic markup for accessibility purposes. +ex: a `
` element should have a corresponding ` + . { - disabled?: boolean; - disabledReason?: string; - display?: string; - label: string; - onClick: (e?: React.MouseEvent) => void; -} - -const AddNewLink = (props: Props) => { - const { - className, - disabled, - disabledReason, - display, - label, - onClick, - ...remainingPropsAsTooltipProps - } = props; - - const baseProps = { - className, - disabled, - onClick, - text: label, - title: label, - }; - - if (!!disabled && !!disabledReason) { - return ( - -
- {/* Wrapping in div because the child of tooltip needs to be able to hold a ref */} - -
-
- ); - } - - return ( - - ); -}; - -export default AddNewLink; diff --git a/packages/manager/src/components/AddNewLink/index.ts b/packages/manager/src/components/AddNewLink/index.ts deleted file mode 100644 index be5b3a5da7f..00000000000 --- a/packages/manager/src/components/AddNewLink/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import AddNewLink, { Props as _AddNewLinkProps } from './AddNewLink'; -/* tslint:disable */ -export type AddNewLinkProps = _AddNewLinkProps; -export default AddNewLink; diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx index d87e298ea51..9f31b39af99 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx @@ -1,10 +1,12 @@ import DoneIcon from '@mui/icons-material/Done'; -import Popper, { PopperProps } from '@mui/material/Popper'; +import Popper from '@mui/material/Popper'; import { styled } from '@mui/material/styles'; import React from 'react'; import { omittedProps } from 'src/utilities/omittedProps'; +import type { PopperProps } from '@mui/material/Popper'; + export const StyledListItem = styled('li', { label: 'StyledListItem', shouldForwardProp: omittedProps(['selectAllOption']), diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index de989e277c0..efb09a73f1c 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -26,7 +26,7 @@ export interface EnhancedAutocompleteProps< AutocompleteProps, 'renderInput' > { - /** Removes "select all" option for multiselect */ + /** Removes "select all" option for mutliselect */ disableSelectAll?: boolean; /** Provides a hint with error styling to assist users. */ errorText?: string; diff --git a/packages/manager/src/components/Avatar/Avatar.test.tsx b/packages/manager/src/components/Avatar/Avatar.test.tsx index e1c553d2c15..65e4ab1baa0 100644 --- a/packages/manager/src/components/Avatar/Avatar.test.tsx +++ b/packages/manager/src/components/Avatar/Avatar.test.tsx @@ -61,7 +61,7 @@ describe('Avatar', () => { }); it('should render an svg instead of first letter for system users', async () => { - const systemUsernames = ['Linode', 'lke-service-account-123']; + const systemUsernames = ['Akamai', 'lke-service-account-123']; systemUsernames.forEach((username, i) => { const { getAllByRole, queryByTestId } = renderWithTheme( diff --git a/packages/manager/src/components/Avatar/Avatar.tsx b/packages/manager/src/components/Avatar/Avatar.tsx index 968ec8b5834..e50bf8bf4fa 100644 --- a/packages/manager/src/components/Avatar/Avatar.tsx +++ b/packages/manager/src/components/Avatar/Avatar.tsx @@ -55,7 +55,7 @@ export const Avatar = (props: AvatarProps) => { const _username = username ?? profile?.username ?? ''; const isAkamai = - _username === 'Linode' || _username.startsWith('lke-service-account'); + _username === 'Akamai' || _username.startsWith('lke-service-account'); const savedAvatarColor = isAkamai || !preferences?.avatarColor diff --git a/packages/manager/src/components/Button/Button.tsx b/packages/manager/src/components/Button/Button.tsx index db54b5cec07..e9e3019b07c 100644 --- a/packages/manager/src/components/Button/Button.tsx +++ b/packages/manager/src/components/Button/Button.tsx @@ -1,7 +1,6 @@ import HelpOutline from '@mui/icons-material/HelpOutline'; -import _Button, { ButtonProps as _ButtonProps } from '@mui/material/Button'; -import { Theme, styled } from '@mui/material/styles'; -import { SxProps } from '@mui/system'; +import _Button from '@mui/material/Button'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; import Reload from 'src/assets/icons/reload.svg'; @@ -10,6 +9,10 @@ import { Tooltip } from 'src/components/Tooltip'; import { rotate360 } from '../../styles/keyframes'; import { omittedProps } from '../../utilities/omittedProps'; +import type { ButtonProps as _ButtonProps } from '@mui/material/Button'; +import type { Theme } from '@mui/material/styles'; +import type { SxProps } from '@mui/system'; + export type ButtonType = 'outlined' | 'primary' | 'secondary'; export interface ButtonProps extends _ButtonProps { diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx new file mode 100644 index 00000000000..c1385003852 --- /dev/null +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx @@ -0,0 +1,148 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DeletionDialog } from './DeletionDialog'; + +import type { DeletionDialogProps } from './DeletionDialog'; + +describe('DeletionDialog', () => { + const defaultArgs: DeletionDialogProps = { + entity: 'Linode', + label: 'my-linode-0', + loading: false, + onClose: vi.fn(), + onDelete: vi.fn(), + open: false, + }; + + it.each([ + ['not render', false], + ['render', true], + ])( + 'should %s a DeletionDialog with the correct title, close button, and action buttons when open is %s', + (_, isOpen) => { + const { queryByRole, queryByTestId, queryByText } = renderWithTheme( + + ); + const title = queryByText( + `Delete ${defaultArgs.entity} ${defaultArgs.label}?` + ); + const dialog = queryByTestId('drawer'); + const closeButton = queryByRole('button', { name: 'Close' }); + const cancelButton = queryByTestId('cancel'); + const deleteButton = queryByTestId('confirm'); + + if (isOpen) { + expect(title).toBeInTheDocument(); + expect(dialog).toBeInTheDocument(); + expect(closeButton).toBeInTheDocument(); + expect(cancelButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).toHaveTextContent(`Delete ${defaultArgs.entity}`); + } else { + expect(title).not.toBeInTheDocument(); + expect(dialog).not.toBeInTheDocument(); + expect(closeButton).not.toBeInTheDocument(); + expect(cancelButton).not.toBeInTheDocument(); + expect(deleteButton).not.toBeInTheDocument(); + } + } + ); + + it('should call onClose when the DeletionDialog close button or Action cancel button is clicked', () => { + const { getByRole, getByTestId } = renderWithTheme( + + ); + + // For close icon button + const closeButton = getByRole('button', { name: 'Close' }); + expect(closeButton).not.toBeDisabled(); + fireEvent.click(closeButton); + + expect(defaultArgs.onClose).toHaveBeenCalled(); + + // For action cancel button + const cancelButton = getByTestId('cancel'); + expect(cancelButton).not.toBeDisabled(); + fireEvent.click(cancelButton); + + expect(defaultArgs.onClose).toHaveBeenCalled(); + }); + + it('should call onDelete when the DeletionDialog delete button is clicked', () => { + const { getByTestId } = renderWithTheme( + + ); + + const deleteButton = getByTestId('confirm'); + expect(deleteButton).not.toBeDisabled(); + fireEvent.click(deleteButton); + + expect(defaultArgs.onDelete).toHaveBeenCalled(); + }); + + it('should render a DeletionDialog with an error message if provided', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Error that will be shown in the dialog.')).toBeVisible(); + }); + + it('should disable delete button and show loading icon if loading is true', () => { + const { getByTestId } = renderWithTheme( + + ); + + const deleteButton = getByTestId('confirm'); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).toBeDisabled(); + + const loadingSvgIcon = deleteButton.querySelector( + '[data-test-id="ReloadIcon"]' + ); + + expect(loadingSvgIcon).toBeInTheDocument(); + }); + + it('should display the correct warning text in the DeletionDialog', () => { + const { getByTestId } = renderWithTheme( + + ); + + const dialog = getByTestId('drawer'); + const warningText = `Warning: Deleting this ${defaultArgs.entity} is permanent and can\u2019t be undone.`; + + expect(dialog).toHaveTextContent(warningText); + }); + + it.each([ + ['not render', false], + ['render', true], + ])( + 'should %s input field with label when typeToConfirm is %s', + (_, typeToConfirm) => { + const { queryByTestId } = renderWithTheme( + + ); + + if (typeToConfirm) { + expect(queryByTestId('inputLabelWrapper')).toBeInTheDocument(); + expect(queryByTestId('textfield-input')).toBeInTheDocument(); + } else { + expect(queryByTestId('inputLabelWrapper')).not.toBeInTheDocument(); + expect(queryByTestId('textfield-input')).not.toBeInTheDocument(); + } + } + ); +}); diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx index b33df97103d..bce1c53ad8d 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx @@ -10,9 +10,9 @@ import { titlecase } from 'src/features/Linodes/presentation'; import { usePreferences } from 'src/queries/profile/preferences'; import { capitalize } from 'src/utilities/capitalize'; -import { DialogProps } from '../Dialog/Dialog'; +import type { DialogProps } from '../Dialog/Dialog'; -interface DeletionDialogProps extends Omit { +export interface DeletionDialogProps extends Omit { entity: string; error?: string; label: string; diff --git a/packages/manager/src/components/Dialog/Dialog.test.tsx b/packages/manager/src/components/Dialog/Dialog.test.tsx new file mode 100644 index 00000000000..0083a31ded2 --- /dev/null +++ b/packages/manager/src/components/Dialog/Dialog.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { Dialog } from './Dialog'; + +import type { DialogProps } from './Dialog'; + +describe('Dialog', () => { + const defaultArgs: DialogProps = { + onClose: vi.fn(), + open: false, + title: 'This is a Dialog', + }; + + it.each([ + ['not render', false], + ['render', true], + ])('should %s a Dialog with title when open is %s', (_, isOpen) => { + const { queryByTestId, queryByText } = renderWithTheme( + + ); + + const title = queryByText('This is a Dialog'); + const dialog = queryByTestId('drawer'); + + if (isOpen) { + expect(title).toBeInTheDocument(); + expect(dialog).toBeInTheDocument(); + } else { + expect(title).not.toBeInTheDocument(); + expect(dialog).not.toBeInTheDocument(); + } + }); + + it('should render a Dialog with children if provided', () => { + const { getByText } = renderWithTheme( + +

Child items can go here!

+
+ ); + + expect(getByText('Child items can go here!')).toBeInTheDocument(); + }); + + it('should render a Dialog with subtitle if provided', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('This is a subtitle')).toBeInTheDocument(); + }); + + it('should call onClose when the Dialog close button is clicked', () => { + const { getByRole } = renderWithTheme( + + ); + + const closeButton = getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + + expect(defaultArgs.onClose).toHaveBeenCalled(); + }); + + it('should render a Dialog with an error message if provided', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Error that will be shown in the dialog.')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/components/Dialog/Dialog.tsx b/packages/manager/src/components/Dialog/Dialog.tsx index 51c9272093a..5c4db71a634 100644 --- a/packages/manager/src/components/Dialog/Dialog.tsx +++ b/packages/manager/src/components/Dialog/Dialog.tsx @@ -15,6 +15,7 @@ export interface DialogProps extends _DialogProps { className?: string; error?: string; fullHeight?: boolean; + subtitle?: string; title: string; titleBottomBorder?: boolean; } @@ -49,6 +50,7 @@ export const Dialog = (props: DialogProps) => { fullWidth, maxWidth = 'md', onClose, + subtitle, title, titleBottomBorder, ...rest @@ -78,6 +80,7 @@ export const Dialog = (props: DialogProps) => { onClose && onClose({}, 'backdropClick')} + subtitle={subtitle} title={title} /> {titleBottomBorder && } diff --git a/packages/manager/src/components/DialogTitle/DialogTitle.tsx b/packages/manager/src/components/DialogTitle/DialogTitle.tsx index 1e7108b9291..4233df20fba 100644 --- a/packages/manager/src/components/DialogTitle/DialogTitle.tsx +++ b/packages/manager/src/components/DialogTitle/DialogTitle.tsx @@ -1,22 +1,25 @@ import Close from '@mui/icons-material/Close'; -import { Box } from 'src/components/Box'; +import { Typography } from '@mui/material'; import _DialogTitle from '@mui/material/DialogTitle'; -import { SxProps } from '@mui/system'; import * as React from 'react'; +import { Box } from 'src/components/Box'; import { IconButton } from 'src/components/IconButton'; +import type { SxProps } from '@mui/system'; + interface DialogTitleProps { className?: string; id?: string; onClose?: () => void; + subtitle?: string; sx?: SxProps; title: string; } const DialogTitle = (props: DialogTitleProps) => { const ref = React.useRef(null); - const { className, id, onClose, sx, title } = props; + const { className, id, onClose, subtitle, sx, title } = props; React.useEffect(() => { if (ref.current === null) { @@ -63,6 +66,7 @@ const DialogTitle = (props: DialogTitleProps) => { )} + {subtitle && {subtitle}} ); }; diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts index 942ff3d8a31..c94d2a6f6a1 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts @@ -11,6 +11,7 @@ export interface linkAnalyticsEvent { export interface ResourcesHeaders { description: string; + logo?: React.ReactNode; subtitle: string; title: string; } diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx index 33386afde6b..6e57fa458d9 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx @@ -111,7 +111,7 @@ export const ResourcesSection = (props: ResourcesSectionProps) => { wide = false, youtubeLinkData, } = props; - const { description, subtitle, title } = headers; + const { description, logo, subtitle, title } = headers; return ( { subtitle={subtitle} title={title} > + {logo} {description} ); diff --git a/packages/manager/src/components/Encryption/constants.tsx b/packages/manager/src/components/Encryption/constants.tsx index 4f44f4fd100..f65ec615baa 100644 --- a/packages/manager/src/components/Encryption/constants.tsx +++ b/packages/manager/src/components/Encryption/constants.tsx @@ -73,7 +73,8 @@ export const ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY = 'Distributed Compute Instances are secured using disk encryption.'; /* Block Storage Encryption constants */ -const BLOCK_STORAGE_ENCRYPTION_GUIDE_LINK = ''; // @TODO BSE: Update with guide link +const BLOCK_STORAGE_ENCRYPTION_GUIDE_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/volumes-disk-encryption'; export const BLOCK_STORAGE_ENCRYPTION_GENERAL_DESCRIPTION = ( <> diff --git a/packages/manager/src/components/ErrorMessage.tsx b/packages/manager/src/components/ErrorMessage.tsx index 8bb3871e750..7297899a89b 100644 --- a/packages/manager/src/components/ErrorMessage.tsx +++ b/packages/manager/src/components/ErrorMessage.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { LinodeResizeAllocationError } from './LinodeResizeAllocationError'; import { MigrateError } from './MigrateError'; import { SupportTicketGeneralError } from './SupportTicketGeneralError'; import { Typography } from './Typography'; @@ -15,6 +16,7 @@ interface Props { export const migrationsDisabledRegex = /migrations are currently disabled/i; export const supportTextRegex = /(open a support ticket|contact Support)/i; +export const allocationErrorRegex = /allocated more disk/i; export const ErrorMessage = (props: Props) => { const { entity, formPayloadValues, message } = props; @@ -33,5 +35,9 @@ export const ErrorMessage = (props: Props) => { ); } + if (allocationErrorRegex.test(message)) { + return ; + } + return {message}; }; diff --git a/packages/manager/src/components/GravatarByEmail.tsx b/packages/manager/src/components/GravatarByEmail.tsx deleted file mode 100644 index 4ff64876d93..00000000000 --- a/packages/manager/src/components/GravatarByEmail.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import Avatar from '@mui/material/Avatar'; -import * as React from 'react'; - -import UserIcon from 'src/assets/icons/account.svg'; -import { getGravatarUrl } from 'src/utilities/gravatar'; - -export const DEFAULT_AVATAR_SIZE = 28; - -export interface GravatarByEmailProps { - className?: string; - email: string; - height?: number; - width?: number; -} - -export const GravatarByEmail = (props: GravatarByEmailProps) => { - const { - className, - email, - height = DEFAULT_AVATAR_SIZE, - width = DEFAULT_AVATAR_SIZE, - } = props; - - const url = getGravatarUrl(email); - - return ( - - - - ); -}; diff --git a/packages/manager/src/components/GravatarByUsername.tsx b/packages/manager/src/components/GravatarByUsername.tsx deleted file mode 100644 index 3adb2262aa3..00000000000 --- a/packages/manager/src/components/GravatarByUsername.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import Avatar from '@mui/material/Avatar'; -import * as React from 'react'; - -import UserIcon from 'src/assets/icons/account.svg'; -import { useAccountUser } from 'src/queries/account/users'; -import { getGravatarUrl } from 'src/utilities/gravatar'; - -import { Box } from './Box'; -import { DEFAULT_AVATAR_SIZE } from './GravatarByEmail'; - -export interface GravatarByUsernameProps { - className?: string; - username: null | string; -} - -export const GravatarByUsername = (props: GravatarByUsernameProps) => { - const { className, username } = props; - const { data: user, isLoading } = useAccountUser(username ?? ''); - const url = user?.email ? getGravatarUrl(user.email) : undefined; - - // Render placeholder instead of flashing default user icon briefly - if (isLoading) { - return ; - } - - return ( - - - - ); -}; diff --git a/packages/manager/src/components/GravatarOrAvatar.tsx b/packages/manager/src/components/GravatarOrAvatar.tsx deleted file mode 100644 index 6a9ab209775..00000000000 --- a/packages/manager/src/components/GravatarOrAvatar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; - -import { useGravatar } from 'src/hooks/useGravatar'; -import { useProfile } from 'src/queries/profile/profile'; - -import { DEFAULT_AVATAR_SIZE } from './Avatar/Avatar'; -import { Box } from './Box'; - -interface Props { - avatar: JSX.Element; - gravatar: JSX.Element; - height?: number; - width?: number; -} - -export const GravatarOrAvatar = (props: Props) => { - const { - avatar, - gravatar, - height = DEFAULT_AVATAR_SIZE, - width = DEFAULT_AVATAR_SIZE, - } = props; - const { data: profile } = useProfile(); - const { hasGravatar, isLoadingGravatar } = useGravatar(profile?.email); - - return isLoadingGravatar ? ( - - ) : hasGravatar ? ( - gravatar - ) : ( - avatar - ); -}; diff --git a/packages/manager/src/components/LinkButton.tsx b/packages/manager/src/components/LinkButton.tsx index 4d4c21c25f9..bcb5a83c521 100644 --- a/packages/manager/src/components/LinkButton.tsx +++ b/packages/manager/src/components/LinkButton.tsx @@ -46,6 +46,7 @@ export const LinkButton = (props: Props) => { disabled={isDisabled} onClick={onClick} style={style} + tabIndex={0} type="button" > {children} diff --git a/packages/manager/src/components/LinodeResizeAllocationError.tsx b/packages/manager/src/components/LinodeResizeAllocationError.tsx new file mode 100644 index 00000000000..54fc4e09551 --- /dev/null +++ b/packages/manager/src/components/LinodeResizeAllocationError.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; + +export const LinodeResizeAllocationError = () => { + return ( + + The current disk size of your Linode is too large for the new service + plan. Please resize your disk to accommodate the new plan. You can read + our{' '} + + Resize Your Linode + {' '} + guide for more detailed instructions. + + ); +}; diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index 565b4c33451..80714ff7e42 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -89,7 +89,7 @@ describe('PrimaryNav', () => { it('should show Databases menu item if the user has the account capability V2 Beta', async () => { const account = accountFactory.build({ - capabilities: ['Managed Databases V2'], + capabilities: ['Managed Databases Beta'], }); server.use( @@ -121,7 +121,7 @@ describe('PrimaryNav', () => { it('should show Databases menu item if the user has the account capability V2', async () => { const account = accountFactory.build({ - capabilities: ['Managed Databases V2'], + capabilities: ['Managed Databases Beta'], }); server.use( @@ -152,7 +152,7 @@ describe('PrimaryNav', () => { it('should show Databases menu item if the user has the account capability V2', async () => { const account = accountFactory.build({ - capabilities: ['Managed Databases V2'], + capabilities: ['Managed Databases Beta'], }); server.use( diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 50f5d00e922..3288454aad9 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -31,7 +31,6 @@ import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils' import { useFlags } from 'src/hooks/useFlags'; import { usePrefetch } from 'src/hooks/usePreFetch'; import { useAccountSettings } from 'src/queries/account/settings'; -import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import useStyles from './PrimaryNav.styles'; import { linkIsActive } from './utils'; @@ -88,34 +87,14 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const flags = useFlags(); const location = useLocation(); - const [ - enableMarketplacePrefetch, - setEnableMarketplacePrefetch, - ] = React.useState(false); - const { data: accountSettings } = useAccountSettings(); const isManaged = accountSettings?.managed ?? false; - const { - data: oneClickApps, - error: oneClickAppsError, - isLoading: oneClickAppsLoading, - } = useMarketplaceAppsQuery(enableMarketplacePrefetch); - - const allowMarketplacePrefetch = - !oneClickApps && !oneClickAppsLoading && !oneClickAppsError; - const { isACLPEnabled } = useIsACLPEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled, isDatabasesV2Beta } = useIsDatabasesEnabled(); - const prefetchMarketplace = () => { - if (!enableMarketplacePrefetch) { - setEnableMarketplacePrefetch(true); - } - }; - const primaryLinkGroups: PrimaryLink[][] = React.useMemo( () => [ [ @@ -220,8 +199,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { display: 'Marketplace', href: '/linodes/create?type=One-Click', icon: , - prefetchRequestCondition: allowMarketplacePrefetch, - prefetchRequestFn: prefetchMarketplace, }, ], [ @@ -248,7 +225,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isDatabasesEnabled, isDatabasesV2Beta, isManaged, - allowMarketplacePrefetch, isPlacementGroupsEnabled, isACLPEnabled, ] diff --git a/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx b/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx index ce65f36bf7f..2dbca4e1466 100644 --- a/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx +++ b/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx @@ -3,18 +3,23 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; -import { Box, BoxProps } from '../Box'; +import { Box } from '../Box'; + +import type { BoxProps } from '../Box'; interface RegionHelperTextProps extends BoxProps { onClick?: () => void; + showCoreHelperText?: boolean; } export const RegionHelperText = (props: RegionHelperTextProps) => { - const { onClick, ...rest } = props; + const { onClick, showCoreHelperText, ...rest } = props; return ( + {showCoreHelperText && + `Data centers in central locations support a robust set of cloud computing services. `} You can use {` `} diff --git a/packages/manager/src/components/SplashScreen.tsx b/packages/manager/src/components/SplashScreen.tsx index af0b993c47d..5b5836a5d3c 100644 --- a/packages/manager/src/components/SplashScreen.tsx +++ b/packages/manager/src/components/SplashScreen.tsx @@ -7,7 +7,6 @@ import { Box } from './Box'; export const SplashScreen = () => { React.useEffect(() => { - // @TODO: The utilility cases a scrollbar to show in the browser, fix it. srSpeak('Loading Linode Cloud Manager', 'polite'); }, []); diff --git a/packages/manager/src/components/TextField.tsx b/packages/manager/src/components/TextField.tsx index a0c1a9240fa..824ec03748b 100644 --- a/packages/manager/src/components/TextField.tsx +++ b/packages/manager/src/components/TextField.tsx @@ -1,9 +1,6 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; -import { Theme, useTheme } from '@mui/material/styles'; -import { - default as _TextField, - StandardTextFieldProps, -} from '@mui/material/TextField'; +import { useTheme } from '@mui/material/styles'; +import { default as _TextField } from '@mui/material/TextField'; import { clamp } from 'ramda'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -13,11 +10,13 @@ import { CircleProgress } from 'src/components/CircleProgress'; import { FormHelperText } from 'src/components/FormHelperText'; import { InputAdornment } from 'src/components/InputAdornment'; import { InputLabel } from 'src/components/InputLabel'; -import { TooltipProps } from 'src/components/Tooltip'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { convertToKebabCase } from 'src/utilities/convertToKebobCase'; +import type { Theme } from '@mui/material/styles'; +import type { StandardTextFieldProps } from '@mui/material/TextField'; import type { BoxProps } from 'src/components/Box'; +import type { TooltipProps } from 'src/components/Tooltip'; const useStyles = makeStyles()((theme: Theme) => ({ absolute: { @@ -480,8 +479,10 @@ export const TextField = (props: TextFieldProps) => { {tooltipText && ( { it('properly renders the File Uploader', () => { const screen = renderWithTheme(); - const browseFiles = screen.getByText('Browse Files').closest('button'); + const browseFiles = screen.getByText('Choose File').closest('button'); expect(browseFiles).toBeVisible(); expect(browseFiles).toBeEnabled(); - const text = screen.getByText( - 'You can browse your device to upload an image file or drop it here.' - ); - expect(text).toBeVisible(); + + expect( + screen.getByText( + 'An image file needs to be raw disk format (.img) that’s compressed using gzip.' + ) + ).toBeVisible(); + expect( + screen.getByText( + 'The maxiumum compressed file size is 5 GB and the file can’t exceed 6 GB when uncompressed.' + ) + ).toBeVisible(); }); it('disables the dropzone', () => { const screen = renderWithTheme(); - const browseFiles = screen.getByText('Browse Files').closest('button'); + const browseFiles = screen.getByText('Choose File').closest('button'); expect(browseFiles).toBeVisible(); expect(browseFiles).toBeDisabled(); expect(browseFiles).toHaveAttribute('aria-disabled', 'true'); const text = screen.getByText( - 'You can browse your device to upload an image file or drop it here.' + 'An image file needs to be raw disk format (.img) that’s compressed using gzip.' ); expect(text).toBeVisible(); }); diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index 0392f8e5145..ca7fe9f7d8c 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -1,7 +1,7 @@ import { styled } from '@mui/material'; import { Duration } from 'luxon'; import * as React from 'react'; -import { DropzoneProps, useDropzone } from 'react-dropzone'; +import { useDropzone } from 'react-dropzone'; import { BarPercent } from 'src/components/BarPercent'; import { Box } from 'src/components/Box'; @@ -12,6 +12,7 @@ import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; import { readableBytes } from 'src/utilities/unitConversions'; import type { AxiosProgressEvent } from 'axios'; +import type { DropzoneProps } from 'react-dropzone'; interface Props extends Partial { /** @@ -45,18 +46,25 @@ export const ImageUploader = React.memo((props: Props) => { return ( - + {acceptedFiles.length === 0 && ( - - You can browse your device to upload an image file or drop it here. - + <> + + An image file needs to be raw disk format (.img) that’s + compressed using gzip. + + + The maxiumum compressed file size is 5 GB and the file can’t + exceed 6 GB when uncompressed. + + )} {acceptedFiles.map((file) => ( {file.name} ({readableBytes(file.size, { base10: true }).formatted}) ))} - + {isUploading && ( @@ -82,7 +90,7 @@ export const ImageUploader = React.memo((props: Props) => { {!isUploading && ( )} diff --git a/packages/manager/src/containers/account.container.ts b/packages/manager/src/containers/account.container.ts deleted file mode 100644 index 191bea9f0b7..00000000000 --- a/packages/manager/src/containers/account.container.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Account } from '@linode/api-v4/lib'; -import { APIError } from '@linode/api-v4/lib/types'; -import * as React from 'react'; -import { UseQueryResult } from '@tanstack/react-query'; - -import { useAccount } from 'src/queries/account/account'; - -export interface WithAccountProps { - account: UseQueryResult; -} - -export const withAccount = ( - Component: React.ComponentType -) => { - return (props: Props) => { - const account = useAccount(); - - return React.createElement(Component, { - ...props, - account, - }); - }; -}; diff --git a/packages/manager/src/containers/accountSettings.container.ts b/packages/manager/src/containers/accountSettings.container.ts deleted file mode 100644 index b297b2c2f31..00000000000 --- a/packages/manager/src/containers/accountSettings.container.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AccountSettings } from '@linode/api-v4/lib'; -import { APIError } from '@linode/api-v4/lib/types'; -import * as React from 'react'; -import { UseQueryResult } from '@tanstack/react-query'; - -import { useAccountSettings } from 'src/queries/account/settings'; - -export interface WithAccountSettingsProps { - accountSettings: UseQueryResult; -} - -export const withAccountSettings = ( - Component: React.ComponentType -) => { - return (props: Props) => { - const accountSettings = useAccountSettings(); - - return React.createElement(Component, { - ...props, - accountSettings, - }); - }; -}; diff --git a/packages/manager/src/containers/events.container.ts b/packages/manager/src/containers/events.container.ts deleted file mode 100644 index 37ffae66821..00000000000 --- a/packages/manager/src/containers/events.container.ts +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; - -import { useEventsPollingActions } from 'src/queries/events/events'; - -export type WithEventsPollingActionProps = ReturnType< - typeof useEventsPollingActions ->; - -export const withEventsPollingActions = ( - Component: React.ComponentType -) => { - return (props: Props) => { - const polling = useEventsPollingActions(); - - return React.createElement(Component, { - ...props, - ...polling, - }); - }; -}; diff --git a/packages/manager/src/containers/globalErrors.container.ts b/packages/manager/src/containers/globalErrors.container.ts deleted file mode 100644 index ce6d04f5b42..00000000000 --- a/packages/manager/src/containers/globalErrors.container.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { connect } from 'react-redux'; - -import { ApplicationState } from 'src/store'; -import { - clearErrors, - setErrors, -} from 'src/store/globalErrors/globalErrors.actions'; -import { State } from 'src/store/globalErrors/types'; -import { ThunkDispatch } from 'src/store/types'; - -interface DispatchProps { - clearErrors: (params: State) => void; - setErrors: (params: State) => void; -} - -export interface StateProps { - globalErrors: State; -} - -/* tslint:disable-next-line */ -export type ReduxState = State; - -export type Props = DispatchProps & StateProps; - -export default ( - mapAccountToProps?: (ownProps: TOuter, errors: State) => TInner -) => - connect( - (state, ownProps) => { - if (mapAccountToProps) { - return mapAccountToProps(ownProps, state.globalErrors); - } - return { - globalErrors: state.globalErrors, - }; - }, - (dispatch: ThunkDispatch) => ({ - clearErrors: (params) => dispatch(clearErrors(params)), - setErrors: (params) => dispatch(setErrors(params)), - }) - ); diff --git a/packages/manager/src/containers/preferences.container.ts b/packages/manager/src/containers/preferences.container.ts deleted file mode 100644 index 48736a01f65..00000000000 --- a/packages/manager/src/containers/preferences.container.ts +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import { - useMutatePreferences, - usePreferences, -} from 'src/queries/profile/preferences'; -import { ManagerPreferences } from 'src/types/ManagerPreferences'; -export interface PreferencesStateProps { - preferences?: ManagerPreferences; -} - -export interface PreferencesActionsProps { - getUserPreferences: () => Promise; - updateUserPreferences: ( - params: ManagerPreferences - ) => Promise; -} - -export type Props = PreferencesActionsProps & PreferencesStateProps; - -const withPreferences = ( - Component: React.ComponentType< - Props & PreferencesStateProps & PreferencesActionsProps - > -) => (props: Props) => { - const { data: preferences, refetch } = usePreferences(); - const { mutateAsync: updateUserPreferences } = useMutatePreferences(); - - return React.createElement(Component, { - ...props, - getUserPreferences: () => - refetch().then(({ data }) => data ?? Promise.reject()), - preferences, - updateUserPreferences, - }); -}; - -export default withPreferences; diff --git a/packages/manager/src/containers/regions.container.ts b/packages/manager/src/containers/regions.container.ts deleted file mode 100644 index c9641ecd959..00000000000 --- a/packages/manager/src/containers/regions.container.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Region } from '@linode/api-v4/lib/regions'; -import { APIError } from '@linode/api-v4/lib/types'; -import * as React from 'react'; - -import { useRegionsQuery } from 'src/queries/regions/regions'; - -export interface RegionsProps { - regionsData: Region[]; - regionsError?: APIError[]; - regionsLoading: boolean; -} - -/** - * Simple wrapper around our Regions query. Originally this was a Redux connect - * function; it is being retained in this way because there are still a few places - * where regions data is needed in class components, some of which are difficult - * or problematic to refactor. - * - * This file can be deleted once the existing class components have been removed or converted - * to FCs (current list is: SelectPlanPanel/SelectPlanQuantityPanel; NodeBalancerCreate; LinodeSelect; - * LinodeCreate/LinodeCreateContainer). Please do NOT use this wrapper for any future components; if a class - * component is needed, best practice is to include an FC container above it (the routing level often works well) - * and pass regions through there. - */ -export const withRegions = ( - Component: React.ComponentType -) => (props: Props) => { - const { data, error, isLoading } = useRegionsQuery(); - return React.createElement(Component, { - regionsData: data ?? [], - regionsError: error ?? undefined, - regionsLoading: isLoading, - ...props, - }); -}; diff --git a/packages/manager/src/containers/types.container.ts b/packages/manager/src/containers/types.container.ts deleted file mode 100644 index 8499b1293d5..00000000000 --- a/packages/manager/src/containers/types.container.ts +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -import { useAllTypes } from 'src/queries/types'; - -import type { APIError, LinodeType } from '@linode/api-v4'; - -export interface WithTypesProps { - typesData?: LinodeType[]; - typesError?: APIError[]; - typesLoading: boolean; -} - -export const withTypes = ( - Component: React.ComponentType, - enabled = true -) => (props: Props) => { - const { - data: typesData, - error: typesError, - isLoading: typesLoading, - } = useAllTypes(enabled); - - return React.createElement(Component, { - ...props, - typesData, - typesError: typesError ?? undefined, - typesLoading, - }); -}; diff --git a/packages/manager/src/containers/withLinodes.container.ts b/packages/manager/src/containers/withLinodes.container.ts deleted file mode 100644 index 11873f5051c..00000000000 --- a/packages/manager/src/containers/withLinodes.container.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - CreateLinodeRequest, - Linode, - LinodeCloneData, -} from '@linode/api-v4/lib/linodes'; -import { APIError } from '@linode/api-v4/lib/types'; -import React from 'react'; - -import { - useAllLinodesQuery, - useCloneLinodeMutation, - useCreateLinodeMutation, -} from 'src/queries/linodes/linodes'; - -interface Actions { - cloneLinode: (data: { - sourceLinodeId: number & LinodeCloneData; - }) => Promise; - createLinode: (data: CreateLinodeRequest) => Promise; -} - -export interface WithLinodesProps { - linodeActions: Actions; - linodesData: Linode[] | undefined; - linodesError: APIError[] | null; - linodesLoading: boolean; -} - -export const withLinodes = ( - Component: React.ComponentType, - enabled = true -) => (props: Props) => { - const { - data: linodesData, - error: linodesError, - isLoading: linodesLoading, - } = useAllLinodesQuery({}, {}, enabled); - - const { mutateAsync: createLinode } = useCreateLinodeMutation(); - const { mutateAsync: cloneLinode } = useCloneLinodeMutation(); - - return React.createElement(Component, { - ...props, - linodeActions: { - cloneLinode, - createLinode, - }, - linodesData, - linodesError, - linodesLoading, - }); -}; diff --git a/packages/manager/src/containers/withMarketplaceApps.ts b/packages/manager/src/containers/withMarketplaceApps.ts deleted file mode 100644 index 17a0a56073e..00000000000 --- a/packages/manager/src/containers/withMarketplaceApps.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { StackScript } from '@linode/api-v4'; -import React from 'react'; -import { useLocation } from 'react-router-dom'; - -import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; -import { useFlags } from 'src/hooks/useFlags'; -import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; -import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; - -const trimOneClickFromLabel = (script: StackScript) => { - return { - ...script, - label: script.label.replace('One-Click', ''), - }; -}; - -export interface WithMarketplaceAppsProps { - appInstances: StackScript[] | undefined; - appInstancesError: string | undefined; - appInstancesLoading: boolean; -} - -export const withMarketplaceApps = ( - Component: React.ComponentType -) => (props: Props) => { - const location = useLocation(); - const flags = useFlags(); - - const type = getQueryParamFromQueryString(location.search, 'type'); - - // Only enable the query when the user is on the Marketplace page - const enabled = type === 'One-Click'; - - const { data, error, isLoading } = useMarketplaceAppsQuery(enabled); - - const newApps = flags.oneClickApps || []; - const allowedApps = Object.keys({ ...oneClickApps, ...newApps }); - - const filteredApps = (data ?? []).filter((script) => { - return ( - !script.label.match(/helpers/i) && allowedApps.includes(String(script.id)) - ); - }); - const trimmedApps = filteredApps.map((stackscript) => - trimOneClickFromLabel(stackscript) - ); - - return React.createElement(Component, { - ...props, - appInstances: trimmedApps, - appInstancesError: error?.[0]?.reason, - appInstancesLoading: isLoading, - }); -}; diff --git a/packages/manager/src/containers/withSearchEntities.ts b/packages/manager/src/containers/withSearchEntities.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/manager/src/containers/withSecureVMNoticesEnabled.container.ts b/packages/manager/src/containers/withSecureVMNoticesEnabled.container.ts deleted file mode 100644 index 73e8472dd55..00000000000 --- a/packages/manager/src/containers/withSecureVMNoticesEnabled.container.ts +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; - -import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; - -export interface WithSecureVMNoticesEnabledProps { - secureVMNoticesEnabled: boolean; -} - -export const withSecureVMNoticesEnabled = ( - Component: React.ComponentType -) => { - return (props: Props) => { - const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); - - return React.createElement(Component, { - ...props, - secureVMNoticesEnabled, - }); - }; -}; diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index eff241f37d5..c056cf9ac56 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -46,6 +46,7 @@ export const accountFactory = Factory.Sync.makeFactory({ 'LKE HA Control Planes', 'Machine Images', 'Managed Databases', + 'Managed Databases Beta', 'NodeBalancers', 'Object Storage Access Key Regions', 'Object Storage Endpoint Types', diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 657a262d804..33d95cca170 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -163,7 +163,7 @@ export const databaseTypeFactory = Factory.Sync.makeFactory({ }, ], }, - id: Factory.each((i) => `g6-standard-${i}`), + id: Factory.each((i) => possibleTypes[i % possibleTypes.length]), label: Factory.each((i) => `Linode ${i} GB`), memory: Factory.each((i) => i * 2048), vcpus: Factory.each((i) => i * 2), @@ -180,17 +180,26 @@ export const databaseInstanceFactory = Factory.Sync.makeFactory ['mysql', 'postgresql'][i % 2] as Engine), - hosts: { - primary: 'db-primary-0.b.linodeb.net', - secondary: 'db-secondary-0.b.linodeb.net', - }, + hosts: Factory.each((i) => + adb10(i) + ? { + primary: 'db-mysql-primary-0.b.linodeb.net', + secondary: 'db-mysql-secondary-0.b.linodeb.net', + } + : { + primary: 'db-mysql-primary-0.b.linodeb.net', + standby: 'db-mysql-secondary-0.b.linodeb.net', + } + ), id: Factory.each((i) => i), instance_uri: '', label: Factory.each((i) => `example.com-database-${i}`), members: { '2.2.2.2': 'primary', }, - platform: Factory.each((i) => (adb10(i) ? 'adb10' : 'adb20')), + platform: Factory.each((i) => + adb10(i) ? 'rdbms-legacy' : 'rdbms-default' + ), region: Factory.each((i) => possibleRegions[i % possibleRegions.length]), status: Factory.each((i) => possibleStatuses[i % possibleStatuses.length]), type: Factory.each((i) => possibleTypes[i % possibleTypes.length]), @@ -211,15 +220,24 @@ export const databaseFactory = Factory.Sync.makeFactory({ created: '2021-12-09T17:15:12', encrypted: false, engine: 'mysql', - hosts: { - primary: 'db-mysql-primary-0.b.linodeb.net', - secondary: 'db-mysql-secondary-0.b.linodeb.net', - }, + hosts: Factory.each((i) => + adb10(i) + ? { + primary: 'db-mysql-primary-0.b.linodeb.net', + secondary: 'db-mysql-secondary-0.b.linodeb.net', + } + : { + primary: 'db-mysql-primary-0.b.linodeb.net', + standby: 'db-mysql-secondary-0.b.linodeb.net', + } + ), id: Factory.each((i) => i), label: Factory.each((i) => `database-${i}`), members: { '2.2.2.2': 'primary', }, + oldest_restore_time: '2024-09-15T17:15:12', + platform: pickRandom(['rdbms-legacy', 'rdbms-default']), port: 3306, region: 'us-east', ssl_connection: false, @@ -239,7 +257,12 @@ export const databaseFactory = Factory.Sync.makeFactory({ }); export const databaseBackupFactory = Factory.Sync.makeFactory({ - created: Factory.each(() => randomDate().toISOString()), + created: Factory.each(() => + randomDate( + new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), + new Date() + ).toISOString() + ), id: Factory.each((i) => i), label: Factory.each(() => `backup-${v4()}`), type: pickRandom(['snapshot', 'auto']), diff --git a/packages/manager/src/factories/nodebalancer.ts b/packages/manager/src/factories/nodebalancer.ts index 711289d7abf..034151cde52 100644 --- a/packages/manager/src/factories/nodebalancer.ts +++ b/packages/manager/src/factories/nodebalancer.ts @@ -1,9 +1,10 @@ -import { +import Factory from 'src/factories/factoryProxy'; + +import type { NodeBalancer, NodeBalancerConfig, NodeBalancerConfigNode, } from '@linode/api-v4/lib/nodebalancers/types'; -import Factory from 'src/factories/factoryProxy'; export const nodeBalancerFactory = Factory.Sync.makeFactory({ client_conn_throttle: 0, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 57cc999ce71..35917a087a6 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -70,6 +70,7 @@ export interface CloudPulseResourceTypeMapFlag { } interface gpuV2 { + egressBanner: boolean; planDivider: boolean; } diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 3d010993877..837f5e54c21 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -16,7 +16,7 @@ import { getUserPreferenceObject } from '../Utils/UserPreference'; import { createObjectCopy } from '../Utils/utils'; import { CloudPulseWidget } from '../Widget/CloudPulseWidget'; import { - all_interval_options, + allIntervalOptions, getInSeconds, getIntervalIndex, } from '../Widget/components/CloudPulseIntervalSelect'; @@ -124,7 +124,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { const getTimeGranularity = (scrapeInterval: string) => { const scrapeIntervalValue = getInSeconds(scrapeInterval); const index = getIntervalIndex(scrapeIntervalValue); - return index < 0 ? all_interval_options[0] : all_interval_options[index]; + return index < 0 ? allIntervalOptions[0] : allIntervalOptions[index]; }; const { @@ -203,7 +203,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { // maintain a copy const newDashboard: Dashboard = createObjectCopy(dashboard)!; return ( - + {{ ...newDashboard }.widgets.map((widget, index) => { // check if widget metric definition is available or not if (widget) { diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx index d0cfe9b3470..d54a52a0500 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx @@ -8,7 +8,7 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { CloudPulseDashboardLanding } from './CloudPulseDashboardLanding'; const dashboardLabel = 'Factory Dashboard-1'; -const selectDashboardLabel = 'Select Dashboard'; +const selectDashboardLabel = 'Select a Dashboard'; const queryMocks = vi.hoisted(() => ({ useCloudPulseDashboardsQuery: vi.fn().mockReturnValue({}), useLoadUserPreferences: vi.fn().mockReturnValue({}), diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index b31ee7f84a8..3907346f617 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -131,7 +131,7 @@ export const CloudPulseDashboardLanding = () => { } return ( - + ({ useCloudPulseDashboardByIdQuery: vi.fn().mockReturnValue({}), })); -const selectTimeDurationPlaceholder = 'Select Time Duration'; +const selectTimeDurationPlaceholder = 'Select a Time Duration'; const circleProgress = 'circle-progress'; const mandatoryFiltersError = 'Mandatory Filters not Selected'; -const customNodeTypePlaceholder = 'Select Node Type'; vi.mock('src/queries/cloudpulse/dashboards', async () => { const actual = await vi.importActual('src/queries/cloudpulse/dashboards'); @@ -64,7 +63,9 @@ describe('CloudPulseDashboardWithFilters component tests', () => { ); - expect(screen.getByText(selectTimeDurationPlaceholder)).toBeDefined(); + expect( + screen.getByPlaceholderText(selectTimeDurationPlaceholder) + ).toBeDefined(); expect(screen.getByTestId(circleProgress)).toBeDefined(); // the dashboards started to render }); @@ -80,7 +81,9 @@ describe('CloudPulseDashboardWithFilters component tests', () => { ); - expect(screen.getByText(selectTimeDurationPlaceholder)).toBeDefined(); + expect( + screen.getByPlaceholderText(selectTimeDurationPlaceholder) + ).toBeDefined(); expect(screen.getByTestId(circleProgress)).toBeDefined(); // the dashboards started to render }); @@ -98,8 +101,7 @@ describe('CloudPulseDashboardWithFilters component tests', () => { expect(screen.getByTestId('CloseIcon')).toBeDefined(); - const inputBox = screen.getByPlaceholderText(customNodeTypePlaceholder); - fireEvent.change(inputBox, { target: { value: '' } }); // clear the value + fireEvent.click(screen.getByTitle('Clear')); // clear the value expect(screen.getByText(mandatoryFiltersError)).toBeDefined(); }); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index 2354bda5533..e8f65dd6842 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -1,4 +1,4 @@ -import { Divider, Grid, styled } from '@mui/material'; +import { Grid, styled } from '@mui/material'; import React from 'react'; import CloudPulseIcon from 'src/assets/icons/entityIcons/monitor.svg'; @@ -100,7 +100,11 @@ export const CloudPulseDashboardWithFilters = React.memo( return ( <> - + - {isFilterBuilderNeeded && ( - <> - - - + )} {isMandatoryFiltersSelected ? ( diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index 248e1fcfa33..b6b290176d7 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -1,9 +1,9 @@ -import { IconButton } from '@mui/material'; +import { IconButton, useTheme } from '@mui/material'; import { Grid } from '@mui/material'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import Reload from 'src/assets/icons/reload.svg'; +import Reload from 'src/assets/icons/refresh.svg'; import { Divider } from 'src/components/Divider'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; @@ -71,44 +71,53 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { [handleAnyFilterChange] ); + const theme = useTheme(); + return ( - - - - + + + + + + + + + handleGlobalRefresh(selectedDashboard)} + size="small" + > + + + - - - + {selectedDashboard && ( + + handleGlobalRefresh(selectedDashboard)} - size="small" - > - - + /> - - - - + )} + {selectedDashboard && ( { thisSeries.data.every((thisPoint) => thisPoint[1] === null) ); }; + +/** + * Returns an autocomplete with updated styles according to UX, this will be used at widget level + */ +export const StyledWidgetAutocomplete = styled(Autocomplete, { + label: 'StyledAutocomplete', +})(({ theme }) => ({ + '&& .MuiFormControl-root': { + minWidth: '90px', + [theme.breakpoints.down('sm')]: { + width: '100%', // 100% width for xs and small screens + }, + width: '90px', + }, +})); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index cc6782926a2..cb2049952c1 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -28,7 +28,7 @@ export const LINODE_CONFIG: Readonly = { isMultiSelect: true, name: 'Resource', neededInServicePage: false, - placeholder: 'Select Resources', + placeholder: 'Select a Resource', priority: 2, }, name: 'Resources', @@ -42,7 +42,7 @@ export const LINODE_CONFIG: Readonly = { isMultiSelect: false, name: TIME_DURATION, neededInServicePage: false, - placeholder: 'Select Duration', + placeholder: 'Select a Duration', priority: 3, }, name: TIME_DURATION, @@ -100,7 +100,7 @@ export const DBAAS_CONFIG: Readonly = { isMultiSelect: true, name: 'Resource', neededInServicePage: false, - placeholder: 'Select DB Cluster Names', + placeholder: 'Select a DB Cluster', priority: 3, }, name: 'Resources', @@ -114,7 +114,7 @@ export const DBAAS_CONFIG: Readonly = { isMultiSelect: false, name: TIME_DURATION, neededInServicePage: false, // we will have a static time duration component, no need render from filter builder - placeholder: 'Select Duration', + placeholder: 'Select a Duration', priority: 4, }, name: TIME_DURATION, @@ -138,7 +138,7 @@ export const DBAAS_CONFIG: Readonly = { label: 'Secondary', }, ], - placeholder: 'Select Node Type', + placeholder: 'Select a Node Type', priority: 5, type: CloudPulseSelectTypes.static, }, diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 06847a0573e..2eb36a257b8 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -1,8 +1,8 @@ -import { Box, Grid, Paper, Stack, Typography } from '@mui/material'; +import { Box, Grid, Stack, Typography, useTheme } from '@mui/material'; import { DateTime } from 'luxon'; import React from 'react'; -import { Divider } from 'src/components/Divider'; +import { Paper } from 'src/components/Paper'; import { useFlags } from 'src/hooks/useFlags'; import { useCloudPulseMetricsQuery } from 'src/queries/cloudpulse/metrics'; import { useProfile } from 'src/queries/profile/profile'; @@ -26,12 +26,12 @@ import { ZoomIcon } from './components/Zoomer'; import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; -import type { Widgets } from '@linode/api-v4'; import type { AvailableMetrics, TimeDuration, TimeGranularity, } from '@linode/api-v4'; +import type { Widgets } from '@linode/api-v4'; import type { DataSet } from 'src/components/LineGraph/LineGraph'; import type { Metrics } from 'src/utilities/statMetrics'; @@ -126,6 +126,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const [widget, setWidget] = React.useState({ ...props.widget }); + const theme = useTheme(); + const { additionalFilters, ariaLabel, @@ -288,36 +290,34 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const metricsApiCallError = error?.[0]?.reason; return ( - - - + + + - - {convertStringToCamelCasesWithSpaces(widget.label)}{' '} - {!isLoading && - `(${currentUnit}${unit.endsWith('ps') ? '/s' : ''})`} + + {convertStringToCamelCasesWithSpaces(widget.label)} ({currentUnit} + {unit.endsWith('ps') ? '/s' : ''}) {availableMetrics?.scrape_interval && ( )} {Boolean( @@ -339,8 +339,6 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { - - { formatTooltip={(value: number) => formatToolTip(value, unit)} gridSize={widget.size} loading={isLoading || metricsApiCallError === jweTokenExpiryError} // keep loading until we fetch the refresh token - nativeLegend showToday={today} timezone={timezone} title={widget.label} /> - - + + ); }; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx index 0c4836abfac..3f881d67b32 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { StyledWidgetAutocomplete } from '../../Utils/CloudPulseWidgetUtils'; export interface AggregateFunctionProperties { /** @@ -37,7 +37,7 @@ export const CloudPulseAggregateFunction = React.memo( ) || props.availableAggregateFunctions[0]; return ( - { return option.label == value.label; }} diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx index 96023a33da8..f2ae44b21ac 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx @@ -7,7 +7,7 @@ import { CloudPulseIntervalSelect } from './CloudPulseIntervalSelect'; import type { TimeGranularity } from '@linode/api-v4'; describe('Interval select component', () => { - const intervalSelectionChange = (_selectedInterval: TimeGranularity) => {}; + const intervalSelectionChange = (_selectedInterval: TimeGranularity) => { }; it('should check for the selected value in interval select dropdown', () => { const scrape_interval = '30s'; @@ -15,9 +15,9 @@ describe('Interval select component', () => { const { getByRole } = renderWithTheme( ); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx index fe0bfed6a9d..7370937f1e3 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx @@ -1,14 +1,20 @@ import React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { StyledWidgetAutocomplete } from '../../Utils/CloudPulseWidgetUtils'; import type { TimeGranularity } from '@linode/api-v4'; +interface IntervalOptions { + label: string; + unit: string; + value: number; +} + export interface IntervalSelectProperties { /** * Default time granularity to be selected */ - default_interval?: TimeGranularity | undefined; + defaultInterval?: TimeGranularity | undefined; /** * Function to be triggered on aggregate function changed from dropdown @@ -18,7 +24,7 @@ export interface IntervalSelectProperties { /** * scrape intervalto filter out minimum time granularity */ - scrape_interval: string; + scrapeInterval: string; } export const getInSeconds = (interval: string) => { @@ -39,7 +45,7 @@ export const getInSeconds = (interval: string) => { }; // Intervals must be in ascending order here -export const all_interval_options = [ +export const allIntervalOptions: IntervalOptions[] = [ { label: '1 min', unit: 'min', @@ -62,14 +68,14 @@ export const all_interval_options = [ }, ]; -const autoIntervalOption = { +const autoIntervalOption: IntervalOptions = { label: 'Auto', unit: 'Auto', value: -1, }; export const getIntervalIndex = (scrapeIntervalValue: number) => { - return all_interval_options.findIndex( + return allIntervalOptions.findIndex( (interval) => scrapeIntervalValue <= getInSeconds(String(interval.value) + interval.unit.slice(0, 1)) @@ -78,26 +84,26 @@ export const getIntervalIndex = (scrapeIntervalValue: number) => { export const CloudPulseIntervalSelect = React.memo( (props: IntervalSelectProperties) => { - const scrapeIntervalValue = getInSeconds(props.scrape_interval); + const scrapeIntervalValue = getInSeconds(props.scrapeInterval); const firstIntervalIndex = getIntervalIndex(scrapeIntervalValue); // all intervals displayed if srape interval > highest available interval. Error handling done by api - const available_interval_options = + const availableIntervalOptions = firstIntervalIndex < 0 - ? all_interval_options.slice() - : all_interval_options.slice( + ? allIntervalOptions.slice() + : allIntervalOptions.slice( firstIntervalIndex, - all_interval_options.length + allIntervalOptions.length ); let default_interval = - props.default_interval?.unit === 'Auto' + props.defaultInterval?.unit === 'Auto' ? autoIntervalOption - : available_interval_options.find( + : availableIntervalOptions.find( (obj) => - obj.value === props.default_interval?.value && - obj.unit === props.default_interval?.unit + obj.value === props.defaultInterval?.value && + obj.unit === props.defaultInterval?.unit ); if (!default_interval) { @@ -109,11 +115,17 @@ export const CloudPulseIntervalSelect = React.memo( } return ( - { + { return option?.value === value?.value && option?.unit === value?.unit; }} - onChange={(_: any, selectedInterval: any) => { + onChange={( + _: React.SyntheticEvent, + selectedInterval: IntervalOptions + ) => { props.onIntervalChange({ unit: selectedInterval?.unit, value: selectedInterval?.value, @@ -127,7 +139,7 @@ export const CloudPulseIntervalSelect = React.memo( fullWidth={false} label="Select an Interval" noMarginTop={true} - options={[autoIntervalOption, ...available_interval_options]} + options={[autoIntervalOption, ...availableIntervalOptions]} sx={{ width: { xs: '100%' } }} /> ); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx index 80958e13749..4547d306b41 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -1,4 +1,4 @@ -import { Box, Typography } from '@mui/material'; +import { Box, Typography, useTheme } from '@mui/material'; import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; @@ -23,6 +23,8 @@ export interface CloudPulseLineGraph extends LineGraphProps { export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { const { ariaLabel, data, error, legendRows, loading, ...rest } = props; + const theme = useTheme(); + if (loading) { return ; } @@ -46,6 +48,11 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { '& .MuiTable-root': { border: 0, }, + backgroundColor: theme.bg.offWhite, + height: `calc(${theme.spacing(14)} + 3px)`, // 115px + maxHeight: `calc(${theme.spacing(14)} + 3px)`, + overflow: 'auto', + padding: theme.spacing(1), }} ariaLabel={ariaLabel} data={data} diff --git a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx index 6ed093ed513..f21148cc831 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx @@ -1,7 +1,9 @@ -import ZoomInMap from '@mui/icons-material/ZoomInMap'; -import ZoomOutMap from '@mui/icons-material/ZoomOutMap'; +import { useTheme } from '@mui/material'; import * as React from 'react'; +import ZoomInMap from 'src/assets/icons/zoomin.svg'; +import ZoomOutMap from 'src/assets/icons/zoomout.svg'; + export interface ZoomIconProperties { className?: string; handleZoomToggle: (zoomIn: boolean) => void; @@ -9,6 +11,8 @@ export interface ZoomIconProperties { } export const ZoomIcon = React.memo((props: ZoomIconProperties) => { + const theme = useTheme(); + const handleClick = (needZoomIn: boolean) => { props.handleZoomToggle(needZoomIn); }; @@ -17,18 +21,26 @@ export const ZoomIcon = React.memo((props: ZoomIconProperties) => { if (props.zoomIn) { return ( handleClick(false)} - style={{ color: 'grey', fontSize: 'x-large' }} /> ); } return ( handleClick(true)} - style={{ color: 'grey', fontSize: 'x-large' }} /> ); }; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx index 90c36cdf381..1b907419a46 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx @@ -47,7 +47,7 @@ describe('ComponentRenderer component tests', () => { ); - expect(getByPlaceholderText('Select Region')).toBeDefined(); + expect(getByPlaceholderText('Select a Region')).toBeDefined(); }), it('it should render provided resource filter in props', () => { const resourceProps = linodeFilterConfig?.filters.find( @@ -81,6 +81,6 @@ describe('ComponentRenderer component tests', () => { })} ); - expect(getByPlaceholderText('Select Resources')).toBeDefined(); + expect(getByPlaceholderText('Select a Resource')).toBeDefined(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx index d4a401825ca..78ae7c77efe 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx @@ -61,8 +61,7 @@ describe('CloudPulseCustomSelect component tests', () => { type={CloudPulseSelectTypes.static} /> ); - - expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); + expect(screen.queryByPlaceholderText(testFilter)).toBeNull(); const keyDown = screen.getByTestId(keyboardArrowDownIcon); fireEvent.click(keyDown); fireEvent.click(screen.getByText('Test1')); @@ -82,8 +81,7 @@ describe('CloudPulseCustomSelect component tests', () => { type={CloudPulseSelectTypes.static} /> ); - - expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); + expect(screen.queryByPlaceholderText(testFilter)).toBeNull(); const keyDown = screen.getByTestId(keyboardArrowDownIcon); fireEvent.click(keyDown); expect(screen.getAllByText('Test1').length).toEqual(2); // here it should be 2 @@ -111,13 +109,17 @@ describe('CloudPulseCustomSelect component tests', () => { type={CloudPulseSelectTypes.dynamic} /> ); - expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); + expect(screen.queryByPlaceholderText(testFilter)).toBeNull(); const keyDown = screen.getByTestId(keyboardArrowDownIcon); fireEvent.click(keyDown); fireEvent.click(screen.getByText('Test1')); const textField = screen.getByTestId('textfield-input'); expect(textField.getAttribute('value')).toEqual('Test1'); expect(selectionChnage).toHaveBeenCalledTimes(1); + + // if we click on clear icon , placeholder should appear for single select + fireEvent.click(screen.getByTitle('Clear')); + expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); }); it('should render a component successfully with required props dynamic multi select', () => { @@ -133,7 +135,7 @@ describe('CloudPulseCustomSelect component tests', () => { type={CloudPulseSelectTypes.dynamic} /> ); - expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); + expect(screen.queryByPlaceholderText(testFilter)).toBeNull(); const keyDown = screen.getByTestId(keyboardArrowDownIcon); fireEvent.click(keyDown); expect(screen.getAllByText('Test1').length).toEqual(2); // here it should be 2 @@ -148,5 +150,9 @@ describe('CloudPulseCustomSelect component tests', () => { expect(screen.getAllByText('Test1').length).toEqual(1); expect(screen.getAllByText('Test2').length).toEqual(1); expect(selectionChnage).toHaveBeenCalledTimes(2); // check if selection change is called twice as we selected two options + + // if we click on clear icon , placeholder should appear + fireEvent.click(screen.getByTitle('Clear')); + expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index 098d3a301e7..6209063e228 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -175,7 +175,6 @@ export const CloudPulseCustomSelect = React.memo( }; let staticErrorText = ''; - // check for input prop errors if ( (CloudPulseSelectTypes.static === type && @@ -205,6 +204,12 @@ export const CloudPulseCustomSelect = React.memo( ? options ?? [] : queriedResources ?? [] } + placeholder={ + selectedResource && + (!Array.isArray(selectedResource) || selectedResource.length) + ? '' + : placeholder || 'Select a Value' + } textFieldProps={{ hideLabel: true, }} @@ -214,7 +219,6 @@ export const CloudPulseCustomSelect = React.memo( label="Select a Value" multiple={isMultiSelect} onChange={handleChange} - placeholder={placeholder ?? 'Select a Value'} value={selectedResource ?? (isMultiSelect ? [] : null)} /> ); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx index aae73a9acd6..006dc05afb6 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx @@ -32,7 +32,7 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { /> ); - expect(getByPlaceholderText('Select DB Cluster Names')).toBeDefined(); - expect(getByPlaceholderText('Select Region')).toBeDefined(); + expect(getByPlaceholderText('Select an Engine')).toBeDefined(); + expect(getByPlaceholderText('Select a Region')).toBeDefined(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index a0e1cd76801..d908924c1a8 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -1,11 +1,10 @@ -import KeyboardArrowDownIcon from '@mui/icons-material/ArrowDropDown'; -import KeyboardArrowRightIcon from '@mui/icons-material/ArrowRight'; -import { Grid, Typography } from '@mui/material'; +import { Grid, Typography, useTheme } from '@mui/material'; import * as React from 'react'; +import KeyboardArrowDownIcon from 'src/assets/icons/arrow_down.svg'; +import KeyboardArrowRightIcon from 'src/assets/icons/arrow_right.svg'; import InfoIcon from 'src/assets/icons/info.svg'; import { Button } from 'src/components/Button/Button'; -import { Divider } from 'src/components/Divider'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import NullComponent from 'src/components/NullComponent'; @@ -59,6 +58,8 @@ export const CloudPulseDashboardFilterBuilder = React.memo( const [showFilter, setShowFilter] = React.useState(true); + const theme = useTheme(); + const dependentFilterReference: React.MutableRefObject<{ [key: string]: FilterValueType; }> = React.useRef({}); @@ -205,8 +206,22 @@ export const CloudPulseDashboardFilterBuilder = React.memo( } return ( - - + + - - - diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx index a4f0ce13c4a..1ff706d78d5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx @@ -66,7 +66,7 @@ describe('CloudPulse Dashboard select', () => { ); expect(getByTestId('cloudpulse-dashboard-select')).toBeInTheDocument(); - expect(getByPlaceholderText('Select Dashboard')).toBeInTheDocument(); + expect(getByPlaceholderText('Select a Dashboard')).toBeInTheDocument(); }), it('Should render dashboard select component with data', () => { renderWithTheme(); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx index 93504ac2882..a27e9051d87 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -59,7 +59,7 @@ export const CloudPulseDashboardSelect = React.memo( const errorText: string = getErrorText(); - const placeHolder = 'Select Dashboard'; + const placeHolder = 'Select a Dashboard'; // sorts dashboards by service type. Required due to unexpected autocomplete grouping behaviour const getSortedDashboardsList = (options: Dashboard[]): Dashboard[] => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index c2f9095f8b6..de9c342a17b 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -62,7 +62,7 @@ export const CloudPulseRegionSelect = React.memo( disabled={!selectedDashboard || !regions} fullWidth label="Select a Region" - placeholder={placeholder ?? 'Select Region'} + placeholder={placeholder ?? 'Select a Region'} regions={regions ? regions : []} value={selectedRegion} /> diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx index 1567a0681d2..ff06cdcd0fc 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -42,7 +42,7 @@ describe('CloudPulseResourcesSelect component tests', () => { /> ); expect(getByTestId('resource-select')).toBeInTheDocument(); - expect(getByPlaceholderText('Select Resources')).toBeInTheDocument(); + expect(getByPlaceholderText('Select a Resource')).toBeInTheDocument(); }), it('should render resources happy path', () => { queryMocks.useResourcesQuery.mockReturnValue({ diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index fa0595d6c81..adaf971e1ba 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -90,6 +90,9 @@ export const CloudPulseResourcesSelect = React.memo( setSelectedResources(resourceSelections); handleResourcesSelection(resourceSelections); }} + placeholder={ + selectedResources?.length ? '' : placeholder || 'Select a Resource' + } textFieldProps={{ InputProps: { sx: { @@ -111,7 +114,6 @@ export const CloudPulseResourcesSelect = React.memo( limitTags={2} multiple options={getResourcesList()} - placeholder={placeholder ? placeholder : 'Select Resources'} value={selectedResources} /> ); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx index e6aeb855f23..b270743305f 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx @@ -85,7 +85,7 @@ export const CloudPulseTimeRangeSelect = React.memo( isOptionEqualToValue={(option, value) => option.value === value.value} label="Select Time Duration" options={options} - placeholder={placeholder ?? 'Select Time Duration'} + placeholder={placeholder ?? 'Select a Time Duration'} value={selectedTimeRange} /> ); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx index 0faa8c693c9..40b0bf1c6ce 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx @@ -39,7 +39,7 @@ describe('Database Create', () => { const standardTypes = [ databaseTypeFactory.build({ class: 'nanode', - id: 'g6-standard-0', + id: 'g6-nanode-1', label: `Nanode 1 GB`, memory: 1024, }), @@ -117,7 +117,7 @@ describe('Database Create', () => { server.use( http.get('*/account', () => { const account = accountFactory.build({ - capabilities: ['Managed Databases V2'], + capabilities: ['Managed Databases Beta'], }); return HttpResponse.json(account); }) diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 20fadc053c9..4f90a4224a8 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -64,9 +64,6 @@ import type { Item } from 'src/components/EnhancedSelect/Select'; import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; import type { ExtendedIP } from 'src/utilities/ipUtils'; -const V1 = 'Managed Databases'; -const V2 = `Managed Databases V2`; - const useStyles = makeStyles()((theme: Theme) => ({ btnCtn: { alignItems: 'center', @@ -199,6 +196,7 @@ interface NodePricing { const DatabaseCreate = () => { const { classes } = useStyles(); const history = useHistory(); + const { isDatabasesV2Beta, isDatabasesV2Enabled } = useIsDatabasesEnabled(); const { data: regionsData, @@ -216,9 +214,9 @@ const DatabaseCreate = () => { data: dbtypes, error: typesError, isLoading: typesLoading, - } = useDatabaseTypesQuery(); - - const { isDatabasesV2Beta, isDatabasesV2Enabled } = useIsDatabasesEnabled(); + } = useDatabaseTypesQuery({ + platform: isDatabasesV2Enabled ? 'rdbms-default' : 'rdbms-legacy', + }); const formRef = React.useRef(null); const { mutateAsync: createDatabase } = useCreateDatabaseMutation(); @@ -280,7 +278,9 @@ const DatabaseCreate = () => { ...values, allow_list: _allow_list, }; - + if (isDatabasesV2Beta) { + delete createPayload.replication_type; + } try { const response = await createDatabase(createPayload); history.push(`/databases/${response.engine}/${response.id}`); @@ -342,7 +342,7 @@ const DatabaseCreate = () => { return dbtypes.map((type) => { const { label } = type; const formattedLabel = formatStorageUnits(label); - const singleNodePricing = type.engines[selectedEngine].find( + const singleNodePricing = type.engines[selectedEngine]?.find( (cluster) => cluster.quantity === 1 ); const price = singleNodePricing?.price ?? { @@ -448,13 +448,13 @@ const DatabaseCreate = () => { const engineType = values.engine.split('/')[0] as Engine; setNodePricing({ - double: type.engines[engineType].find( + double: type.engines[engineType]?.find( (cluster: DatabaseClusterSizeObject) => cluster.quantity === 2 )?.price, - multi: type.engines[engineType].find( + multi: type.engines[engineType]?.find( (cluster: DatabaseClusterSizeObject) => cluster.quantity === 3 )?.price, - single: type.engines[engineType].find( + single: type.engines[engineType]?.find( (cluster: DatabaseClusterSizeObject) => cluster.quantity === 1 )?.price, }); @@ -462,17 +462,26 @@ const DatabaseCreate = () => { 'cluster_size', values.cluster_size < 1 ? 3 : values.cluster_size ); - setFieldValue( - 'replication_type', - determineReplicationType(values.cluster_size, values.engine) - ); - setFieldValue( - 'replication_commit_type', - determineReplicationCommitType(values.engine) - ); + if (!isDatabasesV2Enabled) { + setFieldValue( + 'replication_type', + determineReplicationType(values.cluster_size, values.engine) + ); + setFieldValue( + 'replication_commit_type', + determineReplicationCommitType(values.engine) + ); + } setFieldValue('storage_engine', determineStorageEngine(values.engine)); setFieldValue('compression_type', determineCompressionType(values.engine)); - }, [dbtypes, setFieldValue, values.cluster_size, values.type, values.engine]); + }, [ + dbtypes, + setFieldValue, + values.cluster_size, + values.type, + values.engine, + isDatabasesV2Enabled, + ]); if (regionsLoading || !regionsData || enginesLoading || typesLoading) { return ; @@ -544,7 +553,7 @@ const DatabaseCreate = () => { setFieldValue('region', region.id)} @@ -583,10 +592,11 @@ const DatabaseCreate = () => { ) => { setFieldValue('cluster_size', +e.target.value); - setFieldValue( - 'replication_type', - +e.target.value === 1 ? 'none' : 'semi_synch' - ); + !isDatabasesV2Enabled && + setFieldValue( + 'replication_type', + +e.target.value === 1 ? 'none' : 'semi_synch' + ); }} data-testid="database-nodes" > diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.test.tsx index 128121d878f..5d551ca4253 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.test.tsx @@ -31,21 +31,23 @@ describe('Access Controls', () => { ).toBeInTheDocument(); }); - it('Should disable "Manage Access Control" button if disabled = true', () => { - const database = databaseFactory.build(); - const { getByTitle } = renderWithTheme( - - ); - const manageAccessControlBtn = getByTitle('Manage Access Controls'); - expect(manageAccessControlBtn).toBeDisabled(); - }); - - it('Should enable "Manage Access Control" button if disabled = false', () => { - const database = databaseFactory.build(); - const { getByTitle } = renderWithTheme( - - ); - const manageAccessControlBtn = getByTitle('Manage Access Controls'); - expect(manageAccessControlBtn).toBeEnabled(); - }); + it.each([ + ['disable', true], + ['enable', false], + ])( + 'should %s "Manage Access Control" button when disabled is %s', + (_, isDisabled) => { + const database = databaseFactory.build(); + const { getByRole } = renderWithTheme( + + ); + const button = getByRole('button', { name: 'Manage Access Controls' }); + + if (isDisabled) { + expect(button).toBeDisabled(); + } else { + expect(button).toBeEnabled(); + } + } + ); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx index 69d4d2e4d74..1e2a9070474 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import AddNewLink from 'src/components/AddNewLink'; +import { Button } from 'src/components/Button/Button'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { Notice } from 'src/components/Notice/Notice'; @@ -197,12 +197,14 @@ export const AccessControls = (props: Props) => {
{description ?? null}
- setAddAccessControlDrawerOpen(true)} - /> + > + Manage Access Controls + {ipTable(allowList)} ({ + '.MuiInputAdornment-root': { marginRight: '0' }, + '.MuiInputBase-input': { padding: '8px 0 8px 12px' }, + '.MuiInputBase-root': { borderRadius: '0', padding: '0px' }, + + 'button.MuiButtonBase-root': { + marginRight: '0', + padding: '8px', + }, + height: '34px', + marginTop: '8px', + width: '120px', +})); + +export const StyledDateCalendar = styled(DateCalendar, { + label: 'StyledDateCalendar', +})(({ theme }) => ({ + '.MuiButtonBase-root.MuiPickersDay-root.Mui-disabled': { + color: theme.color.grey3, + }, + '.MuiPickersArrowSwitcher-spacer': { width: '15px' }, + '.MuiPickersCalendarHeader-labelContainer': { + fontSize: '0.95rem', + }, + '.MuiPickersCalendarHeader-root': { + marginTop: '0', + paddingLeft: '17px', + paddingRight: '3px', + }, + '.MuiPickersCalendarHeader-switchViewIcon': { + fontSize: '28px', + }, + '.MuiPickersDay-root': { + fontSize: '0.875rem', + height: '32px', + width: '32px', + }, + '.MuiSvgIcon-root': { + fontSize: '22px', + }, + '.MuiTypography-root': { + fontSize: '0.875rem', + height: '32px', + width: '32px', + }, + '.MuiYearCalendar-root': { + width: '260px', + }, + marginLeft: '0px', + width: '260px', +})); + +export const StyledBox = styled(Box)(({ theme }) => ({ + '& h6': { + fontSize: '0.875rem', + }, + '& span': { + marginBottom: '5px', + marginTop: '7px', + }, + alignItems: 'flex-start', + + border: '1px solid #F4F4F4', + color: theme.name === 'light' ? '#555555' : theme.color.headline, + display: 'flex', + flexDirection: 'column', + height: '100%', + padding: '8px 15px', + background: theme.name === 'light' ? '#FBFBFB' : theme.color.grey2, +})); + +export const StyledTypography = styled(Typography)(() => ({ + lineHeight: '20px', + marginTop: '4px', +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx index 7f260fff41a..081dbc98cf3 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx @@ -14,6 +14,9 @@ import DatabaseBackups from './DatabaseBackups'; describe('Database Backups', () => { it('should render a list of backups after loading', async () => { + const mockDatabase = databaseFactory.build({ + platform: 'rdbms-legacy', + }); const backups = databaseBackupFactory.buildList(7); // Mock the Database because the Backups Details page requires it to be loaded @@ -22,7 +25,7 @@ describe('Database Backups', () => { return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); }), http.get('*/databases/:engine/instances/:id', () => { - return HttpResponse.json(databaseFactory.build()); + return HttpResponse.json(mockDatabase); }), http.get('*/databases/:engine/instances/:id/backups', () => { return HttpResponse.json(makeResourcePage(backups)); @@ -41,10 +44,13 @@ describe('Database Backups', () => { }); it('should render an empty state if there are no backups', async () => { + const mockDatabase = databaseFactory.build({ + platform: 'rdbms-legacy', + }); // Mock the Database because the Backups Details page requires it to be loaded server.use( http.get('*/databases/:engine/instances/:id', () => { - return HttpResponse.json(databaseFactory.build()); + return HttpResponse.json(mockDatabase); }) ); @@ -61,6 +67,9 @@ describe('Database Backups', () => { }); it('should disable the restore button if disabled = true', async () => { + const mockDatabase = databaseFactory.build({ + platform: 'rdbms-legacy', + }); const backups = databaseBackupFactory.buildList(7); server.use( @@ -68,7 +77,7 @@ describe('Database Backups', () => { return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); }), http.get('*/databases/:engine/instances/:id', () => { - return HttpResponse.json(databaseFactory.build()); + return HttpResponse.json(mockDatabase); }), http.get('*/databases/:engine/instances/:id/backups', () => { return HttpResponse.json(makeResourcePage(backups)); @@ -86,7 +95,40 @@ describe('Database Backups', () => { }); }); + it('should disable the restore button if no oldest_restore_time is returned', async () => { + const mockDatabase = databaseFactory.build({ + oldest_restore_time: undefined, + platform: 'rdbms-default', + }); + const backups = databaseBackupFactory.buildList(7); + + server.use( + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); + }), + http.get('*/databases/:engine/instances/:id', () => { + return HttpResponse.json(mockDatabase); + }), + http.get('*/databases/:engine/instances/:id/backups', () => { + return HttpResponse.json(makeResourcePage(backups)); + }) + ); + + const { findAllByText } = renderWithTheme( + + ); + const buttonSpans = await findAllByText('Restore'); + expect(buttonSpans.length).toEqual(1); + buttonSpans.forEach((span: HTMLSpanElement) => { + const button = span.closest('button'); + expect(button).toBeDisabled(); + }); + }); + it('should enable the restore button if disabled = false', async () => { + const mockDatabase = databaseFactory.build({ + platform: 'rdbms-legacy', + }); const backups = databaseBackupFactory.buildList(7); server.use( @@ -94,7 +136,7 @@ describe('Database Backups', () => { return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); }), http.get('*/databases/:engine/instances/:id', () => { - return HttpResponse.json(databaseFactory.build()); + return HttpResponse.json(mockDatabase); }), http.get('*/databases/:engine/instances/:id/backups', () => { return HttpResponse.json(makeResourcePage(backups)); @@ -111,4 +153,29 @@ describe('Database Backups', () => { expect(button).toBeEnabled(); }); }); + + it('should render a time picker when it is a new database', async () => { + const mockDatabase = databaseFactory.build({ + platform: 'rdbms-default', + }); + const backups = databaseBackupFactory.buildList(7); + + server.use( + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); + }), + http.get('*/databases/:engine/instances/:id', () => { + return HttpResponse.json(mockDatabase); + }), + http.get('*/databases/:engine/instances/:id/backups', () => { + return HttpResponse.json(makeResourcePage(backups)); + }) + ); + + const { findByText } = renderWithTheme( + + ); + const timePickerLabel = await findByText('Time (UTC)'); + expect(timePickerLabel).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 2ebd021cc7c..35aafb99e5c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -1,7 +1,17 @@ -import { DatabaseBackup, Engine } from '@linode/api-v4/lib/databases'; +import { FormControl } from '@mui/material'; +import Grid from '@mui/material/Grid'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { DateTime } from 'luxon'; import * as React from 'react'; import { useParams } from 'react-router-dom'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Divider } from 'src/components/Divider'; +import Select from 'src/components/EnhancedSelect/Select'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -12,16 +22,23 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; import { Typography } from 'src/components/Typography'; -import { Paper } from 'src/components/Paper'; +import { + StyledDateCalendar, + StyledTypography, +} from 'src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style'; +import RestoreLegacyFromBackupDialog from 'src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreLegacyFromBackupDialog'; +import RestoreNewFromBackupDialog from 'src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreNewFromBackupDialog'; +import { isOutsideBackupTimeframe } from 'src/features/Databases/utilities'; import { useOrder } from 'src/hooks/useOrder'; import { useDatabaseBackupsQuery, useDatabaseQuery, } from 'src/queries/databases/databases'; -import RestoreFromBackupDialog from './RestoreFromBackupDialog'; import { BackupTableRow } from './DatabaseBackupTableRow'; +import type { DatabaseBackup, Engine } from '@linode/api-v4/lib/databases'; + interface Props { disabled?: boolean; } @@ -38,6 +55,15 @@ export const DatabaseBackups = (props: Props) => { number | undefined >(); + const [selectedDate, setSelectedDate] = React.useState(null); + const [selectedTime, setSelectedTime] = React.useState( + DateTime.now().set({ hour: 1, minute: 0, second: 0 }) + ); + const [ + selectedRestoreTime, + setSelectedRestoreTime, + ] = React.useState(); + const id = Number(databaseId); const { @@ -46,23 +72,25 @@ export const DatabaseBackups = (props: Props) => { isLoading: isDatabaseLoading, } = useDatabaseQuery(engine, id); + const isDefaultDatabase = database?.platform === 'rdbms-default'; + const { data: backups, error: backupsError, isLoading: isBackupsLoading, - } = useDatabaseBackupsQuery(engine, id); + } = useDatabaseBackupsQuery(engine, id, !isDefaultDatabase); const { handleOrderChange, order, orderBy } = useOrder({ order: 'desc', orderBy: 'created', }); - const onRestore = (id: number) => { + const onRestoreLegacyDatabase = (id: number) => { setIdOfBackupToRestore(id); setIsRestoreDialogOpen(true); }; - const backupToRestore = backups?.data.find( + const backupToRestoreLegacy = backups?.data.find( (backup) => backup.id === idOfBackupToRestore ); @@ -94,14 +122,141 @@ export const DatabaseBackups = (props: Props) => { backup={backup} disabled={disabled} key={backup.id} - onRestore={onRestore} + onRestore={onRestoreLegacyDatabase} /> )); } return null; }; - return ( + const oldestBackup = database?.oldest_restore_time + ? DateTime.fromISO(database.oldest_restore_time) + : null; + + const unableToRestoreCopy = !oldestBackup + ? 'You can restore a backup after the first backup is completed.' + : ''; + + const onRestoreNewDatabase = (selectedDate: DateTime | null) => { + const day = selectedDate?.toISODate(); + const time = selectedTime?.toISOTime({ includeOffset: false }); + const selectedDateTime = `${day}T${time}Z`; + + const selectedTimestamp = new Date(selectedDateTime).toISOString(); + + setSelectedRestoreTime(selectedTimestamp); + setIsRestoreDialogOpen(true); + }; + + return isDefaultDatabase ? ( + + Summary + + Databases are automatically backed-up with full daily backups for the + past 10 days, and binary logs recorded continuously. Full backups are + version-specific binary backups, which when combined with binary + logs allow for consistent recovery to a specific point in time (PITR). + + {/* TODO: Uncomment when the all data is available (Number of Full Backups, Newest Full Backup, Oldest Full Backup) */} + {/* + + + + Number of Full Backups + + {backups?.data.length} + + + + + + Newest Full Backup + {newestBackup} (UTC) + + + + + Oldest Full Backup + {oldestBackup} (UTC) + + + + */} + + Restore a Backup + + Select a date and time within the last 10 days you want to create a fork + from. + + {unableToRestoreCopy && ( + + )} + + + Date + + + isOutsideBackupTimeframe(date, oldestBackup) + } + onChange={(newDate) => setSelectedDate(newDate)} + value={selectedDate} + /> + + + + Time (UTC) + + {/* TODO: Replace Time Select to the own custom date-time picker component when it's ready */} + - - - - - - - {!userCannotCreateLinode && selectedStackScriptLabel ? ( - - ) : null} - {!userCannotCreateLinode && - compatibleImages && - compatibleImages.length > 0 ? ( - - ) : ( - - )} - - - - - ); - } - - closeDrawer = () => { - this.setState({ - detailDrawerOpen: false, - }); - }; - - handleChangeUDF = (key: string, value: string) => { - // either overwrite or create new selection - const newUDFData = assocPath([key], value, this.props.selectedUDFs); - - this.props.handleSelectUDFs({ ...this.props.selectedUDFs, ...newUDFData }); - }; - - handleSelectCategory = (categoryItem: Item) => { - const didUserSelectCategory = categoryItem !== null; - let instancesInCategory: StackScript[] | undefined = []; - if (didUserSelectCategory) { - sendMarketplaceSearchEvent('Category Dropdown', categoryItem.label); - const appsInCategory = oneClickApps.filter((oca) => - oca.categories?.includes(categoryItem.value) - ); - const appLabels = appsInCategory.map((app) => app.name.trim()); - instancesInCategory = this.props.appInstances?.filter((instance) => { - return appLabels.includes(instance.label.trim()); - }); - } - this.setState({ - categoryFilter: categoryItem, - filteredApps: didUserSelectCategory ? instancesInCategory : [], - isFiltering: didUserSelectCategory, - isSearching: false, - query: '', - }); - }; - - onSearch = (query: string) => { - if (query === '' || query.trim().length === 0) { - this.setState({ isSearching: false }); - } else { - /** - * Enable ability to search OCA's by category, name, alternative name and - * alternative description keywords. - * */ - const queryWords = query - .replace(/[,.-]/g, '') - .trim() - .toLocaleLowerCase() - .split(' '); - - const matchingOCALabels = oneClickApps.reduce( - (acc: string[], { alt_description, alt_name, categories, name }) => { - const ocaAppString = `${name} ${alt_name} ${categories.join( - ' ' - )} ${alt_description}`.toLocaleLowerCase(); - - const hasMatchingOCA = queryWords.every((queryWord) => - ocaAppString.includes(queryWord) - ); - - if (hasMatchingOCA) { - acc.push(name.trim()); - } - - return acc; - }, - [] - ); - - const appsMatchingQuery = this.props.appInstances?.filter((instance) => { - return matchingOCALabels.includes(instance.label.trim()); - }); - - this.setState({ - categoryFilter: null, - filteredApps: appsMatchingQuery, - isFiltering: false, - isSearching: true, - query, - }); - } - }; - - openDrawer = (stackScriptLabel: string) => { - this.setState({ - detailDrawerOpen: true, - selectedScriptForDrawer: stackScriptLabel, - }); - }; - - state: State = { - categoryFilter: null, - detailDrawerOpen: false, - filteredApps: [], - isFiltering: false, - isSearching: false, - query: '', - selectedScriptForDrawer: '', - }; -} - -const StyledSearchFilterBox = styled(Box, { label: 'StyledSearchFilterBox' })( - ({ theme }) => ({ - '& > h2': { - width: '100%', - }, - display: 'flex', - gap: theme.spacing(), - justifyContent: 'space-between', - marginTop: theme.spacing(), - }) -); - -const StyledFilterBox = styled(Box, { label: 'StyledFilterBox' })({ - flexGrow: 1.5, -}); - -const StyledSearchBox = styled(Box, { label: 'StyledSearchBox' })({ - '& .input': { - maxWidth: 'none', - }, - flexGrow: 10, -}); - -const StyledBoxShadowWrapper = styled('div', { - label: 'StyledBoxShadowWrapper', -})(({ theme }) => ({ - '&:after': { - bottom: 0, - boxShadow: `${theme.color.boxShadow} 0px -15px 10px -10px inset`, - content: '""', - height: '15px', - position: 'absolute', - width: '100%', - }, - position: 'relative', -})); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx deleted file mode 100644 index 4e824c8772f..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from 'react'; -import { linodeFactory } from 'src/factories'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { CombinedProps, FromBackupsContent } from './FromBackupsContent'; - -const mockProps: CombinedProps = { - accountBackupsEnabled: false, - imagesData: {}, - linodesData: [], - regionsData: [], - setBackupID: vi.fn(), - typesData: [], - updateDiskSize: vi.fn(), - updateImageID: vi.fn(), - updateLinodeID: vi.fn(), - updateRegionID: vi.fn(), - updateTypeID: vi.fn(), - userCannotCreateLinode: false, -}; - -describe('FromBackupsContent', () => { - it('should render Placeholder if no valid backups exist', () => { - const { getByText } = renderWithTheme( - - ); - expect( - getByText( - 'You do not have backups enabled for your Linodes. Please visit the Backups panel in the Linode Details view.' - ) - ).toBeVisible(); - }); - - it('should render a linode select if a user has linodes with backups', () => { - const linodes = linodeFactory.buildList(1, { - backups: { enabled: true }, - label: 'this-linode-should-show-up', - }); - - const { getByText } = renderWithTheme( - - ); - - expect(getByText('this-linode-should-show-up')).toBeVisible(); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx deleted file mode 100644 index f228398dc1b..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { - Linode, - LinodeBackupsResponse, - getLinodeBackups, -} from '@linode/api-v4/lib/linodes'; -import * as React from 'react'; - -import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; -import { Paper } from 'src/components/Paper'; -import { Placeholder } from 'src/components/Placeholder/Placeholder'; -import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; -import { reportException } from 'src/exceptionReporting'; -import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; - -import { SelectBackupPanel } from '../SelectBackupPanel'; -import { SelectLinodePanel } from '../SelectLinodePanel/SelectLinodePanel'; -import { - BackupFormStateHandlers, - Info, - ReduxStateProps, - WithLinodesTypesRegionsAndImages, -} from '../types'; -import { StyledGrid } from './CommonTabbedContent.styles'; - -export interface LinodeWithBackups extends Linode { - currentBackups: LinodeBackupsResponse; -} - -interface State { - backupInfo: Info; - backupsError?: string; - isGettingBackups: boolean; - selectedLinodeWithBackups?: LinodeWithBackups; -} - -export type CombinedProps = BackupFormStateHandlers & - ReduxStateProps & - WithLinodesTypesRegionsAndImages; - -const errorResources = { - backup_id: 'Backup ID', - label: 'A label', - region: 'A region selection', - root_pass: 'A root password', - tags: 'Tags for this Linode', - type: 'A plan selection', -}; - -export class FromBackupsContent extends React.Component { - componentDidMount() { - this.mounted = true; - // If there is a selected Linode ID (from props), make sure its information - // is set to state as if it had been selected manually. - if (this.props.selectedLinodeID) { - this.updateRegion(this.props.selectedLinodeID); - this.getBackupsForLinode(this.props.selectedLinodeID); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - render() { - const { isGettingBackups, selectedLinodeWithBackups } = this.state; - const { - errors, - linodesData, - regionsData, - selectedBackupID, - selectedLinodeID, - setBackupID, - userCannotCreateLinode, - } = this.props; - - const hasErrorFor = getAPIErrorFor(errorResources, errors); - - const userHasBackups = linodesData.some( - (thisLinode) => thisLinode.backups.enabled - ); - - const filterLinodesWithBackups = (linodes: Linode[]) => - linodes.filter( - (linode) => - linode.backups.enabled && - !getIsDistributedRegion(regionsData, linode.region) // Hide linodes that are in a distributed region - ); - - return ( - - {!userHasBackups ? ( - - - You do not have backups enabled for your Linodes. Please visit the - Backups panel in the Linode Details view. - - - ) : ( - - - - - )} - - ); - } - - // Find regionID from the selectedLinodeID, and update the parent state. - updateRegion(selectedLinodeID: number) { - /** - * This should never happen, but this is coming from a query string - * so this is just a sanity check - */ - if (typeof selectedLinodeID !== 'number') { - reportException(`selectedLinodeID's type is not a number`, { - selectedLinodeID, - }); - throw new Error('selectedLinodeID is not a number'); - } - const regionID = this.props.linodesData.find( - (linode) => linode.id == selectedLinodeID - )?.region; - this.props.updateRegionID(regionID || ''); - } - - getBackupsForLinode = (linodeId: number) => { - const { linodesData } = this.props; - - if (!linodeId) { - return; - } - - this.setState({ - backupsError: undefined, - isGettingBackups: true, - }); - - getLinodeBackups(linodeId) - .then((backups) => { - const selectedLinode = linodesData.find( - (thisLinode) => thisLinode.id === linodeId - ); - - if (!selectedLinode) { - return this.setState({ isGettingBackups: false }); - } - - const selectedLinodeWithBackups: LinodeWithBackups = { - ...selectedLinode, - currentBackups: { ...backups }, - }; - - this.setState({ isGettingBackups: false, selectedLinodeWithBackups }); - }) - .catch(() => { - this.setState({ - backupsError: 'Error retrieving backups for this Linode.', - isGettingBackups: false, - }); - }); - }; - - handleLinodeSelect = ( - linodeId: number, - type: null | string, - diskSize?: number - ) => { - this.props.updateLinodeID(linodeId, diskSize); - this.updateRegion(linodeId); - this.getBackupsForLinode(linodeId); - this.props.updateTypeID(type); - }; - - handleSelectBackupInfo = (info: Info) => { - this.setState({ backupInfo: info }); - }; - - mounted: boolean = false; - - state: State = { - backupInfo: undefined, - isGettingBackups: false, - }; -} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx deleted file mode 100644 index bb1bc0bc713..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { FromImageContent } from './FromImageContent'; - -import type { CombinedProps } from './FromImageContent'; - -const mockProps: CombinedProps = { - accountBackupsEnabled: false, - imagesData: {}, - regionsData: [], - typesData: [], - updateImageID: vi.fn(), - updateRegionID: vi.fn(), - updateTypeID: vi.fn(), - userCannotCreateLinode: false, -}; - -describe('FromImageContent', () => { - it('should render an image select', () => { - const { getByLabelText } = renderWithTheme( - - ); - - expect(getByLabelText('Images')).toBeVisible(); - }); - - it('should render empty state if user has no images and variant is private', () => { - const { getByText } = renderWithTheme( - - ); - - expect( - getByText('You don’t have any private Images.', { exact: false }) - ).toBeVisible(); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx deleted file mode 100644 index 2432a268d04..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import * as React from 'react'; -import { Link } from 'react-router-dom'; - -import ImageIcon from 'src/assets/icons/entityIcons/image.svg'; -import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; -import { Paper } from 'src/components/Paper'; -import { Placeholder } from 'src/components/Placeholder/Placeholder'; -import { Typography } from 'src/components/Typography'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { filterImagesByType } from 'src/store/image/image.helpers'; - -import { StyledGrid } from './CommonTabbedContent.styles'; - -import type { - BasicFromContentProps, - ReduxStateProps, - WithTypesRegionsAndImages, -} from '../types'; -import type { Image } from '@linode/api-v4'; - -interface Props extends BasicFromContentProps { - error?: string; - imageLabel?: string; - imagePanelTitle?: string; - placeholder?: string; - variant?: 'all' | 'private' | 'public'; -} - -export type CombinedProps = Props & - BasicFromContentProps & - ReduxStateProps & - WithTypesRegionsAndImages; - -export const FromImageContent = (props: CombinedProps) => { - const { - error, - imageLabel, - imagePanelTitle, - imagesData, - placeholder, - userCannotCreateLinode, - variant, - } = props; - - const privateImages = filterImagesByType(imagesData, 'private'); - - const { data: regions } = useRegionsQuery(); - - if (variant === 'private' && Object.keys(privateImages).length === 0) { - return ( - - - - - You don’t have any private Images. Visit the{' '} - Images section to create an Image from - one of your Linode’s disks. - - - - - ); - } - - const onChange = (image: Image | null) => { - props.updateImageID(image?.id ?? ''); - - const selectedRegion = regions?.find( - (r) => r.id === props.selectedRegionID - ); - - // Non-"distributed compatible" Images must only be deployed to core sites. - // Clear the region field if the currently selected region is a distributed site and the Image is only core compatible. - if ( - image && - !image.capabilities.includes('distributed-sites') && - selectedRegion?.site_type === 'distributed' - ) { - props.updateRegionID(''); - } - }; - - return ( - - onChange(image ?? null)} - images={Object.keys(imagesData).map((eachKey) => imagesData[eachKey])} - label={imageLabel} - placeholder={placeholder} - selectedImageID={props.selectedImageID} - title={imagePanelTitle || 'Choose an Image'} - variant={variant} - /> - - ); -}; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx deleted file mode 100644 index 35a3257f7f6..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from 'react'; - -import { linodeFactory } from 'src/factories'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { CombinedProps, FromLinodeContent } from './FromLinodeContent'; - -const mockProps: CombinedProps = { - accountBackupsEnabled: false, - imagesData: {}, - linodesData: [], - regionsData: [], - typesData: [], - updateDiskSize: vi.fn(), - updateImageID: vi.fn(), - updateLinodeID: vi.fn(), - updateRegionID: vi.fn(), - updateTypeID: vi.fn(), - userCannotCreateLinode: false, -}; - -describe('FromLinodeContent', () => { - it('should render an empty state if the user has no Linodes', () => { - const { getByText } = renderWithTheme(); - - expect( - getByText( - 'You do not have any existing Linodes to clone from. Please first create a Linode from either an Image or StackScript.' - ) - ).toBeVisible(); - }); - - it("should render a user's linodes", () => { - const linodes = linodeFactory.buildList(1, { - label: 'this-linode-should-render', - }); - const { getByText } = renderWithTheme( - - ); - - expect(getByText('this-linode-should-render')).toBeVisible(); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx deleted file mode 100644 index d756d80d76d..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { Linode } from '@linode/api-v4/lib/linodes'; -import * as React from 'react'; -import { useHistory } from 'react-router-dom'; - -import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; -import { Paper } from 'src/components/Paper'; -import { Placeholder } from 'src/components/Placeholder/Placeholder'; -import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; -import { buildQueryStringForLinodeClone } from 'src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils'; -import { useFlags } from 'src/hooks/useFlags'; -import { extendType } from 'src/utilities/extendType'; -import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; - -import { SelectLinodePanel } from '../SelectLinodePanel/SelectLinodePanel'; -import { - CloneFormStateHandlers, - ReduxStateProps, - WithLinodesTypesRegionsAndImages, -} from '../types'; -import { StyledGrid } from './CommonTabbedContent.styles'; - -const errorResources = { - label: 'A label', - region: 'region', - root_pass: 'A root password', - type: 'A plan selection', -}; - -export type CombinedProps = CloneFormStateHandlers & - ReduxStateProps & - WithLinodesTypesRegionsAndImages; - -export const FromLinodeContent = (props: CombinedProps) => { - const { - errors, - linodesData, - regionsData, - selectedLinodeID, - typesData, - updateLinodeID, - updateTypeID, - userCannotCreateLinode, - } = props; - - const extendedTypes = typesData?.map(extendType); - - const hasErrorFor = getAPIErrorFor(errorResources, errors); - - const history = useHistory(); - - const flags = useFlags(); - - const updateSearchParams = (search: string) => { - history.replace({ search }); - }; - - /** Set the Linode ID and the disk size and reset the plan selection */ - const handleSelectLinode = (linodeId: number) => { - const linode = props.linodesData.find( - (eachLinode) => eachLinode.id === linodeId - ); - - if (linode) { - updateLinodeID(linode.id, linode.specs.disk); - updateTypeID(linode.type); - updateSearchParams( - buildQueryStringForLinodeClone( - linode.id, - linode.region, - linode.type, - extendedTypes, - regionsData - ) - ); - } - }; - - const filterDistributedRegionsLinodes = (linodes: Linode[]) => - linodes.filter( - (linode) => !getIsDistributedRegion(regionsData, linode.region) // Hide linodes that are in a distributed region - ); - - const filteredLinodes = flags.gecko2?.enabled - ? filterDistributedRegionsLinodes(linodesData) - : linodesData; - - return ( - // eslint-disable-next-line - - {linodesData && linodesData.length === 0 ? ( - - - - You do not have any existing Linodes to clone from. Please first - create a Linode from either an Image or StackScript. - - - - ) : ( - - - To help avoid data corruption during the - cloning process, we recommend powering off your Compute Instance - prior to cloning. - , - 'This newly created Linode will be created with the same password and SSH Keys (if any) as the original Linode.', - ]} - data-qa-linode-panel - disabled={userCannotCreateLinode} - error={hasErrorFor('linode_id')} - handleSelection={handleSelectLinode} - header={'Select Linode to Clone From'} - linodes={filteredLinodes} - selectedLinodeID={selectedLinodeID} - showPowerActions - /> - - )} - - ); -}; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx deleted file mode 100644 index e8079fea1a3..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { getStackScripts } from '@linode/api-v4'; -import * as React from 'react'; - -import { stackScriptFactory } from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { - CombinedProps, - FromStackScriptContent, -} from './FromStackScriptContent'; - -const mockProps: CombinedProps = { - accountBackupsEnabled: false, - category: 'account', - handleSelectUDFs: vi.fn(), - header: '', - imagesData: {}, - regionsData: [], - request: getStackScripts, - updateImageID: vi.fn(), - updateRegionID: vi.fn(), - updateStackScript: vi.fn(), - updateTypeID: vi.fn(), - userCannotCreateLinode: false, -}; - -describe('FromStackScriptContent', () => { - it('should render stackscripts', async () => { - const stackscripts = stackScriptFactory.buildList(3); - - server.use( - http.get('*/v4/linode/stackscripts', () => { - return HttpResponse.json(makeResourcePage(stackscripts)); - }) - ); - - const { findByText } = renderWithTheme( - - ); - - for (const stackscript of stackscripts) { - // eslint-disable-next-line no-await-in-loop - await findByText(stackscript.label); - } - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx deleted file mode 100644 index 0dc9de9ec00..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { Image } from '@linode/api-v4/lib/images'; -import { UserDefinedField } from '@linode/api-v4/lib/stackscripts'; -import { styled } from '@mui/material/styles'; -import { assocPath, equals } from 'ramda'; -import * as React from 'react'; - -import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; -import { ImageEmptyState } from 'src/features/Linodes/LinodesCreate/TabbedContent/ImageEmptyState'; -import SelectStackScriptPanel from 'src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanel'; -import StackScriptDialog from 'src/features/StackScripts/StackScriptDialog'; -import UserDefinedFieldsPanel from 'src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel'; -import { StackScriptsRequest } from 'src/features/StackScripts/types'; -import { filterImagesByType } from 'src/store/image/image.helpers'; -import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; - -import { - ReduxStateProps, - StackScriptFormStateHandlers, - WithTypesRegionsAndImages, -} from '../types'; -import { StyledGrid } from './CommonTabbedContent.styles'; -import { filterUDFErrors } from './formUtilities'; - -interface Props { - category: 'account' | 'community'; - header: string; - request: StackScriptsRequest; -} - -const errorResources = { - image: 'image', - label: 'A label', - region: 'region', - root_pass: 'A root password', - stackscript_id: 'The selected StackScript', - tags: 'Tags', - type: 'A plan selection', -}; - -export type CombinedProps = Props & - ReduxStateProps & - StackScriptFormStateHandlers & - WithTypesRegionsAndImages; - -export class FromStackScriptContent extends React.PureComponent { - render() { - const { - availableStackScriptImages: compatibleImages, - availableUserDefinedFields: userDefinedFields, - errors, - header, - imagesData, - request, - selectedImageID, - selectedStackScriptID, - selectedStackScriptLabel, - selectedStackScriptUsername, - selectedUDFs: udf_data, - updateImageID, - userCannotCreateLinode, - } = this.props; - - // If all of the StackScript's compatibleImages match the full array of images, - // we can assume that the StackScript specified any/all - const showAllImages = equals(compatibleImages, Object.values(imagesData)); - - const hasErrorFor = getAPIErrorFor(errorResources, errors); - - return ( - - - null} - selectedId={selectedStackScriptID} - selectedUsername={selectedStackScriptUsername} - updateFor={[selectedStackScriptID, errors]} - /> - {!userCannotCreateLinode && - userDefinedFields && - userDefinedFields.length > 0 && ( - - )} - {!userCannotCreateLinode && - compatibleImages && - compatibleImages.length > 0 ? ( - - ) : ( - - )} - - - - - ); - } - - handleChangeUDF = (key: string, value: string) => { - // either overwrite or create new selection - const newUDFData = assocPath([key], value, this.props.selectedUDFs); - - this.props.handleSelectUDFs({ ...this.props.selectedUDFs, ...newUDFData }); - }; - - handleSelectStackScript = ( - id: number, - label: string, - username: string, - stackScriptImages: string[], - userDefinedFields: UserDefinedField[] - ) => { - const allowAllImages = stackScriptImages.includes('any/all'); - const { imagesData } = this.props; - - /** - * based on the list of images we get back from the API, compare those - * to our list of public images supported by Linode and filter out the ones - * that aren't compatible with our selected StackScript - */ - const compatibleImages = allowAllImages - ? Object.values(imagesData) - : Object.keys(imagesData).reduce((acc, eachKey) => { - if ( - stackScriptImages.some((eachSSImage) => eachSSImage === eachKey) - ) { - acc.push(imagesData[eachKey]); - } - - return acc; - }, [] as Image[]); - /** - * if a UDF field comes back from the API with a "default" - * value, it means we need to pre-populate the field and form state - */ - const defaultUDFData = userDefinedFields.reduce>( - (accum, eachField) => { - if (eachField.default) { - accum[eachField.name] = eachField.default; - } - return accum; - }, - {} - ); - - this.props.updateStackScript( - id, - label, - username, - userDefinedFields, - compatibleImages, - defaultUDFData - ); - }; -} - -const StyledImageEmptyState = styled(ImageEmptyState, { - label: 'StyledImageEmptyState', -})(({ theme }) => ({ - padding: theme.spacing(3), -})); - -export default FromStackScriptContent; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/formUtilities.test.ts b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/formUtilities.test.ts deleted file mode 100644 index f29165412b9..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/formUtilities.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { APIError } from '@linode/api-v4/lib/types'; - -import { filterUDFErrors } from './formUtilities'; - -describe('Linode Create Utilities', () => { - it('should filter out all errors except UDF errors', () => { - const mockErrors: APIError[] = [ - { - field: 'label', - reason: 'label is required', - }, - { - field: 'ssh_keys', - reason: 'ssh_keys are required', - }, - { - field: 'wp_password', - reason: 'a value for the UDF is required', - }, - ]; - - const errorResources = { - label: 'A label', - ssh_keys: 'ssh_keys', - }; - - const filteredErrors = filterUDFErrors(errorResources, mockErrors); - expect(filteredErrors[0].field).toBe('wp_password'); - expect(filteredErrors).toHaveLength(1); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/formUtilities.ts b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/formUtilities.ts deleted file mode 100644 index a0b9a532a0f..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/formUtilities.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { APIError } from '@linode/api-v4/lib/types'; - -/** - * filter out all the API errors that aren't UDF errors from our error state. - * To do this, we compare the keys from the error state to our "errorResources" - * map and return all the errors that don't match the keys in that object - */ -export const filterUDFErrors = ( - errorResources: Record, - errors?: APIError[] -) => { - if (typeof errorResources !== 'object') { - throw Error('errorResources must be an object.'); - } - return !errors - ? [] - : errors.filter((eachError) => { - return !Object.keys(errorResources).some( - (eachKey) => eachKey === eachError.field - ); - }); -}; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/utils.ts b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/utils.ts deleted file mode 100644 index 92a7d4d3dd3..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const renderBackupsDisplaySection = ( - accountBackups: boolean, - price: number -) => ({ - details: `$${price.toFixed(2)}/month`, - title: accountBackups ? 'Backups (Auto Enrolled)' : 'Backups', -}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.styles.ts b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.styles.ts deleted file mode 100644 index 5e87270c21e..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.styles.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { makeStyles } from 'tss-react/mui'; - -export const useExpandIconStyles = makeStyles()(() => ({ - expandIconStyles: { - marginTop: '8px', - }, -})); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.test.tsx index c9f1d339e4a..931edd0f9ef 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.test.tsx @@ -44,16 +44,6 @@ describe('UserDataAccordion', () => { ).toBeInTheDocument(); }); - it('should display a custom header warning message', () => { - renderWithTheme(); - - const headerWarningMessage = screen.getByText( - 'Existing user data is not accessible when creating a Linode from a backup. You may add new user data now.' - ); - - expect(headerWarningMessage).toBeInTheDocument(); - }); - it('should display a custom notice', () => { renderWithTheme( Custom notice} /> diff --git a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.tsx b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.tsx index e718e2b0d91..ebe548dc6ba 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion.tsx @@ -6,13 +6,10 @@ 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 { CreateTypes } from 'src/store/linodeCreate/linodeCreate.actions'; -import { useExpandIconStyles } from './UserDataAccordion.styles'; import { UserDataAccordionHeading } from './UserDataAccordionHeading'; export interface UserDataAccordionProps { - createType?: CreateTypes; disabled?: boolean; onChange: (userData: string) => void; renderCheckbox?: JSX.Element; @@ -21,15 +18,7 @@ export interface UserDataAccordionProps { } export const UserDataAccordion = (props: UserDataAccordionProps) => { - const { classes, cx } = useExpandIconStyles(); - const { - createType, - disabled, - onChange, - renderCheckbox, - renderNotice, - userData, - } = props; + const { disabled, onChange, renderCheckbox, renderNotice, userData } = props; const [formatWarning, setFormatWarning] = React.useState(false); const checkFormat = ({ @@ -55,9 +44,6 @@ export const UserDataAccordion = (props: UserDataAccordionProps) => { padding: `0px 24px 24px ${renderNotice ? 0 : 24}px`, }; - const fromBackupOrFromLinode = - createType && ['fromBackup', 'fromLinode'].includes(createType); - return ( { }} // for now, these props can be taken as an indicator we're in the Rebuild flow. summaryProps={{ sx: { - alignItems: fromBackupOrFromLinode ? 'flex-start' : 'center', + alignItems: 'center', padding: '5px 24px 0px 24px', }, }} @@ -78,10 +64,7 @@ export const UserDataAccordion = (props: UserDataAccordionProps) => { }, }} detailProps={{ sx: sxDetails }} - expandIconClassNames={cx({ - [classes.expandIconStyles]: !!fromBackupOrFromLinode, - })} - heading={} + heading={} > {renderNotice ? ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx index 24ae144710c..6c400990ae1 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx @@ -2,51 +2,25 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { Link } from 'src/components/Link'; -import { Notice } from 'src/components/Notice/Notice'; import { TooltipIcon } from 'src/components/TooltipIcon'; -import { CreateTypes } from 'src/store/linodeCreate/linodeCreate.actions'; - -interface Props { - createType?: CreateTypes; -} - -export const UserDataAccordionHeading = (props: Props) => { - const { createType } = props; - const warningMessageMap: Record = { - fromApp: null, - fromBackup: - 'Existing user data is not accessible when creating a Linode from a backup. You may add new user data now.', - fromImage: null, - fromLinode: - 'Existing user data is not cloned. You may add new user data now.', - fromStackScript: null, - }; - - const warningMessage = createType ? warningMessageMap[createType] : null; +export const UserDataAccordionHeading = () => { return ( - <> - - Add User Data - - User data allows you to provide additional custom data to - cloud-init to further configure your system.{' '} - - Learn more. - - - } - status="help" - sxTooltipIcon={{ alignItems: 'baseline', padding: '0 8px' }} - /> - - {warningMessage ? ( - - {warningMessage} - - ) : null} - + + Add User Data + + User data allows you to provide additional custom data to cloud-init + to further configure your system.{' '} + + Learn more. + + + } + status="help" + sxTooltipIcon={{ alignItems: 'baseline', padding: '0 8px' }} + /> + ); }; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx deleted file mode 100644 index 8a23365fddb..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { fireEvent, waitFor } from '@testing-library/react'; -import React from 'react'; - -import { regionFactory } from 'src/factories'; -import { queryClientFactory } from 'src/queries/base'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { VLANAccordion } from './VLANAccordion'; - -import type { VLANAccordionProps } from './VLANAccordion'; - -const queryClient = queryClientFactory(); - -const defaultProps: VLANAccordionProps = { - handleVLANChange: vi.fn(), - helperText: 'helper text', - ipamAddress: '', - vlanLabel: '', -}; - -const queryMocks = vi.hoisted(() => ({ - useRegionsQuery: vi.fn().mockReturnValue({}), -})); - -vi.mock('src/queries/regions/regions', async () => { - const actual = await vi.importActual('src/queries/regions/regions'); - return { - ...actual, - useRegionsQuery: queryMocks.useRegionsQuery, - }; -}); - -describe('VLANAccordion Component', () => { - it('renders collapsed VLANAccordion component', () => { - const { getByRole } = renderWithTheme(, { - queryClient, - }); - - expect(getByRole('button', { name: 'VLAN' })).toHaveAttribute( - 'aria-expanded', - 'false' - ); - }); - - it('contains expected elements when expanded', () => { - const { - container, - getByPlaceholderText, - getByRole, - getByTestId, - } = renderWithTheme(, { - queryClient, - }); - - fireEvent.click(getByRole('button', { name: 'VLAN' })); - - expect(getByTestId('notice-warning')).toBeVisible(); - expect( - getByRole('link', { - name: 'Configuration Profile - link opens in a new tab', - }) - ).toBeVisible(); - expect(getByPlaceholderText('Create or select a VLAN')).toBeVisible(); - expect(container.querySelector('#vlan-label-1')).toBeDisabled(); - expect(getByPlaceholderText('192.0.2.0/24')).toBeVisible(); - expect(container.querySelector('#ipam-input-1')).toBeDisabled(); - }); - - it('enables the input fields when a region is selected', () => { - queryMocks.useRegionsQuery.mockReturnValue({ - data: [ - regionFactory.build({ - capabilities: ['Vlans'], - id: 'us-east', - }), - ], - }); - - const { container, getByRole } = renderWithTheme( - , - { - queryClient, - } - ); - - fireEvent.click(getByRole('button', { name: 'VLAN' })); - - expect(container.querySelector('#vlan-label-1')).not.toHaveAttribute( - 'disabled' - ); - expect(container.querySelector('#ipam-input-1')).not.toHaveAttribute( - 'disabled' - ); - }); - - it('contains a tooltip containing avalable VLAN regions', async () => { - const { getAllByRole, getByRole, getByText } = renderWithTheme( - , - { - queryClient, - } - ); - - fireEvent.click(getByRole('button', { name: 'VLAN' })); - fireEvent.mouseOver(getByText('select regions')); - - await waitFor(() => { - expect(getByRole('tooltip')).toBeVisible(); - expect(getAllByRole('listitem').length).toBeGreaterThan(0); - }); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx deleted file mode 100644 index 8785cbf8e29..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import * as React from 'react'; - -import { Accordion } from 'src/components/Accordion'; -import { Box } from 'src/components/Box'; -import { Link } from 'src/components/Link'; -import { TooltipIcon } from 'src/components/TooltipIcon'; -import { Typography } from 'src/components/Typography'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; - -import { InterfaceSelect } from '../LinodesDetail/LinodeSettings/InterfaceSelect'; -import { VLANAvailabilityNotice } from './VLANAvailabilityNotice'; - -import type { Interface } from '@linode/api-v4/lib/linodes'; - -export interface VLANAccordionProps { - handleVLANChange: (updatedInterface: Interface) => void; - helperText?: string; - ipamAddress: string; - ipamError?: string; - labelError?: string; - readOnly?: boolean; - region?: string; - vlanLabel: string; -} - -export const VLANAccordion = React.memo((props: VLANAccordionProps) => { - const { - handleVLANChange, - helperText, - ipamAddress, - ipamError, - labelError, - readOnly, - region, - vlanLabel, - } = props; - - const regions = useRegionsQuery().data ?? []; - const selectedRegion = region || ''; - - const regionSupportsVLANs = doesRegionSupportFeature( - selectedRegion, - regions, - 'Vlans' - ); - - return ( - - VLAN - {helperText && ( - - )} - - } - summaryProps={{ - sx: { - padding: '5px 24px 0px 24px', - }, - }} - sx={{ - marginTop: 2, - }} - data-qa-add-ons - data-testid="vlan-accordion" - > - - - VLANs are used to create a private L2 Virtual Local Area Network between - Linodes. A VLAN created or attached in this section will be assigned to - the eth1 interface, with eth0 being used for connections to the public - internet. VLAN configurations can be further edited in the - Linode’s{' '} - - Configuration Profile - - . - - - handleVLANChange(newInterface) - } - fromAddonsPanel - ipamAddress={ipamAddress} - label={vlanLabel} - purpose="vlan" - readOnly={readOnly || !regionSupportsVLANs || false} - region={region} - slotNumber={1} - /> - - ); -}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/deriveDefaultLabel.test.ts b/packages/manager/src/features/Linodes/LinodesCreate/deriveDefaultLabel.test.ts deleted file mode 100644 index 81ef6d2654b..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/deriveDefaultLabel.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { deriveDefaultLabel } from './deriveDefaultLabel'; - -describe('create label name', () => { - it('creates label name with image, region, type', () => { - expect(deriveDefaultLabel(['ubuntu', 'newark', '1gb'], [])).toBe( - 'ubuntu-newark-1gb' - ); - }); - - it('works without all params', () => { - expect(deriveDefaultLabel(['ubuntu', 'newark'], [])).toBe('ubuntu-newark'); - expect(deriveDefaultLabel(['1gb'], [])).toBe('1gb'); - expect(deriveDefaultLabel(['ubuntu', 'newark'], [])).toBe('ubuntu-newark'); - }); - - it('clamps length when necessary', () => { - expect(deriveDefaultLabel(['really-long-linode-label', 'newark'], [])).toBe( - 'really-long-l-newark' - ); - expect( - deriveDefaultLabel( - ['really-long-linode-label', 'us-east-another-region'], - [] - ) - ).toBe('really-long-l-us-east-anoth'); - expect(deriveDefaultLabel(['123456789', '10', '11', '12131415'], [])).toBe( - '123456789-10-11-12131415' - ); - expect( - deriveDefaultLabel(['123456789', '10', '11', '12131415161718192021'], []) - ).toBe('123456-10-11-121314'); - }); - - it('ensures no double dashes or underscores are present', () => { - expect( - deriveDefaultLabel( - ['really-long-l-inode-label', 'us-east-another-region'], - [] - ) - ).toBe('really-long-l-us-east-anoth'); - expect(deriveDefaultLabel(['this-is__impossible', 'us-west'], [])).toBe( - 'this-is_impossible-us-west' - ); - }); - - it('adds an incrementor', () => { - expect(deriveDefaultLabel(['my', 'label'], ['my-label'])).toBe( - 'my-label-001' - ); - expect(deriveDefaultLabel(['my', 'label'], ['my-label-001'])).toBe( - 'my-label' - ); - expect( - deriveDefaultLabel(['my', 'label'], ['my-label', 'my-label-002']) - ).toBe('my-label-001'); - expect( - deriveDefaultLabel( - ['my', 'label'], - ['my-label', 'my-label-001', 'my-label003'] - ) - ).toBe('my-label-002'); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/deriveDefaultLabel.ts b/packages/manager/src/features/Linodes/LinodesCreate/deriveDefaultLabel.ts deleted file mode 100644 index 1bf2e989d6b..00000000000 --- a/packages/manager/src/features/Linodes/LinodesCreate/deriveDefaultLabel.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { compose, filter, join, map } from 'ramda'; - -// Set this at 28, so we can leave room for a dash and zero-padding (width: 3) if needed -const MAX_LABEL_LENGTH = 28; - -// Only alpha-numeric chars, dashes, and underscores allowed by the API -const labelRegex = /[^a-zA-Z0-9-_]/g; - -export type LabelArgTypes = null | string | undefined; - -export const deriveDefaultLabel = ( - parts: LabelArgTypes[], - existingLabels: string[] -): string => { - return dedupeLabel(generateBaseLabel(parts), existingLabels); -}; - -const generateBaseLabel = (parts: LabelArgTypes[]) => { - const filtered = filter(Boolean)(parts); - const cleaned = map((s: string) => s.replace(labelRegex, '').toLowerCase())( - filtered as string[] - ); - - const withDash = join('-'); - - if (withDash(cleaned).length <= MAX_LABEL_LENGTH) { - return compose(ensureSingleDashesAndUnderscores, withDash)(cleaned); - } - - // If the length will be more than MAX_LABEL_LENGTH, we'll need to do some calculation and clamp each section - const numDashes = filtered.length - 1; - const maxSectionLength = Math.floor( - (MAX_LABEL_LENGTH - numDashes) / filtered.length - ); - - return compose( - ensureSingleDashesAndUnderscores, - withDash, - map((s: string) => s.slice(0, maxSectionLength)) - )(cleaned); -}; - -// The API doesn't allow double dashes or underscores. Just in case joining the param -// sections of the derived label name results in this, we need to do one final 'replace'; -const ensureSingleDashesAndUnderscores = (s: string) => { - return s.replace(/--/g, '-').replace(/__/g, '_'); -}; - -// Searches 'existingLabels' and appends a zero-padded increment-er to the original label -const dedupeLabel = (label: string, existingLabels: string[]): string => { - const ZERO_PAD_WIDTH = 3; - - let dedupedLabel = label; - let i = 1; - - const matchingLabels = existingLabels.filter((l) => l.startsWith(label)); - const findMatchingLabel = (l: string) => { - return l === dedupedLabel; - }; - - while (matchingLabels.find(findMatchingLabel)) { - dedupedLabel = label + '-' + i.toString().padStart(ZERO_PAD_WIDTH, '0'); - i++; - - // EDGE CASE: if a user has 999 iterations of the same name (arch-us-east-001, arch-us-east-002, ...) - // just return the original label. They'll get an API error. - if (i === 999) { - return label; - } - } - return dedupedLabel; -}; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/types.ts b/packages/manager/src/features/Linodes/LinodesCreate/types.ts index 50475fe95aa..9176f1aaaaa 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/types.ts +++ b/packages/manager/src/features/Linodes/LinodesCreate/types.ts @@ -1,164 +1,3 @@ -import type { Image } from '@linode/api-v4/lib/images'; -import type { - CreateLinodeRequest, - Linode, - LinodeTypeClass, -} from '@linode/api-v4/lib/linodes'; -import type { Region } from '@linode/api-v4/lib/regions'; -import type { - StackScript, - UserDefinedField, -} from '@linode/api-v4/lib/stackscripts'; -import type { APIError } from '@linode/api-v4/lib/types'; -import type { Tag } from 'src/components/TagsInput/TagsInput'; -import type { ExtendedType } from 'src/utilities/extendType'; - -export type TypeInfo = - | { - details: string; - hourly: null | number | undefined; - monthly: null | number | undefined; - title: string; - } - | undefined; - -export type Info = { details?: string; title?: string } | undefined; - -/** - * These props are meant purely for what is displayed in the - * Checkout bar - */ -export interface WithDisplayData { - imageDisplayInfo?: Info; - regionDisplayInfo?: Info; - typeDisplayInfo?: TypeInfo; -} - -/** - * Pure Data without the loading and error - * keys. Component with these props have already been - * safe-guarded with null, loading, and error checking - */ -export interface WithTypesRegionsAndImages { - imagesData: Record; - regionsData: Region[]; - typesData?: ExtendedType[]; -} - -export interface WithLinodesTypesRegionsAndImages - extends WithTypesRegionsAndImages { - linodesData: Linode[]; -} - -export interface ReduxStateProps { - accountBackupsEnabled: boolean; - userCannotCreateLinode: boolean; -} - -export type HandleSubmit = ( - payload: CreateLinodeRequest, - linodeID?: number -) => void; - -export type LinodeCreateValidation = (payload: CreateLinodeRequest) => void; - -export interface BasicFromContentProps { - disabledClasses?: LinodeTypeClass[]; - errors?: APIError[]; - regionHelperText?: string; - selectedImageID?: string; - selectedRegionID?: string; - selectedTypeID?: string; - updateImageID: (id: string) => void; - updateRegionID: (id: string) => void; - updateTypeID: (id: string) => void; -} - -/** - * minimum number of state and handlers needed for - * the _create from image_ flow to function - */ -export interface BaseFormStateAndHandlers { - authorized_users: string[]; - backupsEnabled: boolean; - disabledClasses?: LinodeTypeClass[]; - errors?: APIError[]; - formIsSubmitting: boolean; - handleSubmitForm: HandleSubmit; - label: string; - password: string; - privateIPEnabled: boolean; - regionHelperText?: string; - resetCreationState: () => void; - selectedImageID?: string; - selectedRegionID?: string; - selectedTypeID?: string; - setAuthorizedUsers: (usernames: string[]) => void; - tags?: Tag[]; - toggleBackupsEnabled: () => void; - togglePrivateIPEnabled: () => void; - updateImageID: (id: string) => void; - updateLabel: (label: string) => void; - updatePassword: (password: string) => void; - updateRegionID: (id: string) => void; - updateTags: (tags: Tag[]) => void; - updateTypeID: (id: string) => void; -} - -/** - * additional form fields needed when creating a Linode from a Linode - * AKA cloning a Linode - */ -export interface CloneFormStateHandlers extends BasicFromContentProps { - selectedDiskSize?: number; - selectedLinodeID?: number; - updateDiskSize: (id: number) => void; - updateLinodeID: (id: number, diskSize?: number) => void; - updateTypeID: (id: null | string) => void; -} - -/** - * additional form fields needed when creating a Linode from a StackScript - */ -export interface StackScriptFormStateHandlers extends BasicFromContentProps { - availableStackScriptImages?: Image[]; - availableUserDefinedFields?: UserDefinedField[]; - handleSelectUDFs: (stackScripts: any) => void; - selectedStackScriptID?: number; - selectedStackScriptLabel?: string; - selectedStackScriptUsername?: string; - selectedUDFs?: any; - updateStackScript: ( - id: number, - label: string, - username: string, - userDefinedFields: UserDefinedField[], - availableImages: Image[], - defaultData?: any - ) => void; -} - -/** - * additional form fields needed when create a Linode from a backup - * Note that it extends the _Clone_ props because creating from a backup - * requires the Linodes data - */ -export interface BackupFormStateHandlers extends CloneFormStateHandlers { - selectedBackupID?: number; - setBackupID: (id: number) => void; -} - -export interface AppsData { - appInstances?: StackScript[]; - appInstancesError?: string; - appInstancesLoading: boolean; -} - -export type AllFormStateAndHandlers = BaseFormStateAndHandlers & - CloneFormStateHandlers & - StackScriptFormStateHandlers & - BackupFormStateHandlers; - export type LinodeCreateType = | 'Backups' | 'Clone Linode' diff --git a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts index 150809adfdf..21676dee097 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts @@ -1,132 +1,4 @@ -import { imageFactory, normalizeEntities } from 'src/factories'; -import { stackScriptFactory } from 'src/factories/stackscripts'; - -import { - filterOneClickApps, - getMonthlyAndHourlyNodePricing, - handleAppLabel, - trimOneClickFromLabel, - utoa, -} from './utilities'; - -import type { StackScript } from '@linode/api-v4'; - -const linodeImage = imageFactory.build({ - id: 'linode/debian10', - label: 'Debian 10', - vendor: 'linode', -}); - -const images = normalizeEntities(imageFactory.buildList(10)); -images['linode/debian10'] = linodeImage; - -describe('Marketplace cluster pricing', () => { - it('should return the monthly and hourly price multipled by the number of nodes', () => { - expect(getMonthlyAndHourlyNodePricing(30, 0.045, 3)).toEqual({ - hourlyPrice: 0.135, - monthlyPrice: 90, - }); - }); - - it('should round the hourly price to 3 digits', () => { - expect(getMonthlyAndHourlyNodePricing(30, 0.045, 5)).toEqual({ - hourlyPrice: 0.225, - monthlyPrice: 150, - }); - }); -}); - -describe('trimOneClickFromLabel', () => { - const stackScript = stackScriptFactory.build({ - label: 'MongoDB Cluster One-Click', - }); - - it('should remove "One-Click" from the label', () => { - expect(trimOneClickFromLabel(stackScript)).toStrictEqual({ - ...stackScript, - label: 'MongoDB Cluster ', - }); - }); -}); - -describe('filterOneClickApps', () => { - const baseAppIds = [2, 3, 4, 5]; - const newApps = { - 6: 'New App 1', - 7: 'New App 2', - 8: 'New App 3', - 9: 'New App 4', - }; - - // id: 2,3,4,5 - const queryResultsWithHelpers: StackScript[] = [ - ...stackScriptFactory.buildList(3), - stackScriptFactory.build({ label: 'StackScript Helpers' }), - ]; - // id: 6,7,8,9 - const queryResultsWithoutHelpers: StackScript[] = stackScriptFactory.buildList( - 4 - ); - - it('filters OneClickApps and trims labels, excluding StackScripts with Helpers', () => { - // feeding 4 Ids (2,3,4,5) getting 3 back - const filteredOCAsWithHelpersLabel = filterOneClickApps({ - baseAppIds, - newApps, - queryResults: queryResultsWithHelpers, - }); - expect(filteredOCAsWithHelpersLabel.length).toBe(3); - - // feeding 4 Ids (6,7,8,9) getting 4 back - const filteredOCAsWithoutHelpersLabel = filterOneClickApps({ - baseAppIds, - newApps, - queryResults: queryResultsWithoutHelpers, - }); - - expect(filteredOCAsWithoutHelpersLabel.length).toBe(4); - }); - - it('handles empty queryResults', () => { - const emptyQueryResults: StackScript[] = []; - const filteredOCAs = filterOneClickApps({ - baseAppIds, - newApps, - queryResults: emptyQueryResults, - }); - - // Expect an empty array when queryResults is empty - expect(filteredOCAs).toEqual([]); - }); -}); - -describe('handleAppLabel', () => { - it('should decode the label and remove "Cluster" when cluster_size is present', () => { - const stackScript = stackScriptFactory.build({ - label: 'My StackScript Cluster ', - user_defined_fields: [{ name: 'cluster_size' }], - }); - - const result = handleAppLabel(stackScript); - - expect(result.decodedLabel).toBe('My StackScript Cluster '); - expect(result.isCluster).toBe(true); - expect(result.label).toBe('My StackScript'); - }); - - it('should decode the label without removing "Cluster" when cluster_size is not present', () => { - const stackScript = stackScriptFactory.build({ - label: 'My StackScript® Cluster ', - user_defined_fields: [], - }); - - const result = handleAppLabel(stackScript); - - expect(result.decodedLabel).toBe('My StackScript® Cluster '); - expect(result.isCluster).toBe(false); - expect(result.label).toBe('My StackScript® Cluster '); - }); -}); +import { utoa } from './utilities'; /** * This is an example cloud-init config diff --git a/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx b/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx index 68144a1aed7..316de26ad72 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx @@ -1,19 +1,4 @@ -import { decode } from 'he'; - -import type { Region, StackScript } from '@linode/api-v4/lib'; - -export const getMonthlyAndHourlyNodePricing = ( - monthlyPrice: null | number | undefined, - hourlyPrice: null | number | undefined, - numberOfNodes: number -) => { - return { - hourlyPrice: hourlyPrice - ? Math.round(hourlyPrice * numberOfNodes * 1000) / 1000 - : hourlyPrice, - monthlyPrice: monthlyPrice ? monthlyPrice * numberOfNodes : monthlyPrice, - }; -}; +import type { Region } from '@linode/api-v4/lib'; /** * Unicode to ASCII (encode data to Base64) @@ -37,67 +22,3 @@ export const regionSupportsMetadata = ( ?.capabilities.includes('Metadata') ?? false ); }; - -/** - * This function is used to remove the "One-Click" text from the label of an OCA StackScript. - * @param stackScript - * @returns StackScript - */ -export const trimOneClickFromLabel = (stackScript: StackScript) => { - return { - ...stackScript, - label: stackScript.label.replace('One-Click', ''), - }; -}; - -interface FilteredOCAs { - baseAppIds: number[]; - newApps: Record | never[]; - queryResults: StackScript[]; -} - -/** - * This function is used to filter StackScripts OCAs. - * @param baseApps // The base apps that are always displayed (static) - * @param newApps // The new apps defined in the OneClickApps feature flag - * @param queryResults // The results of the query for StackScripts - * @returns an array of OCA StackScripts - */ -export const filterOneClickApps = ({ - baseAppIds, - newApps, - queryResults, -}: FilteredOCAs) => { - const allowedAppIds = [...baseAppIds, ...Object.keys(newApps).map(Number)]; - - // Don't display One-Click Helpers to the user - // Filter out any apps that we don't have info for - const filteredApps: StackScript[] = queryResults.filter( - (app: StackScript) => { - return !app.label.match(/helpers/i) && allowedAppIds.includes(app.id); - } - ); - return filteredApps.map((app) => trimOneClickFromLabel(app)); -}; - -/** - * This function is used to - * - decode the label of a StackScript - * - remove the "Cluster" text from the label of a StackScript since it'll turn into a chip. - * @param app // The StackScript - * @returns the decoded label of the StackScript - */ -export const handleAppLabel = (app: StackScript) => { - const decodedLabel = decode(app.label); - const isCluster = - decodedLabel.endsWith('Cluster ') && - app.user_defined_fields.some((field) => field.name === 'cluster_size'); - - const label = isCluster ? decodedLabel.split(' Cluster')[0] : decodedLabel; - - return { - decodedLabel, - isCluster, - label, - }; -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx index 1b1f25cbe3a..a4118c0c75b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx @@ -2,8 +2,8 @@ import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { useParams } from 'react-router-dom'; -import AddNewLink from 'src/components/AddNewLink'; import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; import { DocsLink } from 'src/components/DocsLink/DocsLink'; import OrderBy from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; @@ -97,11 +97,9 @@ const LinodeConfigs = () => { }} label={'Configuration Profiles'} /> - +
{({ data: orderedData, handleOrderChange, order, orderBy }) => ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx index f6005ec0024..0a465f16b24 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx @@ -1,10 +1,10 @@ -import { useTheme } from '@mui/material/styles'; import { styled } from '@mui/material/styles'; import { parse as parseIP } from 'ipaddr.js'; import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { LinkButton } from 'src/components/LinkButton'; import { TableCell } from 'src/components/TableCell'; import { Typography } from 'src/components/Typography'; import { StyledTableRow } from 'src/features/Linodes/LinodeEntityDetail.styles'; @@ -136,7 +136,6 @@ const RangeRDNSCell = (props: { range: IPRange; }) => { const { linodeId, onViewDetails, range } = props; - const theme = useTheme(); const { data: linode } = useLinodeQuery(linodeId); @@ -169,21 +168,12 @@ const RangeRDNSCell = (props: { } return ( - + {ipsWithRDNS.length} Addresses + ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx index cf08f39b2c2..a097c780f3c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx @@ -3,12 +3,14 @@ import * as React from 'react'; import { Dialog } from 'src/components/Dialog/Dialog'; import EnhancedSelect from 'src/components/EnhancedSelect/Select'; +import { ErrorMessage } from 'src/components/ErrorMessage'; import { Notice } from 'src/components/Notice/Notice'; import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { HostMaintenanceError } from '../HostMaintenanceError'; import { LinodePermissionsError } from '../LinodePermissionsError'; @@ -39,6 +41,7 @@ const passwordHelperText = 'Set a password for your rebuilt Linode.'; export const LinodeRebuildDialog = (props: Props) => { const { linodeId, linodeLabel, onClose, open } = props; + const modalRef = React.useRef(null); const { data: profile } = useProfile(); const { data: grants } = useGrants(); @@ -83,6 +86,7 @@ export const LinodeRebuildDialog = (props: Props) => { const handleRebuildError = (status: string) => { setRebuildError(status); + scrollErrorIntoViewV2(modalRef); }; const toggleDiskEncryptionEnabled = () => { @@ -97,12 +101,23 @@ export const LinodeRebuildDialog = (props: Props) => { maxWidth="md" onClose={onClose} open={open} + ref={modalRef} title={`Rebuild Linode ${linodeLabel ?? ''}`} > {unauthorized && } {hostMaintenance && } - {rebuildError && {rebuildError}} + {rebuildError && ( + + + + )} { const { data: preferences } = usePreferences(open); const { enqueueSnackbar } = useSnackbar(); const [confirmationText, setConfirmationText] = React.useState(''); - const [hasResizeError, setHasResizeError] = React.useState(false); + const [resizeError, setResizeError] = React.useState(''); const formRef = React.useRef(null); const { - error: resizeError, + error, isPending, mutateAsync: resizeLinode, } = useLinodeResizeMutation(linodeId ?? -1); @@ -147,15 +147,15 @@ export const LinodeResize = (props: Props) => { if (!open) { formik.resetForm(); setConfirmationText(''); - setHasResizeError(false); + setResizeError(''); } }, [open]); React.useEffect(() => { - if (resizeError) { - setHasResizeError(true); + if (error) { + setResizeError(error?.[0]?.reason); } - }, [resizeError]); + }, [error]); const tableDisabled = hostMaintenance || isLinodesGrantReadOnly; @@ -179,8 +179,6 @@ export const LinodeResize = (props: Props) => { const currentTypes = types?.filter((thisType) => !Boolean(thisType.successor)) ?? []; - const error = getError(resizeError); - return ( { variant="error" /> )} - {hasResizeError && {error}} + {resizeError && ( + + + + )} If you’re expecting a temporary burst of traffic to your website, or if you’re not using your Linode as much as you diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.utils.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.utils.tsx index 748b879d92b..d5d63c04095 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.utils.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.utils.tsx @@ -1,35 +1,4 @@ -import * as React from 'react'; - -import { Link } from 'src/components/Link'; -import { Typography } from 'src/components/Typography'; - -import type { APIError, Disk, LinodeType } from '@linode/api-v4'; - -export const getError = (error: APIError[] | null) => { - if (!error) { - return null; - } - - const errorText = error?.[0]?.reason; - if ( - typeof errorText === 'string' && - errorText.match(/allocated more disk/i) - ) { - return ( - - The current disk size of your Linode is too large for the new service - plan. Please resize your disk to accommodate the new plan. You can read - our{' '} - - Resize Your Linode - {' '} - guide for more detailed instructions. - - ); - } - - return errorText; -}; +import type { Disk, LinodeType } from '@linode/api-v4'; /** * the user should only be given the option to automatically resize diff --git a/packages/manager/src/features/Linodes/types.ts b/packages/manager/src/features/Linodes/types.ts index 028d578edf3..70a8efc98d9 100644 --- a/packages/manager/src/features/Linodes/types.ts +++ b/packages/manager/src/features/Linodes/types.ts @@ -11,12 +11,6 @@ export type DialogType = | 'resize' | 'upgrade_volumes'; -export type OpenDialog = ( - type: DialogType, - linodeID: number, - linodeLabel?: string -) => void; - export interface LinodeCreateQueryParams extends BaseQueryParams { type: LinodeCreateType; } diff --git a/packages/manager/src/features/Managed/Contacts/Contacts.tsx b/packages/manager/src/features/Managed/Contacts/Contacts.tsx index b82c906679f..1c7d5b88018 100644 --- a/packages/manager/src/features/Managed/Contacts/Contacts.tsx +++ b/packages/manager/src/features/Managed/Contacts/Contacts.tsx @@ -1,8 +1,7 @@ -import { ManagedContact } from '@linode/api-v4/lib/managed'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import AddNewLink from 'src/components/AddNewLink'; +import { Button } from 'src/components/Button/Button'; import { DeletionDialog } from 'src/components/DeletionDialog/DeletionDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Hidden } from 'src/components/Hidden'; @@ -23,14 +22,16 @@ import { } from 'src/queries/managed/managed'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import ContactDrawer from './ContactsDrawer'; -import ContactTableContact from './ContactsTableContent'; -import { ManagedContactGroup, Mode } from './common'; import { - StyledWrapperGrid, - StyledTypography, StyledHeaderGrid, + StyledTypography, + StyledWrapperGrid, } from './Contacts.styles'; +import ContactDrawer from './ContactsDrawer'; +import ContactTableContact from './ContactsTableContent'; + +import type { ManagedContactGroup, Mode } from './common'; +import type { ManagedContact } from '@linode/api-v4/lib/managed'; const Contacts = () => { const { enqueueSnackbar } = useSnackbar(); @@ -99,13 +100,15 @@ const Contacts = () => { spacing={2} > - { setContactDrawerMode('create'); contactDrawer.open(); }} - label="Add Contact" - /> + buttonType="primary" + > + Add Contact + diff --git a/packages/manager/src/features/Managed/Credentials/CredentialList.tsx b/packages/manager/src/features/Managed/Credentials/CredentialList.tsx index ff64d9fa4f3..54770113d01 100644 --- a/packages/manager/src/features/Managed/Credentials/CredentialList.tsx +++ b/packages/manager/src/features/Managed/Credentials/CredentialList.tsx @@ -1,10 +1,7 @@ -import { CredentialPayload } from '@linode/api-v4/lib/managed/types'; -import { APIError } from '@linode/api-v4/lib/types'; -import { FormikBag } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import AddNewLink from 'src/components/AddNewLink'; +import { Button } from 'src/components/Button/Button'; import { DeletionDialog } from 'src/components/DeletionDialog/DeletionDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import OrderBy from 'src/components/OrderBy'; @@ -39,6 +36,10 @@ import AddCredentialDrawer from './AddCredentialDrawer'; import CredentialTableContent from './CredentialTableContent'; import UpdateCredentialDrawer from './UpdateCredentialDrawer'; +import type { CredentialPayload } from '@linode/api-v4/lib/managed/types'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { FormikBag } from 'formik'; + export type FormikProps = FormikBag<{}, CredentialPayload>; export const CredentialList = () => { @@ -204,10 +205,9 @@ export const CredentialList = () => { spacing={2} > - setDrawerOpen(true)} - /> + diff --git a/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx b/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx index 2a8933ff543..95df7aad844 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx @@ -1,11 +1,8 @@ -import { ManagedServicePayload } from '@linode/api-v4/lib/managed'; -import { APIError } from '@linode/api-v4/lib/types'; import Grid from '@mui/material/Unstable_Grid2'; -import { FormikBag } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import AddNewLink from 'src/components/AddNewLink'; +import { Button } from 'src/components/Button/Button'; import { DeletionDialog } from 'src/components/DeletionDialog/DeletionDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import OrderBy from 'src/components/OrderBy'; @@ -41,6 +38,10 @@ import { } from './MonitorTable.styles'; import MonitorTableContent from './MonitorTableContent'; +import type { ManagedServicePayload } from '@linode/api-v4/lib/managed'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { FormikBag } from 'formik'; + export type Modes = 'create' | 'edit'; export type FormikProps = FormikBag<{}, ManagedServicePayload>; @@ -180,10 +181,12 @@ export const MonitorTable = () => { - setMonitorDrawerOpen(true)} - /> + > + Add Monitor + diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx index 1c971babe18..6630b7509c9 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx @@ -1,4 +1,3 @@ -import { Linode } from '@linode/api-v4/lib/linodes'; import { Box } from '@mui/material'; import * as React from 'react'; @@ -6,6 +5,7 @@ import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { privateIPRegex } from 'src/utilities/ipUtils'; +import type { Linode } from '@linode/api-v4/lib/linodes'; import type { TextFieldProps } from 'src/components/TextField'; interface ConfigNodeIPSelectProps { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.test.tsx new file mode 100644 index 00000000000..6ea897d87fa --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.test.tsx @@ -0,0 +1,76 @@ +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerConfigNode } from './NodeBalancerConfigNode'; + +import type { NodeBalancerConfigNodeProps } from './NodeBalancerConfigNode'; + +const node = { + address: 'some address', + label: 'some label', +}; + +const props: NodeBalancerConfigNodeProps = { + configIdx: 1, + disabled: false, + forEdit: true, + idx: 1, + node, + onNodeAddressChange: vi.fn(), + onNodeLabelChange: vi.fn(), + onNodeModeChange: vi.fn(), + onNodePortChange: vi.fn(), + onNodeWeightChange: vi.fn(), + removeNode: vi.fn(), +}; + +describe('NodeBalancerConfigNode', () => { + it('renders the NodeBalancerConfigNode', () => { + const { getByLabelText, getByText, queryByText } = renderWithTheme( + + ); + + expect(getByLabelText('Label')).toBeVisible(); + expect(getByLabelText('Port')).toBeVisible(); + expect(getByLabelText('Weight')).toBeVisible(); + expect(getByText('Mode')).toBeVisible(); + expect(getByText('Remove')).toBeVisible(); + expect(queryByText('Status')).not.toBeInTheDocument(); + }); + + it('renders the node status', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Status')).toBeVisible(); + expect(getByText('DOWN')).toBeVisible(); + }); + + it('cannot change the mode if the node is not for edit', () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Mode')).not.toBeInTheDocument(); + }); + + it('cannot remove the node if the node is not for edit or is the first node', () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Remove')).not.toBeInTheDocument(); + }); + + it('removes the node', async () => { + const { getByText } = renderWithTheme( + + ); + + await userEvent.click(getByText('Remove')); + expect(props.removeNode).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx index af6e03a1118..05f47659c98 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx @@ -1,5 +1,5 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; @@ -13,8 +13,8 @@ import { Typography } from 'src/components/Typography'; import { getErrorMap } from 'src/utilities/errorUtils'; import { ConfigNodeIPSelect } from './ConfigNodeIPSelect'; -import { NodeBalancerConfigNodeFields } from './types'; +import type { NodeBalancerConfigNodeFields } from './types'; import type { NodeBalancerConfigNodeMode } from '@linode/api-v4'; export interface NodeBalancerConfigNodeProps { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx index 7f2716c6f78..fa06be32953 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -28,7 +28,7 @@ const node: NodeBalancerConfigNodeFields = { weight: 100, }; -const props: NodeBalancerConfigPanelProps = { +export const nbConfigPanelMockPropsForTest: NodeBalancerConfigPanelProps = { addNode: vi.fn(), algorithm: 'roundrobin', checkBody: '', @@ -70,7 +70,17 @@ const props: NodeBalancerConfigPanelProps = { sslCertificate: '', }; -const activeHealthChecks = ['Interval', 'Timeout', 'Attempts']; +const activeHealthChecksFormInputs = ['Interval', 'Timeout', 'Attempts']; + +const activeHealthChecksHelperText = [ + 'Seconds between health check probes', + 'Seconds to wait before considering the probe a failure. 1-30. Must be less than check_interval.', + 'Number of failed probes before taking a node out of rotation. 1-30', +]; + +const sslCertificate = 'ssl-certificate'; +const privateKey = 'private-key'; +const proxyProtocol = 'Proxy Protocol'; describe('NodeBalancerConfigPanel', () => { it('renders the NodeBalancerConfigPanel', () => { @@ -79,7 +89,10 @@ describe('NodeBalancerConfigPanel', () => { getByText, queryByLabelText, queryByTestId, - } = renderWithTheme(); + queryByText, + } = renderWithTheme( + + ); expect(getByLabelText('Protocol')).toBeVisible(); expect(getByLabelText('Algorithm')).toBeVisible(); @@ -109,67 +122,99 @@ describe('NodeBalancerConfigPanel', () => { expect(getByText('Add a Node')).toBeVisible(); expect(getByText('Backend Nodes')).toBeVisible(); - activeHealthChecks.forEach((type) => { - expect(queryByLabelText(type)).not.toBeInTheDocument(); + activeHealthChecksFormInputs.forEach((formLabel) => { + expect(queryByLabelText(formLabel)).not.toBeInTheDocument(); }); - expect(queryByTestId('ssl-certificate')).not.toBeInTheDocument(); - expect(queryByTestId('private-key')).not.toBeInTheDocument(); + activeHealthChecksHelperText.forEach((helperText) => { + expect(queryByText(helperText)).not.toBeInTheDocument(); + }); + expect(queryByTestId(sslCertificate)).not.toBeInTheDocument(); + expect(queryByTestId(privateKey)).not.toBeInTheDocument(); expect(queryByTestId('http-path')).not.toBeInTheDocument(); expect(queryByTestId('http-body')).not.toBeInTheDocument(); - expect(queryByLabelText('Proxy Protocol')).not.toBeInTheDocument(); + expect(queryByLabelText(proxyProtocol)).not.toBeInTheDocument(); }); it('renders form fields specific to the HTTPS protocol', () => { const { getByTestId, queryByLabelText } = renderWithTheme( - + ); - expect(getByTestId('ssl-certificate')).toBeVisible(); - expect(getByTestId('private-key')).toBeVisible(); - expect(queryByLabelText('Proxy Protocol')).not.toBeInTheDocument(); + expect(getByTestId(sslCertificate)).toBeVisible(); + expect(getByTestId(privateKey)).toBeVisible(); + expect(queryByLabelText(proxyProtocol)).not.toBeInTheDocument(); }); it('renders form fields specific to the TCP protocol', () => { const { getByLabelText, queryByTestId } = renderWithTheme( - + ); - expect(getByLabelText('Proxy Protocol')).toBeVisible(); - expect(queryByTestId('ssl-certificate')).not.toBeInTheDocument(); - expect(queryByTestId('private-key')).not.toBeInTheDocument(); + expect(getByLabelText(proxyProtocol)).toBeVisible(); + expect(queryByTestId(sslCertificate)).not.toBeInTheDocument(); + expect(queryByTestId(privateKey)).not.toBeInTheDocument(); }); it('renders fields specific to the Active Health Check type of TCP Connection', () => { - const { getByLabelText, queryByTestId } = renderWithTheme( - + const { getByLabelText, getByText, queryByTestId } = renderWithTheme( + ); - activeHealthChecks.forEach((type) => { - expect(getByLabelText(type)).toBeVisible(); + activeHealthChecksFormInputs.forEach((formLabel) => { + expect(getByLabelText(formLabel)).toBeVisible(); + }); + activeHealthChecksHelperText.forEach((helperText) => { + expect(getByText(helperText)).toBeVisible(); }); expect(queryByTestId('http-path')).not.toBeInTheDocument(); expect(queryByTestId('http-body')).not.toBeInTheDocument(); }); it('renders fields specific to the Active Health Check type of HTTP Status', () => { - const { getByLabelText, getByTestId, queryByTestId } = renderWithTheme( - + const { + getByLabelText, + getByTestId, + getByText, + queryByTestId, + } = renderWithTheme( + ); - activeHealthChecks.forEach((type) => { - expect(getByLabelText(type)).toBeVisible(); + activeHealthChecksFormInputs.forEach((formLabel) => { + expect(getByLabelText(formLabel)).toBeVisible(); + }); + activeHealthChecksHelperText.forEach((helperText) => { + expect(getByText(helperText)).toBeVisible(); }); expect(getByTestId('http-path')).toBeVisible(); expect(queryByTestId('http-body')).not.toBeInTheDocument(); }); it('renders fields specific to the Active Health Check type of HTTP Body', () => { - const { getByLabelText, getByTestId } = renderWithTheme( - + const { getByLabelText, getByTestId, getByText } = renderWithTheme( + ); - activeHealthChecks.forEach((type) => { - expect(getByLabelText(type)).toBeVisible(); + activeHealthChecksFormInputs.forEach((formLabel) => { + expect(getByLabelText(formLabel)).toBeVisible(); + }); + activeHealthChecksHelperText.forEach((helperText) => { + expect(getByText(helperText)).toBeVisible(); }); expect(getByTestId('http-path')).toBeVisible(); expect(getByTestId('http-body')).toBeVisible(); @@ -177,7 +222,7 @@ describe('NodeBalancerConfigPanel', () => { it('renders the relevant helper text for the Round Robin algorithm', () => { const { getByText, queryByText } = renderWithTheme( - + ); expect(getByText(ROUND_ROBIN_ALGORITHM_HELPER_TEXT)).toBeVisible(); @@ -189,7 +234,10 @@ describe('NodeBalancerConfigPanel', () => { it('renders the relevant helper text for the Least Connections algorithm', () => { const { getByText, queryByText } = renderWithTheme( - + ); expect(getByText(LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT)).toBeVisible(); @@ -201,7 +249,10 @@ describe('NodeBalancerConfigPanel', () => { it('renders the relevant helper text for the Source algorithm', () => { const { getByText, queryByText } = renderWithTheme( - + ); expect(getByText(SOURCE_ALGORITHM_HELPER_TEXT)).toBeVisible(); @@ -213,51 +264,57 @@ describe('NodeBalancerConfigPanel', () => { ).not.toBeInTheDocument(); }); - it('adds another backend node', () => { + it('adds another backend node', async () => { const { getByText } = renderWithTheme( - + ); const addNodeButton = getByText('Add a Node'); - fireEvent.click(addNodeButton); - expect(props.addNode).toHaveBeenCalled(); + await userEvent.click(addNodeButton); + expect(nbConfigPanelMockPropsForTest.addNode).toHaveBeenCalled(); }); it('cannot remove a backend node if there is only one node', () => { const { queryByText } = renderWithTheme( - + ); expect(queryByText('Remove')).not.toBeInTheDocument(); }); - it('removes a backend node', () => { + it('removes a backend node', async () => { const { getByText } = renderWithTheme( - + ); const removeNodeButton = getByText('Remove'); - fireEvent.click(removeNodeButton); - expect(props.removeNode).toHaveBeenCalled(); + await userEvent.click(removeNodeButton); + expect(nbConfigPanelMockPropsForTest.removeNode).toHaveBeenCalled(); }); - it('deletes the configuration panel', () => { + it('deletes the configuration panel', async () => { const { getByText } = renderWithTheme( - + ); const deleteConfigButton = getByText('Delete'); - fireEvent.click(deleteConfigButton); - expect(props.onDelete).toHaveBeenCalled(); + await userEvent.click(deleteConfigButton); + expect(nbConfigPanelMockPropsForTest.onDelete).toHaveBeenCalled(); }); - it('saves the input after editing the configuration', () => { + it('saves the input after editing the configuration', async () => { const { getByText } = renderWithTheme( - + ); const editConfigButton = getByText('Save'); - fireEvent.click(editConfigButton); - expect(props.onSave).toHaveBeenCalled(); + await userEvent.click(editConfigButton); + expect(nbConfigPanelMockPropsForTest.onSave).toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx new file mode 100644 index 00000000000..3a2f0a171aa --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import NodeBalancerCreate from './NodeBalancerCreate'; + +// Note: see nodeblaancers-create-in-complex-form.spec.ts for an e2e test of this flow +describe('NodeBalancerCreate', () => { + it('renders all parts of the NodeBalancerCreate page', () => { + const { getAllByText, getByLabelText, getByText } = renderWithTheme( + + ); + + // confirm nodebalancer fields render + expect(getByLabelText('NodeBalancer Label')).toBeVisible(); + expect(getByLabelText('Add Tags')).toBeVisible(); + expect(getByLabelText('Region')).toBeVisible(); + + // confirm Firewall panel renders + expect(getByLabelText('Assign Firewall')).toBeVisible(); + expect(getByText('Create Firewall')).toBeVisible(); + expect( + getByText( + /Assign an existing Firewall to this NodeBalancer to control inbound network traffic./ + ) + ).toBeVisible(); + + // confirm default configuration renders - only confirming headers, as we have additional + // unit tests to check the functionality of the NodeBalancerConfigPanel + expect(getByText('Configuration - Port 80')).toBeVisible(); + expect(getByText('Active Health Checks')).toBeVisible(); + expect(getAllByText('Passive Checks')).toHaveLength(2); + expect(getByText('Backend Nodes')).toBeVisible(); + + // confirm summary renders + expect(getByText('Summary')).toBeVisible(); + expect(getByText('Configs')).toBeVisible(); + expect(getByText('Nodes')).toBeVisible(); + expect(getByText('Create NodeBalancer')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.test.tsx new file mode 100644 index 00000000000..de194e3a23b --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.test.tsx @@ -0,0 +1,43 @@ +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerDeleteDialog } from './NodeBalancerDeleteDialog'; + +const props = { + id: 1, + label: 'nb-1', + onClose: vi.fn(), + open: true, +}; + +describe('NodeBalancerDeleteDialog', () => { + it('renders the NodeBalancerDeleteDialog', () => { + const { getByText } = renderWithTheme( + + ); + + expect( + getByText('Deleting this NodeBalancer is permanent and can’t be undone.') + ).toBeVisible(); + expect( + getByText( + 'Traffic will no longer be routed through this NodeBalancer. Please check your DNS settings and either provide the IP address of another active NodeBalancer, or route traffic directly to your Linode.' + ) + ).toBeVisible(); + expect(getByText('Delete nb-1?')).toBeVisible(); + expect(getByText('NodeBalancer Label')).toBeVisible(); + expect(getByText('Cancel')).toBeVisible(); + expect(getByText('Delete')).toBeVisible(); + }); + + it('calls the onClose function of the dialog', async () => { + const { getByText } = renderWithTheme( + + ); + + await userEvent.click(getByText('Cancel')); + expect(props.onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx new file mode 100644 index 00000000000..d82c1156bf9 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx @@ -0,0 +1,188 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { + nodeBalancerConfigFactory, + nodeBalancerConfigNodeFactory, +} from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import NodeBalancerConfigurations from './NodeBalancerConfigurations'; + +const props = { + grants: undefined, + nodeBalancerLabel: 'nb-1', + nodeBalancerRegion: 'us-east', +}; + +const loadingTestId = 'circle-progress'; +const memoryRouter = { initialEntries: ['nodebalancers/1/configurations'] }; +const routePath = 'nodebalancers/:nodeBalancerId/configurations'; + +const nodeBalancerConfig = nodeBalancerConfigFactory.build({ + id: 1, + port: 3000, +}); + +describe('NodeBalancerConfigurations', () => { + beforeEach(() => { + server.resetHandlers(); + }); + + it('renders the NodeBalancerConfigurations component with one configuration', async () => { + server.use( + http.get(`*/nodebalancers/:id/configs`, () => { + return HttpResponse.json(makeResourcePage([nodeBalancerConfig])); + }), + http.get(`*/nodebalancers/:id/configs/1/nodes`, () => { + return HttpResponse.json( + makeResourcePage([nodeBalancerConfigNodeFactory.build({ id: 1 })]) + ); + }) + ); + + const { getByLabelText, getByTestId, getByText } = renderWithTheme( + , + { + MemoryRouter: memoryRouter, + routePath, + } + ); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Expected after mocking the configs returned + expect(getByText('Port 3000')).toBeVisible(); + expect(getByLabelText('Protocol')).toBeInTheDocument(); + expect(getByLabelText('Algorithm')).toBeInTheDocument(); + expect(getByLabelText('Session Stickiness')).toBeInTheDocument(); + expect(getByLabelText('Type')).toBeInTheDocument(); + expect(getByLabelText('Label')).toBeInTheDocument(); + expect(getByLabelText('IP Address')).toBeInTheDocument(); + expect(getByLabelText('Weight')).toBeInTheDocument(); + expect(getByLabelText('Port')).toBeInTheDocument(); + expect(getByText('Listen on this port.')).toBeInTheDocument(); + expect(getByText('Active Health Checks')).toBeInTheDocument(); + expect( + getByText( + 'Route subsequent requests from the client to the same backend.' + ) + ).toBeInTheDocument(); + expect( + getByText( + 'Enable passive checks based on observing communication with back-end nodes.' + ) + ).toBeInTheDocument(); + expect( + getByText( + "Active health checks proactively check the health of back-end nodes. 'HTTP Valid Status' requires a 2xx or 3xx response from the backend node. 'HTTP Body Regex' uses a regex to match against an expected result body." + ) + ).toBeInTheDocument(); + expect(getByText('Add a Node')).toBeInTheDocument(); + expect(getByText('Backend Nodes')).toBeInTheDocument(); + + // Since there is an existing configuration, the Add Configuration button says 'Add Another Configuration' + expect(getByText('Add Another Configuration')).toBeVisible(); + }); + + it('renders the NodeBalancerConfigurations component with no configurations', async () => { + server.use( + http.get(`*/nodebalancers/:id/configs`, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId, getByText, queryByLabelText } = renderWithTheme( + , + { + MemoryRouter: memoryRouter, + routePath, + } + ); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // confirm there are no configs + expect(queryByLabelText('Protocol')).not.toBeInTheDocument(); + expect(queryByLabelText('Algorithm')).not.toBeInTheDocument(); + expect(queryByLabelText('Session Stickiness')).not.toBeInTheDocument(); + + // Since there are no existing configurations, the Add Configuration button says 'Add a Configuration' + expect(getByText('Add a Configuration')).toBeVisible(); + }); + + it('adds another configuration', async () => { + server.use( + http.get(`*/nodebalancers/:id/configs`, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId, getByText, queryByLabelText } = renderWithTheme( + , + { + MemoryRouter: memoryRouter, + routePath, + } + ); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // confirm no configuration exists yet + expect(queryByLabelText('Protocol')).not.toBeInTheDocument(); + expect(queryByLabelText('Algorithm')).not.toBeInTheDocument(); + expect(queryByLabelText('Session Stickiness')).not.toBeInTheDocument(); + + await userEvent.click(getByText('Add a Configuration')); + + // confirm new configuration has been added + expect(queryByLabelText('Protocol')).toBeVisible(); + expect(queryByLabelText('Algorithm')).toBeVisible(); + expect(queryByLabelText('Session Stickiness')).toBeVisible(); + }); + + it('opens the Delete Configuration dialog', async () => { + server.use( + http.get(`*/nodebalancers/:id/configs`, () => { + return HttpResponse.json(makeResourcePage([nodeBalancerConfig])); + }), + http.get(`*/nodebalancers/:id/configs/1/nodes`, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByLabelText, getByTestId, getByText } = renderWithTheme( + , + { + MemoryRouter: memoryRouter, + routePath, + } + ); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + expect(getByText('Port 3000')).toBeVisible(); + expect(getByLabelText('Protocol')).toBeInTheDocument(); + expect(getByLabelText('Algorithm')).toBeInTheDocument(); + + await userEvent.click(getByText('Delete')); + + expect(getByText('Delete this configuration on port 3000?')).toBeVisible(); + expect( + getByText( + 'Are you sure you want to delete this NodeBalancer Configuration?' + ) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx index 620cd6e10f9..66e713df1a8 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx @@ -1,6 +1,4 @@ import { - NodeBalancerConfig, - NodeBalancerConfigNode, createNodeBalancerConfig, createNodeBalancerConfigNode, deleteNodeBalancerConfig, @@ -10,10 +8,8 @@ import { updateNodeBalancerConfig, updateNodeBalancerConfigNode, } from '@linode/api-v4/lib/nodebalancers'; -import { APIError, ResourcePage } from '@linode/api-v4/lib/types'; import { styled } from '@mui/material/styles'; import { - Lens, append, clone, compose, @@ -25,7 +21,7 @@ import { view, } from 'ramda'; import * as React from 'react'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; +import { withRouter } from 'react-router-dom'; import { compose as composeC } from 'recompose'; import { Accordion } from 'src/components/Accordion'; @@ -34,14 +30,10 @@ import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import PromiseLoader, { - PromiseLoaderResponse, -} from 'src/components/PromiseLoader/PromiseLoader'; +import PromiseLoader from 'src/components/PromiseLoader/PromiseLoader'; import { Typography } from 'src/components/Typography'; -import { - WithQueryClientProps, - withQueryClient, -} from 'src/containers/withQueryClient.container'; +import { withQueryClient } from 'src/containers/withQueryClient.container'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; @@ -62,7 +54,15 @@ import type { NodeBalancerConfigNodeFields, } from '../types'; import type { Grants } from '@linode/api-v4'; -import { nodebalancerQueries } from 'src/queries/nodebalancers'; +import type { + NodeBalancerConfig, + NodeBalancerConfigNode, +} from '@linode/api-v4/lib/nodebalancers'; +import type { APIError, ResourcePage } from '@linode/api-v4/lib/types'; +import type { Lens } from 'ramda'; +import type { RouteComponentProps } from 'react-router-dom'; +import type { PromiseLoaderResponse } from 'src/components/PromiseLoader/PromiseLoader'; +import type { WithQueryClientProps } from 'src/containers/withQueryClient.container'; const StyledPortsSpan = styled('span', { label: 'StyledPortsSpan', @@ -171,62 +171,25 @@ class NodeBalancerConfigurations extends React.Component< NodeBalancerConfigurationsProps, State > { - render() { - const { nodeBalancerLabel } = this.props; - const { - configErrors, - configSubmitting, - configs, - hasUnsavedConfig, - panelMessages, - } = this.state; - - const isNodeBalancerReadOnly = this.isNodeBalancerReadOnly(); - - return ( -
- - {Array.isArray(configs) && - configs.map( - this.renderConfig(panelMessages, configErrors, configSubmitting) - )} + static defaultDeleteConfigConfirmDialogState = { + errors: undefined, + idxToDelete: undefined, + open: false, + portToDelete: undefined, + submitting: false, + }; - {!hasUnsavedConfig && ( - - this.addNodeBalancerConfig()} - > - {configs.length === 0 - ? 'Add a Configuration' - : 'Add Another Configuration'} - - - )} + static defaultDeleteNodeConfirmDialogState = { + configIdxToDelete: undefined, + errors: undefined, + nodeIdxToDelete: undefined, + open: false, + submitting: false, + }; - - - Are you sure you want to delete this NodeBalancer Configuration? - - -
- ); - } + static defaultFieldsStates = { + configs: [createNewNodeBalancerConfig(true)], + }; addNode = (configIdx: number) => () => { this.setState( @@ -343,26 +306,6 @@ class NodeBalancerConfigurations extends React.Component< ); }; - static defaultDeleteConfigConfirmDialogState = { - errors: undefined, - idxToDelete: undefined, - open: false, - portToDelete: undefined, - submitting: false, - }; - - static defaultDeleteNodeConfirmDialogState = { - configIdxToDelete: undefined, - errors: undefined, - nodeIdxToDelete: undefined, - open: false, - submitting: false, - }; - - static defaultFieldsStates = { - configs: [createNewNodeBalancerConfig(true)], - }; - deleteConfig = () => { const { deleteConfigConfirmDialog: { idxToDelete }, @@ -749,23 +692,6 @@ class NodeBalancerConfigurations extends React.Component< ); }; - renderConfigConfirmationActions = ({ onClose }: { onClose: () => void }) => ( - - ); - resetSubmitting = (configIdx: number) => { // reset submitting const newSubmitting = clone(this.state.configSubmitting); @@ -1164,6 +1090,78 @@ class NodeBalancerConfigurations extends React.Component< const clampedValue = clampNumericString(0, Number.MAX_SAFE_INTEGER)(value); this.updateState(lens, L, callback)(clampedValue); }; + + render() { + const { nodeBalancerLabel } = this.props; + const { + configErrors, + configSubmitting, + configs, + hasUnsavedConfig, + panelMessages, + } = this.state; + + const isNodeBalancerReadOnly = this.isNodeBalancerReadOnly(); + + return ( +
+ + {Array.isArray(configs) && + configs.map( + this.renderConfig(panelMessages, configErrors, configSubmitting) + )} + + {!hasUnsavedConfig && ( + + this.addNodeBalancerConfig()} + > + {configs.length === 0 + ? 'Add a Configuration' + : 'Add Another Configuration'} + + + )} + + + } + title={ + typeof this.state.deleteConfigConfirmDialog.portToDelete !== + 'undefined' + ? `Delete this configuration on port ${this.state.deleteConfigConfirmDialog.portToDelete}?` + : 'Delete this configuration?' + } + error={this.confirmationConfigError()} + onClose={this.onCloseConfirmation} + open={this.state.deleteConfigConfirmDialog.open} + > + + Are you sure you want to delete this NodeBalancer Configuration? + + +
+ ); + } } const preloaded = PromiseLoader({ diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx index 886043c757e..c951f4e1f8e 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx @@ -24,7 +24,7 @@ import { useGrants } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; import NodeBalancerConfigurations from './NodeBalancerConfigurations'; -import NodeBalancerSettings from './NodeBalancerSettings'; +import { NodeBalancerSettings } from './NodeBalancerSettings'; import { NodeBalancerSummary } from './NodeBalancerSummary/NodeBalancerSummary'; export const NodeBalancerDetail = () => { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.test.tsx new file mode 100644 index 00000000000..2d6747ef8a3 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.test.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; + +import { firewallFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerFirewalls } from './NodeBalancerFirewalls'; + +const firewall = firewallFactory.build({ label: 'mock-firewall-1' }); + +// Set up various mocks for tests + +const queryMocks = vi.hoisted(() => ({ + useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }), +})); + +vi.mock('src/queries/nodebalancers', async () => { + const actual = await vi.importActual('src/queries/nodebalancers'); + return { + ...actual, + useNodeBalancersFirewallsQuery: queryMocks.useNodeBalancersFirewallsQuery, + }; +}); + +const props = { + displayFirewallInfoText: false, + nodeBalancerId: 1, +}; + +describe('NodeBalancerFirewalls', () => { + beforeEach(() => { + queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ + data: { data: [firewall] }, + isLoading: false, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('renders the Firewall table', () => { + const { getByText } = renderWithTheme(); + + expect(getByText('Firewall')).toBeVisible(); + expect(getByText('Status')).toBeVisible(); + expect(getByText('Rules')).toBeVisible(); + expect(getByText('mock-firewall-1')).toBeVisible(); + expect(getByText('Enabled')).toBeVisible(); + expect(getByText('1 Inbound / 1 Outbound')).toBeVisible(); + expect(getByText('Unassign')).toBeVisible(); + }); + + it('displays the FirewallInfo text', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Learn more about creating Firewalls.')).toBeVisible(); + }); + + it('displays a loading placeholder', () => { + queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ + data: { data: [firewall] }, + isLoading: true, + }); + const { getByTestId, getByText } = renderWithTheme( + + ); + + // headers still exist + expect(getByText('Firewall')).toBeVisible(); + expect(getByText('Status')).toBeVisible(); + expect(getByText('Rules')).toBeVisible(); + + // table is loading + expect(getByTestId('table-row-loading')).toBeVisible(); + }); + + it('displays an error for the firewall table', () => { + queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ + data: { data: [firewall] }, + error: [{ reason: 'This is a firewall table error.' }], + isLoading: false, + }); + const { getByText } = renderWithTheme(); + + // headers still exist + expect(getByText('Firewall')).toBeVisible(); + expect(getByText('Status')).toBeVisible(); + expect(getByText('Rules')).toBeVisible(); + + // error message displays if there was an error getting firewalls + expect(getByText('This is a firewall table error.')).toBeVisible(); + }); + + it('shows that no firewalls are assigned', () => { + queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ + data: { data: [] }, + isLoading: false, + }); + const { getByText } = renderWithTheme(); + + // headers still exist + expect(getByText('Firewall')).toBeVisible(); + expect(getByText('Status')).toBeVisible(); + expect(getByText('Rules')).toBeVisible(); + + // no firewalls exist + expect(getByText('No Firewalls are assigned.')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx index 5b962fd53a6..a328bac072e 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx @@ -1,5 +1,4 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ -import { Firewall, FirewallDevice } from '@linode/api-v4'; import { Stack } from '@mui/material'; import * as React from 'react'; @@ -20,6 +19,8 @@ import { useNodeBalancersFirewallsQuery } from 'src/queries/nodebalancers'; import { NodeBalancerFirewallsRow } from './NodeBalancerFirewallsRow'; +import type { Firewall, FirewallDevice } from '@linode/api-v4'; + interface Props { displayFirewallInfoText: boolean; nodeBalancerId: number; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx index 74507f315f6..7c92a160cd0 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; -import { Action } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { noPermissionTooltipText } from 'src/features/Firewalls/FirewallLanding/FirewallActionMenu'; import { checkIfUserCanModifyFirewall } from 'src/features/Firewalls/shared'; import { useGrants, useProfile } from 'src/queries/profile/profile'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + interface Props { firewallID: number; onUnassign: () => void; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.test.tsx new file mode 100644 index 00000000000..c102b00de25 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.test.tsx @@ -0,0 +1,77 @@ +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { firewallFactory } from 'src/factories'; +import { checkIfUserCanModifyFirewall } from 'src/features/Firewalls/shared'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerFirewallsRow } from './NodeBalancerFirewallsRow'; + +const firewall = firewallFactory.build({ label: 'mock-firewall-1' }); + +// Set up various mocks for tests +vi.mock('src/features/Firewalls/shared'); + +const queryMocks = vi.hoisted(() => ({ + useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }), +})); + +vi.mock('src/queries/nodebalancers', async () => { + const actual = await vi.importActual('src/queries/nodebalancers'); + return { + ...actual, + useNodeBalancersFirewallsQuery: queryMocks.useNodeBalancersFirewallsQuery, + }; +}); + +const props = { + firewall, + nodeBalancerID: 1, + onClickUnassign: vi.fn(), +}; + +describe('NodeBalancerFirewallsRow', () => { + beforeEach(() => { + vi.mocked(checkIfUserCanModifyFirewall).mockReturnValue(true); + queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ + data: { data: [firewall] }, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('renders the NodeBalancerFirewallsRow', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('mock-firewall-1')).toBeVisible(); + expect(getByText('Enabled')).toBeVisible(); + expect(getByText('1 Inbound / 1 Outbound')).toBeVisible(); + expect(getByText('Unassign')).toBeVisible(); + }); + + it('unassigns the firewall', async () => { + const { getByText } = renderWithTheme( + + ); + + const unassignButton = getByText('Unassign'); + await userEvent.click(unassignButton); + expect(props.onClickUnassign).toHaveBeenCalled(); + }); + + it('disables unassigning the firewall if user cannot modify firewall', async () => { + vi.mocked(checkIfUserCanModifyFirewall).mockReturnValue(false); + const { getByTestId } = renderWithTheme( + + ); + + const unassignButton = getByTestId('Button'); + expect(unassignButton).toBeDisabled(); + await userEvent.click(unassignButton); + expect(props.onClickUnassign).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx index b16129be7c1..d429cc56d3f 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx @@ -1,4 +1,3 @@ -import { Firewall, FirewallDevice } from '@linode/api-v4'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -14,6 +13,8 @@ import { capitalize } from 'src/utilities/capitalize'; import { NodeBalancerFirewallsActionMenu } from './NodeBalancerFirewallsActionMenu'; +import type { Firewall, FirewallDevice } from '@linode/api-v4'; + interface Props { firewall: Firewall; nodeBalancerID: number; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.test.tsx new file mode 100644 index 00000000000..af1138e660b --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.test.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; + +import { firewallFactory, nodeBalancerFactory } from 'src/factories'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerSettings } from './NodeBalancerSettings'; + +// Set up various mocks for tests +vi.mock('src/hooks/useIsResourceRestricted'); + +const queryMocks = vi.hoisted(() => ({ + useNodeBalancerQuery: vi.fn().mockReturnValue({ data: undefined }), + useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }), +})); + +vi.mock('src/queries/nodebalancers', async () => { + const actual = await vi.importActual('src/queries/nodebalancers'); + return { + ...actual, + useNodeBalancerQuery: queryMocks.useNodeBalancerQuery, + useNodeBalancersFirewallsQuery: queryMocks.useNodeBalancersFirewallsQuery, + }; +}); + +const connectionThrottle = 'Connection Throttle'; + +describe('NodeBalancerSettings', () => { + beforeEach(() => { + queryMocks.useNodeBalancerQuery.mockReturnValue({ + data: nodeBalancerFactory.build(), + }); + queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ + data: { data: [firewallFactory.build({ label: 'mock-firewall-1' })] }, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('renders the NodeBalancerSettings component', () => { + const { + getAllByText, + getByLabelText, + getByTestId, + getByText, + } = renderWithTheme(); + + // NodeBalancer Label panel + expect(getByText('NodeBalancer Label')).toBeVisible(); + expect(getByText('Label')).toBeVisible(); + expect(getByLabelText('Label')).not.toBeDisabled(); + + // Firewall panel + expect(getByText('Firewalls')).toBeVisible(); + expect(getByText('Firewall')).toBeVisible(); + expect(getByText('Status')).toBeVisible(); + expect(getByText('Rules')).toBeVisible(); + expect(getByText('mock-firewall-1')).toBeVisible(); + expect(getByText('Enabled')).toBeVisible(); + expect(getByText('1 Inbound / 1 Outbound')).toBeVisible(); + expect(getByText('Unassign')).toBeVisible(); + + // Client Connection Throttle panel + expect(getByText('Client Connection Throttle')).toBeVisible(); + expect(getByText(connectionThrottle)).toBeVisible(); + expect(getByLabelText(connectionThrottle)).not.toBeDisabled(); + expect( + getByText( + 'To help mitigate abuse, throttle connections from a single client IP to this number per second. 0 to disable.' + ) + ).toBeVisible(); + expect(getAllByText('Save')).toHaveLength(2); + + // Delete panel + expect(getByText('Delete NodeBalancer')).toBeVisible(); + expect(getByText('Delete')).toBeVisible(); + expect(getByTestId('delete-nodebalancer')).not.toBeDisabled(); + }); + + it('disables inputs and buttons if the Node Balancer is read only', () => { + vi.mocked(useIsResourceRestricted).mockReturnValue(true); + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + + expect(getByLabelText('Label')).toBeDisabled(); + expect(getByLabelText(connectionThrottle)).toBeDisabled(); + expect(getByTestId('delete-nodebalancer')).toBeDisabled(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx index 9afca8890d6..2c3b66cc24f 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx @@ -140,6 +140,7 @@ export const NodeBalancerSettings = () => { -
- {selectedApp ? ( - <> - - {`${selectedApp.name} - - - - - {selectedApp.summary} - - - {selectedApp.website ? ( - - Website - - {selectedApp.website} - - - ) : null} - {selectedApp.related_guides ? ( - - Guides - - {( - oneClickAppsDocsOverride?.[selectedApp.name] ?? - selectedApp.related_guides - ).map((link, idx) => ( - - {sanitizeHTML({ - sanitizingTier: 'flexible', - text: link.title, - })} - - ))} - - - ) : null} - {selectedApp.tips ? ( - - Tips - - {selectedApp.tips.map((tip, idx) => ( - - {tip} - - ))} - - - ) : null} - - - ) : ( - - App Details Not Found - - We were unable to load the details of this app. - - - - )} - - ); -}; - -// remove this when we make the svgs white via css -const REUSE_WHITE_ICONS = { - 'mongodbmarketplaceocc.svg': 'mongodb.svg', - 'postgresqlmarketplaceocc.svg': 'postgresql.svg', - 'redissentinelmarketplaceocc.svg': 'redis.svg', -}; - -export default AppDetailDrawer; diff --git a/packages/manager/src/features/OneClickApps/index.tsx b/packages/manager/src/features/OneClickApps/index.tsx deleted file mode 100644 index d910f9ab161..00000000000 --- a/packages/manager/src/features/OneClickApps/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default as AppDetailDrawer } from './AppDetailDrawer'; diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts index 4dbb5d87fc9..61094ec2475 100644 --- a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts +++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts @@ -1,5 +1,3 @@ -import { oneClickAppFactory } from 'src/factories/stackscripts'; - import type { OCA } from './types'; /** @@ -9,10 +7,6 @@ import type { OCA } from './types'; * for it to be visible to users. */ export const oneClickApps: Record = { - 0: oneClickAppFactory.build({ - isNew: true, - name: 'E2E Test App', - }), 401697: { alt_description: 'Popular website content management system.', alt_name: 'CMS: content management system', @@ -2606,7 +2600,7 @@ export const oneClickApps: Record = { related_guides: [ { href: - 'https://www.linode.com/docs/marketplace-docs/guides/apache-spark/', + 'https://www.linode.com/docs/marketplace-docs/guides/apache-spark-cluster/', title: 'Deploy Apache Spark through the Linode Marketplace', }, ], diff --git a/packages/manager/src/features/OneClickApps/utils.test.ts b/packages/manager/src/features/OneClickApps/utils.test.ts deleted file mode 100644 index 1f9809436f6..00000000000 --- a/packages/manager/src/features/OneClickApps/utils.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { oneClickAppFactory } from 'src/factories/stackscripts'; - -import { mapStackScriptLabelToOCA } from './utils'; - -describe('mapStackScriptLabelToOneClickAppName', () => { - const onClickApp = oneClickAppFactory.build(); - - it('should return undefined if no match is found', () => { - const result = mapStackScriptLabelToOCA({ - oneClickApps: [], - stackScriptLabel: '', - }); - - expect(result).toBeUndefined(); - }); - - it('should return the matching app', () => { - const result = mapStackScriptLabelToOCA({ - oneClickApps: [onClickApp], - stackScriptLabel: 'Test App', - }); - - expect(result).toBeDefined(); - }); - - it('should return the matching app when the StackScript label contains unexpected characters', () => { - const onClickAppWithUnexpectedCharacters = oneClickAppFactory.build({ - name: 'Test @App ®', - }); - - const result = mapStackScriptLabelToOCA({ - oneClickApps: [onClickAppWithUnexpectedCharacters], - stackScriptLabel: 'Test App', - }); - - expect(result).toBeDefined(); - }); -}); diff --git a/packages/manager/src/features/OneClickApps/utils.ts b/packages/manager/src/features/OneClickApps/utils.ts deleted file mode 100644 index 302ad6cd953..00000000000 --- a/packages/manager/src/features/OneClickApps/utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { OCA } from './types'; - -const OCA_MAPPING_REGEX = /[^A-Za-z0-9\s\/$*+\-?&.:()]/g; - -interface Options { - oneClickApps: OCA[]; - stackScriptLabel: string; -} - -/** - * Given a StackScript label, return the corresponding One-Click App name - * @param oneClickApps - * @param stackScriptLabel - * @returns {string} - */ -export const mapStackScriptLabelToOCA = ({ - oneClickApps, - stackScriptLabel, -}: Options): OCA | undefined => { - return oneClickApps.find((app) => { - const cleanedStackScriptLabel = stackScriptLabel - .replace(OCA_MAPPING_REGEX, '') - .trim(); - - const cleanedAppName = app.name - .replace('®', '') - .replace(OCA_MAPPING_REGEX, '') - .trim(); - - return cleanedStackScriptLabel === cleanedAppName; - }); -}; diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index 124ffd92732..96de8bb2306 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -32,16 +32,16 @@ const props = { describe('Create API Token Drawer', () => { it('checks API Token Drawer rendering', () => { - const { getByTestId, getByText } = renderWithTheme( + const { getAllByTestId, getByTestId, getByText } = renderWithTheme( ); const drawerTitle = getByText('Add Personal Access Token'); expect(drawerTitle).toBeVisible(); const labelTitle = getByText(/Label/); - const labelField = getByTestId('textfield-input'); + const labelField = getAllByTestId('textfield-input'); expect(labelTitle).toBeVisible(); - expect(labelField).toBeEnabled(); + expect(labelField[0]).toBeEnabled(); const expiry = getByText(/Expiry/); expect(expiry).toBeVisible(); @@ -67,12 +67,12 @@ describe('Create API Token Drawer', () => { }) ); - const { getByLabelText, getByTestId, getByText } = renderWithTheme( + const { getAllByTestId, getByLabelText, getByText } = renderWithTheme( ); - const labelField = getByTestId('textfield-input'); - await userEvent.type(labelField, 'my-test-token'); + const labelField = getAllByTestId('textfield-input'); + await userEvent.type(labelField[0], 'my-test-token'); const selectAllNoAccessPermRadioButton = getByLabelText( 'Select no access for all' @@ -110,8 +110,10 @@ describe('Create API Token Drawer', () => { }); it('Should default to 6 months for expiration', () => { - const { getByText } = renderWithTheme(); - getByText('In 6 months'); + const { getAllByRole } = renderWithTheme( + + ); + expect(getAllByRole('combobox')[0]).toHaveDisplayValue('In 6 months'); }); it('Should show the Child Account Access scope for a parent user account with the parent/child feature flag on', () => { diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index bf6eab61363..2156c1524fc 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -3,8 +3,8 @@ import { DateTime } from 'luxon'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Drawer } from 'src/components/Drawer'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { FormControl } from 'src/components/FormControl'; import { FormHelperText } from 'src/components/FormHelperText'; import { Notice } from 'src/components/Notice/Notice'; @@ -30,7 +30,6 @@ import { StyledSelectCell, } from './APITokenDrawer.styles'; import { - Permission, allScopesAreTheSame, basePermNameMap, hasAccessBeenSelectedForAllScopes, @@ -38,6 +37,8 @@ import { scopeStringToPermTuples, } from './utils'; +import type { Permission } from './utils'; + type Expiry = [string, string]; export const genExpiryTups = (): Expiry[] => { @@ -172,10 +173,6 @@ export const CreateAPITokenDrawer = (props: Props) => { form.setFieldValue('scopes', newScopes); }; - const handleExpiryChange = (e: Item) => { - form.setFieldValue('expiry', e.value); - }; - // Permission scopes with a different default when Selecting All for the specified access level. const excludedScopesFromSelectAll: ExcludedScope[] = [ { @@ -214,11 +211,30 @@ export const CreateAPITokenDrawer = (props: Props) => { value={form.values.label} /> - { + setFieldValue(`security_questions[${index}]`, { + id: item.value, + question: item.label, + response: '', + }); + }} + autoHighlight defaultValue={currentOption} - isClearable={false} + disableClearable label={label} - name={name} - onChange={onChange} options={options} placeholder="Select a question" + value={options.find((option) => option.value === questionResponse?.id)} /> ); }; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/QuestionAndAnswerPair.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/QuestionAndAnswerPair.tsx index 42ed09b940f..cab647664d9 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/QuestionAndAnswerPair.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/QuestionAndAnswerPair.tsx @@ -1,19 +1,20 @@ -import { SecurityQuestion } from '@linode/api-v4/lib/profile'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { Box } from 'src/components/Box'; -import { Item } from 'src/components/EnhancedSelect'; import { Answer } from './Answer'; import { Question } from './Question'; +import type { SelectQuestionOption } from './Question'; +import type { SecurityQuestion } from '@linode/api-v4/lib/profile'; + interface Props { edit: boolean; handleChange: any; index: number; onEdit: () => void; - options: Item[]; + options: SelectQuestionOption[]; questionResponse: SecurityQuestion | undefined; securityQuestionRef?: React.RefObject; setFieldValue: (field: string, value: SecurityQuestion | number) => void; diff --git a/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx index 19b8557ac5d..5eba8e3a707 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx @@ -1,4 +1,5 @@ import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -23,21 +24,37 @@ describe('AvatarColorPicker', () => { expect(getByTestId('avatar')).toBeVisible(); }); - it('calls onClose when Close button is clicked', async () => { - const { getByText } = renderWithTheme( + it('calls onClose when Close/X buttons are clicked', async () => { + const { getAllByRole } = renderWithTheme( ); - await fireEvent.click(getByText('Close')); - expect(mockProps.handleClose).toHaveBeenCalled(); + const closeButtons = getAllByRole('button', { name: 'Close' }); + closeButtons.forEach(async (button) => { + await userEvent.click(button); + expect(mockProps.handleClose).toHaveBeenCalled(); + }); }); - it('closes when Save button is clicked', async () => { - const { getByText } = renderWithTheme( + it('confirms when Save button is enabled and the dialog closes when Save is clicked', async () => { + const { getByLabelText, getByRole } = renderWithTheme( ); - await fireEvent.click(getByText('Save')); + const saveButton = getByRole('button', { name: 'Save' }); + + expect(saveButton).toBeDisabled(); + + fireEvent.input(getByLabelText('Avatar color picker'), { + target: { value: '#333333' }, + }); + + // Verify save button becomes enabled after changing the color + expect(saveButton).toBeEnabled(); + + await userEvent.click(saveButton); + + // Verify dialog closes after saving expect(mockProps.handleClose).toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx index dd0cca24ccf..52c0c3a4378 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx @@ -51,6 +51,7 @@ export const AvatarColorPickerDialog = ( { if (avatarColor) { diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx index 6c003f183c2..f4305fd948c 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx @@ -9,14 +9,10 @@ import { Avatar } from 'src/components/Avatar/Avatar'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Divider } from 'src/components/Divider'; -import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; -import { Link } from 'src/components/Link'; import { Paper } from 'src/components/Paper'; import { SingleTextFieldForm } from 'src/components/SingleTextFieldForm/SingleTextFieldForm'; -import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; -import { useGravatar } from 'src/hooks/useGravatar'; import { useNotificationsQuery } from 'src/queries/account/notifications'; import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; @@ -24,7 +20,6 @@ import { AvatarColorPickerDialog } from './AvatarColorPickerDialog'; import { TimezoneForm } from './TimezoneForm'; import type { ApplicationState } from 'src/store'; -import { GravatarByEmail } from 'src/components/GravatarByEmail'; export const DisplaySettings = () => { const theme = useTheme(); @@ -39,8 +34,6 @@ export const DisplaySettings = () => { const isProxyUser = profile?.user_type === 'proxy'; - const { hasGravatar } = useGravatar(profile?.email); - const [ isColorPickerDialogOpen, setAvatarColorPickerDialogOpen, @@ -70,15 +63,6 @@ export const DisplaySettings = () => { return updateProfile({ email: newEmail }); }; - const tooltipIconText = ( - <> - Go to gravatar.com and register - an account using the same email address as your Cloud Manager account. - Upload your desired profile image to your Gravatar account and it will be - automatically linked. - - ); - const tooltipForDisabledUsernameField = profile?.restricted ? 'Restricted users cannot update their username. Please contact an account administrator.' : isProxyUser @@ -101,50 +85,22 @@ export const DisplaySettings = () => { }} display="flex" > - - } - avatar={} - height={88} - width={88} - /> +
- {hasGravatar ? 'Profile photo' : 'Avatar'} - {hasGravatar && ( - - )} + Avatar - {hasGravatar - ? 'Create, upload, and manage your globally recognized avatar from a single place with Gravatar.' - : 'Your avatar is automatically generated using the first character of your username.'} + Your avatar is automatically generated using the first character + of your username. - {hasGravatar ? ( - - Manage photo - - ) : ( - - )} + +
@@ -194,20 +150,6 @@ export const DisplaySettings = () => { ); }; -const StyledAddImageLink = styled(Link, { - label: 'StyledAddImageLink', -})(({ theme }) => ({ - '& svg': { - height: '1rem', - left: 6, - position: 'relative', - top: 3, - width: '1rem', - }, - fontFamily: theme.font.bold, - fontSize: '1rem', -})); - const StyledProfileCopy = styled(Typography, { label: 'StyledProfileCopy', })(({ theme }) => ({ @@ -215,11 +157,3 @@ const StyledProfileCopy = styled(Typography, { marginTop: 4, maxWidth: 360, })); - -const StyledTooltipIcon = styled(TooltipIcon, { - label: 'StyledTooltip', -})(() => ({ - '& .MuiTooltip-tooltip': { - minWidth: 350, - }, -})); diff --git a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.test.tsx b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.test.tsx index e3e94fe5e58..d011a5ce24d 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.test.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.test.tsx @@ -53,13 +53,11 @@ describe('Timezone change form', () => { }); it("should include text with the user's current time zone", async () => { - const { getByText } = renderWithTheme( + const { queryByTestId } = renderWithTheme( , { queryClient } ); - - expect(getByText('New York', { exact: false })).toBeInTheDocument(); - expect(getByText('Eastern Time', { exact: false })).toBeInTheDocument(); + expect(queryByTestId('admin-notice')).toHaveTextContent('America/New_York'); }); }); diff --git a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx index 3e871764f68..4b7816d9c90 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx @@ -5,14 +5,12 @@ import * as React from 'react'; import timezones from 'src/assets/timezones/timezones'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Box } from 'src/components/Box'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { CircleProgress } from 'src/components/CircleProgress'; -import Select from 'src/components/EnhancedSelect/Select'; +import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; -import type { Item } from 'src/components/EnhancedSelect/Select'; - interface Props { loggedInAsCustomer: boolean; } @@ -23,6 +21,11 @@ interface Timezone { offset: number; } +export interface TimezoneOption { + label: L; + value: T; +} + export const formatOffset = ({ label, offset }: Timezone) => { const minutes = (Math.abs(offset) % 60).toLocaleString(undefined, { minimumIntegerDigits: 2, @@ -34,7 +37,7 @@ export const formatOffset = ({ label, offset }: Timezone) => { return `\(GMT ${isPositive}${hours}:${minutes}\) ${label}`; }; -const renderTimeZonesList = (): Item[] => { +const renderTimezonesList = (): TimezoneOption[] => { return timezones .map((tz) => ({ ...tz, offset: DateTime.now().setZone(tz.name).offset })) .sort((a, b) => a.offset - b.offset) @@ -44,35 +47,38 @@ const renderTimeZonesList = (): Item[] => { }); }; -const timezoneList = renderTimeZonesList(); +const timezoneList = renderTimezonesList(); export const TimezoneForm = (props: Props) => { const { loggedInAsCustomer } = props; const { enqueueSnackbar } = useSnackbar(); const { data: profile } = useProfile(); + const [timezoneValue, setTimezoneValue] = React.useState< + TimezoneOption | string + >(''); const { error, isPending, mutateAsync: updateProfile } = useMutateProfile(); - const [value, setValue] = React.useState | null>(null); const timezone = profile?.timezone ?? ''; - const handleTimezoneChange = (timezone: Item) => { - setValue(timezone); + const handleTimezoneChange = (timezone: TimezoneOption) => { + setTimezoneValue(timezone); }; const onSubmit = () => { - if (value === null) { + if (timezoneValue === '') { return; } - updateProfile({ timezone: String(value.value) }).then(() => { + updateProfile({ timezone: String(timezoneValue) }).then(() => { enqueueSnackbar('Successfully updated timezone', { variant: 'success' }); }); }; - const defaultTimeZone = timezoneList.find((eachZone) => { + const defaultTimezone = timezoneList.find((eachZone) => { return eachZone.value === timezone; }); - const disabled = value === null || defaultTimeZone?.value === value?.value; + const disabled = + timezoneValue === '' || defaultTimezone?.value === timezoneValue; if (!profile) { return ; @@ -88,27 +94,32 @@ export const TimezoneForm = (props: Props) => { ) : null} - ({ - [theme.breakpoints.down('md')]: { - alignItems: 'flex-start', - flexDirection: 'column', - }, - })} - alignItems="flex-end" - display="flex" - justifyContent="space-between" - > - + ) => setLishAuthMethod(item.value)} textFieldProps={{ dataAttrs: { 'data-qa-mode-select': true, }, tooltipText, }} + value={modeOptions.find( + (option) => option.value === lishAuthMethod + )} defaultValue={defaultMode} + disableClearable errorText={authMethodError} + getOptionDisabled={(option) => option.disabled === true} id="mode-select" - isClearable={false} label="Authentication Mode" - name="mode-select" - onChange={onListAuthMethodChange as any} options={modeOptions} /> diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx index 974e8726b88..db289cf3a6e 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import AddNewLink from 'src/components/AddNewLink'; import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Hidden } from 'src/components/Hidden'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -129,10 +129,12 @@ const OAuthClients = () => { marginBottom={1} paddingRight={{ lg: 0, md: 0, sm: 1, xs: 1 }} > - setIsCreateDrawerOpen(true)} - /> + > + Add an OAuth App + diff --git a/packages/manager/src/features/Profile/Profile.tsx b/packages/manager/src/features/Profile/Profile.tsx index 39351969629..3e618aae6d3 100644 --- a/packages/manager/src/features/Profile/Profile.tsx +++ b/packages/manager/src/features/Profile/Profile.tsx @@ -3,7 +3,9 @@ import { useRouteMatch } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; -import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; +import { NavTabs } from 'src/components/NavTabs/NavTabs'; + +import type { NavTab } from 'src/components/NavTabs/NavTabs'; const SSHKeys = React.lazy(() => import('./SSHKeys/SSHKeys').then((module) => ({ @@ -42,7 +44,7 @@ const APITokens = React.lazy(() => })) ); -const Profile = () => { +export const Profile = () => { const { url } = useRouteMatch(); const tabs: NavTab[] = [ @@ -96,5 +98,3 @@ const Profile = () => { ); }; - -export default Profile; diff --git a/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx b/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx index 91afe803212..622bb9dab1b 100644 --- a/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx @@ -1,8 +1,8 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import AddNewLink from 'src/components/AddNewLink'; +import { Button } from 'src/components/Button/Button'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Hidden } from 'src/components/Hidden'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -108,10 +108,12 @@ export const SSHKeys = () => { spacing={2} > - setIsCreateDrawerOpen(true)} - /> + > + Add an SSH Key +
diff --git a/packages/manager/src/features/Support/ExpandableTicketPanel.tsx b/packages/manager/src/features/Support/ExpandableTicketPanel.tsx index 80d5ab5cd5a..5c6efcea93d 100644 --- a/packages/manager/src/features/Support/ExpandableTicketPanel.tsx +++ b/packages/manager/src/features/Support/ExpandableTicketPanel.tsx @@ -1,13 +1,10 @@ -import Avatar from '@mui/material/Avatar'; import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import UserIcon from 'src/assets/icons/account.svg'; -import { Avatar as NewAvatar } from 'src/components/Avatar/Avatar'; +import { Avatar } from 'src/components/Avatar/Avatar'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; import { Typography } from 'src/components/Typography'; import { useProfile } from 'src/queries/profile/profile'; @@ -143,30 +140,17 @@ export const ExpandableTicketPanel = React.memo((props: Props) => { } }, [parentTicket, reply, ticket, ticketUpdated]); - const renderAvatar = (id: string) => { + const renderAvatar = () => { return (
- - } - gravatar={ - - - +
); @@ -182,12 +166,12 @@ export const ExpandableTicketPanel = React.memo((props: Props) => { return ( - {renderAvatar(data.gravatar_id)} + {renderAvatar()} - {data.friendly_name} + {data.friendly_name === 'Linode' ? 'Akamai' : data.friendly_name} {data.from_linode && !OFFICIAL_USERNAMES.includes(data.username) ? ( )} - setIsCreateDrawerOpen(true)} - /> + > + Add a User +
mockMatchMedia()); const props = { @@ -57,7 +58,9 @@ describe('Subnet Assign Linodes Drawer', () => { const assignButton = getByText('Assign Linode'); expect(assignButton).toBeVisible(); - const alreadyAssigned = getByText('Linodes Assigned to Subnet (0)'); + const alreadyAssigned = getByText( + 'Linodes recently assigned to Subnet (0)' + ); expect(alreadyAssigned).toBeVisible(); const doneButton = getByText('Done'); expect(doneButton).toBeVisible(); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 20827c61afa..8a532231cc0 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -17,7 +17,6 @@ import { RemovableSelectionsListTable } from 'src/components/RemovableSelections import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; -import { defaultPublicInterface } from 'src/features/Linodes/LinodesCreate/LinodeCreate'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP, @@ -29,7 +28,6 @@ import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { getAllLinodeConfigs } from 'src/queries/linodes/requests'; import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; -import { ExtendedIP } from 'src/utilities/ipUtils'; import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; import { @@ -48,6 +46,7 @@ import type { Linode, Subnet, } from '@linode/api-v4'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; // @TODO VPC: if all subnet action menu item related components use (most of) this as their props, might be worth // putting this in a common file and naming it something like SubnetActionMenuItemProps or something @@ -548,7 +547,7 @@ export const SubnetAssignLinodesDrawer = ( handleUnassignLinode(data as LinodeAndConfigData); setUnassignLinodesErrors([]); }} - headerText={`Linodes Assigned to Subnet (${assignedLinodesAndConfigData.length})`} + headerText={`Linodes recently assigned to Subnet (${assignedLinodesAndConfigData.length})`} noDataText={'No Linodes have been assigned.'} preferredDataLabel="linodeConfigLabel" selectionData={assignedLinodesAndConfigData} @@ -580,3 +579,9 @@ export const SubnetAssignLinodesDrawer = ( ); }; + +const defaultPublicInterface: InterfacePayload = { + ipam_address: '', + label: '', + purpose: 'public', +}; diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index aadec8974d9..a9c9ec96df3 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -297,7 +297,7 @@ export const VolumeCreate = () => { const shouldDisplayClientLibraryCopy = isBlockStorageEncryptionFeatureEnabled && - values.linode_id !== null && + linode_id !== null && !linodeSupportsBlockStorageEncryption; return ( @@ -502,7 +502,8 @@ export const VolumeCreate = () => {