From 8d029c8d5f74e984fdd04d0cfb5ce36be41688f1 Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Wed, 3 Jan 2024 14:42:01 -0800 Subject: [PATCH 01/13] Add switch account button and basic drawer --- .../features/TopMenu/SwitchAccountDrawer.tsx | 60 +++++++++++++++++++ .../features/TopMenu/UserMenu/UserMenu.tsx | 44 ++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx diff --git a/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx b/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx new file mode 100644 index 00000000000..a3568fec364 --- /dev/null +++ b/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { Drawer } from 'src/components/Drawer'; +import { Notice } from 'src/components/Notice/Notice'; + +interface Props { + handleAccountSwitch: () => void; + isParentTokenError: boolean; + isProxyTokenError: boolean; + onClose: () => void; + open: boolean; + username: string; +} + +export const SwitchAccountDrawer = (props: Props) => { + const { + handleAccountSwitch, + isParentTokenError, + isProxyTokenError, + onClose: _onClose, + open, + } = props; + + const onClose = () => { + _onClose(); + }; + + const [isLoading, setIsLoading] = React.useState(false); + const mockLoadingDelay = 300; + + // Toggle to mock error from API. + // React.useEffect(() => { + // }, [isProxyTokenError, isParentTokenError]); + + return ( + + {(isParentTokenError || isProxyTokenError) && ( + There was an error switching accounts. + )} + {isLoading ? ( + + ) : ( + { + // Mock a 300ms delay in the API response to show loading state. + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, mockLoadingDelay); + handleAccountSwitch(); + }} + > + Linode Child Co. + + )} + + ); +}; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 896f73d137c..e444553d985 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -21,6 +21,8 @@ import { useAccountUser } from 'src/queries/accountUsers'; import { useGrants, useProfile } from 'src/queries/profile'; import { authentication } from 'src/utilities/storage'; +import { SwitchAccountDrawer } from '../SwitchAccountDrawer'; + import type { UserType } from '@linode/api-v4'; interface MenuLink { @@ -75,6 +77,7 @@ export const UserMenu = React.memo(() => { const [anchorEl, setAnchorEl] = React.useState( null ); + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -109,6 +112,9 @@ export const UserMenu = React.memo(() => { grants?.global?.account_access === 'read_write' || !_isRestrictedUser; const showCompanyName = flags.parentChildAccountAccess && user?.user_type !== null && companyName; + const isAccountSwitchable = + flags.parentChildAccountAccess && + (user?.user_type === 'parent' || user?.user_type === 'proxy'); const accountLinks: MenuLink[] = React.useMemo( () => [ @@ -241,12 +247,41 @@ export const UserMenu = React.memo(() => { open={open} > + {isAccountSwitchable && ( + You are currently logged in as: + )} theme.textColors.headlineStatic} fontSize="1.1rem" > {userName} + { + isAccountSwitchable && ( + + ) + // TODO: Parent/Child - M3-7430 + /* {(isProxyTokenError || isParentTokenError) && ( + + There was an error switching accounts. + + )} */ + } My Profile @@ -294,6 +329,15 @@ export const UserMenu = React.memo(() => { )} + null} // {handleAccountSwitch} + isParentTokenError={false} // {isParentTokenError} + isProxyTokenError={false} // {isProxyTokenError} + onClose={() => setIsDrawerOpen(false)} + open={isDrawerOpen} + username={userName} + /> ); }); From d31b711a352e422c8769fde1d47f568d7c8b612c Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Wed, 3 Jan 2024 15:19:50 -0800 Subject: [PATCH 02/13] List child accounts in drawer --- .../features/TopMenu/SwitchAccountDrawer.tsx | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx b/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx index a3568fec364..60acef16f10 100644 --- a/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx @@ -4,6 +4,8 @@ import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { CircleProgress } from 'src/components/CircleProgress'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; +import { Stack } from 'src/components/Stack'; +import { useChildAccounts } from 'src/queries/account'; interface Props { handleAccountSwitch: () => void; @@ -27,8 +29,7 @@ export const SwitchAccountDrawer = (props: Props) => { _onClose(); }; - const [isLoading, setIsLoading] = React.useState(false); - const mockLoadingDelay = 300; + const { data: childAccounts, isLoading } = useChildAccounts({}); // Toggle to mock error from API. // React.useEffect(() => { @@ -42,18 +43,22 @@ export const SwitchAccountDrawer = (props: Props) => { {isLoading ? ( ) : ( - { - // Mock a 300ms delay in the API response to show loading state. - setIsLoading(true); - setTimeout(() => { - setIsLoading(false); - }, mockLoadingDelay); - handleAccountSwitch(); - }} - > - Linode Child Co. - + + {/* TODO */} + {childAccounts && + childAccounts?.data.map((childAccount, key) => { + return ( + { + handleAccountSwitch(); + }} + key={key} + > + {childAccount.company} + + ); + })} + )} ); From 09248ec0afdd22d5c3e7f80b612446d0f1004860 Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Wed, 3 Jan 2024 15:35:01 -0800 Subject: [PATCH 03/13] Use render function --- .../features/TopMenu/SwitchAccountDrawer.tsx | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx b/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx index 60acef16f10..3ce22e45529 100644 --- a/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx @@ -18,7 +18,7 @@ interface Props { export const SwitchAccountDrawer = (props: Props) => { const { - handleAccountSwitch, + // handleAccountSwitch, isParentTokenError, isProxyTokenError, onClose: _onClose, @@ -29,37 +29,44 @@ export const SwitchAccountDrawer = (props: Props) => { _onClose(); }; - const { data: childAccounts, isLoading } = useChildAccounts({}); + const { data: childAccounts, error, isLoading } = useChildAccounts({}); - // Toggle to mock error from API. - // React.useEffect(() => { - // }, [isProxyTokenError, isParentTokenError]); + const renderChildAccounts = React.useCallback(() => { + if (isLoading) { + return ; + } + + if (childAccounts?.results === 0) { + return There are no child accounts.; + } + + if (error) { + return ( + + There was an error loading child accounts. + + ); + } + + return childAccounts?.data.map((childAccount, key) => ( + { + // TODO: Parent/Child - M3-7430 + // handleAccountSwitch(); + }} + key={key} + > + {childAccount.company} + + )); + }, [childAccounts, error, isLoading]); return ( {(isParentTokenError || isProxyTokenError) && ( There was an error switching accounts. )} - {isLoading ? ( - - ) : ( - - {/* TODO */} - {childAccounts && - childAccounts?.data.map((childAccount, key) => { - return ( - { - handleAccountSwitch(); - }} - key={key} - > - {childAccount.company} - - ); - })} - - )} + {renderChildAccounts()} ); }; From 204e92b529152192cf60fe3d25f2930285705a8d Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Wed, 3 Jan 2024 15:44:39 -0800 Subject: [PATCH 04/13] Correct comments so as not to confuse myself --- packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index e444553d985..564e50f1328 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -260,11 +260,9 @@ export const UserMenu = React.memo(() => { isAccountSwitchable && ( ) // TODO: Parent/Child - M3-7430 From c520b4b900a6fcd1a676b5b4c7d68906c6516860 Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Thu, 4 Jan 2024 15:35:00 -0800 Subject: [PATCH 06/13] Clean up and make styling more closely match mocks --- .../features/TopMenu/SwitchAccountDrawer.tsx | 30 +++++++++++++++---- .../features/TopMenu/UserMenu/UserMenu.tsx | 10 +++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx b/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx index 3ce22e45529..0af217a8133 100644 --- a/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx @@ -1,3 +1,4 @@ +import { Typography } from '@mui/material'; import React from 'react'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; @@ -6,6 +7,8 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { Stack } from 'src/components/Stack'; import { useChildAccounts } from 'src/queries/account'; +import { useAccountUser } from 'src/queries/accountUsers'; +import { useProfile } from 'src/queries/profile'; interface Props { handleAccountSwitch: () => void; @@ -19,8 +22,8 @@ interface Props { export const SwitchAccountDrawer = (props: Props) => { const { // handleAccountSwitch, - isParentTokenError, - isProxyTokenError, + // isParentTokenError, + // isProxyTokenError, onClose: _onClose, open, } = props; @@ -29,6 +32,8 @@ export const SwitchAccountDrawer = (props: Props) => { _onClose(); }; + const { data: profile } = useProfile(); + const { data: user } = useAccountUser(profile?.username ?? ''); const { data: childAccounts, error, isLoading } = useChildAccounts({}); const renderChildAccounts = React.useCallback(() => { @@ -63,10 +68,25 @@ export const SwitchAccountDrawer = (props: Props) => { return ( - {(isParentTokenError || isProxyTokenError) && ( + {/* {(isParentTokenError || isProxyTokenError) && ( There was an error switching accounts. - )} - {renderChildAccounts()} + )} */} + + + Select an account to view and manage its settings and configurations + {user?.user_type === 'proxy' && ( + <> + {' '} + or {/* TODO: Parent/Child - M3-7430 */} + null}> + switch back to your account + + + )} + . + + {renderChildAccounts()} + ); }; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 6df475bef29..b3949f6d1ba 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -254,7 +254,7 @@ export const UserMenu = React.memo(() => { color={(theme) => theme.textColors.headlineStatic} fontSize="1.1rem" > - {userName} + {isAccountSwitchable ? companyName : userName} { isAccountSwitchable && ( @@ -263,14 +263,14 @@ export const UserMenu = React.memo(() => { // From proxy accounts, make a request on behalf of the parent account to fetch child accounts. if (user.user_type === 'proxy') { // TODO: Parent/Child - M3-7430 - } else { - handleClose(); - setIsDrawerOpen(true); } + + handleClose(); + setIsDrawerOpen(true); }} buttonType="outlined" > - Switch Accounts + Switch Account ) // TODO: Parent/Child - M3-7430 From a730ba5492b69a3093c931aed1c34b2c0193e0e0 Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Thu, 4 Jan 2024 16:42:13 -0800 Subject: [PATCH 07/13] Make a component out of Switch Account button; add icon --- .../manager/src/assets/icons/swapSmall.svg | 3 ++ .../TopMenu/UserMenu/SwitchAccountButton.tsx | 50 +++++++++++++++++++ .../features/TopMenu/UserMenu/UserMenu.tsx | 20 +++----- 3 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 packages/manager/src/assets/icons/swapSmall.svg create mode 100644 packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx diff --git a/packages/manager/src/assets/icons/swapSmall.svg b/packages/manager/src/assets/icons/swapSmall.svg new file mode 100644 index 00000000000..29190713dab --- /dev/null +++ b/packages/manager/src/assets/icons/swapSmall.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx b/packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx new file mode 100644 index 00000000000..563db47b4e8 --- /dev/null +++ b/packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx @@ -0,0 +1,50 @@ +import { UserType } from '@linode/api-v4/lib/account/types'; +import { styled } from '@mui/material/styles'; +import * as React from 'react'; + +import SwapIcon from 'src/assets/icons/swapSmall.svg'; +import { Button } from 'src/components/Button/Button'; + +interface Props { + handleClose: () => void; + setIsDrawerOpen: (open: boolean) => void; + userType: UserType | null; +} + +export const SwitchAccountButton = (props: Props) => { + const { handleClose, setIsDrawerOpen, userType } = props; + + return ( + { + // From proxy accounts, make a request on behalf of the parent account to fetch child accounts. + if (userType === 'proxy') { + // TODO: Parent/Child - M3-7430 + } + + handleClose(); + setIsDrawerOpen(true); + }} + buttonType="outlined" + > + + Switch Account + + ); +}; + +const StyledButton = styled(Button)(({ theme }) => ({ + '& path': { + fill: theme.textColors.linkActiveLight, + }, + '&:hover, &:focus': { + '& path': { + fill: theme.name === 'dark' ? '#fff' : theme.textColors.linkActiveLight, + }, + }, + marginRight: theme.spacing(1), +})); + +const StyledSwapIcon = styled(SwapIcon)(({ theme }) => ({ + marginRight: theme.spacing(1), +})); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index b3949f6d1ba..cf5cfe0f52c 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -24,6 +24,7 @@ import { authentication } from 'src/utilities/storage'; import { SwitchAccountDrawer } from '../SwitchAccountDrawer'; import type { UserType } from '@linode/api-v4'; +import { SwitchAccountButton } from './SwitchAccountButton'; interface MenuLink { display: string; @@ -258,20 +259,11 @@ export const UserMenu = React.memo(() => { { isAccountSwitchable && ( - + ) // TODO: Parent/Child - M3-7430 /* {(isProxyTokenError || isParentTokenError) && ( From 172e9ded7974bfe0f19127f342498fb931c658a0 Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Fri, 5 Jan 2024 07:08:12 -0800 Subject: [PATCH 08/13] More styling and clean up --- .../features/TopMenu/SwitchAccountDrawer.tsx | 85 ++++++++++--------- .../TopMenu/UserMenu/SwitchAccountButton.tsx | 8 +- .../features/TopMenu/UserMenu/UserMenu.tsx | 6 +- packages/manager/src/mocks/serverHandlers.ts | 1 + 4 files changed, 49 insertions(+), 51 deletions(-) diff --git a/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx b/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx index 0af217a8133..803b69e1d20 100644 --- a/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx @@ -1,4 +1,5 @@ -import { Typography } from '@mui/material'; +import { Typography, styled } from '@mui/material'; +import { AxiosHeaders } from 'axios'; import React from 'react'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; @@ -6,35 +7,38 @@ import { CircleProgress } from 'src/components/CircleProgress'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { Stack } from 'src/components/Stack'; +import { useFlags } from 'src/hooks/useFlags'; import { useChildAccounts } from 'src/queries/account'; import { useAccountUser } from 'src/queries/accountUsers'; import { useProfile } from 'src/queries/profile'; +import { authentication } from 'src/utilities/storage'; interface Props { - handleAccountSwitch: () => void; - isParentTokenError: boolean; - isProxyTokenError: boolean; onClose: () => void; open: boolean; username: string; } export const SwitchAccountDrawer = (props: Props) => { - const { - // handleAccountSwitch, - // isParentTokenError, - // isProxyTokenError, - onClose: _onClose, - open, - } = props; + const { onClose, open } = props; - const onClose = () => { - _onClose(); + const flags = useFlags(); + + const handleClose = () => { + onClose(); }; const { data: profile } = useProfile(); const { data: user } = useAccountUser(profile?.username ?? ''); - const { data: childAccounts, error, isLoading } = useChildAccounts({}); + + // From proxy accounts, make a request on behalf of the parent account to fetch child accounts. + const headers = + flags.parentChildAccountAccess && user?.user_type === 'proxy' + ? new AxiosHeaders({ Authorization: authentication.token.get() }) // TODO: Parent/Child - M3-7430: replace this token with the parent token in local storage. + : undefined; + const { data: childAccounts, error, isLoading } = useChildAccounts({ + headers, + }); const renderChildAccounts = React.useCallback(() => { if (isLoading) { @@ -42,7 +46,7 @@ export const SwitchAccountDrawer = (props: Props) => { } if (childAccounts?.results === 0) { - return There are no child accounts.; + return There are no child accounts.; } if (error) { @@ -53,40 +57,43 @@ export const SwitchAccountDrawer = (props: Props) => { ); } - return childAccounts?.data.map((childAccount, key) => ( - ( + { // TODO: Parent/Child - M3-7430 // handleAccountSwitch(); }} - key={key} + key={`child-account-link-button-${idx}`} > {childAccount.company} - + )); }, [childAccounts, error, isLoading]); return ( - - {/* {(isParentTokenError || isProxyTokenError) && ( - There was an error switching accounts. - )} */} - - - Select an account to view and manage its settings and configurations - {user?.user_type === 'proxy' && ( - <> - {' '} - or {/* TODO: Parent/Child - M3-7430 */} - null}> - switch back to your account - - - )} - . - - {renderChildAccounts()} - + + + Select an account to view and manage its settings and configurations + {user?.user_type === 'proxy' && ( + <> + {' '} + or {/* TODO: Parent/Child - M3-7430 */} + null}> + switch back to your account + + + )} + . + + {renderChildAccounts()} ); }; + +const StyledTypography = styled(Typography)(({ theme }) => ({ + margin: `${theme.spacing(3)} 0`, +})); + +const StyledChildAccountLinkButton = styled(StyledLinkButton)(({ theme }) => ({ + marginBottom: theme.spacing(2), +})); diff --git a/packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx b/packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx index 563db47b4e8..2a2c82dbe17 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx @@ -12,16 +12,11 @@ interface Props { } export const SwitchAccountButton = (props: Props) => { - const { handleClose, setIsDrawerOpen, userType } = props; + const { handleClose, setIsDrawerOpen } = props; return ( { - // From proxy accounts, make a request on behalf of the parent account to fetch child accounts. - if (userType === 'proxy') { - // TODO: Parent/Child - M3-7430 - } - handleClose(); setIsDrawerOpen(true); }} @@ -42,7 +37,6 @@ const StyledButton = styled(Button)(({ theme }) => ({ fill: theme.name === 'dark' ? '#fff' : theme.textColors.linkActiveLight, }, }, - marginRight: theme.spacing(1), })); const StyledSwapIcon = styled(SwapIcon)(({ theme }) => ({ diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index cf5cfe0f52c..0b701850508 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -22,9 +22,9 @@ import { useGrants, useProfile } from 'src/queries/profile'; import { authentication } from 'src/utilities/storage'; import { SwitchAccountDrawer } from '../SwitchAccountDrawer'; +import { SwitchAccountButton } from './SwitchAccountButton'; import type { UserType } from '@linode/api-v4'; -import { SwitchAccountButton } from './SwitchAccountButton'; interface MenuLink { display: string; @@ -320,10 +320,6 @@ export const UserMenu = React.memo(() => { null} // {handleAccountSwitch} - isParentTokenError={false} // {isParentTokenError} - isProxyTokenError={false} // {isProxyTokenError} onClose={() => setIsDrawerOpen(false)} open={isDrawerOpen} username={userName} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index ea55bd18bb0..e92b653e5ea 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1177,6 +1177,7 @@ export const handlers = [ }), ]; return res(ctx.json(makeResourcePage(childAccounts))); + // return res(ctx.json(makeResourcePage(accountFactory.buildList(101)))); }), rest.get('*/account/child-accounts/:euuid', (req, res, ctx) => { const childAccount = accountFactory.build({ From c3c76aef3de72a964d85aff7808b4c3c160b2668 Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Fri, 5 Jan 2024 07:09:44 -0800 Subject: [PATCH 09/13] Added changeset: Add parent/proxy 'Switch Account' button and drawer to user profile dropdown menu --- .../.changeset/pr-10031-upcoming-features-1704467384095.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-10031-upcoming-features-1704467384095.md diff --git a/packages/manager/.changeset/pr-10031-upcoming-features-1704467384095.md b/packages/manager/.changeset/pr-10031-upcoming-features-1704467384095.md new file mode 100644 index 00000000000..59afee40f1c --- /dev/null +++ b/packages/manager/.changeset/pr-10031-upcoming-features-1704467384095.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add parent/proxy 'Switch Account' button and drawer to user profile dropdown menu ([#10031](https://github.com/linode/manager/pull/10031)) From d8676d5b315c591c22e1ba2c230b1a1a5b38c8cb Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Fri, 5 Jan 2024 10:26:46 -0800 Subject: [PATCH 10/13] Clean up and add WIP unit tests --- .../SwitchAccountButton.tsx | 14 +- .../Account/SwitchAccountDrawer.test.tsx | 48 ++++ .../SwitchAccountDrawer.tsx | 0 .../TopMenu/UserMenu/UserMenu.test.tsx | 215 +++++++++++------- .../features/TopMenu/UserMenu/UserMenu.tsx | 10 +- 5 files changed, 197 insertions(+), 90 deletions(-) rename packages/manager/src/features/{TopMenu/UserMenu => Account}/SwitchAccountButton.tsx (72%) create mode 100644 packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx rename packages/manager/src/features/{TopMenu => Account}/SwitchAccountDrawer.tsx (100%) diff --git a/packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx b/packages/manager/src/features/Account/SwitchAccountButton.tsx similarity index 72% rename from packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx rename to packages/manager/src/features/Account/SwitchAccountButton.tsx index 2a2c82dbe17..7c59f378112 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/SwitchAccountButton.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.tsx @@ -1,26 +1,24 @@ -import { UserType } from '@linode/api-v4/lib/account/types'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import SwapIcon from 'src/assets/icons/swapSmall.svg'; -import { Button } from 'src/components/Button/Button'; +import { Button, ButtonProps } from 'src/components/Button/Button'; -interface Props { - handleClose: () => void; +interface Props extends ButtonProps { + onClick: () => void; setIsDrawerOpen: (open: boolean) => void; - userType: UserType | null; } export const SwitchAccountButton = (props: Props) => { - const { handleClose, setIsDrawerOpen } = props; + const { onClick, setIsDrawerOpen, ...rest } = props; return ( { - handleClose(); + onClick(); setIsDrawerOpen(true); }} - buttonType="outlined" + {...rest} > Switch Account diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx new file mode 100644 index 00000000000..58fac16d47a --- /dev/null +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { SwitchAccountDrawer } from './SwitchAccountDrawer'; + +const props = { + onClose: vi.fn(), + open: true, + username: 'mock-user', +}; + +describe('SwitchAccountDrawer', () => { + it('should have a title', () => { + const { getByText } = renderWithTheme(); + expect(getByText('Switch Account')).toBeInTheDocument(); + }); + + it('should display helper text about the accounts', () => { + const { getByText } = renderWithTheme(); + expect( + getByText( + 'Select an account to view and manage its settings and configurations', + { exact: false } + ) + ).toBeInTheDocument(); + }); + + it('should include a link to switch back to the parent account if the active user is a proxy user', () => { + // const { getByText } = renderWithTheme(); + }); + + it('should display a list of child accounts', () => { + // const { getByText } = renderWithTheme(); + }); + + it('should display a notice if there are zero child accounts', () => { + // const { getByText } = renderWithTheme(); + }); + + it('should display an error if child accounts could not be fetched', () => { + // const { getByText } = renderWithTheme(); + }); + + it('should close when the close icon is clicked', () => { + // const { getByText } = renderWithTheme(); + }); +}); diff --git a/packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx similarity index 100% rename from packages/manager/src/features/TopMenu/SwitchAccountDrawer.tsx rename to packages/manager/src/features/Account/SwitchAccountDrawer.tsx diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx index 4001276d807..36e89b9fe75 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx @@ -1,3 +1,4 @@ +import { fireEvent, within } from '@testing-library/react'; import * as React from 'react'; import { accountFactory, profileFactory } from 'src/factories'; @@ -10,91 +11,151 @@ import { UserMenu } from './UserMenu'; // We have to do this because if we don't, the username doesn't render. beforeAll(() => mockMatchMedia()); -it('renders without crashing', () => { - const { getByRole } = renderWithTheme(); - expect(getByRole('button')).toBeInTheDocument(); -}); - -it("shows a parent user's username and company name for a parent user", async () => { - server.use( - rest.get('*/account', (req, res, ctx) => { - return res(ctx.json(accountFactory.build({ company: 'Parent Company' }))); - }), - 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' }))); - }) - ); - - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, +describe('UserMenu', () => { + it('renders without crashing', () => { + const { getByRole } = renderWithTheme(); + expect(getByRole('button')).toBeInTheDocument(); }); - expect(await findByText('parent-user')).toBeInTheDocument(); - expect(await findByText('Parent Company')).toBeInTheDocument(); -}); - -it("shows the parent user's username and child company name for a proxy user", async () => { - server.use( - rest.get('*/account', (req, res, ctx) => { - return res(ctx.json(accountFactory.build({ company: 'Child Company' }))); - }), - 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' }))); - }) - ); - - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, + it("shows a parent user's username and company name for a parent user", async () => { + server.use( + rest.get('*/account', (req, res, ctx) => { + return res( + ctx.json(accountFactory.build({ company: 'Parent Company' })) + ); + }), + 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' }))); + }) + ); + + const { findByText } = renderWithTheme(, { + flags: { parentChildAccountAccess: true }, + }); + + expect(await findByText('parent-user')).toBeInTheDocument(); + expect(await findByText('Parent Company')).toBeInTheDocument(); }); - expect(await findByText('parent-user')).toBeInTheDocument(); - expect(await findByText('Child Company')).toBeInTheDocument(); -}); + it("shows the parent user's username and child company name for a proxy user", async () => { + server.use( + rest.get('*/account', (req, res, ctx) => { + return res( + ctx.json(accountFactory.build({ company: 'Child Company' })) + ); + }), + 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' }))); + }) + ); + + const { findByText } = renderWithTheme(, { + flags: { parentChildAccountAccess: true }, + }); + + expect(await findByText('parent-user')).toBeInTheDocument(); + expect(await findByText('Child Company')).toBeInTheDocument(); + }); -it("shows the child user's username and company name for a child user", async () => { - server.use( - rest.get('*/account', (req, res, ctx) => { - return res(ctx.json(accountFactory.build({ company: 'Child Company' }))); - }), - 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' }))); - }) - ); - - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, + it("shows the child user's username and company name for a child user", async () => { + server.use( + rest.get('*/account', (req, res, ctx) => { + return res( + ctx.json(accountFactory.build({ company: 'Child Company' })) + ); + }), + 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' }))); + }) + ); + + const { findByText } = renderWithTheme(, { + flags: { parentChildAccountAccess: true }, + }); + + expect(await findByText('child-user')).toBeInTheDocument(); + expect(await findByText('Child Company')).toBeInTheDocument(); }); - expect(await findByText('child-user')).toBeInTheDocument(); - expect(await findByText('Child Company')).toBeInTheDocument(); -}); + it("shows the user's username and no company name for a regular user", async () => { + server.use( + rest.get('*/account', (req, res, ctx) => { + return res(ctx.json(accountFactory.build({ company: 'Test Company' }))); + }), + rest.get('*/profile', (req, res, ctx) => { + return res( + ctx.json(profileFactory.build({ username: 'regular-user' })) + ); + }), + rest.get('*/account/users/*', (req, res, ctx) => { + return res(ctx.json(accountUserFactory.build({ user_type: null }))); + }) + ); + + const { findByText, queryByText } = renderWithTheme(, { + flags: { parentChildAccountAccess: true }, + }); + + expect(await findByText('regular-user')).toBeInTheDocument(); + expect(queryByText('Test Company')).not.toBeInTheDocument(); + }); -it("shows the user's username and no company name for a regular user", async () => { - server.use( - rest.get('*/account', (req, res, ctx) => { - return res(ctx.json(accountFactory.build({ company: 'Test Company' }))); - }), - rest.get('*/profile', (req, res, ctx) => { - return res(ctx.json(profileFactory.build({ username: 'regular-user' }))); - }), - rest.get('*/account/users/*', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build({ user_type: null }))); - }) - ); - - const { findByText, queryByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, + it('shows the parent company name and Switch Account button in the dropdown menu for a parent user', async () => { + server.use( + rest.get('*/account', (req, res, ctx) => { + return res( + ctx.json(accountFactory.build({ company: 'Parent Company' })) + ); + }), + rest.get('*/account/users/*', (req, res, ctx) => { + return res(ctx.json(accountUserFactory.build({ user_type: 'parent' }))); + }) + ); + + const { findByLabelText, findByTestId } = renderWithTheme(, { + flags: { parentChildAccountAccess: true }, + }); + + const userMenuButton = await findByLabelText('Profile & Account'); + fireEvent.click(userMenuButton); + + const userMenuPopover = await findByTestId('user-menu-popover'); + + expect(within(userMenuPopover).getByText('Parent Company')).toBeVisible(); + expect(within(userMenuPopover).getByText('Switch Account')).toBeVisible(); }); - expect(await findByText('regular-user')).toBeInTheDocument(); - expect(queryByText('Test Company')).not.toBeInTheDocument(); + it('shows the child company name and Switch Account button in the dropdown menu for a proxy user', async () => { + server.use( + rest.get('*/account', (req, res, ctx) => { + return res( + ctx.json(accountFactory.build({ company: 'Child Company' })) + ); + }), + rest.get('*/account/users/*', (req, res, ctx) => { + return res(ctx.json(accountUserFactory.build({ user_type: 'proxy' }))); + }) + ); + + const { findByLabelText, findByTestId } = renderWithTheme(, { + flags: { parentChildAccountAccess: true }, + }); + + const userMenuButton = await findByLabelText('Profile & Account'); + fireEvent.click(userMenuButton); + + const userMenuPopover = await findByTestId('user-menu-popover'); + + expect(within(userMenuPopover).getByText('Child Company')).toBeVisible(); + expect(within(userMenuPopover).getByText('Switch Account')).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 0b701850508..97a64f9726f 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -15,15 +15,14 @@ import { Link } from 'src/components/Link'; import { Stack } from 'src/components/Stack'; import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; +import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton'; +import { SwitchAccountDrawer } from 'src/features/Account/SwitchAccountDrawer'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useAccountUser } from 'src/queries/accountUsers'; import { useGrants, useProfile } from 'src/queries/profile'; import { authentication } from 'src/utilities/storage'; -import { SwitchAccountDrawer } from '../SwitchAccountDrawer'; -import { SwitchAccountButton } from './SwitchAccountButton'; - import type { UserType } from '@linode/api-v4'; interface MenuLink { @@ -242,6 +241,7 @@ export const UserMenu = React.memo(() => { }, }} anchorEl={anchorEl} + data-testid={id} id={id} marginThreshold={0} onClose={handleClose} @@ -260,9 +260,9 @@ export const UserMenu = React.memo(() => { { isAccountSwitchable && ( ) // TODO: Parent/Child - M3-7430 From 8dcd1c6ad6fb710d7bf90bb1203f8f0552237bb5 Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Fri, 5 Jan 2024 12:26:07 -0800 Subject: [PATCH 11/13] Clean up and fix z-index so user menu is focused on drawer close --- .../features/Account/SwitchAccountButton.tsx | 17 +++-------------- .../src/features/TopMenu/UserMenu/UserMenu.tsx | 5 +++-- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/manager/src/features/Account/SwitchAccountButton.tsx b/packages/manager/src/features/Account/SwitchAccountButton.tsx index 7c59f378112..15778159f5c 100644 --- a/packages/manager/src/features/Account/SwitchAccountButton.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.tsx @@ -4,22 +4,11 @@ import * as React from 'react'; import SwapIcon from 'src/assets/icons/swapSmall.svg'; import { Button, ButtonProps } from 'src/components/Button/Button'; -interface Props extends ButtonProps { - onClick: () => void; - setIsDrawerOpen: (open: boolean) => void; -} - -export const SwitchAccountButton = (props: Props) => { - const { onClick, setIsDrawerOpen, ...rest } = props; +export const SwitchAccountButton = (props: ButtonProps) => { + const { ...rest } = props; return ( - { - onClick(); - setIsDrawerOpen(true); - }} - {...rest} - > + Switch Account diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 97a64f9726f..047aa312c10 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -246,6 +246,8 @@ export const UserMenu = React.memo(() => { marginThreshold={0} onClose={handleClose} open={open} + // When the Switch Account drawer is open, hide the user menu popover so it's not covering the drawer. + sx={{ zIndex: isDrawerOpen ? 0 : 1 }} > {isAccountSwitchable && ( @@ -261,8 +263,7 @@ export const UserMenu = React.memo(() => { isAccountSwitchable && ( setIsDrawerOpen(true)} /> ) // TODO: Parent/Child - M3-7430 From ac786bfd874d979acabe1fd009c1b74f579eeac4 Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Fri, 5 Jan 2024 16:32:13 -0800 Subject: [PATCH 12/13] Add tests for SwitchAccountDrawer --- .../Account/SwitchAccountDrawer.test.tsx | 63 +++++++++++++++---- .../features/Account/SwitchAccountDrawer.tsx | 9 ++- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx index 58fac16d47a..b9e5268bf2a 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx @@ -1,5 +1,10 @@ +import { fireEvent, within } from '@testing-library/react'; import * as React from 'react'; +import { accountFactory } from 'src/factories/account'; +import { accountUserFactory } from 'src/factories/accountUsers'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { SwitchAccountDrawer } from './SwitchAccountDrawer'; @@ -26,23 +31,59 @@ describe('SwitchAccountDrawer', () => { ).toBeInTheDocument(); }); - it('should include a link to switch back to the parent account if the active user is a proxy user', () => { - // const { getByText } = renderWithTheme(); - }); + 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' }))); + }) + ); - it('should display a list of child accounts', () => { - // const { getByText } = renderWithTheme(); - }); + const { findByLabelText, getByText } = renderWithTheme( + + ); - it('should display a notice if there are zero child accounts', () => { - // const { getByText } = renderWithTheme(); + expect( + getByText( + 'Select an account to view and manage its settings and configurations', + { exact: false } + ) + ).toBeInTheDocument(); + expect(await findByLabelText('parent-account-link')).toHaveTextContent( + 'switch back to your account' + ); }); - it('should display an error if child accounts could not be fetched', () => { - // const { getByText } = renderWithTheme(); + it('should display a list of child accounts', async () => { + server.use( + rest.get('*/account/child-accounts', (req, res, ctx) => { + return res( + ctx.json( + makeResourcePage( + accountFactory.buildList(5, { company: 'Child Co.' }) + ) + ) + ); + }) + ); + + const { findByTestId } = renderWithTheme( + + ); + + const childAccounts = await findByTestId('child-account-list'); + expect( + within(childAccounts).getAllByText('Child Co.', { exact: false }) + ).toHaveLength(5); }); it('should close when the close icon is clicked', () => { - // const { getByText } = renderWithTheme(); + const { getByLabelText } = renderWithTheme( + + ); + + const closeIconButton = getByLabelText('Close drawer'); + fireEvent.click(closeIconButton); + + expect(props.onClose).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx index 803b69e1d20..830391bd0a4 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx @@ -78,14 +78,19 @@ export const SwitchAccountDrawer = (props: Props) => { <> {' '} or {/* TODO: Parent/Child - M3-7430 */} - null}> + null} + > switch back to your account )} . - {renderChildAccounts()} + + {renderChildAccounts()} + ); }; From 0c983a81d08a9bfa302d54f65af68279e947c8af Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Mon, 8 Jan 2024 07:15:08 -0800 Subject: [PATCH 13/13] Address feedback: use startIcon --- .../manager/src/assets/icons/swapSmall.svg | 2 +- .../features/Account/SwitchAccountButton.tsx | 23 ++----------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/packages/manager/src/assets/icons/swapSmall.svg b/packages/manager/src/assets/icons/swapSmall.svg index 29190713dab..6711e50df3b 100644 --- a/packages/manager/src/assets/icons/swapSmall.svg +++ b/packages/manager/src/assets/icons/swapSmall.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/manager/src/features/Account/SwitchAccountButton.tsx b/packages/manager/src/features/Account/SwitchAccountButton.tsx index 15778159f5c..fd9f4966c9b 100644 --- a/packages/manager/src/features/Account/SwitchAccountButton.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.tsx @@ -1,31 +1,12 @@ -import { styled } from '@mui/material/styles'; import * as React from 'react'; import SwapIcon from 'src/assets/icons/swapSmall.svg'; import { Button, ButtonProps } from 'src/components/Button/Button'; export const SwitchAccountButton = (props: ButtonProps) => { - const { ...rest } = props; - return ( - - + ); }; - -const StyledButton = styled(Button)(({ theme }) => ({ - '& path': { - fill: theme.textColors.linkActiveLight, - }, - '&:hover, &:focus': { - '& path': { - fill: theme.name === 'dark' ? '#fff' : theme.textColors.linkActiveLight, - }, - }, -})); - -const StyledSwapIcon = styled(SwapIcon)(({ theme }) => ({ - marginRight: theme.spacing(1), -}));