diff --git a/static/app/components/sidebar/expandedContextProvider.tsx b/static/app/components/sidebar/expandedContextProvider.tsx new file mode 100644 index 00000000000000..45a63055f0be80 --- /dev/null +++ b/static/app/components/sidebar/expandedContextProvider.tsx @@ -0,0 +1,33 @@ +import {createContext, useState} from 'react'; +import {useTheme} from '@emotion/react'; + +import PreferencesStore from 'sentry/stores/preferencesStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; +import useMedia from 'sentry/utils/useMedia'; + +export const ExpandedContext = createContext<{ + expandedItemId: string | null; + setExpandedItemId: (mainItemId: string | null) => void; + shouldAccordionFloat: boolean; +}>({ + expandedItemId: null, + setExpandedItemId: () => {}, + shouldAccordionFloat: false, +}); + +// Provides the expanded context to the sidebar accordion when it's in the floating state only (collapsed sidebar or on mobile view) +export function ExpandedContextProvider(props) { + const [expandedItemId, setExpandedItemId] = useState(null); + const theme = useTheme(); + const preferences = useLegacyStore(PreferencesStore); + const horizontal = useMedia(`(max-width: ${theme.breakpoints.medium})`); + const shouldAccordionFloat = horizontal || !!preferences.collapsed; + + return ( + + {props.children} + + ); +} diff --git a/static/app/components/sidebar/index.spec.tsx b/static/app/components/sidebar/index.spec.tsx index 84bf3e39e94663..71f74e0e4d28f3 100644 --- a/static/app/components/sidebar/index.spec.tsx +++ b/static/app/components/sidebar/index.spec.tsx @@ -14,6 +14,13 @@ import type {Organization, SentryServiceStatus} from 'sentry/types'; jest.mock('sentry/actionCreators/serviceIncidents'); +const sidebarAccordionFeatures = [ + 'performance-view', + 'performance-database-view', + 'performance-cache-view', + 'performance-http', +]; + describe('Sidebar', function () { const {organization, routerContext} = initializeOrg(); const broadcast = BroadcastFixture(); @@ -267,4 +274,28 @@ describe('Sidebar', function () { await userEvent.click(screen.getByTestId('sidebar-collapse')); expect(await screen.findByText(organization.name)).toBeInTheDocument(); }); + + describe('when the accordion is used', () => { + const renderSidebarWithFeatures = () => { + renderSidebar({ + organization: { + ...organization, + features: [...organization.features, ...sidebarAccordionFeatures], + }, + }); + }; + + it('should not render floating accordion when expanded', async () => { + renderSidebarWithFeatures(); + await userEvent.click(screen.getByTestId('sidebar-accordion-performance-item')); + expect(screen.queryByTestId('floating-accordion')).not.toBeInTheDocument(); + }); + + it('should render floating accordion when collapsed', async () => { + renderSidebarWithFeatures(); + await userEvent.click(screen.getByTestId('sidebar-collapse')); + await userEvent.click(screen.getByTestId('sidebar-accordion-performance-item')); + expect(await screen.findByTestId('floating-accordion')).toBeInTheDocument(); + }); + }); }); diff --git a/static/app/components/sidebar/index.tsx b/static/app/components/sidebar/index.tsx index 7499f12249a2d2..e5aa15bdb4b857 100644 --- a/static/app/components/sidebar/index.tsx +++ b/static/app/components/sidebar/index.tsx @@ -12,6 +12,10 @@ import {OnboardingContext} from 'sentry/components/onboarding/onboardingContext' import {getMergedTasks} from 'sentry/components/onboardingWizard/taskConfig'; import PerformanceOnboardingSidebar from 'sentry/components/performanceOnboarding/sidebar'; import ReplaysOnboardingSidebar from 'sentry/components/replaysOnboarding/sidebar'; +import { + ExpandedContext, + ExpandedContextProvider, +} from 'sentry/components/sidebar/expandedContextProvider'; import {isDone} from 'sentry/components/sidebar/utils'; import { IconDashboard, @@ -123,6 +127,7 @@ function Sidebar() { const preferences = useLegacyStore(PreferencesStore); const activePanel = useLegacyStore(SidebarPanelStore); const organization = useOrganization({allowNull: true}); + const {shouldAccordionFloat} = useContext(ExpandedContext); const collapsed = !!preferences.collapsed; const horizontal = useMedia(`(max-width: ${theme.breakpoints.medium})`); @@ -259,6 +264,7 @@ function Sidebar() { label={{t('Performance')}} to={`/organizations/${organization.slug}/performance/`} id="performance" + exact={!shouldAccordionFloat} > {t('Starfish')}} to={`/organizations/${organization.slug}/starfish/`} id="starfish" - exact + exact={!shouldAccordionFloat} > - - - - - {showSuperuserWarning() && !isExcludedOrg() && ( - - )} - - - - {hasOrganization && ( - - - {issues} - {projects} - - - - {performance} - {starfish} - {profiling} - {metrics} - {replays} - {aiAnalytics} - {feedback} - {monitors} - {alerts} - - - - {discover2} - {dashboards} - {releases} - {userFeedback} - - - - {stats} - {settings} - - - )} - - - - {hasOrganization && ( - - togglePanel(SidebarPanelKey.PERFORMANCE_ONBOARDING)} - hidePanel={() => hidePanel('performance-sidequest')} - {...sidebarItemProps} - /> - togglePanel(SidebarPanelKey.FEEDBACK_ONBOARDING)} - hidePanel={hidePanel} - {...sidebarItemProps} - /> - togglePanel(SidebarPanelKey.REPLAYS_ONBOARDING)} - hidePanel={hidePanel} - {...sidebarItemProps} - /> - togglePanel(SidebarPanelKey.PROFILING_ONBOARDING)} - hidePanel={hidePanel} - {...sidebarItemProps} - /> - togglePanel(SidebarPanelKey.METRICS_ONBOARDING)} - hidePanel={hidePanel} - {...sidebarItemProps} - /> - - + + + + + {showSuperuserWarning() && !isExcludedOrg() && ( + + )} + + + + {hasOrganization && ( + + + {issues} + {projects} + + + + {performance} + {starfish} + {profiling} + {metrics} + {replays} + {aiAnalytics} + {feedback} + {monitors} + {alerts} + + + + {discover2} + {dashboards} + {releases} + {userFeedback} + + + + {stats} + {settings} + + + )} + + + + {hasOrganization && ( + + togglePanel(SidebarPanelKey.PERFORMANCE_ONBOARDING)} + hidePanel={() => hidePanel('performance-sidequest')} + {...sidebarItemProps} + /> + togglePanel(SidebarPanelKey.ONBOARDING_WIZARD)} + onShowPanel={() => togglePanel(SidebarPanelKey.FEEDBACK_ONBOARDING)} hidePanel={hidePanel} {...sidebarItemProps} /> - - - - {HookStore.get('sidebar:bottom-items').length > 0 && - HookStore.get('sidebar:bottom-items')[0]({ - orientation, - collapsed, - hasPanel, - organization, - })} - togglePanel(SidebarPanelKey.REPLAYS_ONBOARDING)} hidePanel={hidePanel} - organization={organization} + {...sidebarItemProps} /> - togglePanel(SidebarPanelKey.BROADCASTS)} + onShowPanel={() => togglePanel(SidebarPanelKey.PROFILING_ONBOARDING)} hidePanel={hidePanel} - organization={organization} + {...sidebarItemProps} /> - togglePanel(SidebarPanelKey.SERVICE_INCIDENTS)} + onShowPanel={() => togglePanel(SidebarPanelKey.METRICS_ONBOARDING)} hidePanel={hidePanel} + {...sidebarItemProps} /> - + + togglePanel(SidebarPanelKey.ONBOARDING_WIZARD)} + hidePanel={hidePanel} + {...sidebarItemProps} + /> + - {!horizontal && ( - } - label={collapsed ? t('Expand') : t('Collapse')} - onClick={toggleCollapse} + {HookStore.get('sidebar:bottom-items').length > 0 && + HookStore.get('sidebar:bottom-items')[0]({ + orientation, + collapsed, + hasPanel, + organization, + })} + + togglePanel(SidebarPanelKey.BROADCASTS)} + hidePanel={hidePanel} + organization={organization} + /> + togglePanel(SidebarPanelKey.SERVICE_INCIDENTS)} + hidePanel={hidePanel} /> - )} - - )} + + {!horizontal && ( + + } + label={collapsed ? t('Expand') : t('Collapse')} + onClick={toggleCollapse} + /> + + )} + + )} + ); } diff --git a/static/app/components/sidebar/sidebarAccordion.tsx b/static/app/components/sidebar/sidebarAccordion.tsx index 0a8969b2121715..4bdddaf2290ea8 100644 --- a/static/app/components/sidebar/sidebarAccordion.tsx +++ b/static/app/components/sidebar/sidebarAccordion.tsx @@ -1,11 +1,26 @@ -import {Children, isValidElement, useCallback} from 'react'; +import { + Children, + cloneElement, + isValidElement, + type ReactElement, + type ReactNode, + useCallback, + useContext, + useRef, +} from 'react'; +import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from 'sentry/components/button'; import {Chevron} from 'sentry/components/chevron'; +import {Overlay} from 'sentry/components/overlay'; +import {ExpandedContext} from 'sentry/components/sidebar/expandedContextProvider'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; +import useMedia from 'sentry/utils/useMedia'; +import useOnClickOutside from 'sentry/utils/useOnClickOutside'; +import useRouter from 'sentry/utils/useRouter'; import type {SidebarItemProps} from './sidebarItem'; import SidebarItem, {isItemActive} from './sidebarItem'; @@ -16,13 +31,30 @@ type SidebarAccordionProps = SidebarItemProps & { function SidebarAccordion({children, ...itemProps}: SidebarAccordionProps) { const {id, collapsed: sidebarCollapsed} = itemProps; + + const accordionRef = useRef(null); + const mainItemRef = useRef(null); + const floatingAccordionRef = useRef(null); + const {expandedItemId, setExpandedItemId, shouldAccordionFloat} = + useContext(ExpandedContext); + const theme = useTheme(); + const horizontal = useMedia(`(max-width: ${theme.breakpoints.medium})`); + const router = useRouter(); const [expanded, setExpanded] = useLocalStorageState( `sidebar-accordion-${id}:expanded`, true ); + useOnClickOutside(floatingAccordionRef, e => { + if (mainItemRef?.current?.contains(e.target as Node)) { + return; + } + setExpandedItemId(null); + }); + const mainItemId = `sidebar-accordion-${id}-item`; const contentId = `sidebar-accordion-${id}-content`; + const isOpenInFloatingSidebar = expandedItemId === mainItemId; const isActive = isItemActive(itemProps); @@ -36,6 +68,8 @@ function SidebarAccordion({children, ...itemProps}: SidebarAccordionProps) { return false; }); + const childrenWithProps = renderChildrenWithProps(children); + const handleExpandAccordionClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); @@ -44,40 +78,109 @@ function SidebarAccordion({children, ...itemProps}: SidebarAccordionProps) { [expanded, setExpanded] ); + const handleMainItemClick = ( + _: string, + e: React.MouseEvent + ) => { + if ((!horizontal && !sidebarCollapsed) || !children) { + setExpandedItemId(null); + return; + } + + e.preventDefault(); + if (isOpenInFloatingSidebar) { + setExpandedItemId(null); + } else { + setExpandedItemId(mainItemId); + } + }; + + const handleTitleClick: ( + id: string, + e: React.MouseEvent + ) => void = () => { + if (itemProps.to) { + router.push(itemProps.to); + setExpandedItemId(null); + } + }; + + let isMainItemActive = isActive && !hasActiveChildren; + if (shouldAccordionFloat) { + isMainItemActive = isActive || hasActiveChildren; + } + return ( - + - - - - } - /> +
+ + + + } + /> +
- {expanded && ( + {expanded && !horizontal && !sidebarCollapsed && ( - {children} + {childrenWithProps} )} + {isOpenInFloatingSidebar && (horizontal || sidebarCollapsed) && ( + + + {childrenWithProps} + + )}
); } export {SidebarAccordion}; +const renderChildrenWithProps = (children: ReactNode): ReactNode => { + const propsToAdd: Partial = { + isNested: true, + }; + + return Children.map(children, child => { + if (!isValidElement(child)) { + return child; + } + return cloneElement(child as ReactElement, { + ...propsToAdd, + children: renderChildrenWithProps((child as ReactElement).props.children), + }); + }); +}; + function findChildElementsInTree( children: React.ReactNode, componentName: string, @@ -111,6 +214,21 @@ function findChildElementsInTree( return found; } +const StyledOverlay = styled(Overlay)<{ + accordionRef: React.RefObject; + horizontal: boolean; +}>` + position: absolute; + width: ${p => (p.horizontal ? '100%' : '200px')}; + padding: ${space(0.5)}; + top: ${p => + p.horizontal + ? p.theme.sidebar.mobileHeight + : p.accordionRef.current?.getBoundingClientRect().top}; + left: ${p => + p.horizontal ? 0 : `calc(${p.theme.sidebar.collapsedWidth} + ${space(1)})`}; +`; + const SidebarAccordionWrapper = styled('div')` display: flex; flex-direction: column; @@ -153,8 +271,4 @@ const SidebarAccordionSubitemsWrap = styled('div')` display: flex; flex-direction: column; gap: 1px; - - @media (max-width: ${p => p.theme.breakpoints.medium}) { - flex-direction: row; - } `; diff --git a/static/app/components/sidebar/sidebarItem.tsx b/static/app/components/sidebar/sidebarItem.tsx index d793cd08edecb8..c1f2ac06beaead 100644 --- a/static/app/components/sidebar/sidebarItem.tsx +++ b/static/app/components/sidebar/sidebarItem.tsx @@ -1,4 +1,4 @@ -import {Fragment, isValidElement, useCallback, useMemo} from 'react'; +import {Fragment, isValidElement, useCallback, useContext, useMemo} from 'react'; import isPropValid from '@emotion/is-prop-valid'; import type {Theme} from '@emotion/react'; import {css} from '@emotion/react'; @@ -10,6 +10,7 @@ import HookOrDefault from 'sentry/components/hookOrDefault'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import Link from 'sentry/components/links/link'; import {Flex} from 'sentry/components/profiling/flex'; +import {ExpandedContext} from 'sentry/components/sidebar/expandedContextProvider'; import TextOverflow from 'sentry/components/textOverflow'; import {Tooltip} from 'sentry/components/tooltip'; import {space} from 'sentry/styles/space'; @@ -83,6 +84,16 @@ export type SidebarItemProps = { * Additional badge letting users know a tab is in beta. */ isBeta?: boolean; + /** + * Is main item in a floating accordion + */ + isMainItem?: boolean; + + /** + * Is this item nested within another item + */ + isNested?: boolean; + /** * Specify the variant for the badge. */ @@ -125,8 +136,11 @@ function SidebarItem({ onClick, trailingItems, variant, + isNested, + isMainItem, ...props }: SidebarItemProps) { + const {setExpandedItemId, shouldAccordionFloat} = useContext(ExpandedContext); const router = useRouter(); // label might be wrapped in a guideAnchor let labelString = label; @@ -137,8 +151,10 @@ function SidebarItem({ const isActiveRouter = !hasPanel && router && isItemActive({to, label: labelString}, exact); + const isInFloatingAccordion = (isNested || isMainItem) && shouldAccordionFloat; + const isActive = defined(active) ? active : isActiveRouter; - const isTop = orientation === 'top'; + const isTop = orientation === 'top' && !isInFloatingAccordion; const placement = isTop ? 'bottom' : 'right'; const seenSuffix = isNewSeenKeySuffix ?? ''; @@ -170,17 +186,20 @@ function SidebarItem({ const handleItemClick = useCallback( (event: React.MouseEvent) => { + setExpandedItemId(null); !(to || href) && event.preventDefault(); recordAnalytics(); onClick?.(id, event); showIsNew && localStorage.setItem(isNewSeenKey, 'true'); }, - [href, to, id, onClick, recordAnalytics, showIsNew, isNewSeenKey] + [href, to, id, onClick, recordAnalytics, showIsNew, isNewSeenKey, setExpandedItemId] ); + const isInCollapsedState = !isInFloatingAccordion && collapsed; + return ( {label} {badges} @@ -191,6 +210,7 @@ function SidebarItem({ - - {icon} - {!collapsed && !isTop && ( - + + {!isInFloatingAccordion && {icon}} + {!isInCollapsedState && !isTop && ( + {label} {badges} )} - {collapsed && showIsNew && ( + {isInCollapsedState && showIsNew && ( )} - {collapsed && isBeta && ( + {isInCollapsedState && isBeta && ( )} - {collapsed && isAlpha && ( + {isInCollapsedState && isAlpha && ( )} {badge !== undefined && badge > 0 && ( - {badge} + {badge} )} {trailingItems} @@ -263,16 +286,37 @@ export function isItemActive( (item?.label === 'Alerts' && location.pathname.includes('/alerts/') && !location.pathname.startsWith('/settings/')) || - (item?.label === 'Releases' && location.pathname.includes('/release-thresholds/')) + (item?.label === 'Releases' && location.pathname.includes('/release-thresholds/')) || + (item?.label === 'Performance' && location.pathname.includes('/performance/')) || + (item?.label === 'Starfish' && location.pathname.includes('/starfish/')) ); } export default SidebarItem; -const getActiveStyle = ({active, theme}: {active?: string; theme?: Theme}) => { +const getActiveStyle = ({ + active, + theme, + isInFloatingAccordion, +}: { + active?: string; + isInFloatingAccordion?: boolean; + theme?: Theme; +}) => { if (!active) { return ''; } + if (isInFloatingAccordion) { + return css` + background-color: ${theme?.hover}; + + &:active, + &:focus, + &:hover { + color: ${theme?.gray400}; + } + `; + } return css` color: ${theme?.white}; @@ -292,11 +336,11 @@ const StyledSidebarItem = styled(Link, { shouldForwardProp: p => typeof p === 'string' && isPropValid(p), })` display: flex; - color: inherit; + color: ${p => (p.isInFloatingAccordion ? p.theme.gray400 : 'inherit')}; position: relative; cursor: pointer; font-size: 15px; - height: 30px; + height: ${p => (p.isInFloatingAccordion ? '35px' : '30px')}; flex-shrink: 0; border-radius: ${p => p.theme.borderRadius}; transition: none; @@ -328,7 +372,17 @@ const StyledSidebarItem = styled(Link, { &:hover, &:focus-visible { - color: ${p => p.theme.white}; + ${p => { + if (p.isInFloatingAccordion) { + return css` + background-color: ${p.theme.hover}; + color: ${p.theme.gray400}; + `; + } + return css` + color: ${p.theme.white}; + `; + }} } &:focus { @@ -370,8 +424,11 @@ const SidebarItemIcon = styled('span')` } `; -const SidebarItemLabel = styled('span')` - margin-left: 10px; +const SidebarItemLabel = styled('span')<{ + isInFloatingAccordion?: boolean; + isNested?: boolean; +}>` + margin-left: ${p => (p.isNested && p.isInFloatingAccordion ? space(4) : '10px')}; white-space: nowrap; opacity: 1; flex: 1;