From f3439dae84fd139e407a849495050f2b476b1a79 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Mon, 25 Nov 2024 12:08:30 -0500 Subject: [PATCH] feat: [M3-8728] - Add Product Families to Create Menu dropdown (#11260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description ๐Ÿ“ Add Product Families to the Create Menu Dropdown and display the desktop dropdown as 3 columns in a row; mobile will remain as a single column dropdown Note: The Storage section was intentionally moved after Networking (different from the Side Nav Menu) since it has less items and can be in the same column as Databases. UX would prefer not to change the Side Nav order as it "reflects the info hierarchy from Ryan McEntee, that is also being used by TechDocs." ## Changes ๐Ÿ”„ - Add Product Families to Create Menu dropdown Clean up ๐Ÿงน : - Deleted unused nav components - Renamed AddNewMenu to CreateMenu - Variable renaming in PrimaryNav - Updated unit test to use `userEvent` over `fireEvent` ## How to test ๐Ÿงช ### Verification steps - [ ] Open the create menu and ensure you can tab through the items. Clicking links still work as expected, etc - [ ] Check mobile view ``` yarn test CreateMenu ``` --- .../pr-11260-added-1731699680759.md | 5 + .../dbaas-widgets-verification.spec.ts | 2 +- .../linode-widget-verification.spec.ts | 2 +- .../PrimaryNav/AdditionalMenuItems.tsx | 40 ---- .../src/components/PrimaryNav/NavItem.tsx | 95 -------- .../src/components/PrimaryNav/PrimaryLink.tsx | 13 +- .../src/components/PrimaryNav/PrimaryNav.tsx | 45 ++-- .../TopMenu/AddNewMenu/AddNewMenu.test.tsx | 66 ------ .../TopMenu/AddNewMenu/AddNewMenu.tsx | 196 ---------------- .../TopMenu/CreateMenu/CreateMenu.styles.ts | 74 +++++++ .../TopMenu/CreateMenu/CreateMenu.test.tsx | 90 ++++++++ .../TopMenu/CreateMenu/CreateMenu.tsx | 209 ++++++++++++++++++ .../TopMenu/CreateMenu/ProductFamilyGroup.tsx | 53 +++++ .../manager/src/features/TopMenu/TopMenu.tsx | 4 +- 14 files changed, 470 insertions(+), 424 deletions(-) create mode 100644 packages/manager/.changeset/pr-11260-added-1731699680759.md delete mode 100644 packages/manager/src/components/PrimaryNav/AdditionalMenuItems.tsx delete mode 100644 packages/manager/src/components/PrimaryNav/NavItem.tsx delete mode 100644 packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx delete mode 100644 packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx create mode 100644 packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.styles.ts create mode 100644 packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.test.tsx create mode 100644 packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx create mode 100644 packages/manager/src/features/TopMenu/CreateMenu/ProductFamilyGroup.tsx diff --git a/packages/manager/.changeset/pr-11260-added-1731699680759.md b/packages/manager/.changeset/pr-11260-added-1731699680759.md new file mode 100644 index 00000000000..126c63b805c --- /dev/null +++ b/packages/manager/.changeset/pr-11260-added-1731699680759.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Product Families to Create Menu dropdown ([#11260](https://github.com/linode/manager/pull/11260)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 1f146a5e359..19759acf8b2 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -49,7 +49,7 @@ import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const flags : Partial= {aclp: { enabled: true, beta: true}} +const flags: Partial = { aclp: { enabled: true, beta: true } }; const { metrics, diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 22daab72be0..17f2931d875 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -46,7 +46,7 @@ import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; */ const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const flags : Partial = {aclp: {enabled: true, beta: true}} +const flags: Partial = { aclp: { enabled: true, beta: true } }; const { metrics, id, diff --git a/packages/manager/src/components/PrimaryNav/AdditionalMenuItems.tsx b/packages/manager/src/components/PrimaryNav/AdditionalMenuItems.tsx deleted file mode 100644 index 21ebbe2e38e..00000000000 --- a/packages/manager/src/components/PrimaryNav/AdditionalMenuItems.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from 'react'; - -import Help from 'src/assets/icons/help.svg'; - -import { NavItem, PrimaryLink } from './NavItem'; - -interface Props { - closeMenu: () => void; - dividerClasses: string; - isCollapsed?: boolean; - linkClasses: (href?: string) => string; - listItemClasses: string; -} - -export const AdditionalMenuItems = React.memo((props: Props) => { - const { isCollapsed } = props; - const links: PrimaryLink[] = [ - { - QAKey: 'help', - display: 'Get Help', - href: '/support', - icon: , - }, - ]; - - return ( - - {links.map((eachLink) => { - return ( - - ); - })} - - ); -}); diff --git a/packages/manager/src/components/PrimaryNav/NavItem.tsx b/packages/manager/src/components/PrimaryNav/NavItem.tsx deleted file mode 100644 index b42ac1ebe7a..00000000000 --- a/packages/manager/src/components/PrimaryNav/NavItem.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Divider, Tooltip } from '@linode/ui'; -import * as React from 'react'; -import { Link } from 'react-router-dom'; -import { useStyles } from 'tss-react/mui'; - -import { ListItem } from 'src/components/ListItem'; -import { ListItemText } from 'src/components/ListItemText'; - -interface Props extends PrimaryLink { - closeMenu: () => void; - dividerClasses?: string; - isCollapsed?: boolean; - linkClasses: (href?: string) => string; - listItemClasses: string; -} - -export interface PrimaryLink { - QAKey: string; - display: string; - href?: string; - icon?: JSX.Element; - isDisabled?: () => string; - logo?: React.ComponentType; - onClick?: () => void; -} - -export const NavItem = React.memo((props: Props) => { - const { - QAKey, - closeMenu, - display, - href, - icon, - isCollapsed, - isDisabled, - linkClasses, - listItemClasses, - onClick, - } = props; - - const { cx } = useStyles(); - - if (!onClick && !href) { - throw new Error('A Primary Link needs either an href or an onClick prop'); - } - - return ( - /* - href takes priority here. So if an href and onClick - are provided, the onClick will not be applied - */ - - {href ? ( - - {icon && isCollapsed &&
{icon}
} - - - ) : ( - - { - props.closeMenu(); - /* disregarding undefined is fine here because of the error handling thrown above */ - onClick!(); - }} - aria-live="polite" - className={linkClasses()} - data-qa-nav-item={QAKey} - disabled={!!isDisabled ? !!isDisabled() : false} - > - - - - )} - -
- ); -}); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx index 788c0e4e6d0..490fb364a86 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx @@ -4,14 +4,18 @@ import * as React from 'react'; import { StyledActiveLink, StyledPrimaryLinkBox } from './PrimaryNav.styles'; import type { NavEntity } from './PrimaryNav'; +import type { CreateEntity } from 'src/features/TopMenu/CreateMenu/CreateMenu'; -export interface PrimaryLink { - activeLinks?: Array; +export interface BaseNavLink { attr?: { [key: string]: any }; - betaChipClassName?: string; - display: NavEntity; + display: CreateEntity | NavEntity; hide?: boolean; href: string; +} + +export interface PrimaryLink extends BaseNavLink { + activeLinks?: Array; + betaChipClassName?: string; isBeta?: boolean; onClick?: (e: React.ChangeEvent) => void; } @@ -19,7 +23,6 @@ export interface PrimaryLink { interface PrimaryLinkProps extends PrimaryLink { closeMenu: () => void; isActiveLink: boolean; - isBeta?: boolean; isCollapsed: boolean; } diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 49122ffc133..b4ef19137ad 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -33,7 +33,6 @@ import { linkIsActive } from './utils'; import type { PrimaryLink as PrimaryLinkType } from './PrimaryLink'; export type NavEntity = - | 'Account' | 'Account' | 'Betas' | 'Cloud Load Balancers' @@ -56,10 +55,18 @@ export type NavEntity = | 'VPC' | 'Volumes'; -interface PrimaryLinkGroup { +export type ProductFamily = + | 'Compute' + | 'Databases' + | 'Monitor' + | 'More' + | 'Networking' + | 'Storage'; + +export interface ProductFamilyLinkGroup { icon?: React.JSX.Element; - links: PrimaryLinkType[]; - title?: string; + links: T; + name?: ProductFamily; } export interface PrimaryNavProps { @@ -84,7 +91,9 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { data: preferences } = usePreferences(); const { mutateAsync: updatePreferences } = useMutatePreferences(); - const primaryLinkGroups: PrimaryLinkGroup[] = React.useMemo( + const productFamilyLinkGroups: ProductFamilyLinkGroup< + PrimaryLinkType[] + >[] = React.useMemo( () => [ { links: [ @@ -132,7 +141,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/linodes/create?type=One-Click', }, ], - title: 'Compute', + name: 'Compute', }, { icon: , @@ -150,7 +159,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/volumes', }, ], - title: 'Storage', + name: 'Storage', }, { icon: , @@ -172,7 +181,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/domains', }, ], - title: 'Networking', + name: 'Networking', }, { icon: , @@ -184,7 +193,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isBeta: isDatabasesV2Beta, }, ], - title: 'Databases', + name: 'Databases', }, { icon: , @@ -200,7 +209,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isBeta: flags.aclp?.beta, }, ], - title: 'Monitor', + name: 'Monitor', }, { icon: , @@ -219,7 +228,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/support', }, ], - title: 'More', + name: 'More', }, ], // eslint-disable-next-line react-hooks/exhaustive-deps @@ -282,8 +291,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { - {primaryLinkGroups.map((linkGroup, idx) => { - const filteredLinks = linkGroup.links.filter((link) => !link.hide); + {productFamilyLinkGroups.map((productFamily, idx) => { + const filteredLinks = productFamily.links.filter((link) => !link.hide); if (filteredLinks.length === 0) { return null; } @@ -298,7 +307,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { ) ); if (isActiveLink) { - activeProductFamily = linkGroup.title ?? ''; + activeProductFamily = productFamily.name ?? ''; } const props = { closeMenu, @@ -311,17 +320,17 @@ export const PrimaryNav = (props: PrimaryNavProps) => { return (
- {linkGroup.title ? ( // TODO: we can remove this conditional when Managed is removed + {productFamily.name ? ( // TODO: we can remove this conditional when Managed is removed <> - {linkGroup.icon} -

{linkGroup.title}

+ {productFamily.icon} +

{productFamily.name}

} isActiveProductFamily={ - activeProductFamily === linkGroup.title + activeProductFamily === productFamily.name } expanded={!collapsedAccordions.includes(idx)} isCollapsed={isCollapsed} diff --git a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx deleted file mode 100644 index 6331ad7d8bc..00000000000 --- a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { fireEvent } from '@testing-library/react'; -import { createMemoryHistory } from 'history'; -import React from 'react'; -import { Router } from 'react-router-dom'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { AddNewMenu } from './AddNewMenu'; - -describe('AddNewMenu', () => { - test('renders the Create button', () => { - const { getByText } = renderWithTheme(); - const createButton = getByText('Create'); - expect(createButton).toBeInTheDocument(); - }); - - test('opens the menu on button click', () => { - const { getByText, getByRole } = renderWithTheme(); - const createButton = getByText('Create'); - fireEvent.click(createButton); - const menu = getByRole('menu'); - expect(menu).toBeInTheDocument(); - }); - - test('renders Linode menu item', () => { - const { getByText } = renderWithTheme(); - const createButton = getByText('Create'); - fireEvent.click(createButton); - const menuItem = getByText('Linode'); - expect(menuItem).toBeInTheDocument(); - }); - - test('navigates to Linode create page on Linode menu item click', () => { - // Create a mock history object - const history = createMemoryHistory(); - - // Render the component with the Router and history - const { getByText } = renderWithTheme( - - - - ); - - const createButton = getByText('Create'); - fireEvent.click(createButton); - - const menuItem = getByText('Linode'); - fireEvent.click(menuItem); - - // Assert that the history's location has changed to the expected URL - expect(history.location.pathname).toBe('/linodes/create'); - }); - - test('does not render hidden menu items', () => { - const { getByText, queryByText } = renderWithTheme(, { - flags: { databases: false }, - }); - const createButton = getByText('Create'); - fireEvent.click(createButton); - - ['Database'].forEach((createMenuItem: string) => { - const hiddenMenuItem = queryByText(createMenuItem); - expect(hiddenMenuItem).toBeNull(); - }); - }); -}); diff --git a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx deleted file mode 100644 index 62ca4f9b9f4..00000000000 --- a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { Button } from '@linode/ui'; -import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; -import { - Box, - ListItemIcon, - Menu, - MenuItem, - Stack, - Typography, - useTheme, -} from '@mui/material'; -import * as React from 'react'; -import { Link } from 'react-router-dom'; - -import BucketIcon from 'src/assets/icons/entityIcons/bucket.svg'; -import DatabaseIcon from 'src/assets/icons/entityIcons/database.svg'; -import DomainIcon from 'src/assets/icons/entityIcons/domain.svg'; -import FirewallIcon from 'src/assets/icons/entityIcons/firewall.svg'; -import KubernetesIcon from 'src/assets/icons/entityIcons/kubernetes.svg'; -import LinodeIcon from 'src/assets/icons/entityIcons/linode.svg'; -import NodebalancerIcon from 'src/assets/icons/entityIcons/nodebalancer.svg'; -import OneClickIcon from 'src/assets/icons/entityIcons/oneclick.svg'; -import PlacementGroupsIcon from 'src/assets/icons/entityIcons/placement-groups.svg'; -import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; -import VPCIcon from 'src/assets/icons/entityIcons/vpc.svg'; -import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; -import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; - -interface LinkProps { - attr?: { [key: string]: boolean }; - description: string; - entity: string; - hide?: boolean; - icon: React.ComponentClass; - link: string; -} - -export const AddNewMenu = () => { - const theme = useTheme(); - const [anchorEl, setAnchorEl] = React.useState(null); - const open = Boolean(anchorEl); - - const { isDatabasesEnabled } = useIsDatabasesEnabled(); - const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - const links: LinkProps[] = [ - { - description: 'High performance SSD Linux servers', - entity: 'Linode', - icon: LinodeIcon, - link: '/linodes/create', - }, - { - description: 'Attach additional storage to your Linode', - entity: 'Volume', - icon: VolumeIcon, - link: '/volumes/create', - }, - { - description: 'Ensure your services are highly available', - entity: 'NodeBalancer', - icon: NodebalancerIcon, - link: '/nodebalancers/create', - }, - { - description: 'Create a private and isolated network', - entity: 'VPC', - icon: VPCIcon, - link: '/vpcs/create', - }, - { - description: 'Control network access to your Linodes', - entity: 'Firewall', - icon: FirewallIcon, - link: '/firewalls/create', - }, - { - description: "Control your Linodes' physical placement", - entity: 'Placement Groups', - hide: !isPlacementGroupsEnabled, - icon: PlacementGroupsIcon, - link: '/placement-groups/create', - }, - { - description: 'Manage your DNS records', - entity: 'Domain', - icon: DomainIcon, - link: '/domains/create', - }, - { - description: 'High-performance managed database clusters', - entity: 'Database', - hide: !isDatabasesEnabled, - icon: DatabaseIcon, - link: '/databases/create', - }, - { - description: 'Highly available container workloads', - entity: 'Kubernetes', - icon: KubernetesIcon, - link: '/kubernetes/create', - }, - { - description: 'S3-compatible object storage', - entity: 'Bucket', - icon: BucketIcon, - link: '/object-storage/buckets/create', - }, - { - attr: { 'data-qa-one-click-add-new': true }, - description: 'Deploy applications with ease', - entity: 'Marketplace', - icon: OneClickIcon, - link: '/linodes/create?type=One-Click', - }, - ]; - - return ( - - - - {links.map( - (link, i) => - !link.hide && [ - - - - - - {link.entity} - {link.description} - - , - ] - )} - - - ); -}; diff --git a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.styles.ts b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.styles.ts new file mode 100644 index 00000000000..227e902e0d6 --- /dev/null +++ b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.styles.ts @@ -0,0 +1,74 @@ +import { Paper, omittedProps } from '@linode/ui'; +import { MenuItem, MenuList, Stack, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +export const StyledHeading = styled('h3', { + label: 'StyledHeading', + shouldForwardProp: omittedProps(['paddingTop']), +})<{ paddingTop?: boolean }>(({ theme, ...props }) => ({ + '& svg': { + height: 16, + marginRight: theme.spacing(1), + width: 16, + }, + alignItems: 'center', + color: theme.name === 'dark' ? '#B8B8B8' : 'inherit', + display: 'flex', + fontFamily: 'LatoWebBold', + fontSize: '0.7rem', + letterSpacing: '1px', + margin: 0, + padding: '8px 14px', + textTransform: 'uppercase', + [theme.breakpoints.up('lg')]: { + background: 'inherit', + padding: `${props.paddingTop ? '16px' : '8px'} 16px 6px 16px`, + }, +})); + +export const StyledMenuItem = styled(MenuItem, { + label: 'StyledMenuItem', +})(({ theme }) => ({ + padding: '8px 14px', + // We have to do this because in packages/manager/src/index.css we force underline links + textDecoration: 'none !important', + [theme.breakpoints.up('md')]: { + padding: '8px 16px', + }, +})) as typeof MenuItem; + +export const StyledPaper = styled(Paper, { + label: 'StyledPaper', +})(({ theme }) => ({ + background: theme.bg.appBar, + maxHeight: 500, + padding: `${theme.spacing(1)} 0`, + [theme.breakpoints.down('lg')]: { + padding: 0, + }, +})); + +export const StyledStack = styled(Stack, { + label: 'StyledStack', +})(({ theme }) => ({ + [theme.breakpoints.down('lg')]: { + paddingTop: 4, + }, +})); + +export const StyledMenuList = styled(MenuList, { + label: 'StyledMenuList', +})(({ theme }) => ({ + [theme.breakpoints.up('lg')]: { + display: 'flex', + }, +})); + +export const StyledLinkTypography = styled(Typography, { + label: 'StyledLinkTypography', +})(({ theme }) => ({ + color: theme.color.offBlack, + fontFamily: theme.font.bold, + fontSize: '1rem', + lineHeight: '1.4rem', +})); diff --git a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.test.tsx b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.test.tsx new file mode 100644 index 00000000000..61a5a661fba --- /dev/null +++ b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.test.tsx @@ -0,0 +1,90 @@ +import { userEvent } from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import React from 'react'; +import { Router } from 'react-router-dom'; + +import { accountFactory } from 'src/factories'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CreateMenu } from './CreateMenu'; + +describe('CreateMenu', () => { + it('renders the Create button', () => { + const { getByText } = renderWithTheme(); + const createButton = getByText('Create'); + expect(createButton).toBeInTheDocument(); + }); + + it('opens the menu on button click', async () => { + const { getByRole, getByText } = renderWithTheme(); + const createButton = getByText('Create'); + await userEvent.click(createButton); + const menu = getByRole('menu'); + expect(menu).toBeInTheDocument(); + }); + + it('renders product family headings', async () => { + const { getAllByRole, getByText } = renderWithTheme(); + const createButton = getByText('Create'); + await userEvent.click(createButton); + const headings = getAllByRole('heading', { level: 3 }); + const expectedHeadings = ['Compute', 'Networking', 'Storage', 'Databases']; + headings.forEach((heading, i) => { + expect(heading).toHaveTextContent(expectedHeadings[i]); + }); + }); + + it('renders Linode menu item', async () => { + const { getByText } = renderWithTheme(); + const createButton = getByText('Create'); + await userEvent.click(createButton); + const menuItem = getByText('Linode'); + expect(menuItem).toBeInTheDocument(); + }); + + it('navigates to Linode create page on Linode menu item click', async () => { + // Create a mock history object + const history = createMemoryHistory(); + + // Render the component with the Router and history + const { getByText } = renderWithTheme( + + + + ); + + const createButton = getByText('Create'); + await userEvent.click(createButton); + + const menuItem = getByText('Linode'); + await userEvent.click(menuItem); + + // Assert that the history's location has changed to the expected URL + expect(history.location.pathname).toBe('/linodes/create'); + }); + + it('does not render hidden menu items', async () => { + const account = accountFactory.build({ + capabilities: [], + }); + + server.use( + http.get('*/account', () => { + return HttpResponse.json(account); + }) + ); + + const { getByText, queryByText } = renderWithTheme(, { + flags: { databases: false, dbaasV2: { beta: false, enabled: false } }, + }); + + const createButton = getByText('Create'); + await userEvent.click(createButton); + + ['Database'].forEach((createMenuItem: string) => { + const hiddenMenuItem = queryByText(createMenuItem); + expect(hiddenMenuItem).toBeNull(); + }); + }); +}); diff --git a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx new file mode 100644 index 00000000000..09959e681a9 --- /dev/null +++ b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx @@ -0,0 +1,209 @@ +import { Box, Button, Divider } from '@linode/ui'; +import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; +import { Popover, Stack } from '@mui/material'; +import * as React from 'react'; + +import BucketIcon from 'src/assets/icons/entityIcons/bucket.svg'; +import DatabaseIcon from 'src/assets/icons/entityIcons/database.svg'; +import LinodeIcon from 'src/assets/icons/entityIcons/linode.svg'; +import NodebalancerIcon from 'src/assets/icons/entityIcons/nodebalancer.svg'; +import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; +import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; + +import { StyledMenuList, StyledPaper, StyledStack } from './CreateMenu.styles'; +import { ProductFamilyGroup } from './ProductFamilyGroup'; + +import type { BaseNavLink } from 'src/components/PrimaryNav/PrimaryLink'; +import type { ProductFamilyLinkGroup } from 'src/components/PrimaryNav/PrimaryNav'; + +export type CreateEntity = + | 'Bucket' + | 'Database' + | 'Domain' + | 'Firewall' + | 'Image' + | 'Kubernetes' + | 'Linode' + | 'Marketplace' + | 'NodeBalancer' + | 'Object Storage' + | 'Placement Group' + | 'VPC' + | 'Volume'; + +export interface CreateMenuLink extends BaseNavLink { + description?: string; +} + +export const CreateMenu = () => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + + const { isDatabasesEnabled } = useIsDatabasesEnabled(); + const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const productFamilyLinkGroup: ProductFamilyLinkGroup[] = [ + { + icon: , + links: [ + { + description: 'High performance SSD Linux servers', + display: 'Linode', + href: '/linodes/create', + }, + { + description: 'Capture or upload Linux images', + display: 'Image', + href: '/images/create', + }, + { + description: 'Highly available container workloads', + display: 'Kubernetes', + href: '/kubernetes/create', + }, + { + description: "Control your Linodes' physical placement", + display: 'Placement Group', + hide: !isPlacementGroupsEnabled, + href: '/placement-groups/create', + }, + { + attr: { 'data-qa-one-click-add-new': true }, + description: 'Deploy applications with ease', + display: 'Marketplace', + href: '/linodes/create?type=One-Click', + }, + ], + name: 'Compute', + }, + { + icon: , + links: [ + { + description: 'Create a private and isolated network', + display: 'VPC', + href: '/vpcs/create', + }, + { + description: 'Control network access to your Linodes', + display: 'Firewall', + href: '/firewalls/create', + }, + { + description: 'Ensure your services are highly available', + display: 'NodeBalancer', + href: '/nodebalancers/create', + }, + { + description: 'Manage your DNS records', + display: 'Domain', + href: '/domains/create', + }, + ], + name: 'Networking', + }, + { + icon: , + links: [ + { + description: 'S3-compatible object storage', + display: 'Bucket', + href: '/object-storage/buckets/create', + }, + { + description: 'Attach additional storage to your Linode', + display: 'Volume', + href: '/volumes/create', + }, + ], + name: 'Storage', + }, + { + icon: , + links: [ + { + description: 'High-performance managed database clusters', + display: 'Database', + hide: !isDatabasesEnabled, + href: '/databases/create', + }, + ], + name: 'Databases', + }, + ]; + + return ( + + + + + + + + + + + + + + + {productFamilyLinkGroup.slice(2).map((productFamilyGroup) => ( + + ))} + + + + + + ); +}; diff --git a/packages/manager/src/features/TopMenu/CreateMenu/ProductFamilyGroup.tsx b/packages/manager/src/features/TopMenu/CreateMenu/ProductFamilyGroup.tsx new file mode 100644 index 00000000000..20d82ace27d --- /dev/null +++ b/packages/manager/src/features/TopMenu/CreateMenu/ProductFamilyGroup.tsx @@ -0,0 +1,53 @@ +import { Stack } from '@linode/ui'; +import { Typography } from '@mui/material'; +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +import { + StyledHeading, + StyledLinkTypography, + StyledMenuItem, +} from './CreateMenu.styles'; + +import type { CreateMenuLink } from './CreateMenu'; +import type { ProductFamilyLinkGroup } from 'src/components/PrimaryNav/PrimaryNav'; + +interface ProductFamilyGroupProps { + handleClose: () => void; + productFamily: ProductFamilyLinkGroup; +} + +export const ProductFamilyGroup = (props: ProductFamilyGroupProps) => { + const { handleClose, productFamily } = props; + const filteredLinks = productFamily.links.filter((link) => !link.hide); + if (filteredLinks.length === 0) { + return null; + } + + return ( + <> + + {productFamily.icon} + {productFamily.name} + + {filteredLinks.map( + (link) => + !link.hide && [ + + + {link.display} + {link.description} + + , + ] + )} + + ); +}; diff --git a/packages/manager/src/features/TopMenu/TopMenu.tsx b/packages/manager/src/features/TopMenu/TopMenu.tsx index 0bbd5f075ec..03657ec948f 100644 --- a/packages/manager/src/features/TopMenu/TopMenu.tsx +++ b/packages/manager/src/features/TopMenu/TopMenu.tsx @@ -7,8 +7,8 @@ import { Hidden } from 'src/components/Hidden'; import { Toolbar } from 'src/components/Toolbar'; import { useAuthentication } from 'src/hooks/useAuthentication'; -import { AddNewMenu } from './AddNewMenu/AddNewMenu'; import { Community } from './Community'; +import { CreateMenu } from './CreateMenu/CreateMenu'; import { Help } from './Help'; import { NotificationMenu } from './NotificationMenu/NotificationMenu'; import SearchBar from './SearchBar/SearchBar'; @@ -87,7 +87,7 @@ export const TopMenu = React.memo((props: TopMenuProps) => { - +