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']};