diff --git a/static/app/views/issueDetails/actions/newIssueExperienceButton.spec.tsx b/static/app/views/issueDetails/actions/newIssueExperienceButton.spec.tsx deleted file mode 100644 index ec2ceae6243152..00000000000000 --- a/static/app/views/issueDetails/actions/newIssueExperienceButton.spec.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {render, screen} from 'sentry-test/reactTestingLibrary'; - -import {mockTour} from 'sentry/components/tours/testUtils'; -import {NewIssueExperienceButton} from 'sentry/views/issueDetails/actions/newIssueExperienceButton'; - -const mockFeedbackForm = jest.fn(); -jest.mock('sentry/utils/useFeedbackForm', () => ({ - useFeedbackForm: () => mockFeedbackForm(), -})); - -jest.mock('sentry/views/issueDetails/issueDetailsTour', () => ({ - ...jest.requireActual('sentry/views/issueDetails/issueDetailsTour'), - useIssueDetailsTour: () => mockTour(), -})); - -describe('NewIssueExperienceButton', () => { - const organization = OrganizationFixture({streamlineOnly: null}); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('is hidden when no streamlined actions are available', () => { - const {unmount: unmountOptionTrue} = render( -
- -
, - { - organization: { - ...organization, - streamlineOnly: true, - }, - } - ); - expect(screen.getByTestId('test-id')).toBeEmptyDOMElement(); - unmountOptionTrue(); - - const {unmount: unmountOptionFalse} = render( -
- -
, - { - organization: { - ...organization, - streamlineOnly: false, - }, - } - ); - expect(screen.getByTestId('test-id')).toBeEmptyDOMElement(); - unmountOptionFalse(); - }); - - it('appears when feedback action is available', () => { - mockFeedbackForm.mockReturnValue(jest.fn()); - render( -
- -
, - {organization} - ); - expect(screen.getByTestId('test-id')).not.toBeEmptyDOMElement(); - }); -}); diff --git a/static/app/views/issueDetails/actions/newIssueExperienceButton.tsx b/static/app/views/issueDetails/actions/newIssueExperienceButton.tsx deleted file mode 100644 index 5d85faf2691b9c..00000000000000 --- a/static/app/views/issueDetails/actions/newIssueExperienceButton.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; -import styled from '@emotion/styled'; - -import issueDetailsPreview from 'sentry-images/issue_details/issue-details-preview.png'; - -import {openModal} from 'sentry/actionCreators/modal'; -import {DropdownButton} from 'sentry/components/dropdownButton'; -import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {TourAction, TourGuide} from 'sentry/components/tours/components'; -import {StartTourModal, startTourModalCss} from 'sentry/components/tours/startTour'; -import {useMutateAssistant} from 'sentry/components/tours/useAssistant'; -import {IconLab} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; -import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; -import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import { - ISSUE_DETAILS_TOUR_GUIDE_KEY, - useIssueDetailsTour, -} from 'sentry/views/issueDetails/issueDetailsTour'; - -/** - * This hook will cause the promotional modal to appear if: - * - All the steps have been registered - * - The tour has not been completed - * - The tour is not currently active - * - The streamline UI is enabled - * - The user's browser has not stored that they've seen the promo - * - * Returns a function that can be used to reset the modal. - */ -function useIssueDetailsPromoModal() { - const organization = useOrganization(); - const {mutate: mutateAssistant} = useMutateAssistant(); - const { - startTour, - endTour, - currentStepId, - isRegistered: isTourRegistered, - isCompleted: isTourCompleted, - } = useIssueDetailsTour(); - - const [localTourState, setLocalTourState] = useLocalStorageState( - ISSUE_DETAILS_TOUR_GUIDE_KEY, - {hasSeen: false} - ); - - const isPromoVisible = - isTourRegistered && - !isTourCompleted && - currentStepId === null && - !localTourState.hasSeen; - - const handleEndTour = useCallback(() => { - setLocalTourState({hasSeen: true}); - mutateAssistant({guide: ISSUE_DETAILS_TOUR_GUIDE_KEY, status: 'dismissed'}); - endTour(); - trackAnalytics('issue_details.tour.skipped', {organization}); - }, [mutateAssistant, organization, endTour, setLocalTourState]); - - useEffect(() => { - if (isPromoVisible) { - openModal( - props => ( - { - handleEndTour(); - }} - onStartTour={() => { - setLocalTourState({hasSeen: true}); - startTour(); - trackAnalytics('issue_details.tour.started', { - organization, - method: 'modal', - }); - }} - /> - ), - { - modalCss: startTourModalCss, - onClose: reason => { - if (reason) { - handleEndTour(); - } - }, - } - ); - } - }, [ - isPromoVisible, - mutateAssistant, - organization, - endTour, - startTour, - setLocalTourState, - handleEndTour, - ]); - - const resetModal = useCallback(() => { - setLocalTourState({hasSeen: false}); - mutateAssistant({guide: ISSUE_DETAILS_TOUR_GUIDE_KEY, status: 'restart'}); - }, [mutateAssistant, setLocalTourState]); - - return {resetModal}; -} - -export function NewIssueExperienceButton() { - const organization = useOrganization(); - const isSuperUser = isActiveSuperuser(); - const { - startTour, - isRegistered: isTourRegistered, - isCompleted: isTourCompleted, - } = useIssueDetailsTour(); - const {resetModal} = useIssueDetailsPromoModal(); - - // XXX: We use a ref to track the previous state of tour completion - // since we only show the banner when the tour goes from incomplete to complete - const isTourCompletedRef = useRef(isTourCompleted); - const [isReminderVisible, setIsReminderVisible] = useState(false); - useEffect(() => { - // If the tour becomes completed, and started off incomplete, show the reminder. - let timeout: NodeJS.Timeout | undefined; - if (isTourCompleted && !isTourCompletedRef.current) { - setIsReminderVisible(true); - // Auto-dismiss after 5 seconds - timeout = setTimeout(() => { - setIsReminderVisible(false); - trackAnalytics('issue_details.tour.reminder', {organization, method: 'timeout'}); - }, 5000); - } - isTourCompletedRef.current = isTourCompleted; - return () => clearTimeout(timeout); - }, [isTourCompleted, organization]); - - const openForm = useFeedbackForm(); - - const items = [ - { - key: 'take-tour', - label: t('Take a tour'), - hidden: !isTourRegistered, - onAction: () => { - trackAnalytics('issue_details.tour.started', {organization, method: 'dropdown'}); - startTour(); - }, - }, - { - key: 'give-feedback', - label: t('Give feedback on the UI'), - hidden: !openForm, - onAction: () => { - openForm?.({ - messagePlaceholder: t('Tell us what you think about the new UI'), - tags: { - ['feedback.source']: 'streamlined_issue_details', - ['feedback.owner']: 'issues', - }, - }); - }, - }, - { - key: 'reset-tour-modal', - label: t('Reset tour modal (Superuser only)'), - hidden: !isSuperUser || !isTourCompleted, - onAction: resetModal, - }, - ]; - - if (items.every(item => item.hidden)) { - return null; - } - - return ( - { - trackAnalytics('issue_details.tour.reminder', { - organization, - method: 'dismissed', - }); - setIsReminderVisible(false); - }} - > - {t('Got it')} - - } - isOpen={isReminderVisible} - > - {tourProps => ( -
- ( - - {/* Passing icon as child to avoid extra icon margin */} - - - )} - items={items} - position="bottom-end" - /> -
- )} -
- ); -} - -const StyledDropdownButton = styled(DropdownButton)` - color: ${p => p.theme.colors.blue400}; - :hover { - color: ${p => p.theme.colors.blue400}; - } -`; diff --git a/static/app/views/issueDetails/issueDetailsTour.tsx b/static/app/views/issueDetails/issueDetailsTour.tsx index e112555988062f..8a26ed4764eeb1 100644 --- a/static/app/views/issueDetails/issueDetailsTour.tsx +++ b/static/app/views/issueDetails/issueDetailsTour.tsx @@ -1,4 +1,4 @@ -import {createContext, useContext} from 'react'; +import {createContext} from 'react'; import type {TourContextType} from 'sentry/components/tours/tourContext'; @@ -30,11 +30,3 @@ export const ISSUE_DETAILS_TOUR_GUIDE_KEY = 'tour.issue_details'; export const IssueDetailsTourContext = createContext | null>(null); - -export function useIssueDetailsTour(): TourContextType { - const tourContext = useContext(IssueDetailsTourContext); - if (!tourContext) { - throw new Error('Must be used within a TourContextProvider'); - } - return tourContext; -} diff --git a/static/app/views/issueDetails/streamline/header/header.spec.tsx b/static/app/views/issueDetails/streamline/header/header.spec.tsx index 43470e46504e76..f593252eae6e09 100644 --- a/static/app/views/issueDetails/streamline/header/header.spec.tsx +++ b/static/app/views/issueDetails/streamline/header/header.spec.tsx @@ -101,30 +101,10 @@ describe('StreamlinedGroupHeader', () => { screen.getByRole('button', {name: 'Modify issue assignee'}) ).toBeInTheDocument(); expect(screen.getByText('Leander')).toBeInTheDocument(); - expect( - screen.getByRole('button', {name: 'Manage issue experience'}) - ).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Resolve'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Archive'})).toBeInTheDocument(); }); - it('displays new experience button if flag is set', async () => { - render( - , - { - organization, - } - ); - expect( - await screen.findByRole('button', {name: 'Manage issue experience'}) - ).toBeInTheDocument(); - }); - it('displays share icon if issue has been shared', async () => { render( }, + ]; return (
- {hasPageFrameFeature ? ( - - , - }, - ]} - /> - - ) : ( - , - }, - ]} - /> - )} + + + {hasErrorUpsampling && ( - {!hasOnlyOneUIOption && !hasFeedbackForm && ( - } - analyticsEventKey="issue_details.streamline_ui_learn_more" - analyticsEventName="Issue Details: Streamline UI Learn More" - analyticsParams={{show_learn_more: showLearnMore}} - onClick={() => setShowLearnMore(false)} - > - {showLearnMore ? t("See What's New") : null} - - )} - {hasFeedbackForm && feedback ? ( - hasPageFrameFeature ? ( - - - {null} - - - ) : ( - - ) - ) : ( - - )} + @@ -329,6 +242,57 @@ export function StreamlinedGroupHeader({event, group, project}: GroupHeaderProps ); } +function MaybeTopBarSlot({ + name, + children, +}: { + children: ReactNode; + name: ComponentProps['name']; +}) { + const hasPageFrameFeature = useHasPageFrameFeature(); + if (hasPageFrameFeature) { + return {children}; + } + return children; +} + +function HeaderActions({group}: {group: Group}) { + const hasPageFrameFeature = useHasPageFrameFeature(); + const {feedback} = useFeedbackSDKIntegration(); + + const isAIDetectedIssue = AI_DETECTED_ISSUE_TYPES.has(group.issueType); + const hasFeedbackForm = + group.issueType === IssueType.QUERY_INJECTION_VULNERABILITY || + group.issueType === IssueType.PERFORMANCE_N_PLUS_ONE_API_CALLS || + isAIDetectedIssue; + const feedbackSource = + group.issueType === IssueType.QUERY_INJECTION_VULNERABILITY + ? 'issue_details_query_injection' + : isAIDetectedIssue + ? 'issue_details_ai_detected' + : 'issue_details_n_plus_one_api_calls'; + const feedbackOptions = { + messagePlaceholder: t('Please provide feedback on the issue Sentry detected.'), + tags: {['feedback.source']: feedbackSource}, + }; + + if (hasFeedbackForm && feedback) { + return ( + + + {hasPageFrameFeature ? null : t('Give Feedback')} + + + ); + } + + return null; +} + const Header = styled('header')` background-color: ${p => p.theme.tokens.background.primary}; padding: ${p => p.theme.space.md} ${p => p.theme.space['2xl']};