From dea5d57d97c3c1d138c2fb7a90fcffbb72b43a78 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 31 Jan 2024 17:11:26 -0500 Subject: [PATCH 01/38] fix: [M3-7722] - Adjust EditableText styling to prevent pixel shift (#10132) * Adjust EditableText styling * Added changeset: EditableText interaction styling --- packages/manager/.changeset/pr-10132-fixed-1706715510568.md | 5 +++++ .../manager/src/components/EditableText/EditableText.tsx | 4 +--- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-10132-fixed-1706715510568.md diff --git a/packages/manager/.changeset/pr-10132-fixed-1706715510568.md b/packages/manager/.changeset/pr-10132-fixed-1706715510568.md new file mode 100644 index 00000000000..f79b4171d3b --- /dev/null +++ b/packages/manager/.changeset/pr-10132-fixed-1706715510568.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +EditableText interaction styling ([#10132](https://github.com/linode/manager/pull/10132)) diff --git a/packages/manager/src/components/EditableText/EditableText.tsx b/packages/manager/src/components/EditableText/EditableText.tsx index ac7af3a117c..f832f04e7a1 100644 --- a/packages/manager/src/components/EditableText/EditableText.tsx +++ b/packages/manager/src/components/EditableText/EditableText.tsx @@ -9,7 +9,6 @@ import { makeStyles } from 'tss-react/mui'; import { Button } from 'src/components/Button/Button'; import { ClickAwayListener } from 'src/components/ClickAwayListener'; import { H1Header } from 'src/components/H1Header/H1Header'; -import { fadeIn } from 'src/styles/keyframes'; import { TextField, TextFieldProps } from '../TextField'; @@ -63,7 +62,7 @@ const useStyles = makeStyles()( color: theme.color.grey1, }, }, - border: '1px solid transparent', + borderLeft: '1px solid transparent', }, input: { fontFamily: theme.font.bold, @@ -92,7 +91,6 @@ const useStyles = makeStyles()( wordBreak: 'break-all', }, textField: { - animation: `${fadeIn} .3s ease-in-out forwards`, margin: 0, }, underlineOnHover: { From 6cf8490520128474c19f00881223cbdb07a78bf0 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Thu, 1 Feb 2024 09:36:30 -0500 Subject: [PATCH 02/38] upcoming: [M3-7660] - Cleanup files to use `profile` to get `user_type` (#10102) Co-authored-by: Jaalah Ramos Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: mjac0bs --- ...r-10102-upcoming-features-1706069240239.md | 5 ++ .../src/features/Account/AccountLanding.tsx | 9 ++-- .../Account/SwitchAccountDrawer.test.tsx | 6 +-- .../SwitchAccounts/ChildAccountList.test.tsx | 7 ++- .../APITokens/CreateAPITokenDrawer.test.tsx | 20 ++++---- .../APITokens/CreateAPITokenDrawer.tsx | 4 +- .../TopMenu/UserMenu/UserMenu.test.tsx | 49 +++++++++++-------- .../features/TopMenu/UserMenu/UserMenu.tsx | 8 ++- packages/manager/src/queries/account.ts | 7 +-- 9 files changed, 59 insertions(+), 56 deletions(-) create mode 100644 packages/manager/.changeset/pr-10102-upcoming-features-1706069240239.md diff --git a/packages/manager/.changeset/pr-10102-upcoming-features-1706069240239.md b/packages/manager/.changeset/pr-10102-upcoming-features-1706069240239.md new file mode 100644 index 00000000000..6d5341a8db8 --- /dev/null +++ b/packages/manager/.changeset/pr-10102-upcoming-features-1706069240239.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Cleanup files to use profile to get user_type ([#10102](https://github.com/linode/manager/pull/10102)) diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index cc5b5007f3b..7764941e854 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -13,7 +13,6 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { useFlags } from 'src/hooks/useFlags'; import { useAccount } from 'src/queries/account'; -import { useAccountUser } from 'src/queries/accountUsers'; import { useGrants, useProfile } from 'src/queries/profile'; import AccountLogins from './AccountLogins'; @@ -48,7 +47,6 @@ const AccountLanding = () => { const { data: account } = useAccount(); const { data: grants } = useGrants(); const { data: profile } = useProfile(); - const { data: user } = useAccountUser(profile?.username ?? ''); const flags = useFlags(); const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); @@ -56,6 +54,8 @@ const AccountLanding = () => { const accountAccessGrant = grants?.global?.account_access; const readOnlyAccountAccess = accountAccessGrant === 'read_only'; const isAkamaiAccount = account?.billing_source === 'akamai'; + const isProxyUser = profile?.user_type === 'proxy'; + const isParentUser = profile?.user_type === 'parent'; const tabs = [ { @@ -117,8 +117,7 @@ const AccountLanding = () => { const isBillingTabSelected = location.pathname.match(/billing/); const canSwitchBetweenParentOrProxyAccount = - flags.parentChildAccountAccess && - (user?.user_type === 'parent' || user?.user_type === 'proxy'); + flags.parentChildAccountAccess && (isParentUser || isProxyUser); const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { @@ -174,7 +173,7 @@ const AccountLanding = () => { setIsDrawerOpen(false)} open={isDrawerOpen} /> diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx index 399872c11da..dcf11e472ce 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx @@ -1,7 +1,7 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { accountUserFactory } from 'src/factories/accountUsers'; +import { profileFactory } from 'src/factories/profile'; import { rest, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -32,8 +32,8 @@ describe('SwitchAccountDrawer', () => { it('should include a link to switch back to the parent account if the active user is a proxy user', async () => { server.use( - rest.get('*/account/users/*', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build({ user_type: 'proxy' }))); + rest.get('*/profile', (req, res, ctx) => { + return res(ctx.json(profileFactory.build({ user_type: 'proxy' }))); }) ); diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx index 63dd01d786f..555a6ee0801 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx @@ -1,8 +1,7 @@ import { waitFor, within } from '@testing-library/react'; import * as React from 'react'; -import { accountFactory } from 'src/factories/account'; -import { accountUserFactory } from 'src/factories/accountUsers'; +import { accountFactory, profileFactory } from 'src/factories'; import { ChildAccountList } from 'src/features/Account/SwitchAccounts/ChildAccountList'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { rest, server } from 'src/mocks/testServer'; @@ -17,8 +16,8 @@ const props = { it('should display a list of child accounts', async () => { server.use( - rest.get('*/account/users/*', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build({ user_type: 'parent' }))); + rest.get('*/profile', (req, res, ctx) => { + return res(ctx.json(profileFactory.build({ user_type: 'parent' }))); }), rest.get('*/account/child-accounts', (req, res, ctx) => { return res( diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index 9dc003fde62..d59543bfcab 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -3,22 +3,22 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { appTokenFactory } from 'src/factories'; -import { accountUserFactory } from 'src/factories/accountUsers'; +import { profileFactory } from 'src/factories/profile'; import { rest, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateAPITokenDrawer } from './CreateAPITokenDrawer'; -// Mock the useAccountUser hooks to immediately return the expected data, circumventing the HTTP request and loading state. +// Mock the useProfile hooks to immediately return the expected data, circumventing the HTTP request and loading state. const queryMocks = vi.hoisted(() => ({ - useAccountUser: vi.fn().mockReturnValue({}), + useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/accountUsers', async () => { - const actual = await vi.importActual('src/queries/accountUsers'); +vi.mock('src/queries/profile', async () => { + const actual = await vi.importActual('src/queries/profile'); return { ...actual, - useAccountUser: queryMocks.useAccountUser, + useProfile: queryMocks.useProfile, }; }); @@ -88,8 +88,8 @@ describe('Create API Token Drawer', () => { }); it('Should show the Child Account Access scope for a parent user account with the parent/child feature flag on', () => { - queryMocks.useAccountUser.mockReturnValue({ - data: accountUserFactory.build({ user_type: 'parent' }), + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ user_type: 'parent' }), }); const { getByText } = renderWithTheme(, { @@ -100,8 +100,8 @@ describe('Create API Token Drawer', () => { }); it('Should not show the Child Account Access scope for a non-parent user account with the parent/child feature flag on', () => { - queryMocks.useAccountUser.mockReturnValue({ - data: accountUserFactory.build({ user_type: null }), + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ user_type: null }), }); const { queryByText } = renderWithTheme( diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index b9bba270482..685b3eaaf1b 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -19,7 +19,6 @@ import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCe import { VPC_READ_ONLY_TOOLTIP } from 'src/features/VPCs/constants'; import { useFlags } from 'src/hooks/useFlags'; import { useAccount } from 'src/queries/account'; -import { useAccountUser } from 'src/queries/accountUsers'; import { useProfile } from 'src/queries/profile'; import { useCreatePersonalAccessTokenMutation } from 'src/queries/tokens'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; @@ -99,7 +98,6 @@ export const CreateAPITokenDrawer = (props: Props) => { const { data: profile } = useProfile(); const { data: account } = useAccount(); - const { data: user } = useAccountUser(profile?.username ?? ''); const { error, @@ -190,7 +188,7 @@ export const CreateAPITokenDrawer = (props: Props) => { const allPermissions = form.values.scopes; const showFilteredPermissions = - (flags.parentChildAccountAccess && user?.user_type !== 'parent') || + (flags.parentChildAccountAccess && profile?.user_type !== 'parent') || Boolean(!flags.parentChildAccountAccess); const filteredPermissions = allPermissions.filter( diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx index 36e89b9fe75..4338a1be4d4 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx @@ -2,7 +2,6 @@ import { fireEvent, within } from '@testing-library/react'; import * as React from 'react'; import { accountFactory, profileFactory } from 'src/factories'; -import { accountUserFactory } from 'src/factories/accountUsers'; import { rest, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; @@ -25,10 +24,14 @@ describe('UserMenu', () => { ); }), rest.get('*/profile', (req, res, ctx) => { - return res(ctx.json(profileFactory.build({ username: 'parent-user' }))); - }), - rest.get('*/account/users/*', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build({ user_type: 'parent' }))); + return res( + ctx.json( + profileFactory.build({ + user_type: 'parent', + username: 'parent-user', + }) + ) + ); }) ); @@ -48,10 +51,14 @@ describe('UserMenu', () => { ); }), rest.get('*/profile', (req, res, ctx) => { - return res(ctx.json(profileFactory.build({ username: 'parent-user' }))); - }), - rest.get('*/account/users/*', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build({ user_type: 'proxy' }))); + return res( + ctx.json( + profileFactory.build({ + user_type: 'proxy', + username: 'parent-user', + }) + ) + ); }) ); @@ -71,10 +78,11 @@ describe('UserMenu', () => { ); }), rest.get('*/profile', (req, res, ctx) => { - return res(ctx.json(profileFactory.build({ username: 'child-user' }))); - }), - rest.get('*/account/users/*', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build({ user_type: 'child' }))); + return res( + ctx.json( + profileFactory.build({ user_type: 'child', username: 'child-user' }) + ) + ); }) ); @@ -93,11 +101,10 @@ describe('UserMenu', () => { }), rest.get('*/profile', (req, res, ctx) => { return res( - ctx.json(profileFactory.build({ username: 'regular-user' })) + ctx.json( + profileFactory.build({ user_type: null, username: 'regular-user' }) + ) ); - }), - rest.get('*/account/users/*', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build({ user_type: null }))); }) ); @@ -116,8 +123,8 @@ describe('UserMenu', () => { ctx.json(accountFactory.build({ company: 'Parent Company' })) ); }), - rest.get('*/account/users/*', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build({ user_type: 'parent' }))); + rest.get('*/profile', (req, res, ctx) => { + return res(ctx.json(profileFactory.build({ user_type: 'parent' }))); }) ); @@ -141,8 +148,8 @@ describe('UserMenu', () => { ctx.json(accountFactory.build({ company: 'Child Company' })) ); }), - rest.get('*/account/users/*', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build({ user_type: 'proxy' }))); + rest.get('*/profile', (req, res, ctx) => { + return res(ctx.json(profileFactory.build({ user_type: 'proxy' }))); }) ); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 8077365171a..31550b96c53 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -20,7 +20,6 @@ import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton'; import { SwitchAccountDrawer } from 'src/features/Account/SwitchAccountDrawer'; import { useFlags } from 'src/hooks/useFlags'; import { useAccount } from 'src/queries/account'; -import { useAccountUser } from 'src/queries/accountUsers'; import { useGrants, useProfile } from 'src/queries/profile'; import { getStorage } from 'src/utilities/storage'; @@ -56,7 +55,6 @@ export const UserMenu = React.memo(() => { const { data: account } = useAccount(); const { data: profile } = useProfile(); - const { data: user } = useAccountUser(profile?.username ?? ''); const { data: grants } = useGrants(); const { enqueueSnackbar } = useSnackbar(); const flags = useFlags(); @@ -67,13 +65,13 @@ export const UserMenu = React.memo(() => { const hasAccountAccess = !isRestrictedUser || hasGrant('account_access'); const hasReadWriteAccountAccess = hasGrant('account_access') === 'read_write'; const hasParentChildAccountAccess = Boolean(flags.parentChildAccountAccess); - const isParentUser = user?.user_type === 'parent'; - const isProxyUser = user?.user_type === 'proxy'; + const isParentUser = profile?.user_type === 'parent'; + const isProxyUser = profile?.user_type === 'proxy'; const canSwitchBetweenParentOrProxyAccount = hasParentChildAccountAccess && (isParentUser || isProxyUser); const open = Boolean(anchorEl); const id = open ? 'user-menu-popover' : undefined; - const companyName = (user?.user_type && account?.company) ?? ''; + const companyName = (profile?.user_type && account?.company) ?? ''; const showCompanyName = hasParentChildAccountAccess && companyName; // Used for fetching parent profile and account data by making a request with the parent's token. diff --git a/packages/manager/src/queries/account.ts b/packages/manager/src/queries/account.ts index 9adb30e6f26..49faf4e5761 100644 --- a/packages/manager/src/queries/account.ts +++ b/packages/manager/src/queries/account.ts @@ -9,7 +9,6 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; import { useGrants, useProfile } from 'src/queries/profile'; -import { useAccountUser } from './accountUsers'; import { queryPresets } from './base'; import type { @@ -54,7 +53,6 @@ export const useChildAccounts = ({ params, }: RequestOptions) => { const { data: profile } = useProfile(); - const { data: user } = useAccountUser(profile?.username ?? ''); const { data: grants } = useGrants(); const hasExplicitAuthToken = Boolean(headers?.Authorization); @@ -63,7 +61,7 @@ export const useChildAccounts = ({ () => getChildAccounts({ filter, headers, params }), { enabled: - (Boolean(user?.user_type === 'parent') && !profile?.restricted) || + (Boolean(profile?.user_type === 'parent') && !profile?.restricted) || Boolean(grants?.global?.child_account_access) || hasExplicitAuthToken, keepPreviousData: true, @@ -73,7 +71,6 @@ export const useChildAccounts = ({ export const useChildAccount = ({ euuid, headers }: ChildAccountPayload) => { const { data: profile } = useProfile(); - const { data: user } = useAccountUser(profile?.username ?? ''); const { data: grants } = useGrants(); const hasExplicitAuthToken = Boolean(headers?.Authorization); @@ -82,7 +79,7 @@ export const useChildAccount = ({ euuid, headers }: ChildAccountPayload) => { () => getChildAccount({ euuid }), { enabled: - (Boolean(user?.user_type === 'parent') && !profile?.restricted) || + (Boolean(profile?.user_type === 'parent') && !profile?.restricted) || Boolean(grants?.global?.child_account_access) || hasExplicitAuthToken, } From 70b52424b76bfdbbe896adddb58fb0228decb6c8 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Thu, 1 Feb 2024 14:22:25 -0700 Subject: [PATCH 03/38] fix: [M3-7728] - Token issues when account switching (#10138) --- .../manager/src/features/Account/SwitchAccountDrawer.tsx | 6 +++--- packages/manager/src/mocks/serverHandlers.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx index 987bff1687b..87e8f3ce37f 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx @@ -62,7 +62,7 @@ export const SwitchAccountDrawer = (props: Props) => { headers: userType === 'proxy' ? { - Authorization: `Bearer ${token}`, + Authorization: token, } : undefined, }); @@ -105,8 +105,8 @@ export const SwitchAccountDrawer = (props: Props) => { // We don't need to worry about this if we're a proxy user. if (!isProxyUser) { const parentToken = { - expiry: getStorage('authenication/expire'), - scopes: getStorage('authenication/scopes'), + expiry: getStorage('authentication/expire'), + scopes: getStorage('authentication/scopes'), token: currentTokenWithBearer ?? '', }; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 8bc3ef43c29..6d2552aa742 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -552,7 +552,7 @@ export const handlers = [ const profile = profileFactory.build({ restricted: false, // Parent/Child: switch the `user_type` depending on what account view you need to mock. - user_type: 'proxy', + user_type: 'parent', }); return res(ctx.json(profile)); }), From 460012d9c765017b230ce79a53336d38aee79aba Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 6 Feb 2024 09:13:15 -0500 Subject: [PATCH 04/38] fix: [M3-7725] - Unit tests button enabled assertions (#10142) * Save progress * Wrap up interceptor logic * Added changeset: Unit tests Button enabled assertions --- .../pr-10142-fixed-1707158870254.md | 5 ++ packages/manager/src/testSetup.ts | 75 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10142-fixed-1707158870254.md diff --git a/packages/manager/.changeset/pr-10142-fixed-1707158870254.md b/packages/manager/.changeset/pr-10142-fixed-1707158870254.md new file mode 100644 index 00000000000..8b07c826f38 --- /dev/null +++ b/packages/manager/.changeset/pr-10142-fixed-1707158870254.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Unit tests Button enabled assertions ([#10142](https://github.com/linode/manager/pull/10142)) diff --git a/packages/manager/src/testSetup.ts b/packages/manager/src/testSetup.ts index e7d6f33aa01..1660a6821b6 100644 --- a/packages/manager/src/testSetup.ts +++ b/packages/manager/src/testSetup.ts @@ -1,9 +1,8 @@ +import matchers from '@testing-library/jest-dom/matchers'; import Enzyme from 'enzyme'; // @ts-expect-error not a big deal, we can suffer import Adapter from 'enzyme-adapter-react-16'; - import { expect } from 'vitest'; -import matchers from '@testing-library/jest-dom/matchers'; // // Enzyme React 17 adapter. // Enzyme.configure({ adapter: new Adapter() }); @@ -60,3 +59,75 @@ vi.mock('highlight.js/lib/highlight', () => ({ registerLanguage: vi.fn(), }, })); + +/** + *************************************** + * Custom matchers & matchers overrides + *************************************** + */ + +/** + * Matcher override for toBeDisabled and toBeEnabled + * + * The reason for overriding those matchers is that we need to check for the aria-disabled attribute as well. + * When a button is disabled, it will not necessarily have the `disabled` attribute. but it will have an aria-disabled attribute set to true. + */ +const ariaDisabledAttribute = 'aria-disabled'; + +const isElementDisabled = (element: HTMLElement) => { + // We really only want to check for `aria-disabled` on buttons since this is a Cloud Manager customization + return element.tagName.toLowerCase() === 'button' + ? element.getAttribute(ariaDisabledAttribute) === 'true' || + element.hasAttribute('disabled') + : element.hasAttribute('disabled'); +}; + +interface HandleResult { + condition: boolean; + element: HTMLElement; + expectedState: 'disabled' | 'enabled'; + thisInstance: any; +} + +const handleResult = ({ + condition, + element, + expectedState, + thisInstance, +}: HandleResult) => { + const message = `${thisInstance?.utils?.printReceived( + element ?? '' + )}\n\n expected ${element?.tagName} to be ${expectedState}`; + return condition + ? { + message: () => '', + pass: true, + } + : { + message: () => message, + pass: false, + }; +}; + +expect.extend({ + toBeDisabled(this: any, element: HTMLElement) { + const isDisabled = isElementDisabled(element); + + return handleResult({ + condition: isDisabled, + element, + expectedState: 'disabled', + thisInstance: this, + }); + }, + toBeEnabled(this: any, element: HTMLElement) { + const isEnabled = !isElementDisabled(element); + + return handleResult({ + condition: isEnabled, + element, + expectedState: 'enabled', + thisInstance: this, + }); + }, +}); From b8478640a44efd8996d8bceddf6ce87a53348afd Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 6 Feb 2024 11:19:15 -0500 Subject: [PATCH 05/38] Tech Story: [M3-7360] - DC Get Well - Cleanup/Remove feature flag logic (#10146) * Cleanup feature flag data * Added changeset: DC Get Well - Cleanup/Remove feature flag logic --- .../.changeset/pr-10146-tech-stories-1707162412490.md | 5 +++++ .../src/components/RegionSelect/RegionMultiSelect.tsx | 8 ++------ .../manager/src/components/RegionSelect/RegionOption.tsx | 5 +---- .../manager/src/components/RegionSelect/RegionSelect.tsx | 8 ++------ packages/manager/src/dev-tools/FeatureFlagTool.tsx | 1 - packages/manager/src/featureFlags.ts | 1 - 6 files changed, 10 insertions(+), 18 deletions(-) create mode 100644 packages/manager/.changeset/pr-10146-tech-stories-1707162412490.md diff --git a/packages/manager/.changeset/pr-10146-tech-stories-1707162412490.md b/packages/manager/.changeset/pr-10146-tech-stories-1707162412490.md new file mode 100644 index 00000000000..8e5fc42a957 --- /dev/null +++ b/packages/manager/.changeset/pr-10146-tech-stories-1707162412490.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +DC Get Well - Cleanup/Remove feature flag logic ([#10146](https://github.com/linode/manager/pull/10146)) diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index b9acc2d0a1e..4b37ee42435 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { StyledListItem } from 'src/components/Autocomplete/Autocomplete.styles'; -import { useFlags } from 'src/hooks/useFlags'; import { useAccountAvailabilitiesQueryUnpaginated } from 'src/queries/accountAvailability'; import { RegionOption } from './RegionOption'; @@ -36,11 +35,10 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { width, } = props; - const flags = useFlags(); const { data: accountAvailability, isLoading: accountAvailabilityLoading, - } = useAccountAvailabilitiesQueryUnpaginated(flags.dcGetWell); + } = useAccountAvailabilitiesQueryUnpaginated(); const [selectedRegions, setSelectedRegions] = useState( getSelectedRegionsByIds({ @@ -93,9 +91,6 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { <> - Boolean(flags.dcGetWell) && Boolean(option.unavailable) - } groupBy={(option: RegionSelectOption) => { return option?.data?.region; }} @@ -134,6 +129,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { disableClearable={!isClearable} disabled={disabled} errorText={errorText} + getOptionDisabled={(option: RegionSelectOption) => option.unavailable} label={label ?? 'Regions'} loading={accountAvailabilityLoading} multiple diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index 5b4279666b5..38fe2feaccd 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -5,7 +5,6 @@ import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; import { Link } from 'src/components/Link'; import { Tooltip } from 'src/components/Tooltip'; -import { useFlags } from 'src/hooks/useFlags'; import { SelectedIcon, @@ -23,9 +22,7 @@ type Props = { }; export const RegionOption = ({ option, props, selected }: Props) => { - const flags = useFlags(); - const isDisabledMenuItem = - Boolean(flags.dcGetWell) && Boolean(option.unavailable); + const isDisabledMenuItem = option.unavailable; return ( { width, } = props; - const flags = useFlags(); const { data: accountAvailability, isLoading: accountAvailabilityLoading, - } = useAccountAvailabilitiesQueryUnpaginated(flags.dcGetWell); + } = useAccountAvailabilitiesQueryUnpaginated(); const regionFromSelectedId: RegionSelectOption | null = getSelectedRegionById({ @@ -84,9 +82,6 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { return ( - Boolean(flags.dcGetWell) && Boolean(option.unavailable) - } isOptionEqualToValue={( option: RegionSelectOption, { value }: RegionSelectOption @@ -127,6 +122,7 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { disableClearable={!isClearable} disabled={disabled} errorText={errorText} + getOptionDisabled={(option: RegionSelectOption) => option.unavailable} groupBy={(option: RegionSelectOption) => option.data.region} label={label ?? 'Region'} loading={accountAvailabilityLoading} diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index af174364a63..7a5b7586832 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -14,7 +14,6 @@ const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; const options: { flag: keyof Flags; label: string }[] = [ { flag: 'aclb', label: 'ACLB' }, { flag: 'aclbFullCreateFlow', label: 'ACLB Full Create Flow' }, - { flag: 'dcGetWell', label: 'DC Get Well' }, { flag: 'linodeCloneUIChanges', label: 'Linode Clone UI Changes' }, { flag: 'metadata', label: 'Metadata' }, { flag: 'parentChildAccountAccess', label: 'Parent/Child Account' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 0c2127f8336..c622dda8443 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -46,7 +46,6 @@ export interface Flags { databaseBeta: boolean; databaseScaleUp: boolean; databases: boolean; - dcGetWell: boolean; firewallNodebalancer: boolean; ipv6Sharing: boolean; linodeCloneUIChanges: boolean; From a381824c85e34534fdc19b2dbf9d54b21d91f5e4 Mon Sep 17 00:00:00 2001 From: carrillo-erik <119514965+carrillo-erik@users.noreply.github.com> Date: Tue, 6 Feb 2024 11:06:48 -0800 Subject: [PATCH 06/38] upcoming: [M3-7622] - Create Select component Placement Groups (#10100) * upcoming: [M3-7622]: Start working on shell for PG select * Remove boilerplate and map data flow * testing something new * placement group select label * Add logic to filter PGs based on region and error messages * refactor placement group and region logic * Change error message logic * Fix init state bug and add unit tests * Refactor based on PR feedback * Remove Notice component from select component * Update unit test * Remove redundant utils file * Add changeset * Improve and cleanup PR * Remove comments and local state for errorText * Refactor * Remove value prop * Add tooltip icon and change no options message --------- Co-authored-by: Alban Bailly --- ...r-10100-upcoming-features-1706825389536.md | 5 + .../LabelAndTagsPanel/LabelAndTagsPanel.tsx | 69 ++++++++++- .../PlacementGroupsSelect.test.tsx | 31 +++++ .../PlacementGroupsSelect.tsx | 110 ++++++++++++++++++ .../Linodes/LinodesCreate/LinodeCreate.tsx | 38 +++++- .../LinodesCreate/LinodeCreateContainer.tsx | 24 ++-- .../manager/src/queries/placementGroups.ts | 14 +++ 7 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 packages/manager/.changeset/pr-10100-upcoming-features-1706825389536.md create mode 100644 packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx create mode 100644 packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx diff --git a/packages/manager/.changeset/pr-10100-upcoming-features-1706825389536.md b/packages/manager/.changeset/pr-10100-upcoming-features-1706825389536.md new file mode 100644 index 00000000000..e820b5df98c --- /dev/null +++ b/packages/manager/.changeset/pr-10100-upcoming-features-1706825389536.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Create Placement Groups Select component ([#10100](https://github.com/linode/manager/pull/10100)) diff --git a/packages/manager/src/components/LabelAndTagsPanel/LabelAndTagsPanel.tsx b/packages/manager/src/components/LabelAndTagsPanel/LabelAndTagsPanel.tsx index abe3a9e89cb..a733093124d 100644 --- a/packages/manager/src/components/LabelAndTagsPanel/LabelAndTagsPanel.tsx +++ b/packages/manager/src/components/LabelAndTagsPanel/LabelAndTagsPanel.tsx @@ -1,20 +1,46 @@ +import { Region } from '@linode/api-v4/lib/regions'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; +import { Box } from 'src/components/Box'; import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { + PlacementGroupsSelect, + PlacementGroupsSelectProps, +} from 'src/components/PlacementGroupsSelect/PlacementGroupsSelect'; import { TagsInput, TagsInputProps } from 'src/components/TagsInput/TagsInput'; import { TextField, TextFieldProps } from 'src/components/TextField'; -import { Paper } from 'src/components/Paper'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; + +{ + /* TODO VM_Placement: 'Learn more' Link */ +} +const tooltipText = ` +Add your virtual machine (VM) to a group to best meet your needs. +You may want to group VMs closer together to help improve performance, or further apart to enable high-availability configurations. +Learn more.`; interface LabelAndTagsProps { error?: string; labelFieldProps?: TextFieldProps; + placementGroupsSelectProps?: PlacementGroupsSelectProps; + regions?: Region[]; tagsInputProps?: TagsInputProps; } export const LabelAndTagsPanel = (props: LabelAndTagsProps) => { const theme = useTheme(); - const { error, labelFieldProps, tagsInputProps } = props; + const flags = useFlags(); + const showPlacementGroups = Boolean(flags.vmPlacement); + const { + error, + labelFieldProps, + placementGroupsSelectProps, + tagsInputProps, + } = props; return ( { noMarginTop /> {tagsInputProps && } + {showPlacementGroups && ( + <> + {!placementGroupsSelectProps?.selectedRegionId && ( + + + Select a region above to see available Placement Groups. + + + )} + {placementGroupsSelectProps && ( + + + + + )} + + )} ); }; diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx new file mode 100644 index 00000000000..54a2180817c --- /dev/null +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { + PlacementGroupsSelect, + PlacementGroupsSelectProps, +} from './PlacementGroupsSelect'; + +const props: PlacementGroupsSelectProps = { + errorText: '', + handlePlacementGroupSelection: vi.fn(), + id: '', + label: 'Placement Groups in Atlanta, GA (us-southeast)', + noOptionsMessage: '', + selectedRegionId: 'us-southeast', +}; + +describe('PlacementGroupSelect', () => { + it('should render a Select component', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('placement-groups-select')).toBeInTheDocument(); + }); + + it('should render a Select component with the correct label', () => { + const { getByText } = renderWithTheme(); + expect(getByText(/Placement Groups in /)).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx new file mode 100644 index 00000000000..a3cca9d5582 --- /dev/null +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx @@ -0,0 +1,110 @@ +import { PlacementGroup } from '@linode/api-v4'; +import { APIError } from '@linode/api-v4/lib/types'; +import { SxProps } from '@mui/system'; +import * as React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { useUnpaginatedPlacementGroupsQuery } from 'src/queries/placementGroups'; + +export interface PlacementGroupsSelectProps { + clearable?: boolean; + disabled?: boolean; + errorText?: string; + handlePlacementGroupSelection: (selected: PlacementGroup) => void; + id?: string; + label: string; + loading?: boolean; + noOptionsMessage?: string; + onBlur?: (e: React.FocusEvent) => void; + renderOption?: ( + placementGroup: PlacementGroup, + selected: boolean + ) => JSX.Element; + renderOptionLabel?: (placementGroups: PlacementGroup) => string; + selectedRegionId?: string; + sx?: SxProps; +} + +export const PlacementGroupsSelect = (props: PlacementGroupsSelectProps) => { + const { + clearable = true, + disabled, + errorText, + handlePlacementGroupSelection, + id, + label, + loading, + noOptionsMessage, + onBlur, + renderOption, + renderOptionLabel, + selectedRegionId, + sx, + } = props; + + const { + data: placementGroups, + error, + isLoading, + } = useUnpaginatedPlacementGroupsQuery(Boolean(selectedRegionId)); + + const placementGroupsOptions = placementGroups?.filter( + (placementGroup) => placementGroup.region === selectedRegionId + ); + + const handlePlacementGroupChange = (selection: PlacementGroup) => { + handlePlacementGroupSelection(selection); + }; + + return ( + + renderOptionLabel + ? renderOptionLabel(placementGroupsOptions) + : `${placementGroupsOptions.label} (${placementGroupsOptions.affinity_type})` + } + noOptionsText={ + noOptionsMessage ?? getDefaultNoOptionsMessage(error, isLoading) + } + onChange={(_, selectedOption: PlacementGroup) => { + handlePlacementGroupChange(selectedOption); + }} + renderOption={ + renderOption + ? (props, option, { selected }) => { + return ( +
  • + {renderOption(option, selected)} +
  • + ); + } + : undefined + } + clearOnBlur={false} + data-testid="placement-groups-select" + disableClearable={!clearable} + disabled={disabled} + errorText={errorText} + id={id} + key={selectedRegionId} + label={label} + loading={isLoading || loading} + onBlur={onBlur} + options={placementGroupsOptions ?? []} + placeholder="Select a Placement Group" + sx={sx} + /> + ); +}; + +const getDefaultNoOptionsMessage = ( + error: APIError[] | null, + loading: boolean +) => { + if (error) { + return 'An error occurred while fetching your Placement Groups'; + } + return loading + ? 'Loading your Placement Groups...' + : 'No available Placement Groups'; +}; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 1f7b58a7e4b..265c82317e0 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -1,3 +1,4 @@ +import { PlacementGroup } from '@linode/api-v4'; import { InterfacePayload, PriceObject, @@ -38,12 +39,13 @@ import { WithTypesProps } from 'src/containers/types.container'; import { FeatureFlagConsumerProps } from 'src/containers/withFeatureFlagConsumer.container'; import { WithLinodesProps } from 'src/containers/withLinodes.container'; import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox'; +import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utilities'; import { getMonthlyAndHourlyNodePricing, utoa, } from 'src/features/Linodes/LinodesCreate/utilities'; -import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utilities'; import { SMTPRestrictionText } from 'src/features/Linodes/SMTPRestrictionText'; +import { getPlacementGroupLinodeCount } from 'src/features/PlacementGroups/utils'; import { getCommunityStackscripts, getMineAndAccountStackScripts, @@ -117,6 +119,7 @@ export interface LinodeCreateProps { imageDisplayInfo: Info; ipamAddress: null | string; label: string; + placementGroupSelection?: PlacementGroup; regionDisplayInfo: Info; resetCreationState: () => void; selectedSubnetId?: number; @@ -137,6 +140,7 @@ export interface LinodeCreateProps { updateLabel: (label: string) => void; updateLinodeID: (id: number, diskSize?: number | undefined) => void; updatePassword: (password: string) => void; + updatePlacementGroupSelection: (placementGroup: PlacementGroup) => void; updateTags: (tags: Tag[]) => void; updateUserData: (userData: string) => void; userData: string | undefined; @@ -274,6 +278,7 @@ export class LinodeCreate extends React.PureComponent< linodesData, linodesError, linodesLoading, + placementGroupSelection, regionDisplayInfo, regionsData, regionsError, @@ -289,6 +294,7 @@ export class LinodeCreate extends React.PureComponent< typesError, typesLoading, updateLabel, + updatePlacementGroupSelection, updateTags, updateUserData, userCannotCreateLinode, @@ -312,6 +318,18 @@ export class LinodeCreate extends React.PureComponent< return null; } + { + /* TODO VM_Placement: Refactor this into a util method */ + } + const regionLabel = regionsData?.find((r) => r.id === selectedRegionID) + ?.label; + let placementGroupsLabel; + if (selectedRegionID && regionLabel) { + placementGroupsLabel = `Placement Groups in ${regionLabel} (${selectedRegionID})`; + } else { + placementGroupsLabel = 'Placement Group'; + } + const tagsInputProps = { disabled: userCannotCreateLinode, onChange: updateTags, @@ -319,6 +337,15 @@ export class LinodeCreate extends React.PureComponent< value: tags || [], }; + let errorText; + if ( + placementGroupSelection && + getPlacementGroupLinodeCount(placementGroupSelection) >= + placementGroupSelection.capacity + ) { + errorText = `This Placement Group doesn't have any capacity`; + } + const hasBackups = Boolean( this.props.backupsEnabled || accountBackupsEnabled ); @@ -610,12 +637,21 @@ export class LinodeCreate extends React.PureComponent< onChange: (e) => updateLabel(e.target.value), value: label || '', }} + placementGroupsSelectProps={{ + disabled: !selectedRegionID, + errorText, + handlePlacementGroupSelection: updatePlacementGroupSelection, + label: placementGroupsLabel, + noOptionsMessage: 'There are no Placement Groups in this region', + selectedRegionId: selectedRegionID, + }} tagsInputProps={ this.props.createType !== 'fromLinode' ? tagsInputProps : undefined } data-qa-label-and-tags-panel + regions={regionsData!} /> {/* Hide for backups and clone */} {!['fromBackup', 'fromLinode'].includes(this.props.createType) && ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index 72ea5e286bb..84863f4f2f7 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -1,3 +1,4 @@ +import { PlacementGroup } from '@linode/api-v4'; import { Agreements, signAgreement } from '@linode/api-v4/lib/account'; import { Image } from '@linode/api-v4/lib/images'; import { Region } from '@linode/api-v4/lib/regions'; @@ -105,6 +106,7 @@ interface State { errors?: APIError[]; formIsSubmitting: boolean; password: string; + placementGroupSelection?: PlacementGroup; privateIPEnabled: boolean; selectedBackupID?: number; selectedDiskSize?: number; @@ -154,6 +156,7 @@ const defaultState: State = { errors: undefined, formIsSubmitting: false, password: '', + placementGroupSelection: undefined, privateIPEnabled: false, selectedBackupID: undefined, selectedDiskSize: undefined, @@ -309,6 +312,7 @@ class LinodeCreateContainer extends React.PureComponent { updateLabel={this.updateCustomLabel} updateLinodeID={this.setLinodeID} updatePassword={this.setPassword} + updatePlacementGroupSelection={this.setPlacementGroupSelection} updateRegionID={this.setRegionID} updateStackScript={this.setStackScript} updateTags={this.setTags} @@ -596,6 +600,10 @@ class LinodeCreateContainer extends React.PureComponent { setPassword = (password: string) => this.setState({ password }); + setPlacementGroupSelection = (placementGroupSelection: PlacementGroup) => { + this.setState({ placementGroupSelection }); + }; + setRegionID = (selectedRegionId: string) => { const { showGDPRCheckbox } = getGDPRDetails({ agreements: this.props.agreements?.data, @@ -690,10 +698,10 @@ class LinodeCreateContainer extends React.PureComponent { selectedTypeID: this.params.typeID, showGDPRCheckbox: Boolean( !this.props.profile.data?.restricted && - isEURegion( - getSelectedRegionGroup(this.props.regionsData, this.params.regionID) - ) && - this.props.agreements?.data?.eu_model + isEURegion( + getSelectedRegionGroup(this.props.regionsData, this.params.regionID) + ) && + this.props.agreements?.data?.eu_model ), signedAgreement: false, }; @@ -825,10 +833,10 @@ class LinodeCreateContainer extends React.PureComponent { const request = createType === 'fromLinode' ? () => - this.props.linodeActions.cloneLinode({ - sourceLinodeId: linodeID!, - ...payload, - }) + this.props.linodeActions.cloneLinode({ + sourceLinodeId: linodeID!, + ...payload, + }) : () => this.props.linodeActions.createLinode(payload); this.setState({ formIsSubmitting: true }); diff --git a/packages/manager/src/queries/placementGroups.ts b/packages/manager/src/queries/placementGroups.ts index 680e1adfe79..de219030295 100644 --- a/packages/manager/src/queries/placementGroups.ts +++ b/packages/manager/src/queries/placementGroups.ts @@ -15,6 +15,8 @@ import { } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { getAll } from 'src/utilities/getAll'; + import { queryKey as PROFILE_QUERY_KEY } from './profile'; import type { @@ -25,6 +27,18 @@ import type { export const queryKey = 'placement-groups'; +export const useUnpaginatedPlacementGroupsQuery = (enabled = true) => + useQuery({ + enabled, + queryFn: () => getAllPlacementGroupsRequest(), + queryKey: [queryKey, 'all'], + }); + +const getAllPlacementGroupsRequest = () => + getAll((params, filters) => + getPlacementGroups(params, filters) + )().then((data) => data.data); + export const usePlacementGroupsQuery = ( params: Params, filter: Filter, From 418f4f65e9c57517fdf48012bd055a1ad88a18c8 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:01:43 -0500 Subject: [PATCH 07/38] test: [M3-7737] - Resolve Billing Contact Cypress failure (#10150) * Resolve failure by narrowing text selection to billing contact section and drawer --- .../pr-10150-tests-1707236804326.md | 5 + .../e2e/core/billing/billing-contact.spec.ts | 132 ++++++++++-------- 2 files changed, 75 insertions(+), 62 deletions(-) create mode 100644 packages/manager/.changeset/pr-10150-tests-1707236804326.md diff --git a/packages/manager/.changeset/pr-10150-tests-1707236804326.md b/packages/manager/.changeset/pr-10150-tests-1707236804326.md new file mode 100644 index 00000000000..ffee439421b --- /dev/null +++ b/packages/manager/.changeset/pr-10150-tests-1707236804326.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix billing contact Cypress test by narrowing element selection scope ([#10150](https://github.com/linode/manager/pull/10150)) diff --git a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts index be8fd90997c..0db765463af 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts @@ -1,6 +1,7 @@ import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories/account'; import type { Account } from '@linode/api-v4'; +import { ui } from 'support/ui'; /* eslint-disable sonarjs/no-duplicate-string */ const accountData = accountFactory.build({ @@ -66,75 +67,82 @@ describe('Billing Contact', () => { // mock the user's account data and confirm that it is displayed correctly upon page load mockGetAccount(accountData).as('getAccount'); cy.visitWithLogin('/account/billing'); - checkAccountContactDisplay(accountData); // edit the billing contact information mockUpdateAccount(newAccountData).as('updateAccount'); cy.get('[data-qa-contact-summary]').within((_contact) => { + checkAccountContactDisplay(accountData); cy.findByText('Edit').should('be.visible').click(); }); - // check drawer is visible - cy.findByLabelText('First Name') - .should('be.visible') - .click() - .clear() - .type(newAccountData['first_name']); - cy.findByLabelText('Last Name') - .should('be.visible') - .click() - .clear() - .type(newAccountData['last_name']); - cy.findByLabelText('Company Name') - .should('be.visible') - .click() - .clear() - .type(newAccountData['company']); - cy.findByLabelText('Address') - .should('be.visible') - .click() - .clear() - .type(newAccountData['address_1']); - cy.findByLabelText('Address 2') - .should('be.visible') - .click() - .clear() - .type(newAccountData['address_2']); - cy.findByLabelText('Email (required)') - .should('be.visible') - .click() - .clear() - .type(newAccountData['email']); - cy.findByLabelText('City') - .should('be.visible') - .click() - .clear() - .type(newAccountData['city']); - cy.findByLabelText('Postal Code') - .should('be.visible') - .click() - .clear() - .type(newAccountData['zip']); - cy.findByLabelText('Phone') - .should('be.visible') - .click() - .clear() - .type(newAccountData['phone']); - cy.get('[data-qa-contact-country]').click().type('United States{enter}'); - cy.get('[data-qa-contact-state-province]') - .should('be.visible') - .click() - .type(`${newAccountData['state']}{enter}`); - cy.findByLabelText('Tax ID') + + ui.drawer + .findByTitle('Edit Billing Contact Info') .should('be.visible') - .click() - .clear() - .type(newAccountData['tax_id']); - cy.get('[data-qa-save-contact-info="true"]') - .click() - .then(() => { - cy.wait('@updateAccount').then((xhr) => { - expect(xhr.response?.body).to.eql(newAccountData); - }); + .within(() => { + cy.findByLabelText('First Name') + .should('be.visible') + .click() + .clear() + .type(newAccountData['first_name']); + cy.findByLabelText('Last Name') + .should('be.visible') + .click() + .clear() + .type(newAccountData['last_name']); + cy.findByLabelText('Company Name') + .should('be.visible') + .click() + .clear() + .type(newAccountData['company']); + cy.findByLabelText('Address') + .should('be.visible') + .click() + .clear() + .type(newAccountData['address_1']); + cy.findByLabelText('Address 2') + .should('be.visible') + .click() + .clear() + .type(newAccountData['address_2']); + cy.findByLabelText('Email (required)') + .should('be.visible') + .click() + .clear() + .type(newAccountData['email']); + cy.findByLabelText('City') + .should('be.visible') + .click() + .clear() + .type(newAccountData['city']); + cy.findByLabelText('Postal Code') + .should('be.visible') + .click() + .clear() + .type(newAccountData['zip']); + cy.findByLabelText('Phone') + .should('be.visible') + .click() + .clear() + .type(newAccountData['phone']); + cy.get('[data-qa-contact-country]') + .click() + .type('United States{enter}'); + cy.get('[data-qa-contact-state-province]') + .should('be.visible') + .click() + .type(`${newAccountData['state']}{enter}`); + cy.findByLabelText('Tax ID') + .should('be.visible') + .click() + .clear() + .type(newAccountData['tax_id']); + cy.get('[data-qa-save-contact-info="true"]') + .click() + .then(() => { + cy.wait('@updateAccount').then((xhr) => { + expect(xhr.response?.body).to.eql(newAccountData); + }); + }); }); // check the page updates to reflect the edits From 3bb847535f0a5fc8be803b628ab9a59097ffe9e7 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 7 Feb 2024 09:35:18 -0500 Subject: [PATCH 08/38] upcoming: [M3-7612] Placement Group Linodes List (#10123) * Initial commit - save work * Post rebase fixes * Formatting and styling * Cleanup and sorting improvements * Cleanup and sorting improvements * Adding unit tests * Cleanup * Added changeset: Placement GroupLinode List * Simplify logic - avoid useEffect * Feedback --- ...r-10123-upcoming-features-1706733948755.md | 5 + .../src/components/ErrorState/ErrorState.tsx | 15 +-- .../manager/src/factories/placementGroups.ts | 2 + .../PlacementGroupsDetail.test.tsx | 2 +- .../PlacementGroupsDetail.tsx | 6 +- .../PlacementGroupsLinodes.test.tsx | 53 +++++++++ .../PlacementGroupsLinodes.tsx | 111 ++++++++++++++++++ .../PlacementGroupsLinodesTable.test.tsx | 49 ++++++++ .../PlacementGroupsLinodesTable.tsx | 106 +++++++++++++++++ .../PlacementGroupsLinodesTableRow.test.tsx | 26 ++++ .../PlacementGroupsLinodesTableRow.tsx | 45 +++++++ .../PlacementGroupsDrawerContent.tsx | 4 +- .../PlacementGroupsLanding.tsx | 9 +- .../src/features/PlacementGroups/constants.ts | 6 + .../src/features/PlacementGroups/utils.ts | 11 ++ packages/manager/src/mocks/serverHandlers.ts | 15 +++ 16 files changed, 451 insertions(+), 14 deletions(-) create mode 100644 packages/manager/.changeset/pr-10123-upcoming-features-1706733948755.md create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.test.tsx create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.tsx create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx diff --git a/packages/manager/.changeset/pr-10123-upcoming-features-1706733948755.md b/packages/manager/.changeset/pr-10123-upcoming-features-1706733948755.md new file mode 100644 index 00000000000..5659eb29f6f --- /dev/null +++ b/packages/manager/.changeset/pr-10123-upcoming-features-1706733948755.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Placement Group Linodes List ([#10123](https://github.com/linode/manager/pull/10123)) diff --git a/packages/manager/src/components/ErrorState/ErrorState.tsx b/packages/manager/src/components/ErrorState/ErrorState.tsx index f8566b4c5a4..11f007ddc55 100644 --- a/packages/manager/src/components/ErrorState/ErrorState.tsx +++ b/packages/manager/src/components/ErrorState/ErrorState.tsx @@ -76,10 +76,11 @@ const StyledIconContainer = styled('div')({ textAlign: 'center', }); -const ErrorStateRoot = styled(Grid)>( - ({ theme, ...props }) => ({ - marginLeft: 0, - padding: props.compact ? theme.spacing(5) : theme.spacing(10), - width: '100%', - }) -); +const ErrorStateRoot = styled(Grid, { + label: 'ErrorStateRoot', + shouldForwardProp: (prop) => prop !== 'compact', +})>(({ theme, ...props }) => ({ + marginLeft: 0, + padding: props.compact ? theme.spacing(5) : theme.spacing(10), + width: '100%', +})); diff --git a/packages/manager/src/factories/placementGroups.ts b/packages/manager/src/factories/placementGroups.ts index d1fda5e4fee..ba425ccde10 100644 --- a/packages/manager/src/factories/placementGroups.ts +++ b/packages/manager/src/factories/placementGroups.ts @@ -14,9 +14,11 @@ export const placementGroupFactory = Factory.Sync.makeFactory({ id: Factory.each((id) => id), label: Factory.each((id) => `pg-${id}`), linode_ids: Factory.each(() => [ + 0, pickRandom([1, 2, 3]), pickRandom([4, 5, 6]), pickRandom([7, 8, 9]), + 43, ]), region: Factory.each(() => pickRandom(['us-east', 'us-southeast', 'ca-central']) diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx index b308ec34c2a..985f36349a7 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx @@ -64,6 +64,6 @@ describe('PlacementGroupsLanding', () => { expect(getByText(/my first pg \(Anti-affinity\)/i)).toBeInTheDocument(); expect(getByText(/docs/i)).toBeInTheDocument(); expect(getByRole('tab', { name: 'Summary' })).toBeInTheDocument(); - expect(getByRole('tab', { name: 'Linodes (3)' })).toBeInTheDocument(); + expect(getByRole('tab', { name: 'Linodes (5)' })).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx index 487d85218c3..481c25e4e5f 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx @@ -19,6 +19,7 @@ import { import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { getPlacementGroupLinodeCount } from '../utils'; +import { PlacementGroupsLinodes } from './PlacementGroupsLinodes/PlacementGroupsLinodes'; export const PlacementGroupsDetail = () => { const flags = useFlags(); @@ -106,10 +107,11 @@ export const PlacementGroupsDetail = () => { onChange={(i) => history.push(tabs[i].routeName)} > - TODO VM_Placement: summary - TODO VM_Placement: linode list + + + diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.test.tsx new file mode 100644 index 00000000000..7406f8e0945 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.test.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; + +import { placementGroupFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { PLACEMENT_GROUP_LINODES_ERROR_MESSAGE } from '../../constants'; +import { PlacementGroupsLinodes } from './PlacementGroupsLinodes'; + +describe('PlacementGroupsLanding', () => { + it('renders an error state if placement groups are undefined', () => { + const { getByText } = renderWithTheme( + + ); + + expect( + getByText(PLACEMENT_GROUP_LINODES_ERROR_MESSAGE) + ).toBeInTheDocument(); + }); + + it('features the linodes table, a filter field, a create button and a docs link', () => { + const placementGroup = placementGroupFactory.build({ + capacity: 2, + linode_ids: [1], + }); + + const { getByPlaceholderText, getByRole, getByTestId } = renderWithTheme( + + ); + + expect(getByTestId('add-linode-to-placement-group-button')).toHaveAttribute( + 'aria-disabled', + 'false' + ); + expect(getByPlaceholderText('Search Linodes')).toBeInTheDocument(); + expect(getByRole('table')).toBeInTheDocument(); + }); + + it('has a disabled create button if the placement group has reached capacity', () => { + const placementGroup = placementGroupFactory.build({ + capacity: 1, + linode_ids: [1], + }); + + const { getByTestId } = renderWithTheme( + + ); + + expect(getByTestId('add-linode-to-placement-group-button')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); +}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx new file mode 100644 index 00000000000..16420390bc0 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx @@ -0,0 +1,111 @@ +import { useTheme } from '@mui/material'; +import { useMediaQuery } from '@mui/material'; +import Grid from '@mui/material/Unstable_Grid2/Grid2'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; + +import { + MAX_NUMBER_OF_LINODES_IN_PLACEMENT_GROUP_MESSAGE, + PLACEMENT_GROUP_LINODES_ERROR_MESSAGE, +} from '../../constants'; +import { hasPlacementGroupReachedCapacity } from '../../utils'; +import { PlacementGroupsLinodesTable } from './PlacementGroupsLinodesTable'; + +import type { Linode, PlacementGroup } from '@linode/api-v4'; + +interface Props { + placementGroup: PlacementGroup | undefined; +} + +export const PlacementGroupsLinodes = (props: Props) => { + const { placementGroup } = props; + const { + data: placementGroupLinodes, + error: linodesError, + isLoading: linodesLoading, + } = useAllLinodesQuery( + {}, + { + '+or': placementGroup?.linode_ids.map((id) => ({ + id, + })), + } + ); + const theme = useTheme(); + const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); + const [searchText, setSearchText] = React.useState(''); + + if (!placementGroup) { + return ; + } + + const { capacity } = placementGroup; + + const getLinodesList = () => { + if (!placementGroupLinodes) { + return []; + } + + if (searchText) { + return placementGroupLinodes.filter((linode: Linode) => { + return linode.label.toLowerCase().includes(searchText.toLowerCase()); + }); + } + + return placementGroupLinodes; + }; + + return ( + + + + The following Linodes have been assigned to this Placement Group. A + Linode can only be assigned to a single Placement Group. + + + Limit of Linodes for this Placement Group: {capacity} + + + + + + { + setSearchText(value); + }} + debounceTime={250} + hideLabel + label="Search Linodes" + placeholder="Search Linodes" + value={searchText} + /> + + + + + + + {/* TODO VM_Placement: ASSIGN LINODE DRAWER */} + {/* TODO VM_Placement: UNASSIGN LINODE DRAWER */} + + ); +}; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx new file mode 100644 index 00000000000..a15adc30fc2 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +import { linodeFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { PlacementGroupsLinodesTable } from './PlacementGroupsLinodesTable'; + +const defaultProps = { + error: [], + linodes: linodeFactory.buildList(5), + loading: false, +}; + +describe('PlacementGroupsLanding', () => { + it('renders an error state when encountering an API error', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText(/not found/i)).toBeInTheDocument(); + }); + + it('renders a loading skeleton based on the loading prop', () => { + const { getByTestId } = renderWithTheme( + + ); + + expect(getByTestId('table-row-loading')).toBeInTheDocument(); + }); + + it('should have the correct number of columns', () => { + const { getAllByRole } = renderWithTheme( + + ); + + expect(getAllByRole('columnheader')).toHaveLength(3); + }); + + it('should have the correct number of rows', () => { + const { getAllByTestId } = renderWithTheme( + + ); + + expect(getAllByTestId(/placement-group-linode-/i)).toHaveLength(5); + }); +}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.tsx new file mode 100644 index 00000000000..19328b46608 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; + +import OrderBy from 'src/components/OrderBy'; +import Paginate from 'src/components/Paginate'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { PLACEMENT_GROUP_LINODES_ERROR_MESSAGE } from '../../constants'; +import { PlacementGroupsLinodesTableRow } from './PlacementGroupsLinodesTableRow'; + +import type { APIError, Linode } from '@linode/api-v4'; + +export interface Props { + error?: APIError[]; + linodes: Linode[]; + loading: boolean; +} + +export const PlacementGroupsLinodesTable = React.memo((props: Props) => { + const { error, linodes, loading } = props; + + const orderLinodeKey = 'label'; + const orderStatusKey = 'status'; + + const _error = error + ? getAPIErrorOrDefault(error, PLACEMENT_GROUP_LINODES_ERROR_MESSAGE) + : undefined; + + return ( + + {({ data: orderedData, handleOrderChange, order, orderBy }) => ( + + {({ + count, + data: paginatedAndOrderedLinodes, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + <> + + + + + Linode + + + Linode Status + + + + + + + {paginatedAndOrderedLinodes.map((linode) => ( + + ))} + + +
    + + + )} +
    + )} +
    + ); +}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx new file mode 100644 index 00000000000..328be10f6e0 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +import { linodeFactory } from 'src/factories'; +import { wrapWithTableBody } from 'src/utilities/testHelpers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { PlacementGroupsLinodesTableRow } from './PlacementGroupsLinodesTableRow'; + +const defaultProps = { + linode: linodeFactory.build({ + label: 'my-linode', + status: 'running', + }), +}; + +describe('PlacementGroupsLanding', () => { + it('should feature the right table row data', () => { + const { getAllByRole } = renderWithTheme( + wrapWithTableBody() + ); + + expect(getAllByRole('cell')[0]).toHaveTextContent('my-linode'); + expect(getAllByRole('cell')[1]).toHaveTextContent('Running'); + expect(getAllByRole('cell')[2]).toHaveTextContent('Unassign'); + }); +}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx new file mode 100644 index 00000000000..2235ed8f2c2 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { getLinodeIconStatus } from 'src/features/Linodes/LinodesLanding/utils'; +import { capitalizeAllWords } from 'src/utilities/capitalize'; + +import type { Linode } from '@linode/api-v4'; + +interface Props { + linode: Linode; +} + +export const PlacementGroupsLinodesTableRow = React.memo((props: Props) => { + const { linode } = props; + const { label, status } = linode; + const iconStatus = getLinodeIconStatus(status); + + return ( + + + {label} + + + + {capitalizeAllWords(linode.status.replace('_', ' '))} + + + null} // TODO VM_Placement: open unassign drawer + /> + + + ); +}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDrawerContent.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDrawerContent.tsx index 2e5109282f8..f7e11486eca 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDrawerContent.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDrawerContent.tsx @@ -8,6 +8,7 @@ import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; +import { MAX_NUMBER_OF_LINODES_IN_PLACEMENT_GROUP_MESSAGE } from './constants'; import { affinityTypeOptions } from './utils'; import type { PlacementGroupDrawerFormikProps } from './types'; @@ -122,8 +123,7 @@ export const PlacementGroupsDrawerContent = (props: Props) => { : false, label: `${isRenameDrawer ? 'Rename' : 'Create'} Placement Group`, loading: isSubmitting, - tooltipText: - 'You have reached the maximum amount of Placement Groups.', + tooltipText: MAX_NUMBER_OF_LINODES_IN_PLACEMENT_GROUP_MESSAGE, type: 'submit', }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx index 3cdfea37067..5c28ff87176 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -1,4 +1,6 @@ import CloseIcon from '@mui/icons-material/Close'; +import { useMediaQuery } from '@mui/material'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -38,6 +40,8 @@ export const PlacementGroupsLanding = React.memo(() => { const [selectedPlacementGroup, setSelectedPlacementGroup] = React.useState< PlacementGroup | undefined >(); + const theme = useTheme(); + const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); const [query, setQuery] = React.useState(''); const { handleOrderChange, order, orderBy } = useOrder( { @@ -132,7 +136,7 @@ export const PlacementGroupsLanding = React.memo(() => { onButtonClick={handleCreatePlacementGroup} title="Placement Groups" /> - + The maximum amount of Placement Groups is{' '} {MAX_NUMBER_OF_PLACEMENT_GROUPS} per account. @@ -166,6 +170,7 @@ export const PlacementGroupsLanding = React.memo(() => { direction={order} handleClick={handleOrderChange} label="label" + sx={{ width: '40%' }} > Label @@ -180,7 +185,7 @@ export const PlacementGroupsLanding = React.memo(() => { Region - + diff --git a/packages/manager/src/features/PlacementGroups/constants.ts b/packages/manager/src/features/PlacementGroups/constants.ts index ce8bc275987..4409b6f113b 100644 --- a/packages/manager/src/features/PlacementGroups/constants.ts +++ b/packages/manager/src/features/PlacementGroups/constants.ts @@ -2,3 +2,9 @@ export const PLACEMENT_GROUP_LABEL = 'Placement Groups'; export const MAX_NUMBER_OF_PLACEMENT_GROUPS = 5; + +export const MAX_NUMBER_OF_LINODES_IN_PLACEMENT_GROUP_MESSAGE = + "You've reached the maximum number of Linodes in a Placement Group. Please remove a Linode from this Placement Group before adding another."; + +export const PLACEMENT_GROUP_LINODES_ERROR_MESSAGE = + 'There was an error loading Linodes for this Placement Group.'; diff --git a/packages/manager/src/features/PlacementGroups/utils.ts b/packages/manager/src/features/PlacementGroups/utils.ts index d238a195c4d..3513d5e32ac 100644 --- a/packages/manager/src/features/PlacementGroups/utils.ts +++ b/packages/manager/src/features/PlacementGroups/utils.ts @@ -12,6 +12,17 @@ export const getPlacementGroupLinodeCount = ( return placementGroup.linode_ids.length; }; +/** + * Helper to determine if a Placement Group has reached capacity. + */ +export const hasPlacementGroupReachedCapacity = ( + placementGroup: PlacementGroup +): boolean => { + return ( + getPlacementGroupLinodeCount(placementGroup) >= placementGroup.capacity + ); +}; + /** * Helper to populate the affinity_type select options. */ diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6d2552aa742..efaba5eef2d 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -728,6 +728,21 @@ export const handlers = [ eventLinode, multipleIPLinode, ]; + + if (req.headers.get('x-filter')) { + const headers = JSON.parse(req.headers.get('x-filter') || '{}'); + const orFilters = headers['+or']; + + if (orFilters) { + const filteredLinodes = linodes.filter((linode) => { + return orFilters.some( + (filter: { id: number }) => filter.id === linode.id + ); + }); + + return res(ctx.json(makeResourcePage(filteredLinodes))); + } + } return res(ctx.json(makeResourcePage(linodes))); }), rest.get('*/linode/instances/:id', async (req, res, ctx) => { From c80abf43ec4632519fbe9510938e5fdc9d88bdf1 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 7 Feb 2024 10:14:15 -0500 Subject: [PATCH 09/38] refactor: [M3-7736] - Clean up `regionDropdown` feature flag (#10148) * remove `regionDropdown` feature flag * Added changeset: Clean up `regionDropdown` feature flag * improve placeholder @mjac0bs --------- Co-authored-by: Banks Nussman --- .../pr-10148-tech-stories-1707231054836.md | 5 +++++ packages/manager/src/featureFlags.ts | 1 - .../UpdateContactInformationForm.tsx | 15 +++++++-------- 3 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 packages/manager/.changeset/pr-10148-tech-stories-1707231054836.md diff --git a/packages/manager/.changeset/pr-10148-tech-stories-1707231054836.md b/packages/manager/.changeset/pr-10148-tech-stories-1707231054836.md new file mode 100644 index 00000000000..ea621e926f2 --- /dev/null +++ b/packages/manager/.changeset/pr-10148-tech-stories-1707231054836.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Clean up `regionDropdown` feature flag ([#10148](https://github.com/linode/manager/pull/10148)) diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index c622dda8443..c972cf17c3f 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -61,7 +61,6 @@ export interface Flags { promotionalOffers: PromotionalOffer[]; recharts: boolean; referralBannerText: ReferralBannerText; - regionDropdown: boolean; selfServeBetas: boolean; soldOutChips: boolean; taxBanner: TaxBanner; diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx index 88f7ea1501b..18421b988e0 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx @@ -9,7 +9,6 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { useFlags } from 'src/hooks/useFlags'; import { useAccount, useMutateAccount } from 'src/queries/account'; import { useNotificationsQuery } from 'src/queries/accountNotifications'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -28,7 +27,6 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { const { error, isLoading, mutateAsync } = useMutateAccount(); const { data: notifications, refetch } = useNotificationsQuery(); const { classes } = useStyles(); - const flags = useFlags(); const emailRef = React.useRef(); const formik = useFormik({ @@ -241,15 +239,16 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { onChange={(item) => formik.setFieldValue('country', item.value)} options={countryResults} placeholder="Select a Country" - required={flags.regionDropdown} + required /> - {flags.regionDropdown && - (formik.values.country === 'US' || formik.values.country == 'CA') ? ( + {formik.values.country === 'US' || formik.values.country == 'CA' ? ( { label={`${formik.values.country === 'US' ? 'State' : 'Province'}`} onChange={(item) => formik.setFieldValue('state', item.value)} options={filteredRegionResults} - required={flags.regionDropdown} + required /> ) : ( { name="state" onChange={formik.handleChange} placeholder="Enter region" - required={flags.regionDropdown} + required value={formik.values.state} /> )} From e33c125a36cdbb3214fd4ee2276cb377468603d0 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 7 Feb 2024 10:34:42 -0500 Subject: [PATCH 10/38] change: [M3-7584] - Update `react-router-dom` in preparation for React 18 (#10154) * update `react-router-dom` and `@types/react-router-dom` and fix type changes * Added changeset: Update `react-router-dom` in preparation for React 18 --------- Co-authored-by: Banks Nussman --- .../pr-10154-tech-stories-1707251127609.md | 5 ++ packages/manager/package.json | 4 +- .../ResourcesMoreLink.tsx | 7 +- packages/manager/src/components/Link.tsx | 9 +++ yarn.lock | 67 ++++++------------- 5 files changed, 41 insertions(+), 51 deletions(-) create mode 100644 packages/manager/.changeset/pr-10154-tech-stories-1707251127609.md diff --git a/packages/manager/.changeset/pr-10154-tech-stories-1707251127609.md b/packages/manager/.changeset/pr-10154-tech-stories-1707251127609.md new file mode 100644 index 00000000000..255d6c8468b --- /dev/null +++ b/packages/manager/.changeset/pr-10154-tech-stories-1707251127609.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update `react-router-dom` in preparation for React 18 ([#10154](https://github.com/linode/manager/pull/10154)) diff --git a/packages/manager/package.json b/packages/manager/package.json index f0f3e13b000..e9ba2edb845 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -58,7 +58,7 @@ "react-number-format": "^3.5.0", "react-query": "^3.3.2", "react-redux": "~7.1.3", - "react-router-dom": "~5.1.2", + "react-router-dom": "~5.3.4", "react-router-hash-link": "^2.3.1", "react-select": "~3.1.0", "react-vnc": "^0.5.3", @@ -147,7 +147,7 @@ "@types/react-csv": "^1.1.3", "@types/react-dom": "^17.0.9", "@types/react-redux": "~7.1.7", - "@types/react-router-dom": "~5.1.2", + "@types/react-router-dom": "~5.3.3", "@types/react-router-hash-link": "^1.2.1", "@types/react-select": "^3.0.11", "@types/recompose": "^0.30.0", diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesMoreLink.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesMoreLink.tsx index 39e1edabaf6..bf7e0dc090e 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesMoreLink.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesMoreLink.tsx @@ -1,12 +1,13 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { LinkProps } from 'react-router-dom'; import { Link } from 'src/components/Link'; -type ResourcesMoreLinkProps = LinkProps & { +import type { LinkProps } from 'src/components/Link'; + +interface ResourcesMoreLinkProps extends LinkProps { external?: boolean; -}; +} const StyledMoreLink = styled(Link)(({ ...props }) => ({ alignItems: props.external ? 'baseline' : 'center', diff --git a/packages/manager/src/components/Link.tsx b/packages/manager/src/components/Link.tsx index f4845fd0773..bbdb7fc9957 100644 --- a/packages/manager/src/components/Link.tsx +++ b/packages/manager/src/components/Link.tsx @@ -37,6 +37,15 @@ export interface LinkProps extends _LinkProps { * @default false */ hideIcon?: boolean; + /** + * The Link's destination. + * We are overwriting react-router-dom's `to` type because they allow objects, functions, and strings. + * We want to keep our `to` prop simple so that we can easily read and sanitize it. + * + * @example "/profile/display" + * @example "https://linode.com" + */ + to: string; } /** diff --git a/yarn.lock b/yarn.lock index 9db8480f628..5ee60d67922 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1537,13 +1537,20 @@ core-js-pure "^3.25.1" regenerator-runtime "^0.13.11" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== dependencies: regenerator-runtime "^0.13.11" +"@babel/runtime@^7.12.13": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7" + integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.13.10": version "7.23.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885" @@ -4575,13 +4582,6 @@ dependencies: highlight.js "*" -"@types/history@*": - version "5.0.0" - resolved "https://registry.yarnpkg.com/@types/history/-/history-5.0.0.tgz#29f919f0c8e302763798118f45b19cab4a886f14" - integrity sha512-hy8b7Y1J8OGe6LbAjj3xniQrj3v6lsivCcrmf4TzSgPzLkhIeKgc5IZnT7ReIqmEuodjfO8EYAuoFvIrHi/+jQ== - dependencies: - history "*" - "@types/history@^4.7.11": version "4.7.11" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" @@ -4858,7 +4858,7 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" -"@types/react-router-dom@*": +"@types/react-router-dom@*", "@types/react-router-dom@~5.3.3": version "5.3.3" resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83" integrity sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw== @@ -4867,15 +4867,6 @@ "@types/react" "*" "@types/react-router" "*" -"@types/react-router-dom@~5.1.2": - version "5.1.9" - resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.9.tgz#e8a8f687351ecc8c68bb4161d7e4b9df4994416e" - integrity sha512-Go0vxZSigXTyXx8xPkGiBrrc3YbBs82KE14WENMLS6TSUKcRFSmYVbL19zFOnNFqJhqrPqEs2h5eUpJhSRrwZw== - dependencies: - "@types/history" "*" - "@types/react" "*" - "@types/react-router" "*" - "@types/react-router-hash-link@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@types/react-router-hash-link/-/react-router-hash-link-1.2.1.tgz#fba7dc351cef2985791023018b7a5dbd0653c843" @@ -9259,13 +9250,6 @@ highlight.js@~10.4.1: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg== -history@*: - version "5.3.0" - resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" - integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== - dependencies: - "@babel/runtime" "^7.7.6" - history@^4.9.0: version "4.10.1" resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" @@ -11380,14 +11364,6 @@ min-indent@^1.0.0, min-indent@^1.0.1: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== -mini-create-react-context@^0.3.0: - version "0.3.3" - resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.3.3.tgz#b1b2bc6604d3a6c5d9752bad7692615410ebb38e" - integrity sha512-TtF6hZE59SGmS4U8529qB+jJFeW6asTLDIpPgvPLSCsooAwJS7QprHIFTqv9/Qh3NdLwQxFYgiHX5lqb6jqzPA== - dependencies: - "@babel/runtime" "^7.12.1" - tiny-warning "^1.0.3" - minimatch@3.1.2, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -12789,16 +12765,16 @@ react-remove-scroll@2.5.5: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" -react-router-dom@~5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18" - integrity sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew== +react-router-dom@~5.3.4: + version "5.3.4" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.3.4.tgz#2ed62ffd88cae6db134445f4a0c0ae8b91d2e5e6" + integrity sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ== dependencies: - "@babel/runtime" "^7.1.2" + "@babel/runtime" "^7.12.13" history "^4.9.0" loose-envify "^1.3.1" prop-types "^15.6.2" - react-router "5.1.2" + react-router "5.3.4" tiny-invariant "^1.0.2" tiny-warning "^1.0.0" @@ -12809,16 +12785,15 @@ react-router-hash-link@^2.3.1: dependencies: prop-types "^15.7.2" -react-router@5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418" - integrity sha512-yjEuMFy1ONK246B+rsa0cUam5OeAQ8pyclRDgpxuSCrAlJ1qN9uZ5IgyKC7gQg0w8OM50NXHEegPh/ks9YuR2A== +react-router@5.3.4: + version "5.3.4" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.3.4.tgz#8ca252d70fcc37841e31473c7a151cf777887bb5" + integrity sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA== dependencies: - "@babel/runtime" "^7.1.2" + "@babel/runtime" "^7.12.13" history "^4.9.0" hoist-non-react-statics "^3.1.0" loose-envify "^1.3.1" - mini-create-react-context "^0.3.0" path-to-regexp "^1.7.0" prop-types "^15.6.2" react-is "^16.6.0" @@ -14327,7 +14302,7 @@ tiny-invariant@^1.0.2, tiny-invariant@^1.0.6, tiny-invariant@^1.3.1: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw== -tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: +tiny-warning@^1.0.0, tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== From 165b5bffe78808cf111ed2dfa44a5a97f51d73c8 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 7 Feb 2024 09:42:31 -0600 Subject: [PATCH 11/38] upcoming: [M3-7696] - Edit Access key Drawer - Fix Save button is disabled (#10118) * upcoming: [M3-7696] - Edit Access key Drawer - Save Edit Access key Drawer - Fix save button is disabled. * Remove unused imports * Added changeset: Edit Access key Drawer - Fix Save button is disabled. * code cleanup * Update utils.ts * PR - feedback * PR feedback - @abailly-akamai * Update packages/manager/.changeset/pr-10118-upcoming-features-1706544516803.md Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> * PR - feedback - @DevDW * Remove error when regions are selected. --------- Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --- .../src/object-storage/objectStorageKeys.ts | 7 +- ...r-10118-upcoming-features-1706544516803.md | 5 + .../LinodesCreate/LinodeCreateContainer.tsx | 16 +-- .../AccessKeyLanding/AccessKeyDrawer.tsx | 12 ++- .../AccessKeyLanding/AccessKeyLanding.tsx | 16 ++- .../AccessKeyTable/AccessKeyActionMenu.tsx | 2 +- .../AccessKeyLanding/OMC_AccessKeyDrawer.tsx | 34 +++++-- .../AccessKeyLanding/utils.test.ts | 98 +++++++++++++++++++ .../ObjectStorage/AccessKeyLanding/utils.ts | 73 ++++++++++++++ packages/manager/src/mocks/serverHandlers.ts | 43 +++++--- .../src/objectStorageKeys.schema.ts | 18 +++- 11 files changed, 285 insertions(+), 39 deletions(-) create mode 100644 packages/manager/.changeset/pr-10118-upcoming-features-1706544516803.md create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts diff --git a/packages/api-v4/src/object-storage/objectStorageKeys.ts b/packages/api-v4/src/object-storage/objectStorageKeys.ts index 6dd4d417adb..df8f3b0ce48 100644 --- a/packages/api-v4/src/object-storage/objectStorageKeys.ts +++ b/packages/api-v4/src/object-storage/objectStorageKeys.ts @@ -1,4 +1,7 @@ -import { createObjectStorageKeysSchema } from '@linode/validation/lib/objectStorageKeys.schema'; +import { + createObjectStorageKeysSchema, + updateObjectStorageKeysSchema, +} from '@linode/validation/lib/objectStorageKeys.schema'; import { API_ROOT } from '../constants'; import Request, { setData, @@ -51,7 +54,7 @@ export const updateObjectStorageKey = ( Request( setMethod('PUT'), setURL(`${API_ROOT}/object-storage/keys/${encodeURIComponent(id)}`), - setData(data, createObjectStorageKeysSchema) + setData(data, updateObjectStorageKeysSchema) ); /** diff --git a/packages/manager/.changeset/pr-10118-upcoming-features-1706544516803.md b/packages/manager/.changeset/pr-10118-upcoming-features-1706544516803.md new file mode 100644 index 00000000000..8096f0f7f20 --- /dev/null +++ b/packages/manager/.changeset/pr-10118-upcoming-features-1706544516803.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +"Save" button in Edit Access Key drawer disabled unless field values are changed ([#10118](https://github.com/linode/manager/pull/10118)) diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index 84863f4f2f7..770e8708e11 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -698,10 +698,10 @@ class LinodeCreateContainer extends React.PureComponent { selectedTypeID: this.params.typeID, showGDPRCheckbox: Boolean( !this.props.profile.data?.restricted && - isEURegion( - getSelectedRegionGroup(this.props.regionsData, this.params.regionID) - ) && - this.props.agreements?.data?.eu_model + isEURegion( + getSelectedRegionGroup(this.props.regionsData, this.params.regionID) + ) && + this.props.agreements?.data?.eu_model ), signedAgreement: false, }; @@ -833,10 +833,10 @@ class LinodeCreateContainer extends React.PureComponent { const request = createType === 'fromLinode' ? () => - this.props.linodeActions.cloneLinode({ - sourceLinodeId: linodeID!, - ...payload, - }) + this.props.linodeActions.cloneLinode({ + sourceLinodeId: linodeID!, + ...payload, + }) : () => this.props.linodeActions.createLinode(payload); this.setState({ formIsSubmitting: true }); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx index 9a078e7a585..f461ee44804 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx @@ -6,7 +6,7 @@ import { Scope, } from '@linode/api-v4/lib/object-storage'; import { createObjectStorageKeysSchema } from '@linode/validation/lib/objectStorageKeys.schema'; -import { Formik } from 'formik'; +import { Formik, FormikProps } from 'formik'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -33,7 +33,10 @@ export interface AccessKeyDrawerProps { // If the mode is 'editing', we should have an ObjectStorageKey to edit objectStorageKey?: ObjectStorageKey; onClose: () => void; - onSubmit: (values: ObjectStorageKeyRequest, formikProps: any) => void; + onSubmit: ( + values: ObjectStorageKeyRequest, + formikProps: FormikProps + ) => void; open: boolean; } @@ -120,7 +123,10 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { label: initialLabelValue, }; - const handleSubmit = (values: ObjectStorageKeyRequest, formikProps: any) => { + const handleSubmit = ( + values: ObjectStorageKeyRequest, + formikProps: FormikProps + ) => { // If the user hasn't toggled the Limited Access button, // don't include any bucket_access information in the payload. diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index dce6f9c8221..38d48b1d327 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -5,7 +5,7 @@ import { revokeObjectStorageKey, updateObjectStorageKey, } from '@linode/api-v4/lib/object-storage'; -import { FormikBag } from 'formik'; +import { FormikBag, FormikHelpers } from 'formik'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -96,7 +96,11 @@ export const AccessKeyLanding = (props: Props) => { const handleCreateKey = ( values: ObjectStorageKeyRequest, - { setErrors, setStatus, setSubmitting }: FormikProps + { + setErrors, + setStatus, + setSubmitting, + }: FormikHelpers ) => { // Clear out status (used for general errors) setStatus(null); @@ -152,7 +156,11 @@ export const AccessKeyLanding = (props: Props) => { const handleEditKey = ( values: ObjectStorageKeyRequest, - { setErrors, setStatus, setSubmitting }: FormikProps + { + setErrors, + setStatus, + setSubmitting, + }: FormikHelpers ) => { // This shouldn't happen, but just in case. if (!keyToEdit) { @@ -170,7 +178,7 @@ export const AccessKeyLanding = (props: Props) => { setSubmitting(true); - updateObjectStorageKey(keyToEdit.id, { label: values.label }) + updateObjectStorageKey(keyToEdit.id, values) .then((_) => { setSubmitting(false); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx index b8f85026d29..ac87472e441 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx @@ -38,7 +38,7 @@ export const AccessKeyActionMenu = ({ onClick: () => { openDrawer('editing', objectStorageKey); }, - title: 'Edit Label', + title: isObjMultiClusterEnabled ? 'Edit' : 'Edit Label', }, { onClick: () => { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx index 6d4979acab9..a99f0d036ab 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx @@ -5,9 +5,10 @@ import { ObjectStorageKey, ObjectStorageKeyRequest, Scope, + UpdateObjectStorageKeyRequest, } from '@linode/api-v4/lib/object-storage'; import { createObjectStorageKeysSchema } from '@linode/validation/lib/objectStorageKeys.schema'; -import { useFormik } from 'formik'; +import { useFormik, FormikProps } from 'formik'; import React, { useEffect, useState } from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -28,6 +29,7 @@ import { confirmObjectStorage } from '../utilities'; import { AccessKeyRegions } from './AccessKeyRegions/AccessKeyRegions'; import { LimitedAccessControls } from './LimitedAccessControls'; import { MODE } from './types'; +import { generateUpdatePayload, hasLabelOrRegionsChanged } from './utils'; export interface AccessKeyDrawerProps { isRestrictedUser: boolean; @@ -35,7 +37,12 @@ export interface AccessKeyDrawerProps { // If the mode is 'editing', we should have an ObjectStorageKey to edit objectStorageKey?: ObjectStorageKey; onClose: () => void; - onSubmit: (values: ObjectStorageKeyRequest, formikProps: any) => void; + onSubmit: ( + values: ObjectStorageKeyRequest | UpdateObjectStorageKeyRequest, + formikProps: FormikProps< + ObjectStorageKeyRequest | UpdateObjectStorageKeyRequest + > + ) => void; open: boolean; } @@ -116,10 +123,11 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { // and so not included in Formik's types const [limitedAccessChecked, setLimitedAccessChecked] = useState(false); - const title = createMode ? 'Create Access Key' : 'Edit Access Key Label'; + const title = createMode ? 'Create Access Key' : 'Edit Access Key'; const initialLabelValue = !createMode && objectStorageKey ? objectStorageKey.label : ''; + const initialRegions = !createMode && objectStorageKey ? objectStorageKey.regions?.map((region) => region.id) @@ -148,13 +156,25 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { } : { ...values, bucket_access: null }; - onSubmit(payload, formik); + const updatePayload = generateUpdatePayload(values, initialValues); + + if (mode !== 'creating') { + onSubmit(updatePayload, formik); + } else { + onSubmit(payload, formik); + } }, validateOnBlur: true, validateOnChange: false, validationSchema: createObjectStorageKeysSchema, }); + const isSaveDisabled = + isRestrictedUser || + (mode !== 'creating' && + objectStorageKey && + !hasLabelOrRegionsChanged(formik.values, objectStorageKey)); + const beforeSubmit = () => { confirmObjectStorage( accountSettings?.object_storage || 'active', @@ -257,6 +277,7 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { 'bucket_access', getDefaultScopes(bucketsInRegions, regionsLookup) ); + formik.validateField('regions'); }} onChange={(values) => { const bucketsInRegions = buckets?.filter( @@ -287,10 +308,7 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { { + const initialValues: FormState = { + bucket_access: [], + label: 'initialLabel', + regions: ['region1', 'region2'], + }; + + it('should return empty object if no changes', () => { + const updatedValues = { ...initialValues }; + expect(generateUpdatePayload(updatedValues, initialValues)).toEqual({}); + }); + + it('should return updated label if only label changed', () => { + const updatedValues = { ...initialValues, label: 'newLabel' }; + expect(generateUpdatePayload(updatedValues, initialValues)).toEqual({ + label: 'newLabel', + }); + }); + + it('should return updated regions if only regions changed', () => { + const updatedValues = { ...initialValues, regions: ['region3', 'region4'] }; + expect(generateUpdatePayload(updatedValues, initialValues)).toEqual({ + regions: ['region3', 'region4'], + }); + }); + + it('should return updated label and regions if both changed', () => { + const updatedValues = { + bucket_access: [], + label: 'newLabel', + regions: ['region3', 'region4'], + }; + expect(generateUpdatePayload(updatedValues, initialValues)).toEqual({ + label: 'newLabel', + regions: ['region3', 'region4'], + }); + }); +}); + +describe('hasLabelOrRegionsChanged', () => { + const updatedValues: FormState = { + bucket_access: [], + label: 'initialLabel', + regions: ['region3', 'region4'], + }; + const initialValues: ObjectStorageKey = { + access_key: '', + bucket_access: null, + id: 0, + label: updatedValues.label, + limited: false, + regions: [ + { id: 'region3', s3_endpoint: '' }, + { id: 'region4', s3_endpoint: '' }, + ], + + secret_key: '', + }; + + it('returns false when both label and regions are unchanged', () => { + expect(hasLabelOrRegionsChanged(updatedValues, initialValues)).toBe(false); + }); + + it('returns true when only the label has changed', () => { + expect( + hasLabelOrRegionsChanged( + { ...updatedValues, label: 'newLabel' }, + initialValues + ) + ).toBe(true); + }); + + it('returns true when only the regions have changed', () => { + expect( + hasLabelOrRegionsChanged( + { + ...updatedValues, + regions: ['region5'], + }, + initialValues + ) + ).toBe(true); + }); + + it('returns true when both label and regions have changed', () => { + expect( + hasLabelOrRegionsChanged( + { ...updatedValues, label: 'newLabel', regions: ['region5'] }, + initialValues + ) + ).toBe(true); + }); +}); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts new file mode 100644 index 00000000000..8008022a36a --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts @@ -0,0 +1,73 @@ +import { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; + +import { areArraysEqual } from 'src/utilities/areArraysEqual'; +import { sortByString } from 'src/utilities/sort-by'; + +import { FormState } from './OMC_AccessKeyDrawer'; + +type UpdatePayload = + | { label: FormState['label']; regions: FormState['regions'] } + | { label: FormState['label'] } + | { regions: FormState['regions'] } + | {}; + +const sortRegionOptions = (a: string, b: string) => { + return sortByString(a, b, 'asc'); +}; + +/** + * Generates an update payload for edit access key based on changes in form values. + * + * @param {FormState} updatedValues - The current state of the form. + * @param {FormState} initialValues - The initial state of the form for comparison. + * @returns An object containing the fields that have changed. + */ +export const generateUpdatePayload = ( + updatedValues: FormState, + initialValues: FormState +): UpdatePayload => { + let updatePayload = {}; + + const labelChanged = updatedValues.label !== initialValues.label; + const regionsChanged = !areArraysEqual( + [...updatedValues.regions].sort(sortRegionOptions), + [...initialValues.regions].sort(sortRegionOptions) + ); + + if (labelChanged && regionsChanged) { + updatePayload = { + label: updatedValues.label, + regions: updatedValues.regions, + }; + } else if (labelChanged) { + updatePayload = { label: updatedValues.label }; + } else if (regionsChanged) { + updatePayload = { regions: updatedValues.regions }; + } + + return updatePayload; +}; + +/** + * Determines if there have been any changes in the label or regions + * between the updated form values and the initial values. + * + * @param {FormState} updatedValues - The current state of the form. + * @param {ObjectStorageKey} initialValues - The initial values for comparison. + * @returns {boolean} True if there are changes in label or regions, false otherwise. + */ +export const hasLabelOrRegionsChanged = ( + updatedValues: FormState, + initialValues: ObjectStorageKey +): boolean => { + const regionsChanged = !areArraysEqual( + [...updatedValues.regions].sort(sortRegionOptions), + [...initialValues.regions?.map((region) => region.id)].sort( + sortRegionOptions + ) + ); + + const labelChanged = updatedValues.label !== initialValues.label; + + return labelChanged || regionsChanged; +}; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index efaba5eef2d..d3a28d2d172 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1021,33 +1021,34 @@ export const handlers = [ ...objectStorageKeyFactory.buildList(1, { regions: [ { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'nl-ams', s3_endpoint: 'nl-ams.com' }, + { id: 'us-southeast', s3_endpoint: 'us-southeast.com' }, + { id: 'in-maa', s3_endpoint: 'in-maa.com' }, + { id: 'us-lax', s3_endpoint: 'us-lax.com' }, + { id: 'us-mia', s3_endpoint: 'us-mia.com' }, + { id: 'it-mil', s3_endpoint: 'it-mil.com' }, ], }), ...objectStorageKeyFactory.buildList(1, { regions: [ { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'nl-ams', s3_endpoint: 'nl-ams.com' }, + { id: 'us-southeast', s3_endpoint: 'us-southeast.com' }, + { id: 'in-maa', s3_endpoint: 'in-maa.com' }, + { id: 'us-lax', s3_endpoint: 'us-lax.com' }, ], }), ...objectStorageKeyFactory.buildList(1, { regions: [ { id: 'us-east', s3_endpoint: 'us-east.com' }, - { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'nl-ams', s3_endpoint: 'nl-ams.com' }, ], }), ]) ) ); }), + rest.post('*object-storage/keys', (req, res, ctx) => { const { label, regions } = req.body as ObjectStorageKeyRequest; @@ -1065,6 +1066,26 @@ export const handlers = [ ) ); }), + rest.put('*object-storage/keys/:id', (req, res, ctx) => { + const { label, regions } = req.body as ObjectStorageKeyRequest; + + const regionsData = regions?.map((region: string) => ({ + id: region, + s3_endpoint: `${region}.com`, + })); + + return res( + ctx.json( + objectStorageKeyFactory.build({ + label, + regions: regionsData, + }) + ) + ); + }), + rest.delete('*object-storage/keys/:id', (req, res, ctx) => { + return res(ctx.json({})); + }), rest.get('*/domains', (req, res, ctx) => { const domains = domainFactory.buildList(10); return res(ctx.json(makeResourcePage(domains))); diff --git a/packages/validation/src/objectStorageKeys.schema.ts b/packages/validation/src/objectStorageKeys.schema.ts index 9f9406dc3c8..b15dfe2ace8 100644 --- a/packages/validation/src/objectStorageKeys.schema.ts +++ b/packages/validation/src/objectStorageKeys.schema.ts @@ -1,10 +1,24 @@ import { object, string, array } from 'yup'; +const labelErrorMessage = 'Label must be between 3 and 50 characters.'; + export const createObjectStorageKeysSchema = object({ label: string() .required('Label is required.') - .min(3, 'Label must be between 3 and 50 characters.') - .max(50, 'Label must be between 3 and 50 characters.') + .min(3, labelErrorMessage) + .max(50, labelErrorMessage) + .trim(), + regions: array() + .of(string()) + .min(1, 'Regions must include at least one region') + .notRequired(), +}); + +export const updateObjectStorageKeysSchema = object({ + label: string() + .notRequired() + .min(3, labelErrorMessage) + .max(50, labelErrorMessage) .trim(), regions: array() .of(string()) From ef6d8148af371f99802daa59137ec05f11147e7b Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 8 Feb 2024 09:50:18 -0500 Subject: [PATCH 12/38] refactor: [M3-7582] - Remove Enzyme (#10160) * rewrite many enzyme tests * re add `FromStackScriptContent.test.tsx` * re-add `StackScriptCreate.test.tsx` * remove pagey because it is not used * remove unused type * finish refactoring last few enzyme tests * update docs and remove enzyme packages * re-add missing dependency * re-add missing dependency * Added changeset: Remove Enzyme --------- Co-authored-by: Banks Nussman --- docs/development-guide/08-testing.md | 2 +- .../pr-10160-tech-stories-1707338500735.md | 5 + packages/manager/package.json | 8 +- packages/manager/src/__data__/pageyProps.ts | 19 - packages/manager/src/components/OrderBy.tsx | 3 +- .../src/components/Pagey/Pagey.test.tsx | 274 -------------- .../manager/src/components/Pagey/Pagey.ts | 200 ----------- .../manager/src/components/Pagey/index.ts | 13 - .../PromiseLoader/PromiseLoader.test.tsx | 47 +-- .../PromiseLoader/PromiseLoader.tsx | 5 +- .../manager/src/components/Tags/Tags.test.tsx | 18 +- .../src/features/Help/HelpLanding.test.tsx | 19 +- .../src/features/Help/SearchHOC.test.tsx | 306 ++++++---------- .../TabbedContent/FromBackupsContent.test.tsx | 44 +-- .../TabbedContent/FromImageContent.test.tsx | 31 +- .../TabbedContent/FromLinodeContent.test.tsx | 50 +-- .../FromStackScriptContent.test.tsx | 76 ++-- .../Linodes/LinodesLanding/IPAddress.test.tsx | 134 +++---- .../LinodeRow/LinodeRow.test.tsx | 15 +- .../AccessKeyLanding.test.tsx | 10 - .../BucketLanding/BucketTable.test.tsx | 65 ++-- .../StackScriptCreate.test.tsx | 88 ++--- .../Support/TicketAttachmentRow.test.tsx | 36 +- packages/manager/src/hooks/useOrder.ts | 3 +- packages/manager/src/layouts/Logout.test.tsx | 22 +- packages/manager/src/testSetup.ts | 8 - .../manager/src/types/ManagerPreferences.ts | 3 +- yarn.lock | 335 +----------------- 28 files changed, 423 insertions(+), 1416 deletions(-) create mode 100644 packages/manager/.changeset/pr-10160-tech-stories-1707338500735.md delete mode 100644 packages/manager/src/__data__/pageyProps.ts delete mode 100644 packages/manager/src/components/Pagey/Pagey.test.tsx delete mode 100644 packages/manager/src/components/Pagey/Pagey.ts delete mode 100644 packages/manager/src/components/Pagey/index.ts diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index 91df0ddcad1..caf87e86060 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -45,7 +45,7 @@ Test execution will stop at the debugger statement, and you will be able to use ### React Testing Library -We have some older tests that still use the Enzyme framework, but for new tests we generally use [React Testing Library](https://testing-library.com/docs/react-testing-library/intro). This library provides a set of tools to render React components from within the Vitest environment. The library's philosophy is that components should be tested as closely as possible to how they are used. +This library provides a set of tools to render React components from within the Vitest environment. The library's philosophy is that components should be tested as closely as possible to how they are used. A simple test using this library will look something like this: diff --git a/packages/manager/.changeset/pr-10160-tech-stories-1707338500735.md b/packages/manager/.changeset/pr-10160-tech-stories-1707338500735.md new file mode 100644 index 00000000000..99bdc45480e --- /dev/null +++ b/packages/manager/.changeset/pr-10160-tech-stories-1707338500735.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Remove Enzyme ([#10160](https://github.com/linode/manager/pull/10160)) diff --git a/packages/manager/package.json b/packages/manager/package.json index e9ba2edb845..2900cf692e7 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -129,7 +129,6 @@ "@types/chai-string": "^1.4.5", "@types/chart.js": "^2.9.21", "@types/css-mediaquery": "^0.1.1", - "@types/enzyme": "^3.9.3", "@types/he": "^1.1.0", "@types/highlight.js": "~10.1.0", "@types/jest-axe": "^3.5.7", @@ -172,8 +171,6 @@ "cypress-real-events": "^1.11.0", "cypress-vite": "^1.5.0", "dotenv": "^16.0.3", - "enzyme": "^3.10.0", - "enzyme-adapter-react-16": "^1.14.0", "eslint": "^6.8.0", "eslint-config-prettier": "~8.1.0", "eslint-plugin-cypress": "^2.11.3", @@ -189,7 +186,6 @@ "eslint-plugin-storybook": "^0.6.15", "eslint-plugin-testing-library": "^3.1.2", "eslint-plugin-xss": "^0.1.10", - "simple-git": "^3.19.0", "factory.ts": "^0.5.1", "glob": "^10.3.1", "jest-axe": "^8.0.0", @@ -199,9 +195,11 @@ "mocha-junit-reporter": "^2.2.1", "msw": "~1.3.2", "prettier": "~2.2.1", + "react-test-renderer": "16.14.0", "redux-mock-store": "^1.5.3", "reselect-tools": "^0.0.7", "serve": "^14.0.1", + "simple-git": "^3.19.0", "storybook": "^7.6.10", "storybook-dark-mode": "^3.0.3", "ts-node": "^10.9.2", @@ -215,4 +213,4 @@ "Firefox ESR", "not ie < 9" ] -} \ No newline at end of file +} diff --git a/packages/manager/src/__data__/pageyProps.ts b/packages/manager/src/__data__/pageyProps.ts deleted file mode 100644 index 71f9359d803..00000000000 --- a/packages/manager/src/__data__/pageyProps.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { PaginationProps } from 'src/components/Pagey'; - -export const pageyProps: PaginationProps = { - count: 3, - error: undefined, - filter: {}, - handleOrderChange: vi.fn(), - handlePageChange: vi.fn(), - handlePageSizeChange: vi.fn(), - handleSearch: vi.fn(), - loading: false, - onDelete: vi.fn(), - order: 'asc' as 'asc' | 'desc', - orderBy: undefined, - page: 1, - pageSize: 25, - request: vi.fn(), - searching: false, -}; diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index 4b3c0b901b8..a4afc094d55 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -4,7 +4,6 @@ import * as React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; -import { Order } from 'src/components/Pagey'; import { usePrevious } from 'src/hooks/usePrevious'; import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; import { ManagerPreferences } from 'src/types/ManagerPreferences'; @@ -16,6 +15,8 @@ import { sortByUTFDate, } from 'src/utilities/sort-by'; +import type { Order } from 'src/hooks/useOrder'; + export interface OrderByProps extends State { data: T[]; handleOrderChange: (orderBy: string, order: Order) => void; diff --git a/packages/manager/src/components/Pagey/Pagey.test.tsx b/packages/manager/src/components/Pagey/Pagey.test.tsx deleted file mode 100644 index 384b66750af..00000000000 --- a/packages/manager/src/components/Pagey/Pagey.test.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { ResourcePage } from '@linode/api-v4/lib/types'; -import { shallow } from 'enzyme'; -import * as React from 'react'; - -import paginate from './Pagey'; - -const mockData: ResourcePage = { - data: [], - page: 1, - pages: 1, - results: 0, -}; - -const mockFn = vi.fn(() => Promise.resolve(mockData)); - -const setup = (mockRequest: any = mockFn) => { - const MyComponent = paginate(mockRequest)(() =>
    ); - - return { - mockRequest, - wrapper: shallow(), - }; -}; - -describe('Paginator 2: Pagement Day', () => { - describe('props', () => { - const { wrapper } = setup(); - - const { - count, - handlePageChange, - handlePageSizeChange, - loading, - onDelete, - page, - pageSize, - request, - } = wrapper.props(); - - it('should provide a count prop', () => { - expect(count).toBeDefined(); - }); - - it('should default count to 0', () => { - expect(count).toBe(0); - }); - - it('should provide a page prop', () => { - expect(page).toBeDefined(); - }); - - it('should deafult page prop to 1', () => { - expect(page).toEqual(1); - }); - - it('should provide pageSize prop', () => { - expect(pageSize).toBeDefined(); - }); - - it('should deafult pageSize to 25', () => { - expect(pageSize).toEqual(25); - }); - - it('should provide a handlePageChange handler prop', () => { - expect(handlePageChange).toBeDefined(); - expect(handlePageChange).toBeInstanceOf(Function); - }); - - it('should provide a handlePageSizeChange handler prop', () => { - expect(handlePageSizeChange).toBeDefined(); - expect(handlePageSizeChange).toBeInstanceOf(Function); - }); - - it('should provide a loading prop', () => { - expect(loading).toBeDefined(); - }); - - it('should default loading to true', () => { - expect(loading).toBeTruthy(); - }); - - it('should provide a request handler prop', () => { - expect(request).toBeDefined(); - expect(request).toBeInstanceOf(Function); - }); - - it('should provide a onDelete handler prop', () => { - expect(onDelete).toBeDefined(); - expect(onDelete).toBeInstanceOf(Function); - }); - }); - - describe('when onDelete is called', () => { - it('should request the previous page if we deleted the last item', async () => { - /** - * We need to test if Pagey is currently viewing a page of one, and we call onDelete, it requests - * the following page, not the current page. - */ - const mockRequest = vi - .fn(() => Promise.resolve({})) - .mockImplementationOnce(() => - Promise.resolve({ - data: [101], - page: 6, - pages: 5, - results: 101, - }) - ); - - const { wrapper } = setup(mockRequest); - const request = wrapper.prop('request'); - const onDelete = wrapper.prop('onDelete'); - - /** - * This triggers the first call to mockRequest and sets our state to - * { data: [6], page: 2, pages: 2, results: 6 } - */ - await request(); - wrapper.update(); - - /** - * This triggers the second call to mockRequest. - */ - await onDelete(); - wrapper.update(); - - /** We need to check that the second call to our request function is for the preceeding page. */ - expect((mockRequest.mock.calls as any)[1][1]).toEqual({ - page: 5, - page_size: 25, - }); - }); - }); - - describe('when handlePageChange is called', () => { - it('should update page with provided argument', () => { - const { wrapper } = setup(vi.fn(() => Promise.resolve(mockData))); - - const handlePageChange = wrapper.prop('handlePageChange'); - - handlePageChange(9); - - wrapper.update(); - - expect(wrapper.prop('page')).toEqual(9); - }); - - it('should result in the request being called with updated params', () => { - const { mockRequest, wrapper } = setup( - vi.fn(() => Promise.resolve(mockData)) - ); - - const handlePageChange = wrapper.prop('handlePageChange'); - - handlePageChange(9); - - wrapper.update(); - - expect(mockRequest).toBeCalledWith({}, { page: 9, page_size: 25 }, {}); - }); - }); - - describe('when handlePageSizeChange is called', () => { - it('should update pageSize with provided argument', () => { - const { wrapper } = setup(vi.fn(() => Promise.resolve(mockData))); - - const handlePageSizeChange = wrapper.prop('handlePageSizeChange'); - - handlePageSizeChange(100); - - wrapper.update(); - - expect(wrapper.prop('pageSize')).toEqual(100); - expect(wrapper.prop('page')).toEqual(1); - }); - - it('should result in the request being called with updated params', () => { - const { mockRequest, wrapper } = setup( - vi.fn(() => Promise.resolve(mockData)) - ); - - const handlePageSizeChange = wrapper.prop('handlePageSizeChange'); - - handlePageSizeChange(100); - - wrapper.update(); - - expect(mockRequest).toBeCalledWith({}, { page: 1, page_size: 100 }, {}); - }); - }); - - describe('when requesting data', () => { - describe('and the promise resolves', () => { - const mockDataWithData = { - data: [1, 2, 3, 4], - page: 2, - pages: 2, - results: 4, - }; - - const { wrapper } = setup(() => Promise.resolve(mockDataWithData)); - - beforeAll(async () => { - await wrapper.prop('request')(); - wrapper.update(); - }); - - it('should set data to response.data', () => { - expect(wrapper.prop('data')).toEqual([1, 2, 3, 4]); - }); - - it('should set page to response.page', () => { - expect(wrapper.prop('page')).toBe(2); - }); - - it('should set count to response.result', () => { - expect(wrapper.prop('count')).toBe(4); - }); - - it('should apply the map function to the response.result', async () => { - const fn = (numbers: number[]) => numbers.map((n) => n + 1); - await wrapper.prop('request')(fn); - wrapper.update(); - expect(wrapper.prop('data')).toEqual([2, 3, 4, 5]); - }); - }); - - describe('and the promise rejects', () => { - const { wrapper } = setup(() => Promise.reject(new Error())); - - beforeAll(async () => { - await wrapper.prop('request')(); - wrapper.update(); - }); - - it('should set error to rejected value', () => { - expect(wrapper.prop('error')).toBeInstanceOf(Error); - }); - }); - }); - - describe('sorting', () => { - const { mockRequest, wrapper } = setup(); - const handleOrderChange = wrapper.prop('handleOrderChange'); - - beforeEach(() => { - mockRequest.mockClear(); - }); - - it('should provide a handleOrderChange handler prop', () => { - expect(handleOrderChange).toBeDefined(); - expect(handleOrderChange).toBeInstanceOf(Function); - }); - - it('should send request with sort by ascending', () => { - handleOrderChange('label'); - - expect(mockRequest).toHaveBeenCalledWith( - {}, - { page: 1, page_size: 25 }, - { '+order': 'asc', '+order_by': 'label' } - ); - }); - it('should send request with sort by descending', () => { - handleOrderChange('label', 'desc'); - - expect(mockRequest).toHaveBeenCalledWith( - {}, - { page: 1, page_size: 25 }, - { '+order': 'desc', '+order_by': 'label' } - ); - }); - }); -}); diff --git a/packages/manager/src/components/Pagey/Pagey.ts b/packages/manager/src/components/Pagey/Pagey.ts deleted file mode 100644 index 6e7118df245..00000000000 --- a/packages/manager/src/components/Pagey/Pagey.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { APIError, Filter, ResourcePage } from '@linode/api-v4/lib/types'; -import { clone } from 'ramda'; -import * as React from 'react'; - -import { storage } from 'src/utilities/storage'; - -/** - * @todo Document loading prop update as a result of promise resolution/rejection. - * @todo How can we test the transition of loading from false -> true -> result? - * - * @todo Add basic sorting of one type and direction. - * @todo Allow the request to be modified to allow additional filters (beyond sorting). - * @todo Type FilterParams? - */ - -export interface PaginationParams { - page?: number; - page_size?: number; -} - -export type FilterParams = any; - -export type PaginatedRequest = ( - ownProps?: any, - p?: PaginationParams, - f?: FilterParams -) => Promise>; - -export type HandleOrderChange = (key: string, order?: Order) => void; - -export type Order = 'asc' | 'desc'; - -export type OrderBy = string | undefined; - -interface State { - count: number; - data?: T[]; - error?: APIError[]; - filter: Filter; - isSorting?: boolean; - loading: boolean; - order: Order; - orderBy?: OrderBy; - page: number; - pageSize: number; - pages?: number; - searching: boolean; -} - -interface Options { - // props and the result of the request. - cb?: (ownProps: any, response: ResourcePage) => any; - order?: Order; - // Callback to be executed after successful request, with the component's own - orderBy?: OrderBy; -} - -export interface PaginationProps extends State { - handleOrderChange: HandleOrderChange; - handlePageChange: (v: number, showSpinner?: boolean) => void; - handlePageSizeChange: (v: number) => void; - handleSearch: (newFilter: Filter) => void; - onDelete: () => void; - request: (update?: (v: T[]) => U) => Promise; -} - -export default (requestFn: PaginatedRequest, options: Options = {}) => ( - Component: React.ComponentType -) => { - return class WrappedComponent extends React.PureComponent { - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - public render() { - return React.createElement(Component, { - ...this.props, - ...this.state, - handleOrderChange: this.handleOrderChange, - handlePageChange: this.handlePageChange, - handlePageSizeChange: this.handlePageSizeChange, - handleSearch: this.handleSearch, - onDelete: this.onDelete, - request: this.request, - }); - } - - public handleOrderChange = ( - orderBy: string, - order: Order = 'asc', - page: number = 1 - ) => { - this.setState({ isSorting: true, order, orderBy, page }, () => - this.request() - ); - }; - - public handlePageChange = (page: number) => { - /** - * change the page, make the request - */ - this.setState({ page }, () => { - this.request(); - }); - }; - - public handlePageSizeChange = (pageSize: number) => { - this.setState({ page: 1, pageSize }, () => { - this.request(); - }); - storage.pageSize.set(pageSize); - }; - - public handleSearch = (filter: Filter) => { - this.setState({ filter, page: 1, searching: true }, () => this.request()); - }; - - mounted: boolean = false; - - private onDelete = () => { - const { data, page } = this.state; - - /* - * Basically, if we're on page 2 and the user deletes the last entity - * on the page, send the user back to the previous page, AKA the max number - * of pages. - * - * This solves the issue where the user deletes the last entity - * on a page and then sees an empty state instead of going to the - * last page available - * - * Please note that if the deletion of an entity is instant and not - * initiated by the completetion of an event, we need to check that - * the data.length === 1 because we're not calling this.request() to update - * page and pages states - */ - if (data && data.length === 1) { - return this.handlePageChange(page - 1); - } - - return this.request(); - }; - - // eslint-disable-next-line @typescript-eslint/ban-types - private request = (map?: Function) => { - /** - * we might potentially have a search term to filter by - */ - const filters = clone(this.state.filter); - - if (this.state.orderBy) { - filters['+order_by'] = this.state.orderBy; - filters['+order'] = this.state.order; - } - return requestFn( - this.props, - { page: this.state.page, page_size: this.state.pageSize }, - filters - ) - .then((response) => { - if (options.cb) { - options.cb(this.props, response); - } - - if (this.mounted) { - this.setState({ - count: response.results, - data: map ? map(response.data) : response.data, - error: undefined, - isSorting: false, - loading: false, - page: response.page, - pages: response.pages, - searching: false, - }); - } - }) - .catch((response) => { - this.setState({ error: response, loading: false }); - }); - }; - - state: State = { - count: 0, - error: undefined, - filter: {}, - isSorting: false, - loading: true, - order: options.order ?? ('asc' as Order), - orderBy: options.orderBy, - page: 1, - pageSize: storage.pageSize.get() || 25, - searching: false, - }; - }; -}; diff --git a/packages/manager/src/components/Pagey/index.ts b/packages/manager/src/components/Pagey/index.ts deleted file mode 100644 index b2e44589418..00000000000 --- a/packages/manager/src/components/Pagey/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Pagey, { - HandleOrderChange as _HandleOrderChange, - Order as _Order, - OrderBy as _OrderBy, - PaginationProps as _PaginationProps, -} from './Pagey'; - -/* tslint:disable */ -export type HandleOrderChange = _HandleOrderChange; -export type Order = _Order; -export type OrderBy = _OrderBy; -export type PaginationProps = _PaginationProps; -export default Pagey; diff --git a/packages/manager/src/components/PromiseLoader/PromiseLoader.test.tsx b/packages/manager/src/components/PromiseLoader/PromiseLoader.test.tsx index 8c02a394eca..5cb7ab24ea5 100644 --- a/packages/manager/src/components/PromiseLoader/PromiseLoader.test.tsx +++ b/packages/manager/src/components/PromiseLoader/PromiseLoader.test.tsx @@ -1,46 +1,25 @@ -import { ShallowWrapper, shallow } from 'enzyme'; import * as React from 'react'; -import PromiseLoader from './PromiseLoader'; +import { Box } from 'src/components/Box'; +import { renderWithTheme } from 'src/utilities/testHelpers'; -const mockAxiosResponse = (ms: number, result?: any) => - new Promise((resolve) => setTimeout(() => resolve(result), ms)); +import PromiseLoader from './PromiseLoader'; describe('PromiseLoaderSpec', () => { - const Component = () =>
    ; - const data = { name: 'whatever' }; - const preloaded = PromiseLoader({ - resource: async () => { - await mockAxiosResponse(100); - return Promise.resolve(data); - }, - }); - const LoadedComponent = preloaded(Component); - let wrapper: ShallowWrapper; + it('shows a loading state initially and the component afterwards', async () => { + const ExampleComponent = () => Hey; - describe('before resolution', () => { - beforeEach(async () => { - wrapper = shallow(); - }); + const examplePromise = async () => { + await new Promise((r) => setTimeout(r, 100)); + return 'OMG!'; + }; - it('should display loading component.', async () => { - expect(wrapper.find('[data-qa-circle-progress]').exists()).toBeTruthy(); - }); - }); + const Component = PromiseLoader({ data: examplePromise })(ExampleComponent); - describe('after resolution', () => { - beforeEach(async () => { - wrapper = shallow(); - await mockAxiosResponse(120); - wrapper.update(); - }); + const { findByText, getByTestId } = renderWithTheme(); - it('should render the Component.', () => { - expect(wrapper.find('Component').exists()).toBeTruthy(); - }); + expect(getByTestId('circle-progress')).toBeVisible(); - it('should inject props onto Component.', async () => { - expect(wrapper.props()).toHaveProperty('resource', { response: data }); - }); + await findByText('Hey'); }); }); diff --git a/packages/manager/src/components/PromiseLoader/PromiseLoader.tsx b/packages/manager/src/components/PromiseLoader/PromiseLoader.tsx index 70052ff86d8..955dd85fc43 100644 --- a/packages/manager/src/components/PromiseLoader/PromiseLoader.tsx +++ b/packages/manager/src/components/PromiseLoader/PromiseLoader.tsx @@ -16,10 +16,11 @@ export interface PromiseLoaderResponse { response: T; } -/* tslint:disable */ +/** + * @deprecated Please don't use this. Use something like React Query instead. + */ export default function preload

    (requests: RequestMap

    ) { return function (Component: React.ComponentType

    ) { - /* tslint:enable */ return class LoadedComponent extends React.Component { componentDidMount() { this.mounted = true; diff --git a/packages/manager/src/components/Tags/Tags.test.tsx b/packages/manager/src/components/Tags/Tags.test.tsx index 9856acf3c69..75a74e42f57 100644 --- a/packages/manager/src/components/Tags/Tags.test.tsx +++ b/packages/manager/src/components/Tags/Tags.test.tsx @@ -1,20 +1,28 @@ -import { shallow } from 'enzyme'; import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + import { Tags } from './Tags'; describe('Tags list', () => { it('should display "Show More" button if the tags list is more than 3', () => { - const component = shallow( + const { container, getByText } = renderWithTheme( ); - expect(component.find('ShowMore')).toHaveLength(1); + expect(getByText('+2')).toBeVisible(); + expect( + container.querySelector('[data-qa-show-more-chip]') + ).toBeInTheDocument(); }); it('shouldn\'t display the "Show More" button if the tags list contains 3 or fewer tags', () => { - const component = shallow(); + const { container } = renderWithTheme( + + ); - expect(component.find('ShowMore')).toHaveLength(0); + expect( + container.querySelector('[data-qa-show-more-chip]') + ).not.toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Help/HelpLanding.test.tsx b/packages/manager/src/features/Help/HelpLanding.test.tsx index f7554f88ff4..d74a1d32e35 100644 --- a/packages/manager/src/features/Help/HelpLanding.test.tsx +++ b/packages/manager/src/features/Help/HelpLanding.test.tsx @@ -1,19 +1,26 @@ -import { shallow } from 'enzyme'; import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + import { HelpLanding } from './HelpLanding'; describe('Help Landing', () => { - const component = shallow(); - it.skip('should render search panel', () => { - expect(component.find('SearchPanel')).toHaveLength(1); + it('should render search panel', () => { + const { getByText } = renderWithTheme(); + + expect(getByText('What can we help you with?')).toBeVisible(); }); it('should render popular posts panel', () => { - expect(component.find('PopularPosts')).toHaveLength(1); + const { getByText } = renderWithTheme(); + + expect(getByText('Most Popular Documentation:')).toBeVisible(); + expect(getByText('Most Popular Community Posts:')).toBeVisible(); }); it('should render other ways panel', () => { - expect(component.find('OtherWays')).toHaveLength(1); + const { getByText } = renderWithTheme(); + + expect(getByText('Other Ways to Get Help')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/Help/SearchHOC.test.tsx b/packages/manager/src/features/Help/SearchHOC.test.tsx index 980590b8ccc..1793cad9b7f 100644 --- a/packages/manager/src/features/Help/SearchHOC.test.tsx +++ b/packages/manager/src/features/Help/SearchHOC.test.tsx @@ -1,8 +1,3 @@ -import { waitFor } from '@testing-library/react'; -import algoliasearch from 'algoliasearch'; -import { shallow } from 'enzyme'; -import * as React from 'react'; - import { community_answer, community_question, @@ -10,7 +5,7 @@ import { } from 'src/__data__/searchResults'; import { COMMUNITY_BASE_URL, DOCS_BASE_URL } from 'src/constants'; -import withSearch, { +import { cleanDescription, convertCommunityToItems, convertDocsToItems, @@ -19,205 +14,120 @@ import withSearch, { getDocsResultLabel, } from './SearchHOC'; -const HITS_PER_PAGE = 10; - -const mockFn = vi.fn(); - -vi.mock('algoliasearch', () => ({ - default: vi.fn().mockImplementation(() => ({ - search: mockFn, - })), -})); - -const mockResults = { - results: [{ hits: [docs_result] }, { hits: [community_question] }], -}; - -const emptyResults = { - results: [{ hits: [] }, { hits: [] }], -}; - -const getSearchFromQuery = (query: string) => [ - { - indexName: 'linode-docs', - params: { - attributesToRetrieve: ['title', '_highlightResult', 'href'], - hitsPerPage: HITS_PER_PAGE, - }, - query, - }, - { - indexName: 'linode-community', - params: { - attributesToRetrieve: ['title', 'description', '_highlightResult'], - distinct: true, - hitsPerPage: HITS_PER_PAGE, - }, - query, - }, -]; - -const searchable = withSearch({ highlight: false, hitsPerPage: HITS_PER_PAGE }); -const RawComponent = searchable(React.Component); - -const component = shallow(); - -describe('Algolia Search HOC', () => { - describe('external API', () => { - afterEach(() => vi.resetAllMocks()); - it('should initialize the index', () => { - expect(algoliasearch).toHaveBeenCalled(); - expect(component.props().searchEnabled).toBe(true); - }); - it('should search the Algolia indices', () => { - const query = getSearchFromQuery('hat'); - component.props().searchAlgolia('hat'); - expect(mockFn).toHaveBeenCalledWith(query); - }); - it('should save an error to state if the request to Algolia fails', () => { - mockFn.mockImplementationOnce((queries: any, callback: any) => - callback({ code: 500, message: 'I reject this request.' }, undefined) - ); - component.props().searchAlgolia('existentialism'); - expect(component.props().searchError).toEqual( - 'There was an error retrieving your search results.' +describe('internal methods', () => { + describe('getDocsResultLabel', () => { + // eslint-disable-next-line xss/no-mixed-html + it('should return a label with highlighted content marked as ', () => { + const label = getDocsResultLabel(docs_result, true); + expect(label).toBe(docs_result._highlightResult.title.value); + }); + it('should use the unformatted title when highlighting is set to false', () => { + const label2 = getDocsResultLabel(docs_result, false); + expect(label2).toBe(docs_result.title); + }); + it('should return an unformatted label if there is no highlighted result', () => { + const result = { ...docs_result } as any; + result._highlightResult = {}; + const label3 = getDocsResultLabel(result, true); + expect(label3).toBe(result.title); + }); + }); + describe('getCommunityUrl', () => { + it('should parse a community question', () => { + expect(getCommunityUrl('q_123')).toMatch('/questions/123'); + }); + it('should parse a community answer', () => { + expect(getCommunityUrl('a_123')).toMatch('/questions/answer/123'); + }); + it('should handle strange input', () => { + expect(getCommunityUrl("I'm not a URL")).toEqual(COMMUNITY_BASE_URL); + }); + }); + describe('cleanDescription', () => { + it('should return a normal string unchanged', () => { + expect(cleanDescription('just a description')).toBe('just a description'); + }); + /* eslint-disable xss/no-mixed-html */ + it('should trim a tag', () => { + expect(cleanDescription('I have a tag')).toBe('I have a tag'); + }); + it('should trim a tag', () => { + expect(cleanDescription('I also have a tag')).toBe( + 'I also have a tag' ); }); - it('should set search results based on the Algolia response', async () => { - mockFn.mockImplementationOnce(() => mockResults); - component.props().searchAlgolia('existentialism'); - - await waitFor(() => { - expect(component.props().searchResults[0]).toHaveLength(1); - expect(component.props().searchResults[1]).toHaveLength(1); - }); - }); - it('should set results list to empty on a blank query', async () => { - mockFn.mockImplementationOnce(() => emptyResults); - component.props().searchAlgolia('existentialism'); - await waitFor(() => { - expect(component.props().searchResults[0]).toHaveLength(0); - expect(component.props().searchResults[1]).toHaveLength(0); - }); + }); + /* eslint-enable xss/no-mixed-html */ + describe('getCommunityResultLabel', () => { + it('should use the highlighted title if available', () => { + const label4 = getCommunityResultLabel(community_question, true); + expect(label4).toBe(community_question._highlightResult.title.value); + }); + it('should use the unformatted title if highlight is false', () => { + const label5 = getCommunityResultLabel(community_question, false); + expect(label5).toBe(community_question.title); + }); + it('should use the description if no title is available', () => { + const label6 = getCommunityResultLabel(community_answer, true); + expect(label6).toBe(community_answer.description); + }); + it('should truncate the title', () => { + const result = { ...community_answer } as any; + result.description = + "A much longer description that can't possibly fit on one line of a results list."; + const label7 = getCommunityResultLabel(result, true); + expect(label7).toBe('A much longer description that can ...'); }); }); - describe('internal methods', () => { - describe('getDocsResultLabel', () => { - // eslint-disable-next-line xss/no-mixed-html - it('should return a label with highlighted content marked as ', () => { - const label = getDocsResultLabel(docs_result, true); - expect(label).toBe(docs_result._highlightResult.title.value); - }); - it('should use the unformatted title when highlighting is set to false', () => { - const label2 = getDocsResultLabel(docs_result, false); - expect(label2).toBe(docs_result.title); - }); - it('should return an unformatted label if there is no highlighted result', () => { - const result = { ...docs_result } as any; - result._highlightResult = {}; - const label3 = getDocsResultLabel(result, true); - expect(label3).toBe(result.title); - }); - }); - describe('getCommunityUrl', () => { - it('should parse a community question', () => { - expect(getCommunityUrl('q_123')).toMatch('/questions/123'); - }); - it('should parse a community answer', () => { - expect(getCommunityUrl('a_123')).toMatch('/questions/answer/123'); - }); - it('should handle strange input', () => { - expect(getCommunityUrl("I'm not a URL")).toEqual(COMMUNITY_BASE_URL); - }); - }); - describe('cleanDescription', () => { - it('should return a normal string unchanged', () => { - expect(cleanDescription('just a description')).toBe( - 'just a description' - ); - }); - /* eslint-disable xss/no-mixed-html */ - it('should trim a tag', () => { - expect(cleanDescription('I have a tag')).toBe('I have a tag'); - }); - it('should trim a tag', () => { - expect(cleanDescription('I also have a tag')).toBe( - 'I also have a tag' - ); - }); - }); - /* eslint-enable xss/no-mixed-html */ - describe('getCommunityResultLabel', () => { - it('should use the highlighted title if available', () => { - const label4 = getCommunityResultLabel(community_question, true); - expect(label4).toBe(community_question._highlightResult.title.value); - }); - it('should use the unformatted title if highlight is false', () => { - const label5 = getCommunityResultLabel(community_question, false); - expect(label5).toBe(community_question.title); - }); - it('should use the description if no title is available', () => { - const label6 = getCommunityResultLabel(community_answer, true); - expect(label6).toBe(community_answer.description); - }); - it('should truncate the title', () => { - const result = { ...community_answer } as any; - result.description = - "A much longer description that can't possibly fit on one line of a results list."; - const label7 = getCommunityResultLabel(result, true); - expect(label7).toBe('A much longer description that can ...'); - }); - }); - describe('convertDocsToItems', () => { - it('should convert docs to a correctly formatted Item[]', () => { - const formattedResults = convertDocsToItems(false, [docs_result]); - expect(formattedResults).toEqual([ - { - data: { - href: DOCS_BASE_URL + docs_result.href, - source: 'Linode documentation', - }, - label: docs_result.title, - value: 0, + describe('convertDocsToItems', () => { + it('should convert docs to a correctly formatted Item[]', () => { + const formattedResults = convertDocsToItems(false, [docs_result]); + expect(formattedResults).toEqual([ + { + data: { + href: DOCS_BASE_URL + docs_result.href, + source: 'Linode documentation', }, - ]); - }); - it('should handle empty results lists correctly', () => { - const results = convertDocsToItems(false, []); - expect(results).toEqual([]); - }); - }); - describe('convertCommunityToItems', () => { - it('should convert a community question to a correctly formatted Item', () => { - const formattedResults = convertCommunityToItems(false, [ - community_question, - ] as any); - expect(formattedResults).toEqual([ - { - data: { - href: expect.any(String), - source: 'Linode Community Site', - }, - label: community_question.title, - value: 0, + label: docs_result.title, + value: 0, + }, + ]); + }); + it('should handle empty results lists correctly', () => { + const results = convertDocsToItems(false, []); + expect(results).toEqual([]); + }); + }); + describe('convertCommunityToItems', () => { + it('should convert a community question to a correctly formatted Item', () => { + const formattedResults = convertCommunityToItems(false, [ + community_question, + ] as any); + expect(formattedResults).toEqual([ + { + data: { + href: expect.any(String), + source: 'Linode Community Site', }, - ]); - }); - it('should convert a community answer to a correctly formatted Item', () => { - const formattedResults = convertCommunityToItems(false, [ - community_answer, - ] as any); - expect(formattedResults).toEqual([ - { - data: { - href: expect.any(String), - source: 'Linode Community Site', - }, - label: community_question.description, - value: 0, + label: community_question.title, + value: 0, + }, + ]); + }); + it('should convert a community answer to a correctly formatted Item', () => { + const formattedResults = convertCommunityToItems(false, [ + community_answer, + ] as any); + expect(formattedResults).toEqual([ + { + data: { + href: expect.any(String), + source: 'Linode Community Site', }, - ]); - }); + label: community_question.description, + value: 0, + }, + ]); }); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx index 30c9ccc7c70..4e824c8772f 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx @@ -1,7 +1,7 @@ -import { shallow } from 'enzyme'; import * as React from 'react'; +import { linodeFactory } from 'src/factories'; -import { LinodesWithBackups } from 'src/__data__/LinodesWithBackups'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { CombinedProps, FromBackupsContent } from './FromBackupsContent'; @@ -21,35 +21,27 @@ const mockProps: CombinedProps = { }; describe('FromBackupsContent', () => { - const component = shallow(); - - component.setState({ isGettingBackups: false }); // get rid of loading state - it('should render Placeholder if no valid backups exist', () => { - expect(component.find('Placeholder')).toHaveLength(1); + 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(); }); - // @todo: Rewrite these tests with react-testing-library. - describe.skip('FromBackupsContent When Valid Backups Exist', () => { - beforeAll(async () => { - component.setState({ linodesWithBackups: LinodesWithBackups }); - await component.update(); + 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', }); - it('should render SelectLinode panel', () => { - expect( - component.find( - 'WithTheme(WithRenderGuard(WithStyles(SelectLinodePanel)))' - ) - ).toHaveLength(1); - }); + const { getByText } = renderWithTheme( + + ); - it('should render SelectBackup panel', () => { - expect( - component.find( - 'WithTheme(WithRenderGuard(WithStyles(SelectBackupPanel)))' - ) - ).toHaveLength(1); - }); + expect(getByText('this-linode-should-show-up')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx index af2bc8d954a..6780627de63 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx @@ -1,14 +1,9 @@ -import { shallow } from 'enzyme'; import * as React from 'react'; -import { Provider } from 'react-redux'; -import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; -import { storeFactory } from 'src/store'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { CombinedProps, FromImageContent } from './FromImageContent'; -const store = storeFactory(); - const mockProps: CombinedProps = { accountBackupsEnabled: false, imagesData: {}, @@ -21,19 +16,21 @@ const mockProps: CombinedProps = { }; describe('FromImageContent', () => { - const component = shallow( - - - - - - ); + it('should render an image select', () => { + const { getByLabelText } = renderWithTheme( + + ); - it('should render without crashing', () => { - expect(component).toHaveLength(1); + expect(getByLabelText('Images')).toBeVisible(); }); - it.skip('should render SelectImage panel', () => { - expect(component.find('[data-qa-select-image-panel]')).toHaveLength(1); + 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/FromLinodeContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx index 4299dff10ee..35a3257f7f6 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx @@ -1,19 +1,14 @@ -import { shallow } from 'enzyme'; import * as React from 'react'; -import { Provider } from 'react-redux'; -import { linodes } from 'src/__data__/linodes'; -import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; -import { storeFactory } from 'src/store'; +import { linodeFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { CombinedProps, FromLinodeContent } from './FromLinodeContent'; -const store = storeFactory(); - const mockProps: CombinedProps = { accountBackupsEnabled: false, imagesData: {}, - linodesData: linodes, + linodesData: [], regionsData: [], typesData: [], updateDiskSize: vi.fn(), @@ -24,34 +19,25 @@ const mockProps: CombinedProps = { userCannotCreateLinode: false, }; -describe('FromImageContent', () => { - const component = shallow( - - - - - - ); - - const componentWithoutLinodes = shallow( - - - - - - ); +describe('FromLinodeContent', () => { + it('should render an empty state if the user has no Linodes', () => { + const { getByText } = renderWithTheme(); - it('should render without crashing', () => { - expect(component).toHaveLength(1); + 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.skip('should render a Placeholder when linodes prop has no length', () => { - expect(componentWithoutLinodes.find('[data-qa-placeholder]')).toHaveLength( - 1 + it("should render a user's linodes", () => { + const linodes = linodeFactory.buildList(1, { + label: 'this-linode-should-render', + }); + const { getByText } = renderWithTheme( + ); - }); - it.skip('should render SelectLinode panel', () => { - expect(component.find('[data-qa-linode-panel]')).toHaveLength(1); + expect(getByText('this-linode-should-render')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx index aedc6d8d18d..c2e5df96c6e 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx @@ -1,29 +1,24 @@ -import { shallow } from 'enzyme'; +import { getStackScripts } from '@linode/api-v4'; import * as React from 'react'; -import { Provider } from 'react-redux'; -import { UserDefinedFields as mockUserDefinedFields } from 'src/__data__/UserDefinedFields'; -import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; -import { imageFactory } from 'src/factories/images'; -import { storeFactory } from 'src/store'; +import { stackScriptFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { CombinedProps, FromStackScriptContent, } from './FromStackScriptContent'; -const store = storeFactory(); - -const mockImages = imageFactory.buildList(10); - const mockProps: CombinedProps = { accountBackupsEnabled: false, - category: 'community', + category: 'account', handleSelectUDFs: vi.fn(), header: '', imagesData: {}, regionsData: [], - request: vi.fn(), + request: getStackScripts, updateImageID: vi.fn(), updateRegionID: vi.fn(), updateStackScript: vi.fn(), @@ -31,50 +26,23 @@ const mockProps: CombinedProps = { userCannotCreateLinode: false, }; -describe('FromImageContent', () => { - const component = shallow( - - - - - - ); - - const componentWithUDFs = shallow( - - - - - - ); - - it('should render without crashing', () => { - expect(component).toHaveLength(1); - }); - - it.skip('should render SelectStackScript panel', () => { - expect(component.find('[data-qa-select-stackscript]')).toHaveLength(1); - }); - - it.skip('should render UserDefinedFields panel', () => { - expect(componentWithUDFs.find('[data-qa-udf-panel]')).toHaveLength(1); - }); - - it.skip('should not render UserDefinedFields panel if no UDFs', () => { - expect(component.find('[data-qa-udf-panel]')).toHaveLength(0); - }); +describe('FromStackScriptContent', () => { + it('should render stackscripts', async () => { + const stackscripts = stackScriptFactory.buildList(3); - it.skip('should not render SelectImage panel if no compatibleImages', () => { - expect(component.find('[data-qa-select-image-panel]')).toHaveLength(0); - }); + server.use( + rest.get('*/v4/linode/stackscripts', (req, res, ctx) => { + return res(ctx.json(makeResourcePage(stackscripts))); + }) + ); - it.skip('should render SelectImage panel there are compatibleImages', () => { - expect(componentWithUDFs.find('[data-qa-select-image-panel]')).toHaveLength( - 1 + 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/LinodesLanding/IPAddress.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.test.tsx index 2cd5002fd6f..419f7583082 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.test.tsx @@ -1,6 +1,7 @@ -import { shallow } from 'enzyme'; import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + import { IPAddress, sortIPAddress } from './IPAddress'; const publicIP = '8.8.8.8'; @@ -8,77 +9,90 @@ const publicIP2 = '45.45.45.45'; const privateIP = '192.168.220.103'; const privateIP2 = '192.168.220.102'; -const component = shallow(); - describe('IPAddress', () => { - it('should render without error and display one IP address if showAll is false', () => { - component.setProps({ showAll: false, showMore: true }); - const rendered = component.find('[data-qa-copy-ip-text]'); + it('should display one IP address if showAll is false', () => { + const { container, getByText } = renderWithTheme( + + ); + + // first IP address should be visible + expect(getByText('8.8.8.8')).toBeVisible(); - expect(rendered).toHaveLength(1); - expect(rendered.prop('text')).toEqual('8.8.8.8'); + // Show more button should be visible + expect(container.querySelector('[data-qa-show-more-chip]')).toBeVisible(); }); it('should not display ShowMore button unless the showMore prop is true', () => { - component.setProps({ showAll: false, showMore: false }); - expect(component.find('[data-qa-ip-more]')).toHaveLength(0); - }); + const { container, getByText } = renderWithTheme( + + ); + + // first IP address should be visible + expect(getByText('8.8.8.8')).toBeVisible(); - it('should render ShowMore with props.items = IPs', () => { - component.setProps({ showMore: true }); - const showmore = component.find('[data-qa-ip-more]'); - expect(showmore.exists()).toBe(true); - expect(showmore.prop('items')).toEqual(['8.8.4.4']); + // Show more button should not be visible + expect(container.querySelector('[data-qa-show-more-chip]')).toBeNull(); }); - // TODO figure out this test !!!!!!! - it('should render the copy icon, but not show it if showTooltipOnIpHover is false', () => { - const icon = component.find('[data-testid]'); - expect(icon).toHaveLength(1); - expect(icon.get(0).props.isIpHovered).toBe(false); - expect(icon.get(0).props.showTooltipOnIpHover).toBe(false); + it('should render the copy icon if showTooltipOnIpHover is false', () => { + const { container } = renderWithTheme( + + ); - component.setProps({ showTooltipOnIpHover: true }); - const copy = component.find('[data-qa-copy-ip]'); - expect(copy).toHaveLength(1); - const icon2 = component.find('[data-testid]'); - expect(icon2.get(0).props.showTooltipOnIpHover).toBe(true); + expect(container.querySelector('[data-qa-copy-ip-text]')).toBeVisible(); }); - describe('IP address sorting', () => { - it('should place private IPs after public IPs', () => { - expect([publicIP, privateIP].sort(sortIPAddress)).toEqual([ - publicIP, - privateIP, - ]); - expect([privateIP, publicIP].sort(sortIPAddress)).toEqual([ - publicIP, - privateIP, - ]); - }); - it('should not change order of two addresses of the same type', () => { - expect([publicIP, publicIP2].sort(sortIPAddress)).toEqual([ - publicIP, - publicIP2, - ]); - expect([privateIP, privateIP2].sort(sortIPAddress)).toEqual([ - privateIP, - privateIP2, - ]); - }); - it('should sort longer lists correctly', () => { - expect( - [publicIP, privateIP, publicIP2, privateIP2].sort(sortIPAddress) - ).toEqual([publicIP, publicIP2, privateIP, privateIP2]); - expect( - [privateIP, publicIP, publicIP2, privateIP2].sort(sortIPAddress) - ).toEqual([publicIP, publicIP2, privateIP, privateIP2]); - }); + it('should disable copy functionality if disabled is true', () => { + const { container } = renderWithTheme( + + ); + + expect(container.querySelector('[data-qa-copy-ip-text]')).toBeDisabled(); }); +}); - it('should disable copy functionality if disabled is true', () => { - component.setProps({ disabled: true }); - const copyTooltip = component.find('[data-qa-copy-ip-text]'); - expect(copyTooltip.prop('disabled')).toBe(true); +describe('IP address sorting', () => { + it('should place private IPs after public IPs', () => { + expect([publicIP, privateIP].sort(sortIPAddress)).toEqual([ + publicIP, + privateIP, + ]); + expect([privateIP, publicIP].sort(sortIPAddress)).toEqual([ + publicIP, + privateIP, + ]); + }); + it('should not change order of two addresses of the same type', () => { + expect([publicIP, publicIP2].sort(sortIPAddress)).toEqual([ + publicIP, + publicIP2, + ]); + expect([privateIP, privateIP2].sort(sortIPAddress)).toEqual([ + privateIP, + privateIP2, + ]); + }); + it('should sort longer lists correctly', () => { + expect( + [publicIP, privateIP, publicIP2, privateIP2].sort(sortIPAddress) + ).toEqual([publicIP, publicIP2, privateIP, privateIP2]); + expect( + [privateIP, publicIP, publicIP2, privateIP2].sort(sortIPAddress) + ).toEqual([publicIP, publicIP2, privateIP, privateIP2]); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx index 9d42d672c8b..d882e9c0b82 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx @@ -1,5 +1,4 @@ import userEvent from '@testing-library/user-event'; -import { shallow } from 'enzyme'; import * as React from 'react'; import { linodeFactory } from 'src/factories'; @@ -10,15 +9,13 @@ import { LinodeRow, RenderFlag } from './LinodeRow'; describe('LinodeRow', () => { describe('when Linode has mutation', () => { it('should render a Flag', () => { - const wrapper = shallow(); - - const Tooltip = wrapper.find('Tooltip'); - - expect(Tooltip).toHaveLength(1); - expect(Tooltip.props()).toHaveProperty( - 'title', - 'There is a free upgrade available for this Linode' + const { getByLabelText } = renderWithTheme( + ); + + expect( + getByLabelText('There is a free upgrade available for this Linode') + ).toBeVisible(); }); }); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx index 55993a6d7dc..121a8549c38 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx @@ -1,26 +1,16 @@ import { screen } from '@testing-library/react'; import * as React from 'react'; -import { pageyProps } from 'src/__data__/pageyProps'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AccessKeyLanding } from './AccessKeyLanding'; const props = { accessDrawerOpen: false, - classes: { - confirmationDialog: '', - createdCell: '', - headline: '', - helperText: '', - labelCell: '', - paper: '', - }, closeAccessDrawer: vi.fn(), isRestrictedUser: false, mode: 'creating' as any, openAccessDrawer: vi.fn(), - ...pageyProps, }; describe('AccessKeyLanding', () => { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.test.tsx index e6c376799db..bbce7fa8900 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.test.tsx @@ -1,41 +1,46 @@ -import { shallow } from 'enzyme'; import * as React from 'react'; -import { buckets } from 'src/__data__/buckets'; +import { objectStorageBucketFactory } from 'src/factories'; +import { renderWithTheme, mockMatchMedia } from 'src/utilities/testHelpers'; import { BucketTable } from './BucketTable'; -describe('BucketTable', () => { - const wrapper = shallow( - - ); +beforeAll(() => mockMatchMedia()); - const innerComponent = wrapper.dive(); +describe('BucketTable', () => { + it('renders table column headers', () => { + const { getByText } = renderWithTheme( + + ); - it('renders without crashing', () => { - expect(wrapper).toHaveLength(1); + expect(getByText('Name')).toBeVisible(); + expect(getByText('Region')).toBeVisible(); + expect(getByText('Created')).toBeVisible(); + expect(getByText('Size')).toBeVisible(); }); - it('renders a "Name" column', () => { - expect(innerComponent.find('[data-qa-name]')).toHaveLength(1); - }); - it('renders a "Region" column', () => { - expect(innerComponent.find('[data-qa-region]')).toHaveLength(1); - }); - it('renders a "Created" column', () => { - expect(innerComponent.find('[data-qa-created]')).toHaveLength(1); - }); - it('renders a "Size" column', () => { - expect(innerComponent.find('[data-qa-size]')).toHaveLength(1); - }); - it('renders a RenderData component with the provided data', () => { - expect(innerComponent.find('RenderData').prop('data')).toEqual(buckets); + it('renders buckets', () => { + const buckets = objectStorageBucketFactory.buildList(3); + const { getByText } = renderWithTheme( + + ); + + for (const bucket of buckets) { + expect(getByText(bucket.label)).toBeVisible(); + } }); }); diff --git a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.test.tsx b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.test.tsx index a09e9d7cbed..6d9de4f6fe6 100644 --- a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.test.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.test.tsx @@ -1,12 +1,12 @@ import { Grants, Profile } from '@linode/api-v4/lib'; import { APIError } from '@linode/api-v4/lib/types'; -import { shallow } from 'enzyme'; import * as React from 'react'; import { UseQueryResult } from 'react-query'; import { reactRouterProps } from 'src/__data__/reactRouterProps'; import { imageFactory, normalizeEntities, profileFactory } from 'src/factories'; import { queryClientFactory } from 'src/queries/base'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { StackScriptCreate } from './StackScriptCreate'; @@ -14,56 +14,40 @@ const images = normalizeEntities(imageFactory.buildList(10)); const queryClient = queryClientFactory(); describe('StackScriptCreate', () => { - const component = shallow( - - } - grants={{ data: {} } as UseQueryResult} - imagesData={images} - imagesLastUpdated={0} - imagesLoading={false} - mode="create" - queryClient={queryClient} - /> - ); - - it.skip('should container ', () => { - expect(component.find('LandingHeader')).toHaveLength(1); - }); - - it.skip('should render a title that reads "Create StackScript', () => { - const titleText = component - .find('WithStyles(Typography)') - .first() - .children() - .text(); - expect(titleText).toBe('Create StackScript'); - }); - - it.skip(`should render a confirmation dialog with the - title "Clear StackScript Configuration?"`, () => { - const modalTitle = component - .find('WithStyles(ConfirmationDialog)') - .prop('title'); - expect(modalTitle).toBe('Clear StackScript Configuration?'); - }); - - it.skip('should render StackScript Form', () => { - expect(component.find('StackScriptForm')).toHaveLength(1); - }); - - describe('Back Arrow Icon Button', () => { - it.skip('should render back array icon button', () => { - const backIcon = component.find('WithStyles(IconButton)').first(); - expect(backIcon.find('pure(KeyboardArrowLeft)')).toHaveLength(1); - }); - - it.skip('back arrow icon should link back to stackscripts landing', () => { - const backIcon = component.find('WithStyles(IconButton)').first(); - const parentLink = backIcon.closest('Link'); - expect(parentLink.prop('to')).toBe('/stackscripts'); - }); + it('should render header, inputs, and buttons', () => { + const { getByLabelText, getByText } = renderWithTheme( + + } + grants={{ data: {} } as UseQueryResult} + imagesData={images} + imagesLastUpdated={0} + imagesLoading={false} + mode="create" + queryClient={queryClient} + />, + { queryClient } + ); + + expect(getByText('Create')).toBeVisible(); + + expect(getByLabelText('StackScript Label (required)')).toBeVisible(); + expect(getByLabelText('Description')).toBeVisible(); + expect(getByLabelText('Target Images')).toBeVisible(); + expect(getByLabelText('Script (required)')).toBeVisible(); + expect(getByLabelText('Revision Note')).toBeVisible(); + + const createButton = getByText('Create StackScript').closest('button'); + expect(createButton).toBeVisible(); + expect(createButton).toBeDisabled(); + + const resetButton = getByText('Reset').closest('button'); + expect(resetButton).toBeVisible(); + expect(resetButton).toBeEnabled(); }); }); diff --git a/packages/manager/src/features/Support/TicketAttachmentRow.test.tsx b/packages/manager/src/features/Support/TicketAttachmentRow.test.tsx index a355ed51fe5..59c1c3a6f7b 100644 --- a/packages/manager/src/features/Support/TicketAttachmentRow.test.tsx +++ b/packages/manager/src/features/Support/TicketAttachmentRow.test.tsx @@ -1,18 +1,13 @@ import InsertDriveFile from '@mui/icons-material/InsertDriveFile'; import InsertPhoto from '@mui/icons-material/InsertPhoto'; -import { shallow } from 'enzyme'; import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + import { TicketAttachmentRow } from './TicketAttachmentRow'; const props = { attachments: ['file1', 'file2', 'file3'], - classes: { - attachmentIcon: '', - attachmentPaper: '', - attachmentRow: '', - root: '', - }, icons: [ , , @@ -20,27 +15,12 @@ const props = { ], }; -const component = shallow(); - describe('TicketAttachmentRow component', () => { - it('should render', () => { - expect(component).toBeDefined(); - }); - it('should render its props', () => { - expect(component.find('[data-qa-attachment-row]')).toHaveLength(3); - }); - it('should render an icon for each attachment', () => { - expect( - component - .find('[data-qa-attachment-row]') - .first() - .containsMatchingElement() - ).toBeTruthy(); - expect( - component - .find('[data-qa-attachment-row]') - .last() - .containsMatchingElement() - ).toBeTruthy(); + it('should render each attachment', () => { + const { getByText } = renderWithTheme(); + + for (const attachment of props.attachments) { + expect(getByText(attachment)).toBeVisible(); + } }); }); diff --git a/packages/manager/src/hooks/useOrder.ts b/packages/manager/src/hooks/useOrder.ts index ede57ad0fad..ce31f94a334 100644 --- a/packages/manager/src/hooks/useOrder.ts +++ b/packages/manager/src/hooks/useOrder.ts @@ -3,11 +3,12 @@ import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import { getInitialValuesFromUserPreferences } from 'src/components/OrderBy'; -import { Order } from 'src/components/Pagey/Pagey'; import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; import { OrderSet } from 'src/types/ManagerPreferences'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; +export type Order = 'asc' | 'desc'; + /** * useOrder is a hook that allows you to handle ordering tables. It takes into account * the following items when determining inital order diff --git a/packages/manager/src/layouts/Logout.test.tsx b/packages/manager/src/layouts/Logout.test.tsx index c43a98f5b50..d782e2f0f02 100644 --- a/packages/manager/src/layouts/Logout.test.tsx +++ b/packages/manager/src/layouts/Logout.test.tsx @@ -1,18 +1,18 @@ -import { shallow } from 'enzyme'; import * as React from 'react'; -import { Logout } from './Logout'; +import { renderWithTheme } from 'src/utilities/testHelpers'; -describe('layouts/Logout', () => { - const component = shallow( - - ); +import { Logout } from './Logout'; +describe('Logout', () => { it('dispatches logout action on componentDidMount', () => { - const instance = component.instance(); - if (!instance) { - throw Error('Logout component did not mount!'); - } - expect(instance.props.dispatchLogout).toBeCalled(); + const props = { + dispatchLogout: vi.fn(), + token: '', + }; + + renderWithTheme(); + + expect(props.dispatchLogout).toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/testSetup.ts b/packages/manager/src/testSetup.ts index 1660a6821b6..6f87e964ec6 100644 --- a/packages/manager/src/testSetup.ts +++ b/packages/manager/src/testSetup.ts @@ -1,12 +1,6 @@ import matchers from '@testing-library/jest-dom/matchers'; -import Enzyme from 'enzyme'; -// @ts-expect-error not a big deal, we can suffer -import Adapter from 'enzyme-adapter-react-16'; import { expect } from 'vitest'; -// // Enzyme React 17 adapter. -// Enzyme.configure({ adapter: new Adapter() }); - // JSDom matchers. expect.extend(matchers); @@ -18,8 +12,6 @@ afterEach(() => server.resetHandlers()); require('@testing-library/jest-dom/extend-expect'); -Enzyme.configure({ adapter: new Adapter() }); - // @ts-expect-error this prevents some console errors HTMLCanvasElement.prototype.getContext = () => { return 0; diff --git a/packages/manager/src/types/ManagerPreferences.ts b/packages/manager/src/types/ManagerPreferences.ts index dbe683c0899..f388a292248 100644 --- a/packages/manager/src/types/ManagerPreferences.ts +++ b/packages/manager/src/types/ManagerPreferences.ts @@ -1,8 +1,9 @@ import { UserPreferences } from '@linode/api-v4'; -import { Order } from 'src/components/Pagey'; import { ThemeChoice } from 'src/utilities/theme'; +import type { Order } from 'src/hooks/useOrder'; + export interface OrderSet { order: Order; orderBy: string; diff --git a/yarn.lock b/yarn.lock index 5ee60d67922..a7c93940f90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4377,13 +4377,6 @@ dependencies: moment "^2.10.2" -"@types/cheerio@*": - version "0.22.31" - resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.31.tgz#b8538100653d6bb1b08a1e46dec75b4f2a5d5eb6" - integrity sha512-Kt7Cdjjdi2XWSfrZ53v4Of0wG3ZcmaegFXjMmz9tfNrZSkzzo36G0AL1YqSdcIA78Etjt6E609pt5h1xnQkPUw== - dependencies: - "@types/node" "*" - "@types/connect@*": version "3.4.35" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" @@ -4498,14 +4491,6 @@ resolved "https://registry.yarnpkg.com/@types/emscripten/-/emscripten-1.39.9.tgz#cbe73a8d153fc714a2e3177fbda2d7332d45efa7" integrity sha512-ILdWj4XYtNOqxJaW22NEQx2gJsLfV5ncxYhhGX1a1H1lXl2Ta0gUz7QOnOoF1xQbJwWDjImi8gXN9mKdIf6n9g== -"@types/enzyme@^3.9.3": - version "3.10.12" - resolved "https://registry.yarnpkg.com/@types/enzyme/-/enzyme-3.10.12.tgz#ac4494801b38188935580642f772ad18f72c132f" - integrity sha512-xryQlOEIe1TduDWAOphR0ihfebKFSWOXpIsk+70JskCfRfW+xALdnJ0r1ZOTo85F9Qsjk6vtlU7edTYHbls9tA== - dependencies: - "@types/cheerio" "*" - "@types/react" "*" - "@types/escodegen@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@types/escodegen/-/escodegen-0.0.6.tgz#5230a9ce796e042cda6f086dbf19f22ea330659c" @@ -5507,21 +5492,6 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" -airbnb-prop-types@^2.16.0: - version "2.16.0" - resolved "https://registry.yarnpkg.com/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz#b96274cefa1abb14f623f804173ee97c13971dc2" - integrity sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg== - dependencies: - array.prototype.find "^2.1.1" - function.prototype.name "^1.1.2" - is-regex "^1.1.0" - object-is "^1.1.2" - object.assign "^4.1.0" - object.entries "^1.1.2" - prop-types "^15.7.2" - prop-types-exact "^1.2.0" - react-is "^16.13.1" - ajv@8.11.0: version "8.11.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" @@ -5718,37 +5688,6 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== -array.prototype.filter@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array.prototype.filter/-/array.prototype.filter-1.0.2.tgz#5f90ca6e3d01c31ea8db24c147665541db28bb4c" - integrity sha512-us+UrmGOilqttSOgoWZTpOvHu68vZT2YCjc/H4vhu56vzZpaDFBhB+Se2UwqWzMKbDv7Myq5M5pcZLAtUvTQdQ== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - es-array-method-boxes-properly "^1.0.0" - is-string "^1.0.7" - -array.prototype.find@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/array.prototype.find/-/array.prototype.find-2.2.1.tgz#769b8182a0b535c3d76ac025abab98ba1e12467b" - integrity sha512-I2ri5Z9uMpMvnsNrHre9l3PaX+z9D0/z6F7Yt2u15q7wt0I62g5kX6xUKR1SJiefgG+u2/gJUmM8B47XRvQR6w== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - es-shim-unscopables "^1.0.0" - -array.prototype.flat@^1.2.3: - version "1.3.1" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz#ffc6576a7ca3efc2f46a143b9d1dda9b4b3cf5e2" - integrity sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - es-shim-unscopables "^1.0.0" - array.prototype.flatmap@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz#1aae7903c2100433cb8261cd4ed310aab5c4a183" @@ -6091,11 +6030,6 @@ body-parser@1.20.1: type-is "~1.6.18" unpipe "1.0.0" -boolbase@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== - boxen@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/boxen/-/boxen-7.0.0.tgz#9e5f8c26e716793fc96edcf7cf754cdf5e3fbf32" @@ -6485,31 +6419,6 @@ check-more-types@^2.24.0: resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" integrity sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA== -cheerio-select@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" - integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== - dependencies: - boolbase "^1.0.0" - css-select "^5.1.0" - css-what "^6.1.0" - domelementtype "^2.3.0" - domhandler "^5.0.3" - domutils "^3.0.1" - -cheerio@^1.0.0-rc.3: - version "1.0.0-rc.12" - resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" - integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== - dependencies: - cheerio-select "^2.1.0" - dom-serializer "^2.0.0" - domhandler "^5.0.3" - domutils "^3.0.1" - htmlparser2 "^8.0.1" - parse5 "^7.0.0" - parse5-htmlparser2-tree-adapter "^7.0.0" - chokidar@^3.4.2, chokidar@^3.5.1, chokidar@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" @@ -6694,11 +6603,6 @@ commander@^10.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.0.tgz#71797971162cd3cf65f0b9d24eb28f8d303acdf1" integrity sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA== -commander@^2.19.0: - version "2.20.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - commander@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" @@ -6968,22 +6872,6 @@ css-mediaquery@^0.1.2: resolved "https://registry.yarnpkg.com/css-mediaquery/-/css-mediaquery-0.1.2.tgz#6a2c37344928618631c54bd33cedd301da18bea0" integrity sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q== -css-select@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" - integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== - dependencies: - boolbase "^1.0.0" - css-what "^6.1.0" - domhandler "^5.0.2" - domutils "^3.0.1" - nth-check "^2.0.1" - -css-what@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" - integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== - css.escape@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" @@ -7416,11 +7304,6 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" -discontinuous-range@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" - integrity sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ== - doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -7591,7 +7474,7 @@ enquirer@^2.3.6: dependencies: ansi-colors "^4.1.1" -entities@^4.2.0, entities@^4.3.0, entities@^4.4.0: +entities@^4.2.0, entities@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174" integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA== @@ -7606,70 +7489,6 @@ envinfo@^7.7.3: resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475" integrity sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw== -enzyme-adapter-react-16@^1.14.0: - version "1.15.7" - resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.7.tgz#a737e6d8e2c147e9da5acf957755be7634f76201" - integrity sha512-LtjKgvlTc/H7adyQcj+aq0P0H07LDL480WQl1gU512IUyaDo/sbOaNDdZsJXYW2XaoPqrLLE9KbZS+X2z6BASw== - dependencies: - enzyme-adapter-utils "^1.14.1" - enzyme-shallow-equal "^1.0.5" - has "^1.0.3" - object.assign "^4.1.4" - object.values "^1.1.5" - prop-types "^15.8.1" - react-is "^16.13.1" - react-test-renderer "^16.0.0-0" - semver "^5.7.0" - -enzyme-adapter-utils@^1.14.1: - version "1.14.1" - resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.1.tgz#f30db15dafc22e0ccd44f5acc8d93be29218cdcf" - integrity sha512-JZgMPF1QOI7IzBj24EZoDpaeG/p8Os7WeBZWTJydpsH7JRStc7jYbHE4CmNQaLqazaGFyLM8ALWA3IIZvxW3PQ== - dependencies: - airbnb-prop-types "^2.16.0" - function.prototype.name "^1.1.5" - has "^1.0.3" - object.assign "^4.1.4" - object.fromentries "^2.0.5" - prop-types "^15.8.1" - semver "^5.7.1" - -enzyme-shallow-equal@^1.0.1, enzyme-shallow-equal@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.5.tgz#5528a897a6ad2bdc417c7221a7db682cd01711ba" - integrity sha512-i6cwm7hN630JXenxxJFBKzgLC3hMTafFQXflvzHgPmDhOBhxUWDe8AeRv1qp2/uWJ2Y8z5yLWMzmAfkTOiOCZg== - dependencies: - has "^1.0.3" - object-is "^1.1.5" - -enzyme@^3.10.0: - version "3.11.0" - resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-3.11.0.tgz#71d680c580fe9349f6f5ac6c775bc3e6b7a79c28" - integrity sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw== - dependencies: - array.prototype.flat "^1.2.3" - cheerio "^1.0.0-rc.3" - enzyme-shallow-equal "^1.0.1" - function.prototype.name "^1.1.2" - has "^1.0.3" - html-element-map "^1.2.0" - is-boolean-object "^1.0.1" - is-callable "^1.1.5" - is-number-object "^1.0.4" - is-regex "^1.0.5" - is-string "^1.0.5" - is-subset "^0.1.1" - lodash.escape "^4.0.1" - lodash.isequal "^4.5.0" - object-inspect "^1.7.0" - object-is "^1.0.2" - object.assign "^4.1.0" - object.entries "^1.1.1" - object.values "^1.1.1" - raf "^3.4.1" - rst-selector-parser "^2.2.3" - string.prototype.trim "^1.2.1" - error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -7716,11 +7535,6 @@ es-abstract@^1.19.0, es-abstract@^1.20.4: unbox-primitive "^1.0.2" which-typed-array "^1.1.9" -es-array-method-boxes-properly@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" - integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== - es-get-iterator@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" @@ -8857,7 +8671,7 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -function.prototype.name@^1.1.2, function.prototype.name@^1.1.5: +function.prototype.name@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621" integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA== @@ -9281,14 +9095,6 @@ hosted-git-info@^2.1.4, hosted-git-info@^5.0.0: dependencies: lru-cache "^7.5.1" -html-element-map@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/html-element-map/-/html-element-map-1.3.1.tgz#44b2cbcfa7be7aa4ff59779e47e51012e1c73c08" - integrity sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg== - dependencies: - array.prototype.filter "^1.0.0" - call-bind "^1.0.2" - html-encoding-sniffer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" @@ -9324,16 +9130,6 @@ htmlparser2@^8.0.0: domutils "^3.0.1" entities "^4.4.0" -htmlparser2@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.1.tgz#abaa985474fcefe269bc761a779b544d7196d010" - integrity sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA== - dependencies: - domelementtype "^2.3.0" - domhandler "^5.0.2" - domutils "^3.0.1" - entities "^4.3.0" - http-errors@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" @@ -9626,7 +9422,7 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" -is-boolean-object@^1.0.1, is-boolean-object@^1.1.0: +is-boolean-object@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== @@ -9644,7 +9440,7 @@ is-buffer@~1.1.6: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.7: +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== @@ -9830,7 +9626,7 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== -is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.1.4: +is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== @@ -9872,11 +9668,6 @@ is-string@^1.0.5, is-string@^1.0.7: dependencies: has-tostringtag "^1.0.0" -is-subset@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6" - integrity sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw== - is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" @@ -10613,26 +10404,11 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== -lodash.escape@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-4.0.1.tgz#c9044690c21e04294beaa517712fded1fa88de98" - integrity sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw== - -lodash.flattendeep@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" - integrity sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ== - lodash.get@^4.3.0: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== -lodash.isequal@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" - integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== - lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" @@ -11468,11 +11244,6 @@ moment@^2.10.2: resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== -moo@^0.5.0: - version "0.5.2" - resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c" - integrity sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q== - mri@^1.1.0, mri@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" @@ -11564,16 +11335,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -nearley@^2.7.10: - version "2.20.1" - resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474" - integrity sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ== - dependencies: - commander "^2.19.0" - moo "^0.5.0" - railroad-diagrams "^1.0.0" - randexp "0.4.6" - negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" @@ -11687,13 +11448,6 @@ npm-run-path@^5.1.0: dependencies: path-key "^4.0.0" -nth-check@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" - integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== - dependencies: - boolbase "^1.0.0" - number-is-nan@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" @@ -11709,12 +11463,12 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -object-inspect@^1.12.2, object-inspect@^1.12.3, object-inspect@^1.7.0, object-inspect@^1.9.0: +object-inspect@^1.12.2, object-inspect@^1.12.3, object-inspect@^1.9.0: version "1.12.3" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== -object-is@^1.0.1, object-is@^1.0.2, object-is@^1.1.2, object-is@^1.1.5: +object-is@^1.0.1, object-is@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== @@ -11727,7 +11481,7 @@ object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.0, object.assign@^4.1.3, object.assign@^4.1.4: +object.assign@^4.1.3, object.assign@^4.1.4: version "4.1.4" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== @@ -11737,7 +11491,7 @@ object.assign@^4.1.0, object.assign@^4.1.3, object.assign@^4.1.4: has-symbols "^1.0.3" object-keys "^1.1.1" -object.entries@^1.1.1, object.entries@^1.1.2, object.entries@^1.1.6: +object.entries@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.6.tgz#9737d0e5b8291edd340a3e3264bb8a3b00d5fa23" integrity sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w== @@ -11746,7 +11500,7 @@ object.entries@^1.1.1, object.entries@^1.1.2, object.entries@^1.1.6: define-properties "^1.1.4" es-abstract "^1.20.4" -object.fromentries@^2.0.5, object.fromentries@^2.0.6: +object.fromentries@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.6.tgz#cdb04da08c539cffa912dcd368b886e0904bfa73" integrity sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg== @@ -11763,7 +11517,7 @@ object.hasown@^1.1.2: define-properties "^1.1.4" es-abstract "^1.20.4" -object.values@^1.1.1, object.values@^1.1.5, object.values@^1.1.6: +object.values@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.6.tgz#4abbaa71eba47d63589d402856f908243eea9b1d" integrity sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw== @@ -11999,14 +11753,6 @@ parse-srcset@^1.0.2: resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== -parse5-htmlparser2-tree-adapter@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1" - integrity sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g== - dependencies: - domhandler "^5.0.2" - parse5 "^7.0.0" - parse5@^7.0.0, parse5@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" @@ -12389,15 +12135,6 @@ prompts@^2.4.0: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types-exact@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/prop-types-exact/-/prop-types-exact-1.2.0.tgz#825d6be46094663848237e3925a98c6e944e9869" - integrity sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA== - dependencies: - has "^1.0.3" - object.assign "^4.1.0" - reflect.ownkeys "^0.2.0" - prop-types@^15.0.0, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" @@ -12535,11 +12272,6 @@ raf@^3.4.1: dependencies: performance-now "^2.1.0" -railroad-diagrams@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" - integrity sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A== - ramda@0.25.0, ramda@~0.25.0: version "0.25.0" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" @@ -12555,14 +12287,6 @@ ramda@^0.28.0: resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.28.0.tgz#acd785690100337e8b063cab3470019be427cc97" integrity sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA== -randexp@0.4.6: - version "0.4.6" - resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3" - integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ== - dependencies: - discontinuous-range "1.0.0" - ret "~0.1.10" - range-parser@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" @@ -12831,7 +12555,7 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" -react-test-renderer@^16.0.0-0: +react-test-renderer@16.14.0: version "16.14.0" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae" integrity sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg== @@ -13032,11 +12756,6 @@ redux@^4.0.0, redux@^4.0.4, redux@^4.0.5: dependencies: "@babel/runtime" "^7.9.2" -reflect.ownkeys@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" - integrity sha512-qOLsBKHCpSOFKK1NUOCGC5VyeufB6lEsFe92AL2bhIJsacZS1qdoOZSbPk3MYKuT2cFlRDnulKXuuElIrMjGUg== - regenerate-unicode-properties@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c" @@ -13282,11 +13001,6 @@ restricted-input@3.0.5: dependencies: "@braintree/browser-detection" "^1.12.1" -ret@~0.1.10: - version "0.1.15" - resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" - integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== - reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -13354,14 +13068,6 @@ rrweb-cssom@^0.6.0: resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== -rst-selector-parser@^2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz#81b230ea2fcc6066c89e3472de794285d9b03d91" - integrity sha512-nDG1rZeP6oFTLN6yNDV/uiAvs1+FS/KlrEwh7+y7dpuApDBy6bI2HTBcc0/V8lv9OTqfyD34eF7au2pm8aBbhA== - dependencies: - lodash.flattendeep "^4.4.0" - nearley "^2.7.10" - run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -13489,10 +13195,10 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== +"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3: + version "7.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== dependencies: lru-cache "^6.0.0" @@ -13952,15 +13658,6 @@ string.prototype.padend@^3.0.0: define-properties "^1.1.4" es-abstract "^1.20.4" -string.prototype.trim@^1.2.1: - version "1.2.7" - resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz#a68352740859f6893f14ce3ef1bb3037f7a90533" - integrity sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - string.prototype.trimend@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz#c4a27fa026d979d79c04f17397f250a462944533" From 731a274a9136415602db745d12145c604a278ff3 Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Thu, 8 Feb 2024 12:18:01 -0500 Subject: [PATCH 13/38] test: [M3-7698] - Add test to check proxy user disabled username/email field (#10139) * M3-7689: Add test to check proxy user disabled username/email field * fix comments * fix comments * Added changeset: Add test to check proxy user disabled username/email field --- .../pr-10139-tests-1707331596288.md | 5 + .../e2e/core/account/change-username.spec.ts | 93 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 packages/manager/.changeset/pr-10139-tests-1707331596288.md diff --git a/packages/manager/.changeset/pr-10139-tests-1707331596288.md b/packages/manager/.changeset/pr-10139-tests-1707331596288.md new file mode 100644 index 00000000000..af67be49f66 --- /dev/null +++ b/packages/manager/.changeset/pr-10139-tests-1707331596288.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add test to check proxy user disabled username/email field ([#10139](https://github.com/linode/manager/pull/10139)) diff --git a/packages/manager/cypress/e2e/core/account/change-username.spec.ts b/packages/manager/cypress/e2e/core/account/change-username.spec.ts index a2323877ade..faa509ee209 100644 --- a/packages/manager/cypress/e2e/core/account/change-username.spec.ts +++ b/packages/manager/cypress/e2e/core/account/change-username.spec.ts @@ -1,3 +1,11 @@ +import { Profile } from '@linode/api-v4'; +import { profileFactory } from '@src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetProfile } from 'support/intercepts/profile'; import { getProfile } from 'support/api/account'; import { interceptGetProfile } from 'support/intercepts/profile'; import { @@ -7,6 +15,50 @@ import { import { ui } from 'support/ui'; import { randomString } from 'support/util/random'; +const verifyUsernameAndEmail = ( + mockRestrictedProxyProfile: Profile, + tooltip: string, + checkEmail: boolean +) => { + // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetProfile(mockRestrictedProxyProfile); + + // Navigate to User Profile page + cy.visitWithLogin('/profile/display'); + + // Confirm the username and email address fields are disabled, as well their respective save buttons + cy.get('[id="username"]').should('be.disabled'); + ui.button + .findByTitle('Update Username') + .should('be.visible') + .should('be.disabled') + .trigger('mouseover'); + // Click the button first, then confirm the tooltip is shown + ui.tooltip.findByText(tooltip).should('be.visible'); + + // Refresh the page + mockGetProfile(mockRestrictedProxyProfile); + cy.reload(); + + if (checkEmail) { + cy.get('[id="email"]').should('be.disabled'); + ui.button + .findByTitle('Update Email') + .should('be.visible') + .should('be.disabled') + .trigger('mouseover'); + // Click the button first, then confirm the tooltip is shown + ui.tooltip + .findByText('This account type cannot update this field.') + .should('be.visible'); + } +}; + describe('username', () => { /* * - Validates username update flow via the user profile page using mocked data. @@ -96,4 +148,45 @@ describe('username', () => { cy.findByText('Username updated successfully.').should('be.visible'); }); }); + + it('disables username/email fields for restricted proxy user', () => { + const mockRestrictedProxyProfile = profileFactory.build({ + username: 'restricted-proxy-user', + user_type: 'proxy', + restricted: true, + }); + + verifyUsernameAndEmail( + mockRestrictedProxyProfile, + 'This account type cannot update this field.', + true + ); + }); + + it('disables username/email fields for unrestricted proxy user', () => { + const mockUnrestrictedProxyProfile = profileFactory.build({ + username: 'unrestricted-proxy-user', + user_type: 'proxy', + }); + + verifyUsernameAndEmail( + mockUnrestrictedProxyProfile, + 'This account type cannot update this field.', + true + ); + }); + + it('disables username/email fields for regular restricted user', () => { + const mockRegularRestrictedProfile = profileFactory.build({ + username: 'regular-restricted-user', + user_type: null, + restricted: true, + }); + + verifyUsernameAndEmail( + mockRegularRestrictedProfile, + 'Restricted users cannot update their username. Please contact an account administrator.', + false + ); + }); }); From 49ea1320b1d7b6ee7a00b93ecb20d3e46cc41727 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Thu, 8 Feb 2024 10:49:05 -0700 Subject: [PATCH 14/38] fix: [M3-7741] - Hide error notices for $0 regions in Resize Pool and Add a Node Pool drawers (#10157) * Allow -zsh LKE prices without error notices in Resize Pool and Add Pool drawers * Fix loading spinner displaying above what was supposed to be loading * Fix conditional to render notice if either price is invalid * Add test coverage * Added changeset: Hide error notices for /bin/sh regions for LKE Resize and Add Node Pools * Fix changeset wording * Address feedback: use invalid price util --- .../pr-10157-fixed-1707328749030.md | 5 + .../e2e/core/kubernetes/lke-update.spec.ts | 225 ++++++++++++++++++ .../support/constants/dc-specific-pricing.ts | 5 +- .../NodePoolsDisplay/AddNodePoolDrawer.tsx | 10 +- .../ResizeNodePoolDrawer.test.tsx | 13 +- .../NodePoolsDisplay/ResizeNodePoolDrawer.tsx | 124 +++++----- .../NodePoolsDisplay/utils.test.ts | 19 ++ .../NodePoolsDisplay/utils.ts | 13 + 8 files changed, 344 insertions(+), 70 deletions(-) create mode 100644 packages/manager/.changeset/pr-10157-fixed-1707328749030.md create mode 100644 packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts create mode 100644 packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts diff --git a/packages/manager/.changeset/pr-10157-fixed-1707328749030.md b/packages/manager/.changeset/pr-10157-fixed-1707328749030.md new file mode 100644 index 00000000000..3495d7c8dd3 --- /dev/null +++ b/packages/manager/.changeset/pr-10157-fixed-1707328749030.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Error notices for $0 regions in LKE Resize and Add Node Pools drawers ([#10157](https://github.com/linode/manager/pull/10157)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index 258b469209c..bc5b2e91c21 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1011,4 +1011,229 @@ describe('LKE cluster updates for DC-specific prices', () => { // Confirm total price updates in Kube Specs: $14.40/mo existing pool + $28.80/mo new pool. cy.findByText('$43.20/month').should('be.visible'); }); + + /* + * - Confirms node pool resize UI flow using mocked API responses. + * - Confirms that pool size can be changed. + * - Confirms that drawer reflects $0 pricing. + * - Confirms that details page still shows $0 pricing after resizing. + */ + it('can resize pools with region prices of $0', () => { + const dcSpecificPricingRegion = getRegionById('us-southeast'); + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, + control_plane: { + high_availability: false, + }, + }); + + const mockNodePoolResized = nodePoolFactory.build({ + count: 3, + type: dcPricingMockLinodeTypes[2].id, + nodes: kubeLinodeFactory.buildList(3), + }); + + const mockNodePoolInitial = { + ...mockNodePoolResized, + count: 1, + nodes: [mockNodePoolResized.nodes[0]], + }; + + const mockLinodes: Linode[] = mockNodePoolResized.nodes.map( + (node: PoolNodeResponse): Linode => { + return linodeFactory.build({ + id: node.instance_id ?? undefined, + ipv4: [randomIp()], + region: dcSpecificPricingRegion.id, + type: dcPricingMockLinodeTypes[2].id, + }); + } + ); + + const mockNodePoolDrawerTitle = 'Resize Pool: Linode 2 GB Plan'; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( + 'getNodePools' + ); + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType'); + mockGetKubernetesVersions().as('getVersions'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getLinodes', + '@getVersions', + '@getLinodeType', + ]); + + // Confirm that nodes are visible. + mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { + cy.get(`tr[data-qa-node-row="${node.id}"]`) + .should('be.visible') + .within(() => { + const nodeLinode = mockLinodes.find( + (linode: Linode) => linode.id === node.instance_id + ); + if (nodeLinode) { + cy.findByText(nodeLinode.label).should('be.visible'); + } + }); + }); + + // Confirm total price is listed in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + + // Click "Resize Pool" and increase size to 4 nodes. + ui.button + .findByTitle('Resize Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as( + 'resizeNodePool' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as( + 'getNodePools' + ); + + ui.drawer + .findByTitle(mockNodePoolDrawerTitle) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.disabled'); + + cy.findByText('Current pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + cy.findByText('Resized pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + + cy.findByLabelText('Add 1') + .should('be.visible') + .should('be.enabled') + .click() + .click() + .click(); + + cy.findByLabelText('Edit Quantity').should('have.value', '4'); + cy.findByText('Current pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + cy.findByText('Resized pool: $0/month (4 nodes at $0/month)').should( + 'be.visible' + ); + + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@resizeNodePool', '@getNodePools']); + + // Confirm total price is still $0 in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + }); + + /* + * - Confirms UI flow when adding node pools using mocked API responses. + * - Confirms that drawer reflects $0 prices. + * - Confirms that details page still shows $0 pricing after adding node pool. + */ + it('can add node pools with region prices of $0', () => { + const dcSpecificPricingRegion = getRegionById('us-southeast'); + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, + control_plane: { + high_availability: false, + }, + }); + + const mockNewNodePool = nodePoolFactory.build({ + count: 2, + type: dcPricingMockLinodeTypes[2].id, + nodes: kubeLinodeFactory.buildList(2), + }); + + const mockNodePool = nodePoolFactory.build({ + count: 1, + type: dcPricingMockLinodeTypes[2].id, + nodes: kubeLinodeFactory.buildList(1), + }); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); + mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType'); + mockGetLinodeTypes(dcPricingMockLinodeTypes); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']); + + // Assert that initial node pool is shown on the page. + cy.findByText('Linode 2 GB', { selector: 'h2' }).should('be.visible'); + + // Confirm total price of $0 is listed in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + + // Add a new node pool, select plan, submit form in drawer. + ui.button + .findByTitle('Add a Node Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as( + 'getNodePools' + ); + + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Linode 2 GB') + .should('be.visible') + .closest('tr') + .within(() => { + // Assert that $0 prices are displayed the plan table, then add a node pool with 2 linodes. + cy.findAllByText('$0').should('have.length', 2); + cy.findByLabelText('Add 1').should('be.visible').click().click(); + }); + + // Assert that $0 prices are displayed as helper text. + cy.contains( + 'This pool will add $0/month (2 nodes at $0/month) to this cluster.' + ).should('be.visible'); + + ui.button + .findByTitle('Add pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for API responses. + cy.wait(['@addNodePool', '@getNodePools']); + + // Confirm total price is still $0 in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + }); }); diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index 9fb1445e7a0..bfb0e14222b 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -75,9 +75,10 @@ export const dcPricingMockLinodeTypes = linodeTypeFactory.buildList(3, { monthly: 12.2, }, { - hourly: 0.006, + // Mock a DC with $0 region prices, which is possible in some circumstances (e.g. Limited Availability). + hourly: 0.0, id: 'us-southeast', - monthly: 4.67, + monthly: 0.0, }, ], }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index f8914146720..f34cc4228c9 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -1,4 +1,5 @@ import { Theme } from '@mui/material/styles'; +import { isNumber } from 'lodash'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -20,6 +21,7 @@ import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { KubernetesPlansPanel } from '../../KubernetesPlansPanel/KubernetesPlansPanel'; import { nodeWarning } from '../../kubeUtils'; +import { hasInvalidNodePoolPrice } from './utils'; import type { Region } from '@linode/api-v4'; @@ -102,10 +104,12 @@ export const AddNodePoolDrawer = (props: Props) => { ?.monthly; const totalPrice = - selectedTypeInfo && pricePerNode + selectedTypeInfo && isNumber(pricePerNode) ? selectedTypeInfo.count * pricePerNode : undefined; + const hasInvalidPrice = hasInvalidNodePoolPrice(pricePerNode, totalPrice); + React.useEffect(() => { if (open) { resetDrawer(); @@ -199,7 +203,7 @@ export const AddNodePoolDrawer = (props: Props) => { /> )} - {selectedTypeInfo && !totalPrice && !pricePerNode && ( + {selectedTypeInfo && hasInvalidPrice && ( { )} { await findByText(/linode 1 GB/i); }); - it('should display a warning if the user tries to resize a node pool to < 3 nodes', () => { - const { getByText } = renderWithTheme( + it('should display a warning if the user tries to resize a node pool to < 3 nodes', async () => { + const { findByText } = renderWithTheme( ); - expect(getByText(/minimum of 3 nodes/i)); + expect(await findByText(/minimum of 3 nodes/i)); }); - it('should display a warning if the user tries to resize to a smaller node count', () => { - const { getByTestId, getByText } = renderWithTheme( + it('should display a warning if the user tries to resize to a smaller node count', async () => { + const { findByTestId, getByText } = renderWithTheme( ); - const decrement = getByTestId('decrement-button'); + + const decrement = await findByTestId('decrement-button'); fireEvent.click(decrement); expect(getByText(/resizing to fewer nodes/i)); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx index 5c77d3ee9a9..5f44f7681cd 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx @@ -1,7 +1,7 @@ import { KubeNodePoolResponse, Region } from '@linode/api-v4'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { CircleProgress } from 'src/components/CircleProgress'; @@ -19,6 +19,8 @@ import { getKubernetesMonthlyPrice } from 'src/utilities/pricing/kubernetes'; import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { nodeWarning } from '../../kubeUtils'; +import { hasInvalidNodePoolPrice } from './utils'; +import { isNumber } from 'lodash'; const useStyles = makeStyles()((theme: Theme) => ({ helperText: { @@ -107,85 +109,89 @@ export const ResizeNodePoolDrawer = (props: Props) => { types: planType ? [planType] : [], }); + const hasInvalidPrice = hasInvalidNodePoolPrice( + pricePerNode, + totalMonthlyPrice + ); + return ( - {isLoadingTypes && } -

    ) => { - e.preventDefault(); - handleSubmit(); - }} - > -
    - {totalMonthlyPrice && ( + {isLoadingTypes ? ( + + ) : ( + ) => { + e.preventDefault(); + handleSubmit(); + }} + > +
    Current pool: $ - {renderMonthlyPriceToCorrectDecimalPlace(totalMonthlyPrice)}/month{' '} - ({pluralize('node', 'nodes', nodePool.count)} at $ + {renderMonthlyPriceToCorrectDecimalPlace(totalMonthlyPrice)} + /month ({pluralize('node', 'nodes', nodePool.count)} at $ {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} /month) - )} -
    - - {error && } - -
    - - Enter the number of nodes you'd like in this pool: - - -
    +
    + + {error && } -
    - {/* Renders total pool price/month for N nodes at price per node/month. */} - {pricePerNode && ( +
    + + Enter the number of nodes you'd like in this pool: + + +
    + +
    + {/* Renders total pool price/month for N nodes at price per node/month. */} {`Resized pool: $${renderMonthlyPriceToCorrectDecimalPlace( - updatedCount * pricePerNode + isNumber(pricePerNode) ? updatedCount * pricePerNode : undefined )}/month`}{' '} ({pluralize('node', 'nodes', updatedCount)} at $ {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} /month) +
    + + {updatedCount < nodePool.count && ( + + )} + + {updatedCount < 3 && ( + )} -
    - - {updatedCount < nodePool.count && ( - - )} - - {updatedCount < 3 && ( - - )} - - {nodePool.count && (!pricePerNode || !totalMonthlyPrice) && ( - + )} + + - )} - - - + + )} ); }; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts new file mode 100644 index 00000000000..ac3166ae4f3 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts @@ -0,0 +1,19 @@ +import { hasInvalidNodePoolPrice } from './utils'; + +describe('hasInvalidNodePoolPrice', () => { + it('returns false if the prices are both zero, which is valid', () => { + expect(hasInvalidNodePoolPrice(0, 0)).toBe(false); + }); + + it('returns true if at least one of the prices is undefined', () => { + expect(hasInvalidNodePoolPrice(0, undefined)).toBe(true); + expect(hasInvalidNodePoolPrice(undefined, 0)).toBe(true); + expect(hasInvalidNodePoolPrice(undefined, undefined)).toBe(true); + }); + + it('returns true if at least one of the prices is null', () => { + expect(hasInvalidNodePoolPrice(0, null)).toBe(true); + expect(hasInvalidNodePoolPrice(null, 0)).toBe(true); + expect(hasInvalidNodePoolPrice(null, null)).toBe(true); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts new file mode 100644 index 00000000000..3b52451b27d --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts @@ -0,0 +1,13 @@ +/** + * Checks whether prices are valid - 0 is valid, but undefined and null prices are invalid. + * @returns true if either value is null or undefined + */ +export const hasInvalidNodePoolPrice = ( + pricePerNode: null | number | undefined, + totalPrice: null | number | undefined +) => { + const isInvalidPricePerNode = !pricePerNode && pricePerNode !== 0; + const isInvalidTotalPrice = !totalPrice && totalPrice !== 0; + + return isInvalidPricePerNode || isInvalidTotalPrice; +}; From b31f6b164d3e73ce51991243a1c6267cdf7a13b4 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Thu, 8 Feb 2024 13:10:27 -0500 Subject: [PATCH 15/38] fix: [M3-7746] - Fix $0 region price error in "Enable All Backups" drawer (#10161) * Remove error indicator for Linodes in $0 regions * Fix $0 total price display issue * Cover $0 pricing cases in Cypress backup tests * Add BackupLinodeRow tests to account for error states and $0 regions * Add unit tests for BackupDrawer component --- .../pr-10161-fixed-1707341493849.md | 5 + .../e2e/core/linodes/backup-linode.spec.ts | 19 +- .../support/constants/dc-specific-pricing.ts | 5 + packages/manager/src/factories/index.ts | 1 + .../features/Backups/BackupDrawer.test.tsx | 172 ++++++++++++++++++ .../src/features/Backups/BackupDrawer.tsx | 4 +- .../features/Backups/BackupLinodeRow.test.tsx | 67 +++++++ .../src/features/Backups/BackupLinodeRow.tsx | 7 +- .../src/utilities/pricing/backups.test.tsx | 33 ++++ .../manager/src/utilities/pricing/backups.ts | 11 +- 10 files changed, 310 insertions(+), 14 deletions(-) create mode 100644 packages/manager/.changeset/pr-10161-fixed-1707341493849.md create mode 100644 packages/manager/src/features/Backups/BackupDrawer.test.tsx diff --git a/packages/manager/.changeset/pr-10161-fixed-1707341493849.md b/packages/manager/.changeset/pr-10161-fixed-1707341493849.md new file mode 100644 index 00000000000..156c095a6cc --- /dev/null +++ b/packages/manager/.changeset/pr-10161-fixed-1707341493849.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Error in Enable All Backups drawer when one or more Linode is in a $0 region ([#10161](https://github.com/linode/manager/pull/10161)) diff --git a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts index 68d5c24693e..01694c1fa4a 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -270,16 +270,22 @@ describe('"Enable Linode Backups" banner', () => { // See `dcPricingMockLinodeTypes` exported from `support/constants/dc-specific-pricing.ts`. linodeFactory.build({ label: randomLabel(), - region: 'us-east', + region: 'us-ord', backups: { enabled: false }, type: dcPricingMockLinodeTypesForBackups[0].id, }), linodeFactory.build({ label: randomLabel(), - region: 'us-west', + region: 'us-east', backups: { enabled: false }, type: dcPricingMockLinodeTypesForBackups[1].id, }), + linodeFactory.build({ + label: randomLabel(), + region: 'us-west', + backups: { enabled: false }, + type: dcPricingMockLinodeTypesForBackups[2].id, + }), linodeFactory.build({ label: randomLabel(), region: 'us-central', @@ -317,6 +323,7 @@ describe('"Enable Linode Backups" banner', () => { // The expected backup price for each Linode, as shown in backups drawer table. const expectedPrices = [ + '$0.00/mo', // us-ord mocked price. '$3.57/mo', // us-east mocked price. '$4.17/mo', // us-west mocked price. '$2.00/mo', // regular price. @@ -358,7 +365,7 @@ describe('"Enable Linode Backups" banner', () => { ); // Confirm that expected total cost is shown. - cy.contains(`Total for 3 Linodes: ${expectedTotal}`).should( + cy.contains(`Total for 4 Linodes: ${expectedTotal}`).should( 'be.visible' ); @@ -377,6 +384,10 @@ describe('"Enable Linode Backups" banner', () => { .closest('tr') .within(() => { cy.findByText(expectedPrice).should('be.visible'); + // Confirm no error indicator appears for $0.00 prices. + cy.findByLabelText( + 'There was an error loading the price.' + ).should('not.exist'); }); }); @@ -398,7 +409,7 @@ describe('"Enable Linode Backups" banner', () => { cy.wait([...enableBackupAliases, '@updateAccountSettings']); ui.toast.assertMessage( - '3 Linodes have been enrolled in automatic backups, and all new Linodes will automatically be backed up.' + '4 Linodes have been enrolled in automatic backups, and all new Linodes will automatically be backed up.' ); }); }); diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index bfb0e14222b..3843a35aceb 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -93,6 +93,11 @@ export const dcPricingMockLinodeTypesForBackups = linodeTypeFactory.buildList( monthly: 2.0, }, region_prices: [ + { + hourly: 0, + id: 'us-ord', + monthly: 0, + }, { hourly: 0.0048, id: 'us-east', diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index 3b6507ec78f..95233470184 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -41,6 +41,7 @@ export * from './statusPage'; export * from './subnets'; export * from './support'; export * from './tags'; +export * from './types'; export * from './volume'; export * from './vlans'; export * from './vpcs'; diff --git a/packages/manager/src/features/Backups/BackupDrawer.test.tsx b/packages/manager/src/features/Backups/BackupDrawer.test.tsx new file mode 100644 index 00000000000..26eeecebc10 --- /dev/null +++ b/packages/manager/src/features/Backups/BackupDrawer.test.tsx @@ -0,0 +1,172 @@ +import * as React from 'react'; +import { + accountSettingsFactory, + linodeFactory, + typeFactory, +} from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { BackupDrawer } from './BackupDrawer'; + +const queryMocks = vi.hoisted(() => ({ + useAllLinodesQuery: vi.fn().mockReturnValue({ + data: undefined, + }), + useAllTypes: vi.fn().mockReturnValue({ + data: undefined, + }), + useTypeQuery: vi.fn().mockReturnValue({ + data: undefined, + }), + useAccountSettings: vi.fn().mockReturnValue({ + data: undefined, + }), +})); + +vi.mock('src/queries/linodes/linodes', async () => { + const actual = await vi.importActual('src/queries/linodes/linodes'); + return { + ...actual, + useAllLinodesQuery: queryMocks.useAllLinodesQuery, + }; +}); + +vi.mock('src/queries/types', async () => { + const actual = await vi.importActual('src/queries/types'); + return { + ...actual, + useAllTypes: queryMocks.useAllTypes, + useTypeQuery: queryMocks.useTypeQuery, + }; +}); + +vi.mock('src/queries/accountSettings', async () => { + const actual = await vi.importActual('src/queries/accountSettings'); + return { + ...actual, + useAccountSettings: queryMocks.useAccountSettings, + }; +}); + +describe('BackupDrawer', () => { + beforeEach(() => { + const mockType = typeFactory.build({ + id: 'mock-linode-type', + label: 'Mock Linode Type', + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'es-mad', + monthly: 0, + }, + ], + }, + }, + }); + queryMocks.useAccountSettings.mockReturnValue({ + data: accountSettingsFactory.build({ + backups_enabled: false, + }), + }); + queryMocks.useAllTypes.mockReturnValue({ + data: [mockType], + }); + queryMocks.useTypeQuery.mockReturnValue({ + data: mockType, + }); + }); + + describe('Total price display', () => { + it('displays total backup price', async () => { + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [ + linodeFactory.build({ + region: 'es-mad', + type: 'mock-linode-type', + backups: { enabled: false }, + }), + ...linodeFactory.buildList(5, { + region: 'us-east', + type: 'mock-linode-type', + backups: { enabled: false }, + }), + ], + }); + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('Total for 6 Linodes:')).toBeVisible(); + expect(await findByText('$12.50')).toBeVisible(); + }); + + it('displays total backup price when total is $0', async () => { + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [ + linodeFactory.build({ + region: 'es-mad', + type: 'mock-linode-type', + backups: { enabled: false }, + }), + ], + }); + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('Total for 1 Linode:')).toBeVisible(); + expect(await findByText('$0.00')).toBeVisible(); + }); + + it('displays placeholder when total backup price cannot be determined', async () => { + queryMocks.useAllTypes.mockReturnValue({ + data: undefined, + }); + + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [linodeFactory.build({ backups: { enabled: false } })], + }); + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('Total for 1 Linode:')).toBeVisible(); + expect(await findByText('$--.--')).toBeVisible(); + }); + }); + + describe('Linode list', () => { + it('Only lists Linodes that do not have backups enabled', async () => { + const mockLinodesWithBackups = linodeFactory.buildList(3, { + backups: { enabled: true }, + }); + + const mockLinodesWithoutBackups = linodeFactory.buildList(3, { + backups: { enabled: false }, + }); + + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [...mockLinodesWithBackups, ...mockLinodesWithoutBackups], + }); + + const { findByText, queryByText } = renderWithTheme( + + ); + // Confirm that Linodes without backups are listed in table. + /* eslint-disable no-await-in-loop */ + for (const mockLinode of mockLinodesWithoutBackups) { + expect(await findByText(mockLinode.label)).toBeVisible(); + } + // Confirm that Linodes with backups are not listed in table. + for (const mockLinode of mockLinodesWithBackups) { + expect(queryByText(mockLinode.label)).toBeNull(); + } + }); + }); +}); diff --git a/packages/manager/src/features/Backups/BackupDrawer.tsx b/packages/manager/src/features/Backups/BackupDrawer.tsx index 952ebe1aa6c..67265b2c07e 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.tsx @@ -181,9 +181,7 @@ all new Linodes will automatically be backed up.`   diff --git a/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx b/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx index 68bce89c5dc..cfeb5e3cb2a 100644 --- a/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx +++ b/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx @@ -78,4 +78,71 @@ describe('BackupLinodeRow', () => { expect(await findByText('Jakarta, ID')).toBeVisible(); expect(await findByText('$3.57/mo')).toBeVisible(); }); + + it('should render error indicator when price cannot be determined', async () => { + server.use( + rest.get('*/linode/types/linode-type-test', (req, res, ctx) => { + return res.networkError('A hypothetical network error has occurred!'); + }) + ); + + const linode = linodeFactory.build({ + label: 'my-dc-pricing-linode-to-back-up', + region: 'id-cgk', + type: 'linode-type-test', + }); + + const { findByText, findByLabelText } = renderWithTheme( + wrapWithTableBody() + ); + + expect(await findByText('$--.--/mo')).toBeVisible(); + expect( + await findByLabelText('There was an error loading the price.') + ).toBeVisible(); + }); + + it('should not render error indicator for $0 price', async () => { + server.use( + rest.get('*/linode/types/linode-type-test', (req, res, ctx) => { + return res( + ctx.json( + linodeTypeFactory.build({ + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'id-cgk', + monthly: 0, + }, + ], + }, + }, + label: 'Linode Test Type', + }) + ) + ); + }) + ); + + const linode = linodeFactory.build({ + label: 'my-dc-pricing-linode-to-back-up', + region: 'id-cgk', + type: 'linode-type-test', + }); + + const { findByText, queryByLabelText } = renderWithTheme( + wrapWithTableBody() + ); + + expect(await findByText('$0.00/mo')).toBeVisible(); + expect( + queryByLabelText('There was an error loading the price.') + ).toBeNull(); + }); }); diff --git a/packages/manager/src/features/Backups/BackupLinodeRow.tsx b/packages/manager/src/features/Backups/BackupLinodeRow.tsx index 47d29c22619..213bea721cc 100644 --- a/packages/manager/src/features/Backups/BackupLinodeRow.tsx +++ b/packages/manager/src/features/Backups/BackupLinodeRow.tsx @@ -32,6 +32,9 @@ export const BackupLinodeRow = (props: Props) => { const regionLabel = regions?.find((r) => r.id === linode.region)?.label ?? linode.region; + const hasInvalidPrice = + backupsMonthlyPrice === null || backupsMonthlyPrice === undefined; + return ( @@ -53,8 +56,8 @@ export const BackupLinodeRow = (props: Props) => { {regionLabel ?? 'Unknown'} {`$${backupsMonthlyPrice?.toFixed(2) ?? UNKNOWN_PRICE}/mo`} diff --git a/packages/manager/src/utilities/pricing/backups.test.tsx b/packages/manager/src/utilities/pricing/backups.test.tsx index a2d0d90b7f1..a3ef5db57af 100644 --- a/packages/manager/src/utilities/pricing/backups.test.tsx +++ b/packages/manager/src/utilities/pricing/backups.test.tsx @@ -100,4 +100,37 @@ describe('getTotalBackupsPrice', () => { }) ).toBe(8.57); }); + + it('correctly calculates the total price with $0 DC-specific pricing for Linode backups', () => { + const basePriceLinodes = linodeFactory.buildList(2, { type: 'my-type' }); + const zeroPriceLinode = linodeFactory.build({ + region: 'es-mad', + type: 'my-type', + }); + const linodes = [...basePriceLinodes, zeroPriceLinode]; + const types = linodeTypeFactory.buildList(1, { + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'es-mad', + monthly: 0, + }, + ], + }, + }, + id: 'my-type', + }); + expect( + getTotalBackupsPrice({ + linodes, + types, + }) + ).toBe(5); + }); }); diff --git a/packages/manager/src/utilities/pricing/backups.ts b/packages/manager/src/utilities/pricing/backups.ts index 6fe31cafe5a..f6337e2a44f 100644 --- a/packages/manager/src/utilities/pricing/backups.ts +++ b/packages/manager/src/utilities/pricing/backups.ts @@ -75,11 +75,12 @@ export const getTotalBackupsPrice = ({ return undefined; } - const backupsMonthlyPrice: PriceObject['monthly'] | undefined = - getMonthlyBackupsPrice({ - region: linode.region, - type, - }) || undefined; + const backupsMonthlyPrice: + | PriceObject['monthly'] + | undefined = getMonthlyBackupsPrice({ + region: linode.region, + type, + }); if (backupsMonthlyPrice === null || backupsMonthlyPrice === undefined) { return undefined; From 48f3c132861f56514f65981082651148555c9008 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Thu, 8 Feb 2024 14:02:39 -0500 Subject: [PATCH 16/38] fix: [M3-7004] - Allow IPv6 ranges transfers (#10156) * Saving work * Responsive changes * Query invalidation improvement * Improve add IP drawer * Improve add IP drawer: styling * Added changeset: Allow IPv6 ranges transfers * Feedback * Cleanup comment --- .../pr-10156-fixed-1707363343774.md | 5 + .../LinodeNetworking/AddIPDrawer.tsx | 123 ++++++++-------- .../LinodeNetworking/IPTransfer.tsx | 133 +++++++++++------- .../LinodeNetworking/LinodeIPAddresses.tsx | 2 +- .../manager/src/queries/linodes/networking.ts | 14 +- 5 files changed, 168 insertions(+), 109 deletions(-) create mode 100644 packages/manager/.changeset/pr-10156-fixed-1707363343774.md diff --git a/packages/manager/.changeset/pr-10156-fixed-1707363343774.md b/packages/manager/.changeset/pr-10156-fixed-1707363343774.md new file mode 100644 index 00000000000..30e30a1564d --- /dev/null +++ b/packages/manager/.changeset/pr-10156-fixed-1707363343774.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Allow IPv6 ranges transfers ([#10156](https://github.com/linode/manager/pull/10156)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx index 2b93f89d5a8..241ffc44a80 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx @@ -1,8 +1,10 @@ import { IPv6Prefix } from '@linode/api-v4/lib/networking'; -import { useTheme } from '@mui/material/styles'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Box } from 'src/components/Box'; +import { Divider } from 'src/components/Divider'; import { Drawer } from 'src/components/Drawer'; import { Item } from 'src/components/EnhancedSelect/Select'; import { FormControlLabel } from 'src/components/FormControlLabel'; @@ -10,6 +12,7 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Radio } from 'src/components/Radio/Radio'; import { RadioGroup } from 'src/components/RadioGroup'; +import { Stack } from 'src/components/Stack'; import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; import { @@ -80,7 +83,6 @@ interface Props { export const AddIPDrawer = (props: Props) => { const { linodeId, onClose, open, readOnly } = props; - const theme = useTheme(); const { error: ipv4Error, @@ -163,94 +165,87 @@ export const AddIPDrawer = (props: Props) => { return ( - - IPv4 + + IPv4 {Boolean(ipv4Error) && ( - + )} - - Select type - - - {ipOptions.map((option, idx) => ( - } - data-qa-radio={option.label} - key={idx} - label={option.label} - value={option.value} - /> - ))} - - {selectedIPv4 && ( - - {explainerCopy[selectedIPv4]} - - )} + Select type + + {ipOptions.map((option, idx) => ( + } + data-qa-radio={option.label} + key={idx} + label={option.label} + value={option.value} + /> + ))} + + + {selectedIPv4 && {explainerCopy[selectedIPv4]}} {_tooltipCopy ? (
    -
    ) : ( - )} - + + IPv6 {Boolean(ipv6Error) && ( - + )} - - Select prefix - - - {prefixOptions.map((option, idx) => ( - } - data-qa-radio={option.label} - key={idx} - label={option.label} - value={option.value} - /> - ))} - + Select prefix + + {prefixOptions.map((option, idx) => ( + } + data-qa-radio={option.label} + key={idx} + label={option.label} + value={option.value} + /> + ))} + + {selectedIPv6Prefix && ( - - {IPv6ExplanatoryCopy[selectedIPv6Prefix]} - + {IPv6ExplanatoryCopy[selectedIPv6Prefix]} )} IPv6 addresses are allocated as ranges, which you can choose to @@ -260,16 +255,34 @@ export const AddIPDrawer = (props: Props) => { . - -
    +
    ); }; + +const StyledRadioGroup = styled(RadioGroup, { + label: 'StyledApiDrawerRadioGroup', +})(({ theme }) => ({ + '& label': { + minWidth: 100, + }, + '& p': { + fontFamily: theme.font.bold, + marginTop: theme.spacing(), + }, + marginBottom: '0 !important', +})); + +const StyledActionsPanel = styled(ActionsPanel, { + label: 'StyledActionsPanel', +})(({ theme }) => ({ + paddingTop: theme.spacing(2), +})); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx index 5c3a51b7312..cea0386e54e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx @@ -91,19 +91,27 @@ export const getLinodeIPv6Ranges = ( ); }; -const LinodeNetworkingIPTransferPanel = (props: Props) => { +export const IPTransfer = (props: Props) => { const { linodeId, onClose, open, readOnly } = props; const theme = useTheme(); - const { mutateAsync: assignAddresses } = useAssignAdressesMutation(); + const { mutateAsync: assignAddresses } = useAssignAdressesMutation({ + currentLinodeId: linodeId, + }); const { data: linode } = useLinodeQuery(linodeId, open); const { data: _ips } = useLinodeIPsQuery(linodeId); - const publicIPs = _ips?.ipv4.public.map((i) => i.address) ?? []; - const privateIPs = _ips?.ipv4.private.map((i) => i.address) ?? []; + const publicIPv4Addresses = _ips?.ipv4.public.map((i) => i.address) ?? []; + const privateIPv4Addresses = _ips?.ipv4.private.map((i) => i.address) ?? []; + const ipv6Addresses = + _ips?.ipv6?.global.map((i) => `${i.range}/${i.prefix}`) ?? []; - const ipAddresses = [...publicIPs, ...privateIPs]; + const ipAddresses = [ + ...publicIPv4Addresses, + ...privateIPv4Addresses, + ...ipv6Addresses, + ]; const [ips, setIPs] = React.useState( ipAddresses.reduce( @@ -252,30 +260,42 @@ const LinodeNetworkingIPTransferPanel = (props: Props) => { ]; return ( - + - + + + IP address:{' '} + {state.sourceIP} - + { overflowPortal value={defaultLinode} /> - + ); }; @@ -350,7 +370,7 @@ const LinodeNetworkingIPTransferPanel = (props: Props) => { }); return ( - +