diff --git a/static/app/components/hovercard.tsx b/static/app/components/hovercard.tsx index b360ee6f481222..5105af1d840ba4 100644 --- a/static/app/components/hovercard.tsx +++ b/static/app/components/hovercard.tsx @@ -1,7 +1,8 @@ -import {Fragment, useCallback, useRef} from 'react'; +import {createContext, Fragment, useCallback, useContext, useMemo, useRef} from 'react'; import {createPortal} from 'react-dom'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; +import type {State} from '@popperjs/core'; import {useResizeObserver} from '@react-aria/utils'; import {AnimatePresence} from 'framer-motion'; @@ -16,6 +17,10 @@ interface HovercardProps extends Omit { * Classname to apply to the hovercard */ children: React.ReactNode; + /** + * Whether to animate the hovercard in/out + */ + animated?: boolean; /** * Element to display in the body */ @@ -32,6 +37,11 @@ interface HovercardProps extends Omit { * Element to display in the header */ header?: React.ReactNode; + /** + * Container to render the hovercard content + * Defaults to document.body + */ + portalContainer?: HTMLElement; /** * Color of the arrow tip border */ @@ -47,11 +57,33 @@ type UseOverOverlayState = ReturnType; interface HovercardContentProps extends Pick< HovercardProps, - 'bodyClassName' | 'className' | 'header' | 'body' | 'tipColor' | 'tipBorderColor' + | 'animated' + | 'bodyClassName' + | 'className' + | 'header' + | 'body' + | 'tipColor' + | 'tipBorderColor' > { hoverOverlayState: Omit; } +interface HovercardProviderValue { + isOpen: boolean; + reset: () => void; + update: (() => Promise>) | null; +} + +const HovercardContext = createContext({ + isOpen: false, + reset: () => {}, + update: null, +}); + +export function useHovercardContext() { + return useContext(HovercardContext); +} + function useUpdateOverlayPositionOnContentChange({ update, }: Pick) { @@ -70,6 +102,7 @@ function useUpdateOverlayPositionOnContentChange({ } function HovercardContent({ + animated, body, bodyClassName, className, @@ -84,7 +117,7 @@ function HovercardContent({ return ( ( + () => ({ + isOpen, + reset: hoverOverlayState.reset, + update: hoverOverlayState.update, + }), + [isOpen, hoverOverlayState.reset, hoverOverlayState.update] + ); // Nothing to render if no header or body. Be consistent with wrapping the // children with the trigger in the case that the body / header is set while // the trigger is hovered. @@ -131,9 +174,10 @@ function Hovercard({ return {wrapTrigger(children)}; } - const hovercardContent = isOpen && ( + const hovercardContent = isOpen ? ( + ) : null; + + const hovercard = animated ? ( + {hovercardContent} + ) : ( + hovercardContent ); return ( - + {wrapTrigger(children)} - {createPortal({hovercardContent}, document.body)} - + {createPortal(hovercard, portalContainer)} + ); } diff --git a/static/app/views/admin/adminLayout.tsx b/static/app/views/admin/adminLayout.tsx index 523c7f2826a556..9805234d9b099b 100644 --- a/static/app/views/admin/adminLayout.tsx +++ b/static/app/views/admin/adminLayout.tsx @@ -5,6 +5,7 @@ import {t} from 'sentry/locale'; import type {RouteComponentProps} from 'sentry/types/legacyReactRouter'; import useOrganization from 'sentry/utils/useOrganization'; import {prefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {PrimaryNavGroup} from 'sentry/views/nav/types'; import {BreadcrumbProvider} from 'sentry/views/settings/components/settingsBreadcrumb/context'; import SettingsLayout from 'sentry/views/settings/components/settingsLayout'; import SettingsNavigation from 'sentry/views/settings/components/settingsNavigation'; @@ -40,6 +41,7 @@ export function AdminNavigation() { ], }, ]} + primaryNavGroup={PrimaryNavGroup.ADMIN} /> ); } diff --git a/static/app/views/nav/index.spec.tsx b/static/app/views/nav/index.spec.tsx index 2c4d165e44544b..89157eb4026cdf 100644 --- a/static/app/views/nav/index.spec.tsx +++ b/static/app/views/nav/index.spec.tsx @@ -78,6 +78,16 @@ describe('Nav', function () { body: {}, }); + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/explore/saved/`, + body: [], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/dashboards/`, + body: [], + }); + ConfigStore.set('user', { ...ConfigStore.get('user'), options: { @@ -243,13 +253,19 @@ describe('Nav', function () { await userEvent.click(screen.getByRole('button', {name: 'Collapse'})); - expect(screen.getByTestId('collapsed-secondary-sidebar')).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByRole('navigation', {name: 'Secondary Navigation'}) + ).not.toBeInTheDocument(); + }); - await userEvent.click(screen.getByRole('button', {name: 'Expand'})); + await userEvent.hover(screen.getByRole('link', {name: 'Issues'})); expect( - screen.queryByTestId('collapsed-secondary-sidebar') - ).not.toBeInTheDocument(); + await screen.findByRole('navigation', {name: 'Secondary Navigation'}) + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Expand'})); }); it('remembers collapsed state', async function () { @@ -257,44 +273,28 @@ describe('Nav', function () { renderNav(); - expect( - await screen.findByTestId('collapsed-secondary-sidebar') - ).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Expand'})).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByRole('navigation', {name: 'Secondary Navigation'}) + ).not.toBeInTheDocument(); + }); }); - it('expands on hover', async function () { + it('closes secondary nav overlay when navigating to a new route', async function () { localStorage.setItem(NAV_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY, 'true'); renderNav(); - expect( - await screen.findByTestId('collapsed-secondary-sidebar') - ).toBeInTheDocument(); + await userEvent.hover(screen.getByRole('link', {name: 'Explore'})); - expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute( - 'data-visible', - 'false' - ); + await screen.findByRole('navigation', {name: 'Secondary Navigation'}); - // Moving pointer over the primary navigation should expand the sidebar - await userEvent.hover( - screen.getByRole('navigation', {name: 'Primary Navigation'}) - ); - expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute( - 'data-visible', - 'true' - ); + await userEvent.click(screen.getByRole('link', {name: 'Traces'})); - // Moving pointer away should hide the sidebar - await userEvent.unhover( - screen.getByRole('navigation', {name: 'Primary Navigation'}) - ); await waitFor(() => { - expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute( - 'data-visible', - 'false' - ); + expect( + screen.queryByRole('navigation', {name: 'Secondary Navigation'}) + ).not.toBeInTheDocument(); }); }); }); diff --git a/static/app/views/nav/index.tsx b/static/app/views/nav/index.tsx index 78f995f2a4a439..aaf44ea0fab169 100644 --- a/static/app/views/nav/index.tsx +++ b/static/app/views/nav/index.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import {useNavContext} from 'sentry/views/nav/context'; import MobileTopbar from 'sentry/views/nav/mobileTopbar'; +import {SecondaryNav} from 'sentry/views/nav/secondary/secondary'; import {SecondaryNavContent} from 'sentry/views/nav/secondary/secondaryNavContent'; import {Sidebar} from 'sentry/views/nav/sidebar'; import { @@ -11,11 +12,13 @@ import { useStackedNavigationTour, } from 'sentry/views/nav/tour/tour'; import {NavLayout} from 'sentry/views/nav/types'; +import {useActiveNavGroup} from 'sentry/views/nav/useActiveNavGroup'; function NavContent() { const {layout, navParentRef} = useNavContext(); const {currentStepId, endTour} = useStackedNavigationTour(); const tourIsActive = currentStepId !== null; + const activeNavGroup = useActiveNavGroup(); // The tour only works with the sidebar layout, so if we change to the mobile // layout in the middle of the tour, it needs to end. @@ -32,7 +35,9 @@ function NavContent() { isMobile={layout === NavLayout.MOBILE} > {layout === NavLayout.SIDEBAR ? : } - + + + ); } diff --git a/static/app/views/nav/primary/components.tsx b/static/app/views/nav/primary/components.tsx index 21297a7949ce8b..adeb2e56a073c2 100644 --- a/static/app/views/nav/primary/components.tsx +++ b/static/app/views/nav/primary/components.tsx @@ -1,10 +1,12 @@ import {Fragment, type MouseEventHandler} from 'react'; -import {css, type Theme, useTheme} from '@emotion/react'; +import type {Theme} from '@emotion/react'; +import {css, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from 'sentry/components/core/button'; import {Tooltip} from 'sentry/components/core/tooltip'; import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu'; +import {useHovercardContext} from 'sentry/components/hovercard'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import Link, {linkStyles} from 'sentry/components/links/link'; import {SIDEBAR_NAVIGATION_SOURCE} from 'sentry/components/sidebar/utils'; @@ -19,12 +21,15 @@ import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {PRIMARY_SIDEBAR_WIDTH} from 'sentry/views/nav/constants'; import {useNavContext} from 'sentry/views/nav/context'; +import {PRIMARY_NAV_GROUP_CONFIG} from 'sentry/views/nav/primary/config'; +import {SecondaryHovercard} from 'sentry/views/nav/primary/secondaryHovercard'; +import type {PrimaryNavGroup} from 'sentry/views/nav/types'; import {NavLayout} from 'sentry/views/nav/types'; import {isLinkActive} from 'sentry/views/nav/utils'; interface SidebarItemLinkProps { analyticsKey: string; - label: string; + group: PrimaryNavGroup; to: string; activeTo?: string; children?: React.ReactNode; @@ -135,42 +140,72 @@ export function SidebarMenu({ ); } -export function SidebarLink({ +function SidebarNavLink({ children, to, activeTo = to, analyticsKey, - label, + group, }: SidebarItemLinkProps) { - const theme = useTheme(); const organization = useOrganization(); + const {reset: closeCollapsedNavHovercard} = useHovercardContext(); + const {layout} = useNavContext(); + const theme = useTheme(); const location = useLocation(); const isActive = isLinkActive(normalizeUrl(activeTo, location), location.pathname); - const {layout} = useNavContext(); + const label = PRIMARY_NAV_GROUP_CONFIG[group].label; + + return ( + { + recordPrimaryItemClick(analyticsKey, organization); + + // When the nav is collapsed, clicking on a link will close the hovercard. + closeCollapsedNavHovercard(); + }} + aria-selected={isActive} + aria-current={isActive ? 'page' : undefined} + isMobile={layout === NavLayout.MOBILE} + > + {layout === NavLayout.MOBILE ? ( + + {theme.isChonk ? null : } + {children} + {label} + + ) : ( + + {children} + {label} + + )} + + ); +} + +export function SidebarLink({ + children, + to, + activeTo = to, + analyticsKey, + group, +}: SidebarItemLinkProps) { + const label = PRIMARY_NAV_GROUP_CONFIG[group].label; return ( - recordPrimaryItemClick(analyticsKey, organization)} - aria-selected={isActive} - aria-current={isActive ? 'page' : undefined} - isMobile={layout === NavLayout.MOBILE} - > - {layout === NavLayout.MOBILE ? ( - - {theme.isChonk ? null : } - {children} - {label} - - ) : ( - - {children} - {label} - - )} - + + + {children} + + ); } diff --git a/static/app/views/nav/primary/index.tsx b/static/app/views/nav/primary/index.tsx index c6ee454353b180..e0f3c15bacf56e 100644 --- a/static/app/views/nav/primary/index.tsx +++ b/static/app/views/nav/primary/index.tsx @@ -22,7 +22,6 @@ import { SidebarLink, SidebarList, } from 'sentry/views/nav/primary/components'; -import {PRIMARY_NAV_GROUP_CONFIG} from 'sentry/views/nav/primary/config'; import {PrimaryNavigationHelp} from 'sentry/views/nav/primary/help'; import {PrimaryNavigationOnboarding} from 'sentry/views/nav/primary/onboarding'; import {PrimaryNavigationServiceIncidents} from 'sentry/views/nav/primary/serviceIncidents'; @@ -61,7 +60,7 @@ export function PrimaryNavigationItems() { @@ -76,7 +75,7 @@ export function PrimaryNavigationItems() { to={`/${prefix}/explore/${getDefaultExploreRoute(organization)}/`} activeTo={`/${prefix}/explore`} analyticsKey="explore" - label={PRIMARY_NAV_GROUP_CONFIG[PrimaryNavGroup.EXPLORE].label} + group={PrimaryNavGroup.EXPLORE} > @@ -96,7 +95,7 @@ export function PrimaryNavigationItems() { to={`/${prefix}/dashboards/`} activeTo={`/${prefix}/dashboard`} analyticsKey="dashboards" - label={PRIMARY_NAV_GROUP_CONFIG[PrimaryNavGroup.DASHBOARDS].label} + group={PrimaryNavGroup.DASHBOARDS} > @@ -113,7 +112,7 @@ export function PrimaryNavigationItems() { to={`/${prefix}/insights/frontend/`} activeTo={`/${prefix}/insights`} analyticsKey="insights" - label={PRIMARY_NAV_GROUP_CONFIG[PrimaryNavGroup.INSIGHTS].label} + group={PrimaryNavGroup.INSIGHTS} > @@ -125,7 +124,7 @@ export function PrimaryNavigationItems() { to={`/${prefix}/${CODECOV_BASE_URL}/${COVERAGE_BASE_URL}/commits/`} activeTo={`/${prefix}/${CODECOV_BASE_URL}/`} analyticsKey="codecov" - label={PRIMARY_NAV_GROUP_CONFIG[PrimaryNavGroup.CODECOV].label} + group={PrimaryNavGroup.CODECOV} > @@ -142,7 +141,7 @@ export function PrimaryNavigationItems() { to={`/settings/${organization.slug}/`} activeTo={`/settings/`} analyticsKey="settings" - label={PRIMARY_NAV_GROUP_CONFIG[PrimaryNavGroup.SETTINGS].label} + group={PrimaryNavGroup.SETTINGS} > diff --git a/static/app/views/nav/primary/secondaryHovercard.tsx b/static/app/views/nav/primary/secondaryHovercard.tsx new file mode 100644 index 00000000000000..6715b3a3112023 --- /dev/null +++ b/static/app/views/nav/primary/secondaryHovercard.tsx @@ -0,0 +1,57 @@ +import {ClassNames} from '@emotion/react'; +import styled from '@emotion/styled'; + +import {Hovercard} from 'sentry/components/hovercard'; +import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; +import {useNavContext} from 'sentry/views/nav/context'; +import {SecondaryNavContent} from 'sentry/views/nav/secondary/secondaryNavContent'; +import type {PrimaryNavGroup} from 'sentry/views/nav/types'; +import {NavLayout} from 'sentry/views/nav/types'; + +interface SecondaryHovercardProps { + children: React.ReactNode; + group: PrimaryNavGroup; +} + +export function SecondaryHovercard({children, group}: SecondaryHovercardProps) { + const {layout, isCollapsed, navParentRef} = useNavContext(); + + if (layout !== NavLayout.SIDEBAR || !isCollapsed) { + return children; + } + + return ( + + {({css}) => ( + + + + + + } + position="right-start" + animated={false} + delay={50} + displayTimeout={50} + bodyClassName={css` + padding: 0; + max-height: 80vh; + display: grid; + grid-template-rows: auto 1fr auto; + `} + containerDisplayMode="block" + offset={0} + portalContainer={navParentRef.current ?? undefined} + > + {children} + + )} + + ); +} + +const SecondaryBody = styled('div')` + display: contents; +`; diff --git a/static/app/views/nav/secondary/secondary.tsx b/static/app/views/nav/secondary/secondary.tsx index 21631d192e28cb..5c0cf521500652 100644 --- a/static/app/views/nav/secondary/secondary.tsx +++ b/static/app/views/nav/secondary/secondary.tsx @@ -6,6 +6,7 @@ import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from 'sentry/components/core/button'; +import {useHovercardContext} from 'sentry/components/hovercard'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import Link, {type LinkProps} from 'sentry/components/links/link'; import {SIDEBAR_NAVIGATION_SOURCE} from 'sentry/components/sidebar/utils'; @@ -19,9 +20,7 @@ import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {useNavContext} from 'sentry/views/nav/context'; -import {PRIMARY_NAV_GROUP_CONFIG} from 'sentry/views/nav/primary/config'; import {NavLayout} from 'sentry/views/nav/types'; -import {useActiveNavGroup} from 'sentry/views/nav/useActiveNavGroup'; import {isLinkActive} from 'sentry/views/nav/utils'; type SecondaryNavProps = { @@ -59,7 +58,6 @@ export function SecondaryNav({children}: SecondaryNavProps) { SecondaryNav.Header = function SecondaryNavHeader({children}: {children?: ReactNode}) { const {isCollapsed, setIsCollapsed, layout} = useNavContext(); - const group = useActiveNavGroup(); if (layout === NavLayout.MOBILE) { return null; @@ -67,7 +65,7 @@ SecondaryNav.Header = function SecondaryNavHeader({children}: {children?: ReactN return (
-
{children ?? PRIMARY_NAV_GROUP_CONFIG[group].label}
+
{children}