From d4f17e9b41c5c365a24353237f7e4067451b6e0f Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 5 Aug 2026 07:21:47 +0200 Subject: [PATCH] fix(navigation): Refine responsive top bar actions Keep top bar actions responsive, render feedback actions transparently, and compose the optional Ask Seer label while retaining its icon. --- .../featureFlags/eventFeatureFlagSection.tsx | 1 + .../feedbackSummaryCategories.tsx | 1 + .../feedbackButton/feedbackButton.tsx | 12 +- .../breadcrumbs/replayComparisonModal.tsx | 1 + .../tokens/filterKeyListBox/index.tsx | 1 + .../alerts/rules/metric/details/sidebar.tsx | 1 + .../triggers/dynamicAlertsFeedbackButton.tsx | 1 + .../dashboards/widgets/widget/widget.tsx | 1 + .../releases/detail/header/releaseActions.tsx | 2 +- .../views/explore/replays/detail/ai/ai.tsx | 1 + .../metricDetectorTriggeredSection.tsx | 1 + .../supergroups/supergroupDrawer.tsx | 1 + .../views/navigation/index.mobile.spec.tsx | 31 +++++ .../app/views/navigation/mobileNavigation.tsx | 24 +++- static/app/views/navigation/navigation.tsx | 16 ++- .../views/navigation/primary/components.tsx | 11 +- .../navigation/primary/helpMenu.spec.tsx | 38 +++++- .../app/views/navigation/primary/helpMenu.tsx | 52 +++++++- .../app/views/navigation/primary/whatsNew.tsx | 37 ++++- static/app/views/navigation/searchButton.tsx | 41 +++--- static/app/views/navigation/topBar.spec.tsx | 126 +++++++++++++++--- static/app/views/navigation/topBar.tsx | 33 +++-- .../views/navigation/topBarActions.spec.tsx | 66 +++++++++ .../navigation/useTopBarActionDisplay.tsx | 21 +++ .../seerExplorer/components/askSeerButton.tsx | 113 +++++++++------- .../amCheckout/components/checkoutSuccess.tsx | 6 +- 26 files changed, 508 insertions(+), 131 deletions(-) create mode 100644 static/app/views/navigation/topBarActions.spec.tsx create mode 100644 static/app/views/navigation/useTopBarActionDisplay.tsx diff --git a/static/app/components/events/featureFlags/eventFeatureFlagSection.tsx b/static/app/components/events/featureFlags/eventFeatureFlagSection.tsx index 5533330d6c8d..9df96fda77f8 100644 --- a/static/app/components/events/featureFlags/eventFeatureFlagSection.tsx +++ b/static/app/components/events/featureFlags/eventFeatureFlagSection.tsx @@ -62,6 +62,7 @@ function BaseEventFeatureFlagList({event, group, project}: EventFeatureFlagSecti const feedbackButton = isXsScreen ? null : ( { return ( } tooltipProps={{ diff --git a/static/app/components/feedbackButton/feedbackButton.tsx b/static/app/components/feedbackButton/feedbackButton.tsx index d80d4dca5143..2ade6826111d 100644 --- a/static/app/components/feedbackButton/feedbackButton.tsx +++ b/static/app/components/feedbackButton/feedbackButton.tsx @@ -30,7 +30,6 @@ interface Props extends Omit { * @example * // Mix of Button and Feedback props * { * * @param children - The content to display inside the button. If not provided, the default label 'Give Feedback' will be used. * - * @param * - All standard Button props except `icon` (icon is fixed to megaphone). - * Includes size, priority, disabled, onClick handlers, etc. + * @param * - All standard Button props. The button defaults to the `transparent` variant, + * `sm` size, and megaphone icon; callers can override these defaults. * * @returns A Button that opens the feedback widget on click, or null if feedback is not enabled */ -export function FeedbackButton({feedbackOptions, ...buttonProps}: Props) { +export function FeedbackButton({ + feedbackOptions, + variant = 'transparent', + ...buttonProps +}: Props) { const buttonRef = useRef(null); const openForm = useFeedbackForm(); @@ -67,6 +70,7 @@ export function FeedbackButton({feedbackOptions, ...buttonProps}: Props) { ref={buttonRef} size="sm" icon={} + variant={variant} {...buttonProps} onClick={e => { openForm?.(feedbackOptions); diff --git a/static/app/components/replays/breadcrumbs/replayComparisonModal.tsx b/static/app/components/replays/breadcrumbs/replayComparisonModal.tsx index ce45e672a644..09dd75a02eba 100644 --- a/static/app/components/replays/breadcrumbs/replayComparisonModal.tsx +++ b/static/app/components/replays/breadcrumbs/replayComparisonModal.tsx @@ -89,6 +89,7 @@ export default function ReplayComparisonModal({ ) : null} {focusTrap ? ( { focusTrap.pause(); diff --git a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/index.tsx b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/index.tsx index cfdb0b26d3aa..37411414bc9a 100644 --- a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/index.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/index.tsx @@ -102,6 +102,7 @@ function FeedbackFooter({ ) : null} {showFeedbackButton ? ( - + {t('Give Feedback')} ) : null} diff --git a/static/app/views/explore/replays/detail/ai/ai.tsx b/static/app/views/explore/replays/detail/ai/ai.tsx index b83fd5591bec..d281a51f1dd4 100644 --- a/static/app/views/explore/replays/detail/ai/ai.tsx +++ b/static/app/views/explore/replays/detail/ai/ai.tsx @@ -264,6 +264,7 @@ function ThumbsUpDownButton({ }) { return ( } tooltipProps={{ diff --git a/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.tsx b/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.tsx index bd93a0eac001..f9405d96e871 100644 --- a/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.tsx +++ b/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.tsx @@ -419,6 +419,7 @@ function TriggeredConditionDetails({ actions={ {t('Experimental')} { ).toBeInTheDocument(); }); + it("moves the Command Palette into the mobile row and What's New into the Help menu", async () => { + const context = navigationContext(); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/broadcasts/', + body: [BroadcastFixture({title: 'Mobile Broadcast', hasSeen: true})], + }); + + render( + + + , + context + ); + renderGlobalModal({organization: context.organization}); + + const commandPaletteButton = screen.getByRole('button', { + name: 'Command Palette', + }); + expect(commandPaletteButton.parentElement).toContainElement( + screen.getByRole('button', {name: 'Help'}) + ); + expect(screen.queryByRole('button', {name: "What's New"})).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Help'})); + await userEvent.click(screen.getByRole('menuitemradio', {name: "What's New"})); + + expect(await screen.findByText('Mobile Broadcast')).toBeInTheDocument(); + }); + describe('secondary nav route inference', () => { it('opens secondary navigation by default when on a sub-view', async () => { render( diff --git a/static/app/views/navigation/mobileNavigation.tsx b/static/app/views/navigation/mobileNavigation.tsx index a72d630b95fd..03021d37663d 100644 --- a/static/app/views/navigation/mobileNavigation.tsx +++ b/static/app/views/navigation/mobileNavigation.tsx @@ -7,6 +7,7 @@ import {Flex, type FlexProps, Stack} from '@sentry/scraps/layout'; import {SizeProvider} from '@sentry/scraps/sizeContext'; import {useScrollLock} from '@sentry/scraps/useScrollLock'; +import {ErrorBoundary} from 'sentry/components/errorBoundary'; import {IconMenu} from 'sentry/icons'; import {t} from 'sentry/locale'; import {useOnClickOutside} from 'sentry/utils/useOnClickOutside'; @@ -20,7 +21,12 @@ import { PrimaryNavigationItems, } from 'sentry/views/navigation/navigation'; import {PrimaryNavigation} from 'sentry/views/navigation/primary/components'; +import { + PrimaryNavigationHelpMenu, + useWhatsNewHelpMenuOptions, +} from 'sentry/views/navigation/primary/helpMenu'; import {OrganizationDropdown} from 'sentry/views/navigation/primary/organizationDropdown'; +import {SearchButton} from 'sentry/views/navigation/searchButton'; import {SecondaryNavigation} from 'sentry/views/navigation/secondary/components'; import {SecondaryNavigationContent} from 'sentry/views/navigation/secondary/content'; import {useSecondaryNavigation} from 'sentry/views/navigation/secondaryNavigationContext'; @@ -48,6 +54,15 @@ function MobileNavigationHeader(props: FlexProps<'header'>) { ); } +function MobileWhatsNewHelpMenu() { + const whatsNewHelpMenuOptions = useWhatsNewHelpMenuOptions(); + return ; +} + +function MobileHelpMenuFallback() { + return ; +} + function MobilePrimaryNavigation() { const {view} = useSecondaryNavigation(); @@ -136,7 +151,14 @@ export function MobileNavigation() { /> - + + + {buttonProps => } + + + + + diff --git a/static/app/views/navigation/navigation.tsx b/static/app/views/navigation/navigation.tsx index cc05684feff0..409dc65bb05a 100644 --- a/static/app/views/navigation/navigation.tsx +++ b/static/app/views/navigation/navigation.tsx @@ -1,4 +1,4 @@ -import {Fragment, type RefObject, useMemo, useRef} from 'react'; +import {Fragment, type PropsWithChildren, type RefObject, useMemo, useRef} from 'react'; import {mergeProps} from '@react-aria/utils'; import {motion, type MotionProps} from 'framer-motion'; @@ -92,7 +92,12 @@ export function Navigation() { paddingBottom="md" > - + + + + + + @@ -282,7 +287,7 @@ export function PrimaryNavigationItems({listRef}: PrimaryNavigationItemsProps) { /** * Returns the list of items from the footer of the primary navigation */ -export function PrimaryNavigationFooterItems() { +export function PrimaryNavigationFooterItems({children}: PropsWithChildren) { const organization = useOrganization(); return ( @@ -302,10 +307,7 @@ export function PrimaryNavigationFooterItems() { - - - - + {children} ); } diff --git a/static/app/views/navigation/primary/components.tsx b/static/app/views/navigation/primary/components.tsx index 3f533a8aca57..6d2bea34af1d 100644 --- a/static/app/views/navigation/primary/components.tsx +++ b/static/app/views/navigation/primary/components.tsx @@ -324,7 +324,7 @@ function PrimaryNavigationMenu(props: PrimaryNavigationMenuProps) { > ) => { if (organization) { trackAnalytics('navigation.primary_item_clicked', { @@ -361,7 +361,7 @@ function NavigationButton(props: DistributedOmit) { const {layout} = usePrimaryNavigation(); return ( - + {p => ( ) { {...(layout === 'mobile' ? {variant: 'secondary'} : {variant: props.variant})} /> )} - + ); } +function PrimaryNavigationButtonContainer(props: React.ComponentProps) { + return ; +} + /** * @TODO(JonasBadalic) Scraps buttons have been setting overflow hidden onto the inner surface wrapper ever since * we inherited that component, and we need to override that to ensure that the indicator is visible as it will @@ -569,6 +573,7 @@ export const PrimaryNavigation = { ListItem: PrimaryNavigationListItem, Link: PrimaryNavigationLink, Button: PrimaryNavigationButton, + ButtonContainer: PrimaryNavigationButtonContainer, ButtonBar: PrimaryNavigationButtonBar, Menu: PrimaryNavigationMenu, ButtonOverlay: PrimaryNavigationButtonOverlay, diff --git a/static/app/views/navigation/primary/helpMenu.spec.tsx b/static/app/views/navigation/primary/helpMenu.spec.tsx index 2825044f5d30..9ea03ff5f5c8 100644 --- a/static/app/views/navigation/primary/helpMenu.spec.tsx +++ b/static/app/views/navigation/primary/helpMenu.spec.tsx @@ -1,10 +1,25 @@ +import {BroadcastFixture} from 'sentry-fixture/broadcast'; import {OrganizationFixture} from 'sentry-fixture/organization'; -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import { + render, + renderGlobalModal, + screen, + userEvent, +} from 'sentry-test/reactTestingLibrary'; import {ConfigStore} from 'sentry/stores/configStore'; +import {ModalStore} from 'sentry/stores/modalStore'; import * as intercom from 'sentry/utils/intercom'; -import {PrimaryNavigationHelpMenu} from 'sentry/views/navigation/primary/helpMenu'; +import { + PrimaryNavigationHelpMenu, + useWhatsNewHelpMenuOptions, +} from 'sentry/views/navigation/primary/helpMenu'; + +function HelpMenuWithWhatsNew() { + const whatsNewOptions = useWhatsNewHelpMenuOptions(); + return ; +} jest.mock('sentry/utils/intercom', () => ({ showIntercom: jest.fn(), @@ -18,6 +33,7 @@ async function expandResourcesSubmenu() { describe('PrimaryNavigationHelpMenu', () => { beforeEach(() => { jest.clearAllMocks(); + ModalStore.reset(); ConfigStore.set('supportEmail', 'support@sentry.io'); }); @@ -31,4 +47,22 @@ describe('PrimaryNavigationHelpMenu', () => { expect(intercom.showIntercom).toHaveBeenCalledWith(organization.slug); }); + + it("updates What's New when broadcasts finish loading", async () => { + const organization = OrganizationFixture(); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/broadcasts/`, + match: [MockApiClient.matchQuery({show: 'latest', limit: '3'})], + asyncDelay: 100, + body: [BroadcastFixture({id: '1', title: 'New Broadcast', hasSeen: true})], + }); + + render(, {organization}); + renderGlobalModal({organization}); + + await userEvent.click(screen.getByRole('button', {name: 'Help'})); + await userEvent.click(screen.getByRole('menuitemradio', {name: "What's New"})); + + expect(await screen.findByText('New Broadcast')).toBeInTheDocument(); + }); }); diff --git a/static/app/views/navigation/primary/helpMenu.tsx b/static/app/views/navigation/primary/helpMenu.tsx index 3a51cb4545f8..45897a987992 100644 --- a/static/app/views/navigation/primary/helpMenu.tsx +++ b/static/app/views/navigation/primary/helpMenu.tsx @@ -1,9 +1,12 @@ -import {useEffect} from 'react'; +import {Fragment, useEffect} from 'react'; import {Flex} from '@sentry/scraps/layout'; +import {openModal} from 'sentry/actionCreators/modal'; import type {MenuItemProps} from 'sentry/components/dropdownMenu'; +import {ErrorBoundary} from 'sentry/components/errorBoundary'; import { + IconBroadcast, IconBuilding, IconDiscord, IconDocs, @@ -26,8 +29,20 @@ import {showIntercom} from 'sentry/utils/intercom'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; import {useOrganization} from 'sentry/utils/useOrganization'; import {PrimaryNavigation} from 'sentry/views/navigation/primary/components'; +import { + useWhatsNewBroadcasts, + WhatsNewContent, +} from 'sentry/views/navigation/primary/whatsNew'; + +interface PrimaryNavigationHelpMenuProps { + additionalItems?: MenuItemProps[]; + indicator?: 'accent' | 'danger' | 'warning'; +} -export function PrimaryNavigationHelpMenu() { +export function PrimaryNavigationHelpMenu({ + additionalItems = [], + indicator, +}: PrimaryNavigationHelpMenuProps = {}) { const organization = useOrganization(); const contactSupportItem = getContactSupportItem(organization); const openForm = useFeedbackForm(); @@ -38,6 +53,7 @@ export function PrimaryNavigationHelpMenu() { }, [organization]); const items: MenuItemProps[] = [ + ...additionalItems, { key: 'resources', label: t('Resources'), @@ -199,10 +215,42 @@ export function PrimaryNavigationHelpMenu() { analyticsKey="help" label={t('Help')} icon={} + indicator={indicator} /> ); } +export function useWhatsNewHelpMenuOptions(): PrimaryNavigationHelpMenuProps { + const {unseenPostIds} = useWhatsNewBroadcasts(); + + return { + additionalItems: [ + { + key: 'whats-new', + label: t("What's New"), + leadingItems: ( + + + + ), + onAction() { + openModal(({Header, Body}) => ( + +
{t("What's New")}
+ + + + + +
+ )); + }, + }, + ], + indicator: unseenPostIds.length > 0 ? 'accent' : undefined, + }; +} + function getContactSupportItem(organization: Organization): MenuItemProps | null { const supportEmail = ConfigStore.get('supportEmail'); diff --git a/static/app/views/navigation/primary/whatsNew.tsx b/static/app/views/navigation/primary/whatsNew.tsx index 6f22ee87cd99..6b3593713de4 100644 --- a/static/app/views/navigation/primary/whatsNew.tsx +++ b/static/app/views/navigation/primary/whatsNew.tsx @@ -49,7 +49,7 @@ function BroadcastImage({src, alt}: {alt: string; src: string}) { ); } -function WhatsNewContent({ +function WhatsNewBroadcastList({ unseenPostIds, isPending, broadcasts = [], @@ -186,7 +186,15 @@ function WhatsNewContent({ ); } -export function PrimaryNavigationWhatsNew() { +/** + * Recent broadcasts plus the derived unseen/deduped views of them. Shared by the + * standalone What's New button and the help menu entry that replaces it when the + * navigation row has no room for a dedicated trigger. + * + * Pass `enabled: false` from a caller that only conditionally surfaces + * broadcasts, so it does not fetch them when it will not show them. + */ +export function useWhatsNewBroadcasts({enabled = true}: {enabled?: boolean} = {}) { const organization = useOrganization(); const {isPending, data: broadcasts} = useApiQuery( [ @@ -196,6 +204,7 @@ export function PrimaryNavigationWhatsNew() { {query: {show: 'latest', limit: '3'}}, ], { + enabled, // Five minute stale time prevents window focus frequent refetches staleTime: 1000 * 60 * 5, // 10 minutes poll @@ -224,6 +233,24 @@ export function PrimaryNavigationWhatsNew() { }); }, [allBroadcasts]); + return {isPending, unseenPostIds, uniqueBroadcasts}; +} + +export function WhatsNewContent() { + const {isPending, unseenPostIds, uniqueBroadcasts} = useWhatsNewBroadcasts(); + + return ( + + ); +} + +export function PrimaryNavigationWhatsNew() { + const {unseenPostIds} = useWhatsNewBroadcasts(); + const { isOpen, triggerProps: overlayTriggerProps, @@ -243,11 +270,7 @@ export function PrimaryNavigationWhatsNew() { /> {isOpen && ( - + )} diff --git a/static/app/views/navigation/searchButton.tsx b/static/app/views/navigation/searchButton.tsx index a68ebb23b21b..a8db668d06b7 100644 --- a/static/app/views/navigation/searchButton.tsx +++ b/static/app/views/navigation/searchButton.tsx @@ -1,6 +1,3 @@ -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; - import {Button} from '@sentry/scraps/button'; import {Hotkey} from '@sentry/scraps/hotkey'; import {Flex} from '@sentry/scraps/layout'; @@ -12,24 +9,33 @@ import { } from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; import {IconSearch} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSeerExplorerContext} from 'sentry/views/seerExplorer/useSeerExplorerContext'; import {isSeerExplorerEnabled} from 'sentry/views/seerExplorer/utils'; -export function SearchButton() { - const theme = useTheme(); +interface SearchButtonProps { + className?: string; +} + +export function SearchButton(props: SearchButtonProps) { const organization = useOrganization({allowNull: true}); const state = useCommandPaletteState(); const dispatch = useCommandPaletteDispatch(); const {openSeerExplorer} = useSeerExplorerContext(); - const isDesktop = useMedia(`(min-width: ${theme.breakpoints.md})`); - return ( - } - aria-label={t('Search')} + aria-label={t('Command Palette')} + tooltipProps={{ + title: ( + + {t('Command Palette')} + + + ), + }} onClick={() => { if (!organization) { return; @@ -43,19 +49,6 @@ export function SearchButton() { isSeerExplorerEnabled(organization) ? openSeerExplorer : undefined ); }} - > - {isDesktop ? ( - - {t('Search')} - - - ) : null} - + /> ); } - -const StyledButton = styled(Button)` - > span:last-child { - overflow: visible; - } -`; diff --git a/static/app/views/navigation/topBar.spec.tsx b/static/app/views/navigation/topBar.spec.tsx index 09a1cdf8a74d..7930df99cd2e 100644 --- a/static/app/views/navigation/topBar.spec.tsx +++ b/static/app/views/navigation/topBar.spec.tsx @@ -1,23 +1,55 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ThemeFixture} from 'sentry-fixture/theme'; import {render, screen, within} from 'sentry-test/reactTestingLibrary'; -import {getEmotionRules} from 'sentry-test/utils'; import {BreadcrumbList} from '@sentry/scraps/breadcrumbList'; +import {Flex} from '@sentry/scraps/layout'; + +import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import {TopBar} from './topBar'; -function renderTopBar() { - render( +const theme = ThemeFixture(); + +jest.mock('sentry/components/feedbackButton/feedbackButton', () => ({ + FeedbackButton: ({variant}: {variant?: string}) => ( + + ), +})); + +jest.mock('sentry/views/seerExplorer/utils', () => ({ + ...jest.requireActual('sentry/views/seerExplorer/utils'), + isSeerExplorerEnabled: () => true, +})); + +function renderTopBar(width?: number) { + if (width !== undefined) { + jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(width); + } + + const topBar = ( Page title - , - {organization: OrganizationFixture()} + + ); + + render( + width === undefined ? topBar : {topBar}, + { + organization: OrganizationFixture({ + features: ['gen-ai-features', 'seer-explorer'], + }), + } ); } describe('TopBar title slot', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('renders the title as an h1 by default', () => { renderTopBar(); @@ -26,20 +58,6 @@ describe('TopBar title slot', () => { ).toBeInTheDocument(); }); - it('hides the empty breadcrumbs outlet when only the title slot is used', () => { - renderTopBar(); - - const emptyBreadcrumbsOutlet = Array.from( - screen.getByRole('banner').querySelectorAll('*') - ).find(element => - getEmotionRules(element).some( - rule => /display:\s*none/.test(rule) && /flex:\s*0 1 auto/.test(rule) - ) - ); - - expect(emptyBreadcrumbsOutlet).toBeDefined(); - }); - it('keeps BreadcrumbList titles inside the single TopBar heading', () => { render( @@ -59,4 +77,74 @@ describe('TopBar title slot', () => { screen.getByRole('heading', {name: 'Current Issue', level: 1}) ).toBeInTheDocument(); }); + + it('uses icon-only actions below sm', () => { + renderTopBar(Number.parseFloat(theme.container.sm) - 1); + + expect( + within(screen.getByRole('button', {name: 'Command Palette'})).queryByText( + 'Command Palette' + ) + ).not.toBeInTheDocument(); + expect( + within(screen.getByRole('button', {name: 'Ask Seer'})).queryByText('Ask Seer') + ).not.toBeInTheDocument(); + }); + + it('keeps Search icon-only and Feedback transparent below xl', () => { + renderTopBar(Number.parseFloat(theme.container.xl) - 1); + const askSeerButton = screen.getByRole('button', {name: 'Ask Seer'}); + + expect( + within(screen.getByRole('button', {name: 'Command Palette'})).queryByText( + 'Command Palette' + ) + ).not.toBeInTheDocument(); + expect(within(askSeerButton).getByText('Ask Seer')).toBeInTheDocument(); + expect(askSeerButton.querySelector('kbd')).not.toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Give Feedback'})).toHaveAttribute( + 'data-variant', + 'transparent' + ); + }); + + it('keeps Search icon-only, second last, and Feedback transparent at xl', () => { + renderTopBar(Number.parseFloat(theme.container.xl)); + const askSeerButton = screen.getByRole('button', {name: 'Ask Seer'}); + + expect( + within(screen.getByRole('button', {name: 'Command Palette'})).queryByText( + 'Command Palette' + ) + ).not.toBeInTheDocument(); + expect(within(askSeerButton).getByText('Ask Seer')).toBeInTheDocument(); + expect(askSeerButton.querySelector('kbd')).not.toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Give Feedback'})).toHaveAttribute( + 'data-variant', + 'transparent' + ); + + expect(within(screen.getByRole('banner')).getAllByRole('button')).toEqual([ + askSeerButton, + screen.getByRole('button', {name: 'Command Palette'}), + screen.getByRole('button', {name: 'Give Feedback'}), + ]); + }); + + it('supports a transparent page-specific Feedback button', () => { + render( + + + + + + , + {organization: OrganizationFixture()} + ); + + expect(screen.getByRole('button', {name: 'Give Feedback'})).toHaveAttribute( + 'data-variant', + 'transparent' + ); + }); }); diff --git a/static/app/views/navigation/topBar.tsx b/static/app/views/navigation/topBar.tsx index ae485690692a..a7e12f3d2f6c 100644 --- a/static/app/views/navigation/topBar.tsx +++ b/static/app/views/navigation/topBar.tsx @@ -11,6 +11,7 @@ import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import {t} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SearchButton} from 'sentry/views/navigation/searchButton'; +import {useTopBarActionDisplay} from 'sentry/views/navigation/useTopBarActionDisplay'; import {useTopOffset} from 'sentry/views/navigation/useTopOffset'; import {AskSeerButton} from 'sentry/views/seerExplorer/components/askSeerButton'; import {useSeerExplorerChatState} from 'sentry/views/seerExplorer/seerExplorerChatStateContext'; @@ -33,6 +34,7 @@ function TopBarContent() { const {pageContentTop} = useTopOffset(); const organization = useOrganization({allowNull: true}); + const {display: actionDisplay, isSearchInMobileRow} = useTopBarActionDisplay(); useEffect(() => { document.documentElement.style.setProperty(TOP_BAR_HEIGHT_CSS_VAR, pageContentTop); @@ -88,16 +90,11 @@ function TopBarContent() { containerType="inline-size" > - {(props, hasConsumers) => ( - - )} + {(props, hasConsumers) => + hasConsumers ? ( + + ) : null + } @@ -113,15 +110,23 @@ function TopBarContent() { - {props => } + {(props, hasConsumers) => + hasConsumers ? : null + } - {props => } + {(props, hasConsumers) => + hasConsumers ? : null + } - - {isSeerExplorerEnabled(organization) ? : null} + {isSeerExplorerEnabled(organization) ? ( + + {actionDisplay === 'icon' ? null : t('Ask Seer')} + + ) : null} + {isSearchInMobileRow ? null : } {props => ( diff --git a/static/app/views/navigation/topBarActions.spec.tsx b/static/app/views/navigation/topBarActions.spec.tsx new file mode 100644 index 000000000000..b7ccb5672cb6 --- /dev/null +++ b/static/app/views/navigation/topBarActions.spec.tsx @@ -0,0 +1,66 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; + +import {Flex} from '@sentry/scraps/layout'; + +import {SearchButton} from 'sentry/views/navigation/searchButton'; +import {AskSeerButton} from 'sentry/views/seerExplorer/components/askSeerButton'; + +interface RenderActionsOptions { + showSeerLabel?: boolean; +} + +function renderActions({showSeerLabel = false}: RenderActionsOptions = {}) { + return render( + + + {showSeerLabel ? 'Ask Seer' : null} + , + {organization: OrganizationFixture()} + ); +} + +describe('top bar actions', () => { + it('shows tooltips for icon-only actions', async () => { + renderActions(); + + const searchButton = screen.getByRole('button', {name: 'Command Palette'}); + const askSeerButton = screen.getByRole('button', {name: 'Ask Seer'}); + expect(within(searchButton).queryByText('Command Palette')).not.toBeInTheDocument(); + expect(within(askSeerButton).queryByText('Ask Seer')).not.toBeInTheDocument(); + + await userEvent.hover(searchButton); + expect(document.querySelector('[data-tooltip]')).toHaveTextContent('Command Palette'); + + await userEvent.hover(askSeerButton); + expect(document.querySelector('[data-tooltip]')).toHaveTextContent('Ask Seer'); + }); + + it('shows the Ask Seer shortcut in its tooltip when labeled', async () => { + renderActions({showSeerLabel: true}); + + const askSeerButton = screen.getByRole('button', {name: 'Ask Seer'}); + expect( + within(screen.getByRole('button', {name: 'Command Palette'})).queryByText( + 'Command Palette' + ) + ).not.toBeInTheDocument(); + expect(within(askSeerButton).getByText('Ask Seer')).toBeInTheDocument(); + expect(askSeerButton.querySelector('kbd')).not.toBeInTheDocument(); + + await userEvent.hover(askSeerButton); + expect(screen.getByText('/')).toBeInTheDocument(); + }); + + it('keeps the Command Palette icon-only when Ask Seer shows its label', () => { + renderActions({showSeerLabel: true}); + + const searchButton = screen.getByRole('button', {name: 'Command Palette'}); + const askSeerButton = screen.getByRole('button', {name: 'Ask Seer'}); + expect(within(searchButton).queryByText('Command Palette')).not.toBeInTheDocument(); + expect(within(askSeerButton).getByText('Ask Seer')).toBeInTheDocument(); + expect(searchButton.querySelector('kbd')).not.toBeInTheDocument(); + expect(askSeerButton.querySelector('kbd')).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/views/navigation/useTopBarActionDisplay.tsx b/static/app/views/navigation/useTopBarActionDisplay.tsx new file mode 100644 index 000000000000..c889b0be69f2 --- /dev/null +++ b/static/app/views/navigation/useTopBarActionDisplay.tsx @@ -0,0 +1,21 @@ +import {useResponsivePropValue} from '@sentry/scraps/layout'; + +import {usePrimaryNavigation} from 'sentry/views/navigation/primaryNavigationContext'; + +export type TopBarActionDisplay = 'icon' | 'label'; + +export function useTopBarActionDisplay(): { + display: TopBarActionDisplay; + isSearchInMobileRow: boolean; +} { + const isSearchInMobileRow = usePrimaryNavigation().layout === 'mobile'; + const responsiveDisplay = useResponsivePropValue({ + zero: 'icon', + sm: 'label', + }); + + return { + display: isSearchInMobileRow ? 'icon' : responsiveDisplay, + isSearchInMobileRow, + }; +} diff --git a/static/app/views/seerExplorer/components/askSeerButton.tsx b/static/app/views/seerExplorer/components/askSeerButton.tsx index 9fcbb7673425..5fcf72c82f48 100644 --- a/static/app/views/seerExplorer/components/askSeerButton.tsx +++ b/static/app/views/seerExplorer/components/askSeerButton.tsx @@ -1,3 +1,4 @@ +import type {ReactNode} from 'react'; import styled from '@emotion/styled'; import {useReducedMotion} from 'framer-motion'; @@ -12,10 +13,15 @@ import {IconSeer} from 'sentry/icons'; import {t} from 'sentry/locale'; import {useSeerExplorerContext} from 'sentry/views/seerExplorer/useSeerExplorerContext'; -export function AskSeerButton() { +interface AskSeerButtonProps { + children?: ReactNode; +} + +export function AskSeerButton({children}: AskSeerButtonProps) { const {isOpen, toggleSeerExplorer, sessionState: state} = useSeerExplorerContext(); const showMessageIndicator = !isOpen && state === 'done-thinking'; const prefersReducedMotion = useReducedMotion(); + const hasLabel = children !== null && children !== undefined; return ( + {t('Ask Seer')} + + + ), + }} icon={ - + + + {showMessageIndicator && !hasLabel ? : null} + } > - - - {t('Ask Seer')} - - - - - {state === 'thinking' ? ( - - {prefersReducedMotion ? ( - {t('Thinking...')} - ) : ( - - )} - - ) : null} - {showMessageIndicator ? ( + {hasLabel ? ( + - + {children} - ) : null} - + {state === 'thinking' ? ( + + {prefersReducedMotion ? ( + {t('Thinking...')} + ) : ( + + )} + + ) : null} + {showMessageIndicator ? : null} + + ) : null} ); } +function MessageIndicator() { + return ( + + + + ); +} + const SeerLoader = styled(Flex)` color: ${p => p.theme.tokens.graphics.accent.vibrant}; `; diff --git a/static/gsApp/views/amCheckout/components/checkoutSuccess.tsx b/static/gsApp/views/amCheckout/components/checkoutSuccess.tsx index 9297586939a4..77c6424382eb 100644 --- a/static/gsApp/views/amCheckout/components/checkoutSuccess.tsx +++ b/static/gsApp/views/amCheckout/components/checkoutSuccess.tsx @@ -615,7 +615,11 @@ export function CheckoutSuccess({ > {t('Edit plan')} - +