Skip to content

Commit 05c6834

Browse files
feat(quota alert): Auto popup when not explicitly dismissed (#89122)
Nav quota alert should automatically open when: - Alert has not been explicitly dismissed (via checkbox) AND at least one of the following is true for the user in the current organization: - Categories since alert was last shown have changed - It has been more than one day since alert was last shown - The alert was last shown before the current usage cycle started This PR also introduces the `useSubscription` hook. Previously, when using `withSubscription`, implementing the `useEffect` hook seen in this PR would result in max update depth errors, a weird interaction between the `withSubscription` component and the `usePrimaryOverlay` hook. Finally, this PR fixes the snooze/dismissal check to use a custom function (the existing `promptIsDismissed` function doesn't work for this case because instead of having a defined number of days to snooze for, we have a defined period for which it should be snoozed). https://github.com/user-attachments/assets/e421040a-2e05-447b-9f8a-6eeefc04dbd3
1 parent 1c73b91 commit 05c6834

File tree

4 files changed

+300
-58
lines changed

4 files changed

+300
-58
lines changed

static/app/actionCreators/prompts.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,12 @@ export function usePrompts({
144144
projectId,
145145
daysToSnooze,
146146
options,
147+
isDismissed = promptIsDismissed,
147148
}: {
148149
features: string[];
149150
organization: Organization | null;
150151
daysToSnooze?: number;
152+
isDismissed?: (prompt: PromptData, daysToSnooze?: number) => boolean;
151153
options?: Partial<UseApiQueryOptions<PromptResponse>>;
152154
projectId?: string;
153155
}) {
@@ -159,7 +161,7 @@ export function usePrompts({
159161
return features.reduce(
160162
(acc, feature) => {
161163
const prompt = prompts.data.features?.[feature];
162-
acc[feature] = promptIsDismissed(
164+
acc[feature] = isDismissed(
163165
{dismissedTime: prompt?.dismissed_ts, snoozedTime: prompt?.snoozed_ts},
164166
daysToSnooze
165167
);
@@ -169,7 +171,7 @@ export function usePrompts({
169171
);
170172
}
171173
return {};
172-
}, [prompts.isSuccess, prompts.data?.features, features, daysToSnooze]);
174+
}, [prompts.isSuccess, prompts.data?.features, features, daysToSnooze, isDismissed]);
173175

174176
const dismissPrompt = useCallback(
175177
(feature: string) => {

static/gsApp/components/navBillingStatus.spec.tsx

Lines changed: 199 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,37 @@ import {UserFixture} from 'sentry-fixture/user';
55

66
import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
77
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
8+
import {resetMockDate, setMockDate} from 'sentry-test/utils';
89

910
import ConfigStore from 'sentry/stores/configStore';
1011

1112
import PrimaryNavigationQuotaExceeded from 'getsentry/components/navBillingStatus';
1213
import SubscriptionStore from 'getsentry/stores/subscriptionStore';
14+
import {OnDemandBudgetMode} from 'getsentry/types';
15+
16+
// Jun 06 2022 - with milliseconds
17+
const MOCK_TODAY = 1654492173000;
18+
19+
// prompts activity timestamps do not include milliseconds
20+
const MOCK_PERIOD_START = 1652140800; // May 10 2022
21+
const MOCK_BEFORE_PERIOD_START = 1652054400; // May 09 2022
1322

1423
describe('PrimaryNavigationQuotaExceeded', function () {
1524
const organization = OrganizationFixture();
1625
const subscription = SubscriptionFixture({
1726
organization,
1827
plan: 'am3_business',
28+
onDemandPeriodStart: '2022-05-10',
29+
onDemandPeriodEnd: '2022-06-09',
1930
});
2031
let promptMock: jest.Mock;
2132
let requestUpgradeMock: jest.Mock;
2233
let customerPutMock: jest.Mock;
2334

2435
beforeEach(() => {
36+
setMockDate(MOCK_TODAY);
37+
localStorage.clear();
2538
organization.access = [];
26-
MockApiClient.clearMockResponses();
2739
subscription.categories.errors!.usageExceeded = true;
2840
subscription.categories.replays!.usageExceeded = true;
2941
subscription.categories.spans!.usageExceeded = true;
@@ -39,6 +51,7 @@ describe('PrimaryNavigationQuotaExceeded', function () {
3951
},
4052
})
4153
);
54+
MockApiClient.clearMockResponses();
4255

4356
MockApiClient.addMockResponse({
4457
method: 'GET',
@@ -55,25 +68,14 @@ describe('PrimaryNavigationQuotaExceeded', function () {
5568
url: `/organizations/${organization.slug}/projects/`,
5669
body: [ProjectFixture()],
5770
});
58-
MockApiClient.addMockResponse({
59-
method: 'GET',
60-
url: `/subscriptions/${organization.slug}/`,
61-
body: subscription,
62-
});
6371
MockApiClient.addMockResponse({
6472
method: 'GET',
6573
url: `/organizations/${organization.slug}/prompts-activity/`,
6674
body: {},
6775
});
68-
MockApiClient.addMockResponse({
69-
method: 'PUT',
70-
url: `/organizations/${organization.slug}/prompts-activity/`,
71-
});
72-
7376
promptMock = MockApiClient.addMockResponse({
7477
method: 'PUT',
7578
url: `/organizations/${organization.slug}/prompts-activity/`,
76-
body: {},
7779
});
7880
requestUpgradeMock = MockApiClient.addMockResponse({
7981
method: 'POST',
@@ -85,8 +87,31 @@ describe('PrimaryNavigationQuotaExceeded', function () {
8587
url: `/customers/${organization.slug}/`,
8688
body: SubscriptionFixture({organization}),
8789
});
90+
91+
// set localStorage to prevent auto-popup
92+
localStorage.setItem(
93+
`billing-status-last-shown-categories-${organization.id}`,
94+
'errors-replays-spans' // exceeded categories
95+
);
96+
localStorage.setItem(
97+
`billing-status-last-shown-date-${organization.id}`,
98+
'Mon Jun 06 2022' // MOCK_TODAY
99+
);
88100
});
89101

102+
afterEach(() => {
103+
resetMockDate();
104+
});
105+
106+
function assertLocalStorageStateAfterAutoOpen() {
107+
expect(
108+
localStorage.getItem(`billing-status-last-shown-categories-${organization.id}`)
109+
).toBe('errors-replays-spans');
110+
expect(
111+
localStorage.getItem(`billing-status-last-shown-date-${organization.id}`)
112+
).toBe('Mon Jun 06 2022');
113+
}
114+
90115
it('should render', async function () {
91116
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
92117

@@ -104,7 +129,11 @@ describe('PrimaryNavigationQuotaExceeded', function () {
104129
expect(screen.getByRole('checkbox')).not.toBeChecked();
105130
});
106131

107-
it('should render PAYG categories when there is PAYG', async function () {
132+
it('should render PAYG categories when there is shared PAYG', async function () {
133+
localStorage.setItem(
134+
`billing-status-last-shown-categories-${organization.id}`,
135+
'errors-replays-spans-monitorSeats-profileDuration' // exceeded categories
136+
);
108137
subscription.onDemandMaxSpend = 100;
109138
SubscriptionStore.set(organization.slug, subscription);
110139
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
@@ -123,6 +152,44 @@ describe('PrimaryNavigationQuotaExceeded', function () {
123152
subscription.onDemandMaxSpend = 0;
124153
});
125154

155+
it('should render PAYG categories with per category PAYG', async function () {
156+
localStorage.setItem(
157+
`billing-status-last-shown-categories-${organization.id}`,
158+
'errors-replays-spans-monitorSeats' // exceeded categories
159+
);
160+
subscription.onDemandBudgets = {
161+
budgetMode: OnDemandBudgetMode.PER_CATEGORY,
162+
budgets: {
163+
monitorSeats: 100,
164+
},
165+
usedSpends: {},
166+
enabled: true,
167+
attachmentsBudget: 0,
168+
errorsBudget: 0,
169+
replaysBudget: 0,
170+
transactionsBudget: 0,
171+
attachmentSpendUsed: 0,
172+
errorSpendUsed: 0,
173+
transactionSpendUsed: 0,
174+
};
175+
SubscriptionStore.set(organization.slug, subscription);
176+
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
177+
178+
// open the alert
179+
await userEvent.click(await screen.findByRole('button', {name: 'Billing Status'}));
180+
expect(await screen.findByText('Quota Exceeded')).toBeInTheDocument();
181+
expect(
182+
screen.getByText(
183+
/Youve run out of errors, replays, spans, and cron monitors for this billing cycle./
184+
)
185+
).toBeInTheDocument();
186+
expect(screen.getByRole('checkbox')).not.toBeChecked();
187+
188+
// reset
189+
subscription.onDemandMaxSpend = 0;
190+
subscription.onDemandBudgets = undefined;
191+
});
192+
126193
it('should not render for managed orgs', function () {
127194
subscription.canSelfServe = false;
128195
SubscriptionStore.set(organization.slug, subscription);
@@ -145,29 +212,7 @@ describe('PrimaryNavigationQuotaExceeded', function () {
145212

146213
// stop the alert from animating
147214
await userEvent.click(screen.getByRole('checkbox'));
148-
expect(promptMock).toHaveBeenCalled();
149-
150-
// overlay is still visible, need to click out to close
151-
expect(screen.getByText('Quota Exceeded')).toBeInTheDocument();
152-
await userEvent.click(document.body);
153-
expect(screen.queryByText('Quota Exceeded')).not.toBeInTheDocument();
154-
155-
// open again
156-
await userEvent.click(await screen.findByRole('button', {name: 'Billing Status'}));
157-
expect(await screen.findByText('Quota Exceeded')).toBeInTheDocument();
158-
expect(screen.getByRole('checkbox')).toBeChecked();
159-
160-
// uncheck the checkbox
161-
await userEvent.click(screen.getByRole('checkbox'));
162-
expect(promptMock).toHaveBeenCalled();
163-
164-
// close and open again
165-
expect(screen.getByText('Quota Exceeded')).toBeInTheDocument();
166-
await userEvent.click(document.body);
167-
expect(screen.queryByText('Quota Exceeded')).not.toBeInTheDocument();
168-
await userEvent.click(await screen.findByRole('button', {name: 'Billing Status'}));
169-
expect(await screen.findByText('Quota Exceeded')).toBeInTheDocument();
170-
expect(screen.getByRole('checkbox')).not.toBeChecked();
215+
expect(promptMock).toHaveBeenCalledTimes(3); // one for each category
171216
});
172217

173218
it('should update prompts when non-billing user takes action', async function () {
@@ -180,9 +225,8 @@ describe('PrimaryNavigationQuotaExceeded', function () {
180225

181226
// click the button
182227
await userEvent.click(screen.getByText('Request Additional Quota'));
183-
expect(promptMock).toHaveBeenCalled();
228+
expect(promptMock).toHaveBeenCalledTimes(3);
184229
expect(requestUpgradeMock).toHaveBeenCalled();
185-
expect(screen.getByRole('checkbox')).toBeChecked();
186230
});
187231

188232
it('should update prompts when billing user on free plan takes action', async function () {
@@ -193,6 +237,16 @@ describe('PrimaryNavigationQuotaExceeded', function () {
193237
});
194238
freeSub.categories.replays!.usageExceeded = true;
195239
SubscriptionStore.set(organization.slug, freeSub);
240+
localStorage.setItem(
241+
`billing-status-last-shown-categories-${organization.id}`,
242+
'replays'
243+
);
244+
MockApiClient.addMockResponse({
245+
method: 'GET',
246+
url: `/subscriptions/${organization.slug}/`,
247+
body: freeSub,
248+
});
249+
196250
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
197251

198252
// open the alert
@@ -202,7 +256,113 @@ describe('PrimaryNavigationQuotaExceeded', function () {
202256

203257
// click the button
204258
await userEvent.click(screen.getByText('Start Trial'));
205-
expect(promptMock).toHaveBeenCalled();
259+
expect(promptMock).toHaveBeenCalledTimes(1);
206260
expect(customerPutMock).toHaveBeenCalled();
207261
});
262+
263+
it('should auto open based on localStorage', async function () {
264+
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
265+
expect(
266+
await screen.findByRole('button', {name: 'Billing Status'})
267+
).toBeInTheDocument();
268+
expect(screen.queryByText('Quota Exceeded')).not.toBeInTheDocument();
269+
270+
localStorage.clear();
271+
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
272+
expect(
273+
await screen.findByRole('button', {name: 'Billing Status'})
274+
).toBeInTheDocument();
275+
expect(await screen.findByText('Quota Exceeded')).toBeInTheDocument();
276+
assertLocalStorageStateAfterAutoOpen();
277+
});
278+
279+
it('should not auto open if explicitly dismissed', async function () {
280+
MockApiClient.addMockResponse({
281+
method: 'GET',
282+
url: `/organizations/${organization.slug}/prompts-activity/`,
283+
body: {
284+
features: {
285+
errors_overage_alert: {
286+
snoozed_ts: MOCK_PERIOD_START,
287+
},
288+
replays_overage_alert: {
289+
snoozed_ts: MOCK_PERIOD_START,
290+
},
291+
spans_overage_alert: {
292+
snoozed_ts: MOCK_PERIOD_START,
293+
},
294+
},
295+
}, // dismissed at beginning of billing cycle
296+
});
297+
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
298+
expect(
299+
await screen.findByRole('button', {name: 'Billing Status'})
300+
).toBeInTheDocument();
301+
expect(screen.queryByText('Quota Exceeded')).not.toBeInTheDocument();
302+
303+
// even when localStorage is cleared, the alert should not show
304+
localStorage.clear();
305+
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
306+
expect(
307+
await screen.findByRole('button', {name: 'Billing Status'})
308+
).toBeInTheDocument();
309+
expect(screen.queryByText('Quota Exceeded')).not.toBeInTheDocument();
310+
expect(localStorage).toHaveLength(0);
311+
});
312+
313+
it('should auto open if explicitly dismissed before current billing cycle', async function () {
314+
MockApiClient.addMockResponse({
315+
method: 'GET',
316+
url: `/organizations/${organization.slug}/prompts-activity/`,
317+
body: {
318+
features: {
319+
errors_overage_alert: {
320+
snoozed_ts: MOCK_BEFORE_PERIOD_START,
321+
},
322+
replays_overage_alert: {
323+
snoozed_ts: MOCK_BEFORE_PERIOD_START,
324+
},
325+
spans_overage_alert: {
326+
snoozed_ts: MOCK_BEFORE_PERIOD_START,
327+
},
328+
},
329+
}, // dismissed on last day before current billing cycle
330+
});
331+
localStorage.clear();
332+
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
333+
expect(await screen.findByText('Quota Exceeded')).toBeInTheDocument();
334+
});
335+
336+
it('should auto open the alert when categories have changed', async function () {
337+
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
338+
expect(screen.queryByText('Quota Exceeded')).not.toBeInTheDocument();
339+
340+
localStorage.setItem(
341+
`billing-status-last-shown-categories-${organization.id}`,
342+
'errors-replays'
343+
); // spans not included, so alert should show even though last opened "today"
344+
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
345+
expect(await screen.findByText('Quota Exceeded')).toBeInTheDocument();
346+
assertLocalStorageStateAfterAutoOpen();
347+
});
348+
349+
it('should auto open the alert when more than a day has passed', async function () {
350+
localStorage.setItem(
351+
`billing-status-last-shown-date-${organization.id}`,
352+
'Sun Jun 05 2022'
353+
); // more than a day, so alert should show even though categories haven't changed
354+
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
355+
expect(await screen.findByText('Quota Exceeded')).toBeInTheDocument();
356+
assertLocalStorageStateAfterAutoOpen();
357+
});
358+
359+
it('should auto open the alert when the last shown date is before the current usage cycle started', async function () {
360+
localStorage.setItem(
361+
`billing-status-last-shown-date-${organization.id}`,
362+
'Sun May 29 2022'
363+
); // last seen before current usage cycle started, so alert should show even though categories haven't changed
364+
render(<PrimaryNavigationQuotaExceeded organization={organization} />);
365+
expect(await screen.findByText('Quota Exceeded')).toBeInTheDocument();
366+
assertLocalStorageStateAfterAutoOpen();
367+
});
208368
});

0 commit comments

Comments
 (0)