Skip to content

Commit 216fe6e

Browse files
malwilleyevanh
authored andcommitted
feat(rollback): Display dismissable banner in sidebar (#81061)
With the rollback flag, displays a banner on the sidebar itself to draw attention to the feature. When dismissed, it will display a dot on the org dropdown to show the user that the rollback is still available in the dropdown. Once that is opened, the dot will go away.
1 parent ac545fb commit 216fe6e

File tree

9 files changed

+317
-36
lines changed

9 files changed

+317
-36
lines changed

static/app/actionCreators/prompts.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {useCallback} from 'react';
22

33
import type {Client} from 'sentry/api';
44
import type {Organization, OrganizationSummary} from 'sentry/types/organization';
5+
import {defined} from 'sentry/utils';
56
import {promptIsDismissed} from 'sentry/utils/promptIsDismissed';
67
import type {ApiQueryKey, UseApiQueryOptions} from 'sentry/utils/queryClient';
78
import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
@@ -41,7 +42,7 @@ type PromptCheckParams = {
4142
* The prompt feature name
4243
*/
4344
feature: string | string[];
44-
organization: OrganizationSummary;
45+
organization: OrganizationSummary | null;
4546
/**
4647
* The numeric project ID as a string
4748
*/
@@ -89,10 +90,10 @@ export async function promptsCheck(
8990
): Promise<PromptData> {
9091
const query = {
9192
feature: params.feature,
92-
organization_id: params.organization.id,
93+
organization_id: params.organization?.id,
9394
...(params.projectId === undefined ? {} : {project_id: params.projectId}),
9495
};
95-
const url = `/organizations/${params.organization.slug}/prompts-activity/`;
96+
const url = `/organizations/${params.organization?.slug}/prompts-activity/`;
9697
const response: PromptResponse = await api.requestPromise(url, {
9798
query,
9899
});
@@ -112,22 +113,23 @@ export const makePromptsCheckQueryKey = ({
112113
organization,
113114
projectId,
114115
}: PromptCheckParams): ApiQueryKey => {
115-
const url = `/organizations/${organization.slug}/prompts-activity/`;
116+
const url = `/organizations/${organization?.slug}/prompts-activity/`;
116117
return [
117118
url,
118-
{query: {feature, organization_id: organization.id, project_id: projectId}},
119+
{query: {feature, organization_id: organization?.id, project_id: projectId}},
119120
];
120121
};
121122

122123
export function usePromptsCheck(
123124
{feature, organization, projectId}: PromptCheckParams,
124-
options: Partial<UseApiQueryOptions<PromptResponse>> = {}
125+
{enabled = true, ...options}: Partial<UseApiQueryOptions<PromptResponse>> = {}
125126
) {
126127
return useApiQuery<PromptResponse>(
127128
makePromptsCheckQueryKey({feature, organization, projectId}),
128129
{
129130
staleTime: 120000,
130131
retry: false,
132+
enabled: defined(organization) && enabled,
131133
...options,
132134
}
133135
);
@@ -141,7 +143,7 @@ export function usePrompt({
141143
options,
142144
}: {
143145
feature: string;
144-
organization: Organization;
146+
organization: Organization | null;
145147
daysToSnooze?: number;
146148
options?: Partial<UseApiQueryOptions<PromptResponse>>;
147149
projectId?: string;
@@ -162,6 +164,9 @@ export function usePrompt({
162164
: undefined;
163165

164166
const dismissPrompt = useCallback(() => {
167+
if (!organization) {
168+
return;
169+
}
165170
promptsUpdate(api, {
166171
organization,
167172
projectId,
@@ -189,6 +194,9 @@ export function usePrompt({
189194
}, [api, feature, organization, projectId, queryClient]);
190195

191196
const snoozePrompt = useCallback(() => {
197+
if (!organization) {
198+
return;
199+
}
192200
promptsUpdate(api, {
193201
organization,
194202
projectId,
@@ -216,6 +224,9 @@ export function usePrompt({
216224
}, [api, feature, organization, projectId, queryClient]);
217225

218226
const showPrompt = useCallback(() => {
227+
if (!organization) {
228+
return;
229+
}
219230
promptsUpdate(api, {
220231
organization,
221232
projectId,

static/app/components/sidebar/index.spec.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {logout} from 'sentry/actionCreators/account';
1111
import {OnboardingContextProvider} from 'sentry/components/onboarding/onboardingContext';
1212
import SidebarContainer from 'sentry/components/sidebar';
1313
import ConfigStore from 'sentry/stores/configStore';
14+
import PreferenceStore from 'sentry/stores/preferencesStore';
1415
import type {Organization} from 'sentry/types/organization';
1516
import type {StatuspageIncident} from 'sentry/types/system';
1617
import localStorage from 'sentry/utils/localStorage';
@@ -438,4 +439,100 @@ describe('Sidebar', function () {
438439
expect(await screen.findByTestId('floating-accordion')).toBeInTheDocument();
439440
});
440441
});
442+
443+
describe('Rollback prompts', () => {
444+
beforeEach(() => {
445+
PreferenceStore.showSidebar();
446+
});
447+
448+
it('should render the sidebar banner with no dismissed prompts and an existing rollback', async () => {
449+
MockApiClient.addMockResponse({
450+
url: `/organizations/${organization.slug}/prompts-activity/`,
451+
body: {data: {}},
452+
});
453+
454+
MockApiClient.addMockResponse({
455+
url: `/organizations/${organization.slug}/user-rollback/`,
456+
body: {data: {}},
457+
});
458+
459+
renderSidebarWithFeatures(['sentry-rollback-2024']);
460+
461+
expect(await screen.findByText(/Your 2024 Rollback/)).toBeInTheDocument();
462+
});
463+
464+
it('will not render anything if the user does not have a rollback', async () => {
465+
MockApiClient.addMockResponse({
466+
url: `/organizations/${organization.slug}/prompts-activity/`,
467+
body: {data: {}},
468+
});
469+
470+
MockApiClient.addMockResponse({
471+
url: `/organizations/${organization.slug}/user-rollback/`,
472+
statusCode: 404,
473+
});
474+
475+
renderSidebarWithFeatures(['sentry-rollback-2024']);
476+
477+
await screen.findByText('OS');
478+
479+
await waitFor(() => {
480+
expect(screen.queryByText(/Your 2024 Rollback/)).not.toBeInTheDocument();
481+
});
482+
});
483+
484+
it('will not render sidebar banner when collapsed', async () => {
485+
MockApiClient.addMockResponse({
486+
url: `/organizations/${organization.slug}/prompts-activity/`,
487+
body: {data: {}},
488+
});
489+
490+
MockApiClient.addMockResponse({
491+
url: `/organizations/${organization.slug}/user-rollback/`,
492+
body: {data: {}},
493+
});
494+
495+
renderSidebarWithFeatures(['sentry-rollback-2024']);
496+
497+
await userEvent.click(screen.getByTestId('sidebar-collapse'));
498+
499+
await waitFor(() => {
500+
expect(screen.queryByText(/Your 2024 Rollback/)).not.toBeInTheDocument();
501+
});
502+
});
503+
504+
it('should show dot on org dropdown after dismissing sidebar banner', async () => {
505+
MockApiClient.addMockResponse({
506+
url: `/organizations/${organization.slug}/prompts-activity/`,
507+
body: {data: {}},
508+
});
509+
510+
MockApiClient.addMockResponse({
511+
url: `/organizations/${organization.slug}/user-rollback/`,
512+
body: {data: {}},
513+
});
514+
515+
const dismissMock = MockApiClient.addMockResponse({
516+
url: `/organizations/${organization.slug}/prompts-activity/`,
517+
method: 'PUT',
518+
body: {},
519+
});
520+
521+
renderSidebarWithFeatures(['sentry-rollback-2024']);
522+
523+
await userEvent.click(await screen.findByRole('button', {name: /Dismiss/}));
524+
525+
expect(await screen.findByTestId('rollback-notification-dot')).toBeInTheDocument();
526+
expect(screen.queryByText(/Your 2024 Rollback/)).not.toBeInTheDocument();
527+
expect(dismissMock).toHaveBeenCalled();
528+
529+
// Opening the org dropdown will remove the dot
530+
await userEvent.click(screen.getByTestId('sidebar-dropdown'));
531+
await waitFor(() => {
532+
expect(screen.queryByTestId('rollback-notification-dot')).not.toBeInTheDocument();
533+
});
534+
535+
expect(dismissMock).toHaveBeenCalledTimes(2);
536+
});
537+
});
441538
});

static/app/components/sidebar/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ExpandedContextProvider,
2020
} from 'sentry/components/sidebar/expandedContextProvider';
2121
import {NewOnboardingStatus} from 'sentry/components/sidebar/newOnboardingStatus';
22+
import {DismissableRollbackBanner} from 'sentry/components/sidebar/rollback/dismissableBanner';
2223
import {isDone} from 'sentry/components/sidebar/utils';
2324
import {
2425
IconDashboard,
@@ -721,6 +722,13 @@ function Sidebar() {
721722
)}
722723
</DropdownSidebarSection>
723724

725+
{organization ? (
726+
<DismissableRollbackBanner
727+
organization={organization}
728+
collapsed={collapsed}
729+
/>
730+
) : null}
731+
724732
<PrimaryItems>
725733
{hasOrganization && (
726734
<Fragment>
Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,25 @@
11
import styled from '@emotion/styled';
22

3-
import {LinkButton} from 'sentry/components/button';
3+
import {Button, LinkButton} from 'sentry/components/button';
44
import Panel from 'sentry/components/panels/panel';
5-
import {IconOpen} from 'sentry/icons';
5+
import {IconClose, IconOpen} from 'sentry/icons';
66
import {t} from 'sentry/locale';
77
import {space} from 'sentry/styles/space';
8-
import {useApiQuery} from 'sentry/utils/queryClient';
9-
import useOrganization from 'sentry/utils/useOrganization';
8+
import type {Organization} from 'sentry/types/organization';
109

11-
type RollbackBannerProps = {};
12-
13-
function useRollback() {
14-
const organization = useOrganization();
15-
16-
return useApiQuery([`/organizations/${organization.slug}/user-rollback/`], {
17-
staleTime: Infinity,
18-
retry: false,
19-
enabled: organization.features.includes('sentry-rollback-2024'),
20-
retryOnMount: false,
21-
});
22-
}
23-
24-
export function RollbackBanner({}: RollbackBannerProps) {
25-
const organization = useOrganization();
26-
const {data} = useRollback();
27-
28-
if (!data) {
29-
return null;
30-
}
10+
type RollbackBannerProps = {
11+
organization: Organization;
12+
className?: string;
13+
handleDismiss?: () => void;
14+
};
3115

16+
export function RollbackBanner({
17+
className,
18+
handleDismiss,
19+
organization,
20+
}: RollbackBannerProps) {
3221
return (
33-
<StyledPanel>
22+
<StyledPanel className={className}>
3423
<Title>🥳 {t('Your 2024 Rollback')}</Title>
3524
<Description>
3625
{t("See what you did (and didn't do) with %s this year.", organization.name)}
@@ -40,30 +29,47 @@ export function RollbackBanner({}: RollbackBannerProps) {
4029
href={`https://rollback.sentry.io/${organization.slug}/`}
4130
icon={<IconOpen />}
4231
priority="primary"
43-
size="sm"
32+
size="xs"
33+
analyticsEventKey="rollback.sidebar_view_clicked"
34+
analyticsEventName="Rollback: Sidebar View Clicked"
4435
>
4536
{t('View My Rollback')}
4637
</RollbackButton>
38+
{handleDismiss ? (
39+
<DismissButton
40+
icon={<IconClose />}
41+
aria-label={t('Dismiss')}
42+
onClick={handleDismiss}
43+
size="xs"
44+
borderless
45+
analyticsEventKey="rollback.sidebar_dismiss_clicked"
46+
analyticsEventName="Rollback: Sidebar Dismiss Clicked"
47+
/>
48+
) : null}
4749
</StyledPanel>
4850
);
4951
}
5052

5153
const StyledPanel = styled(Panel)`
54+
position: relative;
5255
background: linear-gradient(
5356
269.35deg,
5457
${p => p.theme.backgroundTertiary} 0.32%,
5558
rgba(245, 243, 247, 0) 99.69%
5659
);
5760
padding: ${space(1)};
5861
margin: ${space(1)};
62+
color: ${p => p.theme.textColor};
5963
`;
6064

6165
const Title = styled('p')`
66+
font-size: ${p => p.theme.fontSizeSmall};
6267
font-weight: ${p => p.theme.fontWeightBold};
6368
margin: 0;
6469
`;
6570

6671
const Description = styled('p')`
72+
font-size: ${p => p.theme.fontSizeSmall};
6773
margin: ${space(0.5)} 0;
6874
`;
6975

@@ -77,3 +83,15 @@ const RollbackButton = styled(LinkButton)`
7783
border-color: #ff45a8;
7884
}
7985
`;
86+
87+
const DismissButton = styled(Button)`
88+
position: absolute;
89+
top: 0;
90+
right: 0;
91+
92+
color: currentColor;
93+
94+
&:hover {
95+
color: currentColor;
96+
}
97+
`;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import styled from '@emotion/styled';
2+
3+
import {RollbackBanner} from 'sentry/components/sidebar/rollback/banner';
4+
import {useRollbackPrompts} from 'sentry/components/sidebar/rollback/useRollbackPrompts';
5+
import ConfigStore from 'sentry/stores/configStore';
6+
import {useLegacyStore} from 'sentry/stores/useLegacyStore';
7+
import {space} from 'sentry/styles/space';
8+
import type {Organization} from 'sentry/types/organization';
9+
10+
type DismissableRollbackBannerProps = {collapsed: boolean; organization: Organization};
11+
12+
export function DismissableRollbackBanner({
13+
collapsed,
14+
organization,
15+
}: DismissableRollbackBannerProps) {
16+
const config = useLegacyStore(ConfigStore);
17+
18+
const isDarkMode = config.theme === 'dark';
19+
20+
const {shouldShowSidebarBanner, onDismissSidebarBanner} = useRollbackPrompts({
21+
collapsed,
22+
organization,
23+
});
24+
25+
if (!shouldShowSidebarBanner || !organization) {
26+
return null;
27+
}
28+
29+
return (
30+
<Wrapper>
31+
<TranslucentBackgroundBanner
32+
organization={organization}
33+
isDarkMode={isDarkMode}
34+
handleDismiss={onDismissSidebarBanner}
35+
/>
36+
</Wrapper>
37+
);
38+
}
39+
40+
const Wrapper = styled('div')`
41+
padding: 0 ${space(1)};
42+
`;
43+
44+
const TranslucentBackgroundBanner = styled(RollbackBanner)<{isDarkMode: boolean}>`
45+
position: relative;
46+
background: rgba(245, 243, 247, ${p => (p.isDarkMode ? 0.05 : 0.1)});
47+
border: 1px solid rgba(245, 243, 247, ${p => (p.isDarkMode ? 0.1 : 0.15)});
48+
color: ${p => (p.isDarkMode ? p.theme.textColor : '#ebe6ef')};
49+
margin: ${space(0.5)} ${space(1)};
50+
`;

0 commit comments

Comments
 (0)