diff --git a/static/app/components/demo/demoHeader.tsx b/static/app/components/demo/demoHeader.tsx index b3ccab954a77ff..8b305e7b16964f 100644 --- a/static/app/components/demo/demoHeader.tsx +++ b/static/app/components/demo/demoHeader.tsx @@ -99,6 +99,10 @@ const Wrapper = styled('div')<{collapsed: boolean}>` -1 * ${p => (p.collapsed ? p.theme.sidebar.collapsedWidth : p.theme.sidebar.expandedWidth)} ); + .sidebar-v2 & { + margin-left: calc(-1 * ${p => p.theme.sidebar.v2_width}); + } + position: fixed; width: 100%; border-bottom: 1px solid ${p => p.theme.border}; diff --git a/static/app/components/sidebar/broadcasts.tsx b/static/app/components/sidebar/broadcasts.tsx index bae0605da3570b..08f0c7e497d647 100644 --- a/static/app/components/sidebar/broadcasts.tsx +++ b/static/app/components/sidebar/broadcasts.tsx @@ -116,7 +116,8 @@ class Broadcasts extends Component { } render() { - const {orientation, collapsed, currentPanel, hidePanel, organization} = this.props; + const {orientation, collapsed, currentPanel, hidePanel, organization, hasNewNav} = + this.props; const {broadcasts, loading} = this.state; const unseenPosts = this.unseenIds; @@ -132,7 +133,8 @@ class Broadcasts extends Component { active={currentPanel === SidebarPanelKey.BROADCASTS} badge={unseenPosts.length} icon={} - label={t("What's new")} + hasNewNav={hasNewNav} + label={hasNewNav ? t('New') : t("What's new")} onClick={this.handleShowPanel} id="broadcasts" /> diff --git a/static/app/components/sidebar/help.tsx b/static/app/components/sidebar/help.tsx index a1f8ad92560f70..287d8ee88a1d09 100644 --- a/static/app/components/sidebar/help.tsx +++ b/static/app/components/sidebar/help.tsx @@ -20,7 +20,13 @@ type Props = Pick< organization: Organization; }; -function SidebarHelp({orientation, collapsed, hidePanel, organization}: Props) { +function SidebarHelp({ + orientation, + collapsed, + hidePanel, + organization, + hasNewNav, +}: Props) { return ( {({isOpen, getActorProps, getMenuProps}) => ( @@ -31,6 +37,7 @@ function SidebarHelp({orientation, collapsed, hidePanel, organization}: Props) { orientation={orientation} collapsed={collapsed} hasPanel={false} + hasNewNav={hasNewNav} icon={} label={t('Help')} id="help" diff --git a/static/app/components/sidebar/index.spec.tsx b/static/app/components/sidebar/index.spec.tsx index 6f132193db5a7f..3aead9b4878bfa 100644 --- a/static/app/components/sidebar/index.spec.tsx +++ b/static/app/components/sidebar/index.spec.tsx @@ -109,20 +109,6 @@ describe('Sidebar', function () { await userEvent.click(screen.getByTestId('sidebar-dropdown')); }); - it('does not render collapse with navigation-sidebar-v2 flag', async function () { - renderSidebar({ - organization: {...organization, features: ['navigation-sidebar-v2']}, - }); - - // await for the page to be rendered - expect(await screen.findByText('Issues')).toBeInTheDocument(); - // Check that the user name is no longer visible - expect(screen.queryByText(user.name)).not.toBeInTheDocument(); - // Check that the organization name is no longer visible - expect(screen.queryByText(organization.name)).not.toBeInTheDocument(); - expect(screen.queryByTestId('sidebar-collapse')).not.toBeInTheDocument(); - }); - it('has can logout', async function () { renderSidebar({ organization: OrganizationFixture({access: ['member:read']}), @@ -323,9 +309,17 @@ describe('Sidebar', function () { expect(apiMocks.broadcasts).toHaveBeenCalled(); }); - expect( - screen.getByRole('navigation', {name: 'Primary Navigation'}) - ).toBeInTheDocument(); + expect(screen.getByLabelText('Primary Navigation')).toBeInTheDocument(); + }); + + it('does not render a secondary subnav', async function () { + renderSidebar({organization}); + + await waitFor(function () { + expect(apiMocks.broadcasts).toHaveBeenCalled(); + }); + + expect(screen.queryByLabelText('Secondary Navigation')).not.toBeInTheDocument(); }); it('in self-hosted-errors-only mode, only shows links to basic features', async function () { @@ -436,4 +430,43 @@ describe('Sidebar', function () { expect(await screen.findByTestId('floating-accordion')).toBeInTheDocument(); }); }); + + describe('Stacked navigation with navigation-sidebar-v2 flag', () => { + it('renders all primary nav items with visible text', async function () { + renderSidebarWithFeatures(['navigation-sidebar-v2']); + + const nav = await screen.findByLabelText('Primary Navigation'); + const links = nav.querySelectorAll('a'); + const labels = [...links].map(link => link.textContent); + expect(labels).toStrictEqual([ + 'Issues', + 'Projects', + 'Explore', + 'Alerts', + 'Help', + 'New', + 'Service status', + 'Stats', + 'Settings', + ]); + }); + + it('renders a secondary subnav', async function () { + renderSidebarWithFeatures(['navigation-sidebar-v2']); + + const subnav = await screen.findByLabelText('Secondary Navigation'); + expect(subnav).toBeInTheDocument(); + }); + + it('does not render collapse with navigation-sidebar-v2 flag', async function () { + renderSidebarWithFeatures(['navigation-sidebar-v2']); + + expect(await screen.findByText('Issues')).toBeInTheDocument(); + // Check that the user name is no longer visible + expect(screen.queryByText(user.name)).not.toBeInTheDocument(); + // Check that the organization name is no longer visible + expect(screen.queryByText(organization.name)).not.toBeInTheDocument(); + expect(screen.queryByTestId('sidebar-collapse')).not.toBeInTheDocument(); + }); + }); }); diff --git a/static/app/components/sidebar/index.tsx b/static/app/components/sidebar/index.tsx index cabfdf5fd2d480..213064293b0c18 100644 --- a/static/app/components/sidebar/index.tsx +++ b/static/app/components/sidebar/index.tsx @@ -16,6 +16,7 @@ import { ExpandedContext, ExpandedContextProvider, } from 'sentry/components/sidebar/expandedContextProvider'; +import {SubnavContainer} from 'sentry/components/sidebar/subnav'; import {isDone} from 'sentry/components/sidebar/utils'; import { IconDashboard, @@ -54,6 +55,7 @@ import useProjects from 'sentry/utils/useProjects'; import {useModuleURLBuilder} from 'sentry/views/insights/common/utils/useModuleURL'; import {MODULE_SIDEBAR_TITLE as HTTP_MODULE_SIDEBAR_TITLE} from 'sentry/views/insights/http/settings'; import {MODULE_TITLES} from 'sentry/views/insights/settings'; +import {getSearchForIssueGroup, IssueGroup} from 'sentry/views/issueList/utils'; import MetricsOnboardingSidebar from 'sentry/views/metrics/ddmOnboarding/sidebar'; import {ProfilingOnboardingSidebar} from '../profiling/profilingOnboardingSidebar'; @@ -62,7 +64,7 @@ import Broadcasts from './broadcasts'; import SidebarHelp from './help'; import OnboardingStatus from './onboardingStatus'; import ServiceIncidents from './serviceIncidents'; -import {SidebarAccordion} from './sidebarAccordion'; +import {SubnavMenu} from './sidebarAccordion'; import SidebarDropdown from './sidebarDropdown'; import SidebarItem from './sidebarItem'; import type {SidebarOrientation} from './types'; @@ -119,11 +121,11 @@ function Sidebar() { const activePanel = useLegacyStore(SidebarPanelStore); const organization = useOrganization({allowNull: true}); const {shouldAccordionFloat} = useContext(ExpandedContext); - const hasNewNav = organization?.features.includes('navigation-sidebar-v2'); + const hasNewNav = organization?.features.includes('navigation-sidebar-v2') ?? false; + const collapsed = hasNewNav ? true : !!preferences.collapsed; const hasOrganization = !!organization; const isSelfHostedErrorsOnly = ConfigStore.get('isSelfHostedErrorsOnly'); - const collapsed = hasNewNav ? true : !!preferences.collapsed; const horizontal = useMedia(`(max-width: ${theme.breakpoints.medium})`); // Panel determines whether to highlight const hasPanel = !!activePanel; @@ -131,10 +133,10 @@ function Sidebar() { const sidebarItemProps = { orientation, - collapsed, hasPanel, organization, hasNewNav, + collapsed, }; // Avoid showing superuser UI on self-hosted instances const showSuperuserWarning = () => { @@ -149,12 +151,13 @@ function Sidebar() { useOpenOnboardingSidebar(); const toggleCollapse = useCallback(() => { + if (hasNewNav) return; if (collapsed) { showSidebar(); } else { hideSidebar(); } - }, [collapsed]); + }, [collapsed, hasNewNav]); // Close panel on any navigation useEffect(() => void hidePanel(), [location?.pathname]); @@ -193,12 +196,12 @@ function Sidebar() { const bcl = document.body.classList; if (hasNewNav) { - bcl.add('hasNewNav'); + bcl.add('sidebar-v2'); } else { - bcl.remove('hasNewNav'); + bcl.remove('sidebar-v2'); } - return () => bcl.remove('hasNewNav'); + return () => bcl.remove('sidebar-v2'); }, [hasNewNav]); const sidebarAnchor = isDemoWalkthrough() ? ( @@ -220,7 +223,7 @@ function Sidebar() { /> ); - const issues = hasOrganization && ( + let issues = hasOrganization && ( } @@ -228,9 +231,74 @@ function Sidebar() { to={`/organizations/${organization.slug}/issues/`} search="?referrer=sidebar" id="issues" - hasNewNav={hasNewNav} /> ); + if (hasNewNav && hasOrganization) { + issues = ( + } + label={{t('Issues')}} + to={`/organizations/${organization.slug}/issues/`} + search="?referrer=sidebar" + id="issues" + > + } + label={{t('All')}} + to={`/organizations/${organization.slug}/issues/`} + search="?referrer=sidebar" + id="issues-all" + hasNewNav={hasNewNav} + /> + } + label={{t('Errors & Outages')}} + to={`/organizations/${organization.slug}/issues/`} + search={getSearchForIssueGroup(IssueGroup.ERRORS_OUTAGES)} + id="issues-errors-outages" + hasNewNav={hasNewNav} + /> + } + label={{t('Trends')}} + to={`/organizations/${organization.slug}/issues/`} + search={getSearchForIssueGroup(IssueGroup.TRENDS)} + id="issues-trends" + hasNewNav={hasNewNav} + /> + } + label={{t('Craftsmanship')}} + to={`/organizations/${organization.slug}/issues/`} + search={getSearchForIssueGroup(IssueGroup.CRAFTSMANSHIP)} + id="issues-craftsmanship" + hasNewNav={hasNewNav} + /> + } + label={{t('Security')}} + to={`/organizations/${organization.slug}/issues/`} + search={getSearchForIssueGroup(IssueGroup.SECURITY)} + id="issues-security" + hasNewNav={hasNewNav} + /> + } + label={t('Feedback')} + variant="short" + to={`/organizations/${organization.slug}/feedback/`} + id="issues-feedback" + /> + + ); + } const discover2 = hasOrganization && ( } + icon={} label={{t('Discover')}} to={getDiscoverLandingUrl(organization)} id="discover-v2" @@ -259,7 +327,7 @@ function Sidebar() { } to={`/organizations/${organization.slug}/${moduleURLBuilder('db')}/`} id="performance-database" - icon={} + icon={} /> ); @@ -273,7 +341,7 @@ function Sidebar() { } to={`/organizations/${organization.slug}/${moduleURLBuilder('http')}/`} id="performance-http" - icon={} + icon={} /> ); @@ -287,7 +355,7 @@ function Sidebar() { } to={`/organizations/${organization.slug}/${moduleURLBuilder('cache')}/`} id="performance-cache" - icon={} + icon={} /> ); @@ -301,7 +369,7 @@ function Sidebar() { } to={`/organizations/${organization.slug}/${moduleURLBuilder('vital')}/`} id="performance-webvitals" - icon={} + icon={} /> ); @@ -315,7 +383,7 @@ function Sidebar() { } to={`/organizations/${organization.slug}/${moduleURLBuilder('queue')}/`} id="performance-queues" - icon={} + icon={} /> ); @@ -336,7 +404,7 @@ function Sidebar() { label={MODULE_TITLES.screen_load} to={`/organizations/${organization.slug}/${moduleURLBuilder('screen_load')}/`} id="performance-mobile-screens" - icon={} + icon={} /> ); @@ -348,7 +416,7 @@ function Sidebar() { label={MODULE_TITLES.app_start} to={`/organizations/${organization.slug}/${moduleURLBuilder('app_start')}/`} id="performance-mobile-app-startup" - icon={} + icon={} /> ); @@ -364,7 +432,7 @@ function Sidebar() { label={MODULE_TITLES['mobile-ui']} to={`/organizations/${organization.slug}/${moduleURLBuilder('mobile-ui')}/`} id="performance-mobile-ui" - icon={} + icon={} isAlpha /> @@ -381,7 +449,7 @@ function Sidebar() { label={MODULE_TITLES['mobile-screens']} to={`/organizations/${organization.slug}/${moduleURLBuilder('mobile-screens')}/`} id="performance-mobile-screens" - icon={} + icon={} /> ); @@ -393,7 +461,7 @@ function Sidebar() { label={{MODULE_TITLES.resource}} to={`/organizations/${organization.slug}/${moduleURLBuilder('resource')}/`} id="performance-browser-resources" - icon={} + icon={} /> ); @@ -405,7 +473,7 @@ function Sidebar() { label={{t('Traces')}} to={`/organizations/${organization.slug}/traces/`} id="performance-trace-explorer" - icon={} + icon={} isBeta /> @@ -415,7 +483,7 @@ function Sidebar() { } + icon={} label={MODULE_TITLES.ai} to={`/organizations/${organization.slug}/${moduleURLBuilder('ai')}/`} id="llm-monitoring" @@ -465,7 +533,7 @@ function Sidebar() { ); - const feedback = hasOrganization && ( + const feedback = hasOrganization && !hasNewNav && ( } + icon={} label={t('Replays')} to={`/organizations/${organization.slug}/replays/`} id="replays" @@ -520,7 +588,7 @@ function Sidebar() { const metrics = hasOrganization && hasCustomMetrics(organization) && ( } + icon={} label={t('Metrics')} to={metricsPath} search={location?.pathname === normalizeUrl(metricsPath) ? location.search : ''} @@ -543,7 +611,7 @@ function Sidebar() { {...sidebarItemProps} index icon={} - label={hasNewNav ? 'Dash.' : t('Dashboards')} + label={hasNewNav ? t('Boards') : t('Dashboards')} to={`/organizations/${organization.slug}/dashboards/`} id="customizable-dashboards" /> @@ -560,7 +628,7 @@ function Sidebar() { } + icon={} label={t('Profiles')} to={`/organizations/${organization.slug}/profiling/`} id="profiling" @@ -590,7 +658,7 @@ function Sidebar() { const insights = hasOrganization && ( - } label={{t('Insights')}} @@ -609,14 +677,15 @@ function Sidebar() { {mobileUI} {mobileScreens} {llmMonitoring} - + {hasNewNav && monitors} + {hasNewNav && releases} + ); // Sidebar accordion includes a secondary list of nav items - // TODO: replace with a secondary panel const explore = ( - } label={{t('Explore')}} @@ -628,167 +697,186 @@ function Sidebar() { {profiling} {replays} {discover2} - + ); return ( - - - - - - - {showSuperuserWarning() && !isExcludedOrg() && ( - - )} - - - - {hasOrganization && ( - - - {issues} - {projects} - + + + + + + + + + {showSuperuserWarning() && !isExcludedOrg() && ( + + )} + - {!isSelfHostedErrorsOnly && ( + + {hasOrganization && ( - {explore} - {insights} + {issues} + {projects} - - {performance} - {feedback} - {monitors} - {alerts} - {dashboards} - {releases} - + {!isSelfHostedErrorsOnly && !hasNewNav && ( + + + {explore} + {insights} + + + {performance} + {feedback} + {monitors} + {alerts} + {dashboards} + {releases} + + + )} + + {!isSelfHostedErrorsOnly && hasNewNav && ( + + {explore} + {insights} + {performance} + {feedback} + {alerts} + {dashboards} + + )} + + {isSelfHostedErrorsOnly && ( + + {alerts} + {discover2} + {dashboards} + {releases} + {userFeedback} + + )} + + {!hasNewNav && ( + + {stats} + {settings} + + )} )} + + - {isSelfHostedErrorsOnly && ( - - - {alerts} - {discover2} - {dashboards} - {releases} - {userFeedback} - - - )} + {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} + /> + + togglePanel(SidebarPanelKey.ONBOARDING_WIZARD)} + hidePanel={hidePanel} + {...sidebarItemProps} + /> + - {stats} - {settings} + {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} + /> + {hasNewNav && ( + + {stats} + {settings} + + )} - - )} - - - - {hasOrganization && ( - - {/* What are the onboarding sidebars? */} - 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} - /> - - togglePanel(SidebarPanelKey.ONBOARDING_WIZARD)} - hidePanel={hidePanel} - {...sidebarItemProps} - /> - - - - {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 && !hasNewNav && ( - - } - label={collapsed ? t('Expand') : t('Collapse')} - onClick={toggleCollapse} - /> - + + {!horizontal && !hasNewNav && ( + + } + label={collapsed ? t('Expand') : t('Collapse')} + onClick={toggleCollapse} + /> + + )} + )} - - )} - + + + ); } @@ -804,28 +892,38 @@ const responsiveFlex = css` } `; -export const SidebarWrapper = styled('nav')<{collapsed: boolean; hasNewNav?: boolean}>` - background: ${p => p.theme.sidebarGradient}; - color: ${p => p.theme.sidebar.color}; - line-height: 1; - padding: 12px 0 2px; /* Allows for 32px avatars */ - width: ${p => - p.theme.sidebar[ - p.hasNewNav - ? 'semiCollapsedWidth' - : p.collapsed - ? 'collapsedWidth' - : 'expandedWidth' - ]}; +const SidebarWrapper = styled('nav')` position: fixed; top: ${p => (ConfigStore.get('demoMode') ? p.theme.demo.headerSize : 0)}; left: 0; bottom: 0; justify-content: space-between; z-index: ${p => p.theme.zIndex.sidebar}; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: stretch; +`; + +export const SidebarPrimaryWrapper = styled('div')<{ + collapsed: boolean; + hasNewNav?: boolean; +}>` + background: ${p => p.theme.sidebarGradient}; + color: ${p => p.theme.sidebar.color}; + line-height: 1; + padding: 12px 0 2px; /* Allows for 32px avatars */ + width: ${p => (p.collapsed ? 'collapsedWidth' : 'expandedWidth')}; + justify-content: space-between; + z-index: ${p => p.theme.zIndex.sidebar}; border-right: solid 1px ${p => p.theme.sidebarBorder}; ${responsiveFlex}; + .sidebar-v2 & { + padding: ${space(1)} ${space(0.75)} ${space(2)}; + width: ${p => p.theme.sidebar.v2_width}; + } + @media (max-width: ${p => p.theme.breakpoints.medium}) { top: 0; left: 0; @@ -840,11 +938,20 @@ export const SidebarWrapper = styled('nav')<{collapsed: boolean; hasNewNav?: boo } `; +export const SubnavPanelWrapper = styled('nav')` + display: flex; + flex-direction: column; + background: ${p => p.theme.white}; + border-right: 1px solid ${p => p.theme.gray400}; + width: ${p => p.theme.sidebar.v2_panelWidth}; + height: 100%; +`; + const SidebarSectionGroup = styled('div')<{hasNewNav?: boolean}>` ${responsiveFlex}; flex-shrink: 0; /* prevents shrinking on Safari */ - gap: 1px; - ${p => p.hasNewNav && `align-items: center;`} + gap: 8px; + ${p => p.hasNewNav && `flex-direction: column; align-items: stretch;`} `; const SidebarSectionGroupPrimary = styled('div')` @@ -866,7 +973,7 @@ const PrimaryItems = styled('div')` flex: 1; display: flex; flex-direction: column; - gap: 1px; + gap: ${space(1)}; -ms-overflow-style: -ms-autohiding-scrollbar; scrollbar-color: ${p => p.theme.sidebar.scrollbarThumbColor} @@ -913,6 +1020,8 @@ const SidebarSection = styled(SidebarSectionGroup)<{ }>` ${p => !p.noMargin && !p.hasNewNav && `margin: ${space(1)} 0`}; ${p => !p.noPadding && !p.hasNewNav && `padding: 0 ${space(2)}`}; + align-items: stretch; + gap: ${space(1)}; @media (max-width: ${p => p.theme.breakpoints.small}) { margin: 0; diff --git a/static/app/components/sidebar/serviceIncidents.tsx b/static/app/components/sidebar/serviceIncidents.tsx index 7382cd606d4b7f..becce636d0f769 100644 --- a/static/app/components/sidebar/serviceIncidents.tsx +++ b/static/app/components/sidebar/serviceIncidents.tsx @@ -21,6 +21,7 @@ function ServiceIncidents({ hidePanel, collapsed, orientation, + hasNewNav, }: Props) { const {data: incidents} = useServiceIncidents({statusFilter: 'unresolved'}); @@ -43,6 +44,7 @@ function ServiceIncidents({ collapsed={collapsed} active={active} icon={} + hasNewNav={hasNewNav} label={t('Service status')} onClick={onShowPanel} /> diff --git a/static/app/components/sidebar/sidebarAccordion.tsx b/static/app/components/sidebar/sidebarAccordion.tsx index ac6d7e39d39572..d30bf1b6293e02 100644 --- a/static/app/components/sidebar/sidebarAccordion.tsx +++ b/static/app/components/sidebar/sidebarAccordion.tsx @@ -1,6 +1,7 @@ import { Children, cloneElement, + Fragment, isValidElement, useCallback, useContext, @@ -13,6 +14,7 @@ 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 {SubnavPanel} from 'sentry/components/sidebar/subnav'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; @@ -28,6 +30,47 @@ type SidebarAccordionProps = SidebarItemProps & { initiallyExpanded?: boolean; }; +function SubnavMenu(props: SidebarAccordionProps) { + const {hasNewNav} = props; + const Component = hasNewNav ? SubnavContainer : SidebarAccordion; + + return ; +} + +function SubnavContainer({children, hasNewNav, ...itemProps}: SidebarAccordionProps) { + const {id} = itemProps; + + const mainItemId = `sidebar-accordion-${id}-item`; + const contentId = `sidebar-accordion-${id}-content`; + const childSidebarItems = findChildElementsInTree(children, 'SidebarItem'); + + const hasActiveChildren = Children.toArray(childSidebarItems).some(child => { + if (isValidElement(child)) { + return isItemActive(child.props); + } + return false; + }); + const to = Children.toArray(childSidebarItems).find(child => isValidElement(child)) + ?.props?.to; + const childrenWithProps = renderChildrenWithProps(children); + + return ( + + + {hasActiveChildren && childrenWithProps} + + ); +} + function SidebarAccordion({ children, initiallyExpanded, @@ -171,7 +214,7 @@ function SidebarAccordion({ ); } -export {SidebarAccordion}; +export {SubnavMenu, SidebarAccordion}; const renderChildrenWithProps = (children: React.ReactNode): React.ReactNode => { const propsToAdd: Partial = { diff --git a/static/app/components/sidebar/sidebarDropdown/index.tsx b/static/app/components/sidebar/sidebarDropdown/index.tsx index 495e4a8baf1ff6..d5910704b721ed 100644 --- a/static/app/components/sidebar/sidebarDropdown/index.tsx +++ b/static/app/components/sidebar/sidebarDropdown/index.tsx @@ -65,7 +65,7 @@ export default function SidebarDropdown({orientation, collapsed, hideOrgLinks}: collapsed={collapsed} organization={org ?? undefined} user={!org ? user : undefined} - size={32} + size={38} round={false} /> ) : ( @@ -200,7 +200,7 @@ const UserBadgeNoOverflow = styled(IdBadge)` const SidebarDropdownRoot = styled('div')` position: relative; - padding: 0 3px; /* align org icon with sidebar item icons */ + padding: 0; /* align org icon with sidebar item icons */ `; // So that long org names and user names do not overflow @@ -243,8 +243,9 @@ const SidebarDropdownActor = styled('button')` `; const StyledAvatar = styled(Avatar)<{collapsed: boolean}>` - margin: ${space(0.25)} 0; + margin: 0; margin-right: ${p => (p.collapsed ? '0' : space(1.5))}; + margin-bottom: ${space(1)}; box-shadow: 0 2px 0 rgba(0, 0, 0, 0.08); border-radius: 6px; /* Fixes background bleeding on corners */ diff --git a/static/app/components/sidebar/sidebarItem.tsx b/static/app/components/sidebar/sidebarItem.tsx index 833d6d24d51469..1bd6e8eff12459 100644 --- a/static/app/components/sidebar/sidebarItem.tsx +++ b/static/app/components/sidebar/sidebarItem.tsx @@ -159,17 +159,20 @@ function SidebarItem({ if (isValidElement(label)) { labelString = label?.props?.children ?? label; } + // If there is no active panel open and if path is active according to react-router - const isActiveRouter = - !hasPanel && router && isItemActive({to, label: labelString}, exact); + let isActiveRouter = + !hasPanel && router && isItemActive({to, label: labelString, search}, exact); - // TODO: floating accordion should be transformed into secondary panel - let isInFloatingAccordion = (isNested || isMainItem) && shouldAccordionFloat; - if (hasNewNav) { - isInFloatingAccordion = false; + if (hasNewNav && !hasPanel) { + isActiveRouter = router && isItemActive({to, label: labelString, search}, exact); } + + const isInFloatingAccordion = (isNested || isMainItem) && shouldAccordionFloat; + const isInSubnav = hasNewNav && isNested; const hasLink = Boolean(to); - const isInCollapsedState = (!isInFloatingAccordion && collapsed) || hasNewNav; + const isInCollapsedState = + (!isInFloatingAccordion && collapsed) || (hasNewNav ? isInSubnav : false); const isActive = defined(active) ? active : isActiveRouter; const isTop = orientation === 'top' && !isInFloatingAccordion; @@ -220,9 +223,9 @@ function SidebarItem({ return ( @@ -237,6 +240,7 @@ function SidebarItem({ {...props} id={`sidebar-item-${id}`} isInFloatingAccordion={isInFloatingAccordion} + isInSubnav={isInSubnav} active={isActive ? 'true' : undefined} to={toProps} disabled={!hasLink && isInFloatingAccordion} @@ -245,17 +249,17 @@ function SidebarItem({ onClick={handleItemClick} hasNewNav={hasNewNav} > - {hasNewNav ? ( - - ) : ( - - )} - - {!isInFloatingAccordion && ( + + + {!isInFloatingAccordion && !isInSubnav && ( {icon} )} {!isInCollapsedState && !isTop && ( @@ -269,6 +273,29 @@ function SidebarItem({ )} + {!hasNewNav && ( + + {isInCollapsedState && isBeta && ( + + )} + {isInCollapsedState && isAlpha && ( + + )} + {badge !== undefined && badge > 0 && ( + + {badge} + + )} + + )} {isInCollapsedState && showIsNew && ( )} - {isInCollapsedState && isBeta && ( - - )} - {isInCollapsedState && isAlpha && ( - - )} - {badge !== undefined && badge > 0 && ( - - {badge} - - )} - {!isInFloatingAccordion && hasNewNav && ( + {hasNewNav && ( {label} {additionalContent ?? badges} @@ -311,9 +319,28 @@ function SidebarItem({ } export function isItemActive( - item: Pick, + item: Pick, exact?: boolean ): boolean { + // issue subnav matches against issue groups in search params + if (location.pathname.startsWith('/issues/') && item?.to?.includes('/issues/')) { + const search = new URLSearchParams(location.search); + const itemSearch = new URLSearchParams(item.search); + const itemQuery = itemSearch.get('query'); + const query = search.get('query'); + if (item?.label === 'All') { + return !query && !itemQuery; + } + if (itemQuery && query) { + let match = false; + for (const key of itemQuery?.split(' ')) { + match = query.includes(key); + if (!match) break; + } + return match; + } + return !itemQuery; + } // take off the query params for matching const toPathWithoutReferrer = item?.to?.split('?')[0]; if (!toPathWithoutReferrer) { @@ -355,15 +382,29 @@ const getActiveStyle = ({ active, theme, isInFloatingAccordion, + isInSubnav, }: { active?: string; hasNewNav?: boolean; isInFloatingAccordion?: boolean; + isInSubnav?: boolean; theme?: Theme; }) => { if (!active) { return ''; } + if (isInSubnav) { + return css` + &, + &:active, + &:focus, + &:hover { + color: ${theme?.gray500}; + background-color: ${theme?.hover}; + border: 1px solid ${theme?.translucentGray100}; + } + `; + } if (isInFloatingAccordion) { return css` &:active, @@ -392,37 +433,45 @@ const StyledSidebarItem = styled(Link, { shouldForwardProp: p => typeof p === 'string' && isPropValid(p), })` display: flex; - color: ${p => (p.isInFloatingAccordion ? p.theme.gray400 : 'inherit')}; + color: ${p => (p.isInFloatingAccordion || p.isInSubnav ? p.theme.gray400 : 'inherit')}; position: relative; cursor: pointer; font-size: 15px; - height: ${p => (p.isInFloatingAccordion ? '35px' : p.hasNewNav ? '40px' : '30px')}; + height: ${p => + p.isInFloatingAccordion || p.isInSubnav ? '35px' : p.hasNewNav ? '40px' : '30px'}; + align-items: center; + justify-content: center; flex-shrink: 0; border-radius: ${p => p.theme.borderRadius}; transition: none; - ${p => { - if (!p.hasNewNav) { - return css` - &:before { - display: block; - content: ''; - position: absolute; - top: 4px; - left: calc(-${space(2)} - 1px); - bottom: 6px; - width: 5px; - border-radius: 0 3px 3px 0; - background-color: transparent; - transition: 0.15s background-color linear; - } - `; - } - return css` - margin: ${space(2)} 0; - width: 100px; - align-self: center; - `; - }} + margin: ${p => (p.hasNewNav && p.isInSubnav ? `0 ${space(1)}` : '0')}; + + ${p => + !p.hasNewNav && + css` + &:before { + display: block; + content: ''; + position: absolute; + top: 4px; + left: calc(-${space(2)} - 1px); + bottom: 6px; + width: 5px; + border-radius: 0 3px 3px 0; + background-color: transparent; + transition: 0.15s background-color linear; + } + `} + ${p => + p.hasNewNav && + css` + border: 1px solid transparent; + + &:before { + background-color: ${p.theme.gray500}; + border: 1px solid ${p.theme.translucentGray200}; + } + `} @media (max-width: ${p => p.theme.breakpoints.medium}) { &:before { @@ -439,7 +488,7 @@ const StyledSidebarItem = styled(Link, { &:hover, &:focus-visible { ${p => { - if (p.isInFloatingAccordion) { + if (p.isInFloatingAccordion || p.isInSubnav) { return css` background-color: ${p.theme.hover}; color: ${p.theme.gray400}; @@ -457,18 +506,45 @@ const StyledSidebarItem = styled(Link, { &:focus-visible { outline: none; - box-shadow: 0 0 0 2px ${p => p.theme.purple300}; + box-shadow: inset 0 0 0 2px ${p => p.theme.purple300}; } + ${p => { + if (p.hasNewNav && !p.isInSubnav) { + return css` + & { + font-size: 11px; + align-self: center; + justify-content: center; + height: 52px; + width: 100%; + flex-grow: 1; + } + `; + } + return ''; + }} + ${getActiveStyle}; `; -const SidebarItemWrapper = styled('div')<{collapsed?: boolean; hasNewNav?: boolean}>` +const SidebarItemWrapper = styled('div')<{ + collapsed?: boolean; + hasNewNav?: boolean; + isInSubnav?: boolean; +}>` display: flex; align-items: center; - justify-content: center; - ${p => p.hasNewNav && 'flex-direction: column;'} + justify-content: ${p => (p.isInSubnav ? 'initial' : 'center')}; width: 100%; + flex-direction: ${p => (p.hasNewNav && !p.isInSubnav ? 'column' : 'row')}; + ${p => + p.hasNewNav && + p.isInSubnav && + css` + height: 32px; + padding: 0 ${space(1)}; + `} ${p => !p.collapsed && `padding-right: ${space(1)};`} @media (max-width: ${p => p.theme.breakpoints.medium}) { @@ -482,10 +558,11 @@ const SidebarItemIcon = styled('span')<{hasNewNav?: boolean}>` justify-content: center; flex-shrink: 0; width: 37px; + padding: 6px 8px; svg { display: block; - margin: 0 auto; + margin: auto; width: 18px; height: 18px; } @@ -559,6 +636,9 @@ const CollapsedFeatureBadge = styled(FeatureBadge)` `; const StyledInteractionStateLayer = styled(InteractionStateLayer)` - height: ${16 * 2 + 40}px; - width: 70px; + .sidebar-v2 & { + height: 53px; + width: 58px; + border-radius: ${p => p.theme.borderRadius}; + } `; diff --git a/static/app/components/sidebar/sidebarPanel.tsx b/static/app/components/sidebar/sidebarPanel.tsx index a4fb5e3a0ff2b6..2f154f813dc27a 100644 --- a/static/app/components/sidebar/sidebarPanel.tsx +++ b/static/app/components/sidebar/sidebarPanel.tsx @@ -38,6 +38,10 @@ const PanelContainer = styled('div')` left: ${p.collapsed ? p.theme.sidebar.collapsedWidth : p.theme.sidebar.expandedWidth}; + + .sidebar-v2 & { + left: ${p.theme.sidebar.v2_width}; + } `}; `; diff --git a/static/app/components/sidebar/subnav.tsx b/static/app/components/sidebar/subnav.tsx new file mode 100644 index 00000000000000..f3cf513a63ea71 --- /dev/null +++ b/static/app/components/sidebar/subnav.tsx @@ -0,0 +1,65 @@ +import { + createContext, + Fragment, + type RefObject, + useContext, + useLayoutEffect, + useRef, +} from 'react'; +import {createPortal} from 'react-dom'; +import styled from '@emotion/styled'; + +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; + +interface SubnavContextData { + containerRef: RefObject; +} + +const subnavContext = createContext({ + containerRef: {current: null}, +}); + +export function SubnavContainer({children, hasNewNav}) { + const containerRef = useRef(null); + useLayoutEffect(() => { + if (!hasNewNav) return; + if (!containerRef.current) return; + if (containerRef.current.children.length === 0) { + document.body.dataset.navLevel = '1'; + } else { + document.body.dataset.navLevel = '2'; + } + }, [hasNewNav]); + if (!hasNewNav) return {children}; + return ( + + {children} + + + ); +} + +export function SubnavPanel({children}) { + const {containerRef} = useContext(subnavContext); + if (!containerRef.current) return null; + return {createPortal(children, containerRef.current)}; +} + +export const SidebarSecondaryWrapper = styled('div')` + display: flex; + flex-direction: column; + align-items: stretch; + background: ${p => p.theme.surface300}; + border-right: 1px solid ${p => p.theme.translucentGray200}; + width: ${p => p.theme.sidebar.v2_panelWidth}; + height: 100%; + padding: ${space(1)} 0; + + &:empty { + display: none; + } +`; diff --git a/static/app/utils/replays/hooks/useReplayLayout.tsx b/static/app/utils/replays/hooks/useReplayLayout.tsx index 5abe5b8320c129..2e22d2ed630d60 100644 --- a/static/app/utils/replays/hooks/useReplayLayout.tsx +++ b/static/app/utils/replays/hooks/useReplayLayout.tsx @@ -68,8 +68,9 @@ function isLayout(val: string): val is LayoutKey { function useReplayLayout() { const collapsed = !!useLegacyStore(PreferencesStore).collapsed; - const defaultLayout = getDefaultLayout(collapsed); const organization = useOrganization(); + const hasNewNav = organization?.features.includes('navigation-sidebar-v2') ?? false; + const defaultLayout = getDefaultLayout(collapsed, hasNewNav); const {getParamValue, setParamValue} = useUrlParams('l_page', defaultLayout); diff --git a/static/app/utils/theme.tsx b/static/app/utils/theme.tsx index b6d6e01d010a45..16547298f6a3c9 100644 --- a/static/app/utils/theme.tsx +++ b/static/app/utils/theme.tsx @@ -808,6 +808,8 @@ const commonTheme = { collapsedWidth: '70px', semiCollapsedWidth: '100px', expandedWidth: '220px', + v2_width: '80px', + v2_panelWidth: '160px', mobileHeightNumber: 54, mobileHeight: '54px', menuSpacing: '15px', diff --git a/static/app/views/issueList/utils.tsx b/static/app/views/issueList/utils.tsx index 069731ba299323..d886f84381b063 100644 --- a/static/app/views/issueList/utils.tsx +++ b/static/app/views/issueList/utils.tsx @@ -217,3 +217,32 @@ export const FOR_REVIEW_QUERIES: string[] = [Query.FOR_REVIEW]; export const SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY = 'issue-stream-saved-searches-sidebar-open'; + +export enum IssueGroup { + ALL = 'all', + ERRORS_OUTAGES = 'errors_outages', + TRENDS = 'trends', + CRAFTSMANSHIP = 'craftsmanship', + SECURITY = 'security', +} + +const IssueGroupFilter: Record = { + [IssueGroup.ALL]: '', + [IssueGroup.ERRORS_OUTAGES]: 'issue.category:[error,cron,uptime]', + [IssueGroup.TRENDS]: + 'issue.type:[profile_function_regression,performance_p95_endpoint_regression,performance_n_plus_one_db_queries]', + [IssueGroup.CRAFTSMANSHIP]: + 'issue.category:replay issue.type:[performance_n_plus_one_db_queries,performance_n_plus_one_api_calls,performance_consecutive_db_queries,performance_render_blocking_asset_span,performance_uncompressed_assets,profile_file_io_main_thread,profile_image_decode_main_thread,profile_json_decode_main_thread,profile_regex_main_thread]', + [IssueGroup.SECURITY]: 'event.type:[nel,csp]', +}; + +function getIssueGroupFilter(group: IssueGroup): string { + if (!Object.hasOwn(IssueGroupFilter, group)) { + throw new Error(`Unknown issue group "${group}"`); + } + return IssueGroupFilter[group]; +} + +export function getSearchForIssueGroup(group: IssueGroup): string { + return `?query=is:unresolved+${getIssueGroupFilter(group)}`; +} diff --git a/static/app/views/replays/detail/layout/utils.tsx b/static/app/views/replays/detail/layout/utils.tsx index 7646862204b095..4610fdfa0e0391 100644 --- a/static/app/views/replays/detail/layout/utils.tsx +++ b/static/app/views/replays/detail/layout/utils.tsx @@ -1,13 +1,14 @@ import {LayoutKey} from 'sentry/utils/replays/hooks/useReplayLayout'; import theme from 'sentry/utils/theme'; -export const getDefaultLayout = (collapsed: boolean): LayoutKey => { +export const getDefaultLayout = (collapsed: boolean, hasNewNav: boolean): LayoutKey => { const {innerWidth, innerHeight} = window; - const sidebarWidth = parseInt( - collapsed ? theme.sidebar.collapsedWidth : theme.sidebar.expandedWidth, - 10 - ); + let width = collapsed ? theme.sidebar.collapsedWidth : theme.sidebar.expandedWidth; + if (hasNewNav) { + width = theme.sidebar.v2_width; + } + const sidebarWidth = parseInt(width, 10); const mediumScreenWidth = parseInt(theme.breakpoints.medium, 10); diff --git a/static/less/layout.less b/static/less/layout.less index e8b0a44b8ad8d5..050fad3aeb7de0 100644 --- a/static/less/layout.less +++ b/static/less/layout.less @@ -7,8 +7,15 @@ body { &.collapsed { padding-left: @sidebar-collapsed-width; } - &.hasNewNav { - padding-left: @sidebar-semi-collapsed-width; + + &.sidebar-v2 { + &[data-nav-level="1"] { + padding-left: @sidebar-v2-width; + } + + &[data-nav-level="2"] { + padding-left: calc(@sidebar-v2-width + @sidebar-v2-panel-width); + } } } @@ -42,9 +49,6 @@ body.narrow { &.collapsed { padding-left: @sidebar-collapsed-width; } - &.hasNewNav { - padding-left: @sidebar-semi-collapsed-width; - } } &.dialog { @@ -56,7 +60,7 @@ body.narrow { line-height: 1.2; } - .app > .container { + .app>.container { padding-top: 40px; max-width: 860px; @@ -114,7 +118,7 @@ body.narrow { /* Integration templates use body.auth! */ body.auth { - .app > .container { + .app>.container { padding-top: 5vh; max-width: 740px; } @@ -136,11 +140,9 @@ body.auth { padding-top: 20px; width: 60px; background: #564f64; - background-image: linear-gradient( - -180deg, - rgba(52, 44, 62, 0) 0%, - rgba(52, 44, 62, 0.5) 100% - ); + background-image: linear-gradient(-180deg, + rgba(52, 44, 62, 0) 0%, + rgba(52, 44, 62, 0.5) 100%); box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.1); border-radius: 4px 0 0 4px; margin-top: -1px; @@ -220,7 +222,7 @@ body.auth { align-items: center; margin-top: 20px; - > .btn { + >.btn { margin-right: 6px; } @@ -243,11 +245,11 @@ body.auth { margin-left: auto; margin-right: 0; - > a { + >a { display: flex; align-items: center; - > svg { + >svg { height: 17px; width: 20px; margin-right: 5px; @@ -304,7 +306,7 @@ body.auth { */ .windowed-small { - .app > .container { + .app>.container { padding-top: 60px !important; max-width: 600px !important; margin: 0 auto; @@ -415,18 +417,18 @@ body.auth { padding: 0 14px; } - .narrow .app > .container, - .narrow.windowed-small .app > .container { + .narrow .app>.container, + .narrow.windowed-small .app>.container { padding-top: 30px !important; } - .narrow.dialog .app > .container, - .narrow.dialog.windowed-small .app > .container { + .narrow.dialog .app>.container, + .narrow.dialog.windowed-small .app>.container { padding-top: 10px !important; } .nav-tabs { - > li { + >li { text-transform: capitalize; margin-right: 11px; @@ -458,7 +460,7 @@ body.auth { padding-left: 30px; } - .app > .container { + .app>.container { .box-content.with-padding { padding: 20px 20px 10px; } @@ -486,7 +488,7 @@ body.auth { padding-left: 20px; padding-right: 20px; - > div { + >div { width: 100%; padding: 0; margin: 0; @@ -507,7 +509,7 @@ body.auth { .join-request { margin-left: auto; - > a > svg { + >a>svg { height: 16px; width: 19px; margin-right: 4px; diff --git a/static/less/variables.less b/static/less/variables.less index 03c430c5fbccf6..5216befe9d57f6 100644 --- a/static/less/variables.less +++ b/static/less/variables.less @@ -34,6 +34,8 @@ // // Sets up sidebar offsets +@sidebar-v2-width: 80px; +@sidebar-v2-panel-width: 160px; @sidebar-collapsed-width: 70px; @sidebar-semi-collapsed-width: 100px; @sidebar-expanded-width: 220px;