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 e57b259d25e..421f389ee1e 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 731e53bf993..90ee3b2c8dc 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 fd6b10de5c5..d282f93b5c4 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 32d23d5fc3f..0d2d8e531b0 100644 --- a/packages/manager/src/features/TopMenu/TopMenu.tsx +++ b/packages/manager/src/features/TopMenu/TopMenu.tsx @@ -8,8 +8,8 @@ import { Toolbar } from 'src/components/Toolbar'; import { Typography } from 'src/components/Typography'; 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'; @@ -88,7 +88,7 @@ export const TopMenu = React.memo((props: TopMenuProps) => { - +