From 95cd1977a3be823e0ce39bf5e8fd587659d1cbab Mon Sep 17 00:00:00 2001 From: Brendan Hy Date: Thu, 22 May 2025 16:05:45 -0700 Subject: [PATCH 1/3] feat(seer): change plan in admin --- .../components/changePlanAction.spec.tsx | 46 ++++++++++++++++++- .../gsAdmin/components/changePlanAction.tsx | 14 ++++-- static/gsAdmin/components/planList.tsx | 33 +++++++++++++ static/gsAdmin/views/customerDetails.tsx | 2 +- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/static/gsAdmin/components/changePlanAction.spec.tsx b/static/gsAdmin/components/changePlanAction.spec.tsx index 853533bf617312..ce3707931275ff 100644 --- a/static/gsAdmin/components/changePlanAction.spec.tsx +++ b/static/gsAdmin/components/changePlanAction.spec.tsx @@ -89,7 +89,7 @@ describe('ChangePlanAction', () => { async function openAndLoadModal(props = {}) { triggerChangePlanAction({ subscription, - orgId: mockOrg.slug, + organization: mockOrg, onSuccess: jest.fn(), partnerPlanId: null, ...props, @@ -184,6 +184,7 @@ describe('ChangePlanAction', () => { }); it('completes form submission flow', async () => { + mockOrg.features = []; // Mock the PUT endpoint response const putMock = MockApiClient.addMockResponse({ url: `/customers/${mockOrg.slug}/subscription/`, @@ -212,6 +213,48 @@ describe('ChangePlanAction', () => { '1' ); + expect(screen.queryByText('Available Products')).not.toBeInTheDocument(); + + expect(screen.getByRole('button', {name: 'Change Plan'})).toBeEnabled(); + await userEvent.click(screen.getByRole('button', {name: 'Change Plan'})); + + // Verify the PUT API was called + expect(putMock).toHaveBeenCalled(); + const requestData = putMock.mock.calls[0][1].data; + expect(requestData).toHaveProperty('plan', 'am3_business'); + }); + + it('completes form with seer', async () => { + mockOrg.features = ['seer-billing']; + // Mock the PUT endpoint response + const putMock = MockApiClient.addMockResponse({ + url: `/customers/${mockOrg.slug}/subscription/`, + method: 'PUT', + body: {success: true}, + }); + + openAndLoadModal(); + + // Wait for component to load + await waitFor(() => { + expect(screen.getByRole('tab', {name: 'AM3'})).toBeInTheDocument(); + }); + + await userEvent.click(screen.getAllByRole('radio')[0] as HTMLElement); + + await selectEvent.select(screen.getByRole('textbox', {name: 'Errors'}), '100,000'); + await selectEvent.select(screen.getByRole('textbox', {name: 'Replays'}), '50'); + await selectEvent.select(screen.getByRole('textbox', {name: 'Spans'}), '10,000,000'); + await selectEvent.select(screen.getByRole('textbox', {name: 'Cron monitors'}), '1'); + await selectEvent.select(screen.getByRole('textbox', {name: 'Uptime monitors'}), '1'); + await selectEvent.select( + screen.getByRole('textbox', {name: 'Attachments (GB)'}), + '1' + ); + + expect(screen.getByText('Available Products')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Seer')); + expect(screen.getByRole('button', {name: 'Change Plan'})).toBeEnabled(); await userEvent.click(screen.getByRole('button', {name: 'Change Plan'})); @@ -219,6 +262,7 @@ describe('ChangePlanAction', () => { expect(putMock).toHaveBeenCalled(); const requestData = putMock.mock.calls[0][1].data; expect(requestData).toHaveProperty('plan', 'am3_business'); + expect(requestData).toHaveProperty('seer', true); }); it('updates plan list when switching between tiers', async () => { diff --git a/static/gsAdmin/components/changePlanAction.tsx b/static/gsAdmin/components/changePlanAction.tsx index 5b372c790c1797..2d612185a800dc 100644 --- a/static/gsAdmin/components/changePlanAction.tsx +++ b/static/gsAdmin/components/changePlanAction.tsx @@ -16,6 +16,7 @@ import {TabList, Tabs} from 'sentry/components/tabs'; import ConfigStore from 'sentry/stores/configStore'; import {space} from 'sentry/styles/space'; import type {DataCategory} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; import {useApiQuery} from 'sentry/utils/queryClient'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import useApi from 'sentry/utils/useApi'; @@ -29,14 +30,14 @@ const ALLOWED_TIERS = [PlanTier.MM2, PlanTier.AM1, PlanTier.AM2, PlanTier.AM3]; type Props = { onSuccess: () => void; - orgId: string; + organization: Organization; partnerPlanId: string | null; subscription: Subscription; } & ModalRenderProps; function ChangePlanAction({ subscription, - orgId, + organization, partnerPlanId, onSuccess, closeModal, @@ -46,6 +47,7 @@ function ChangePlanAction({ const [activeTier, setActiveTier] = useState(PlanTier.AM3); const [activePlan, setActivePlan] = useState(null); const [formModel] = useState(() => new FormModel()); + const orgId = organization.slug; const api = useApi({persistInFlight: true}); const { @@ -156,7 +158,10 @@ function ChangePlanAction({ } Object.entries(subscription.categories).forEach(([category, metricHistory]) => { - if (metricHistory.reserved) { + if ( + metricHistory.reserved && + plan.checkoutCategories.includes(category as DataCategory) + ) { const closestTier = findClosestTier( plan, category as DataCategory, @@ -319,6 +324,7 @@ function ChangePlanAction({ formModel={formModel} activePlan={activePlan} subscription={subscription} + organization={organization} onSubmit={handleSubmit} onCancel={closeModal} onSubmitSuccess={(data: Data) => { @@ -340,7 +346,7 @@ function ChangePlanAction({ type Options = { onSuccess: () => void; - orgId: string; + organization: Organization; partnerPlanId: string | null; subscription: Subscription; }; diff --git a/static/gsAdmin/components/planList.tsx b/static/gsAdmin/components/planList.tsx index 94746bbc2b0da6..e18f8d0ab02f36 100644 --- a/static/gsAdmin/components/planList.tsx +++ b/static/gsAdmin/components/planList.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; +import CheckboxField from 'sentry/components/forms/fields/checkboxField'; import InputField from 'sentry/components/forms/fields/inputField'; import RadioField from 'sentry/components/forms/fields/radioField'; import SelectField from 'sentry/components/forms/fields/selectField'; @@ -9,6 +10,7 @@ import type FormModel from 'sentry/components/forms/model'; import type {Data, OnSubmitCallback} from 'sentry/components/forms/types'; import {space} from 'sentry/styles/space'; import {DataCategory} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import {ANNUAL} from 'getsentry/constants'; @@ -24,6 +26,7 @@ type Props = { onSubmit: OnSubmitCallback; onSubmitError: (error: any) => void; onSubmitSuccess: (data: Data) => void; + organization: Organization; subscription: Subscription; tierPlans: BillingConfig['planList']; }; @@ -31,6 +34,7 @@ type Props = { function PlanList({ activePlan, subscription, + organization, onSubmit, onCancel, onSubmitSuccess, @@ -74,6 +78,17 @@ function PlanList({ 100000: '100K', }; + const availableProducts = Object.values(activePlan?.availableReservedBudgetTypes || {}) + .filter( + productInfo => + productInfo.isFixed && // NOTE: for now, we only supported fixed budget products in checkout + productInfo.billingFlag && + organization.features.includes(productInfo.billingFlag) + ) + .map(productInfo => { + return productInfo; + }); + return (
)} + {availableProducts.length > 0 && ( + +

Available Products

+ {availableProducts.map(productInfo => { + return ( + { + formModel.setValue(productInfo.productName, value.target.checked); + }} + /> + ); + })} +
+ )} triggerChangePlanAction({ - orgId, + organization, subscription, partnerPlanId: subscription.partner?.isActive ? subscription.planDetails.id From ec4a39b4dc379f33fbfbdf4157e68015eb8be22c Mon Sep 17 00:00:00 2001 From: Brendan Hy Date: Thu, 22 May 2025 16:08:28 -0700 Subject: [PATCH 2/3] test --- static/gsAdmin/components/changePlanAction.spec.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/static/gsAdmin/components/changePlanAction.spec.tsx b/static/gsAdmin/components/changePlanAction.spec.tsx index ce3707931275ff..9cc3b444f2af63 100644 --- a/static/gsAdmin/components/changePlanAction.spec.tsx +++ b/static/gsAdmin/components/changePlanAction.spec.tsx @@ -226,7 +226,6 @@ describe('ChangePlanAction', () => { it('completes form with seer', async () => { mockOrg.features = ['seer-billing']; - // Mock the PUT endpoint response const putMock = MockApiClient.addMockResponse({ url: `/customers/${mockOrg.slug}/subscription/`, method: 'PUT', @@ -235,7 +234,6 @@ describe('ChangePlanAction', () => { openAndLoadModal(); - // Wait for component to load await waitFor(() => { expect(screen.getByRole('tab', {name: 'AM3'})).toBeInTheDocument(); }); @@ -258,7 +256,6 @@ describe('ChangePlanAction', () => { expect(screen.getByRole('button', {name: 'Change Plan'})).toBeEnabled(); await userEvent.click(screen.getByRole('button', {name: 'Change Plan'})); - // Verify the PUT API was called expect(putMock).toHaveBeenCalled(); const requestData = putMock.mock.calls[0][1].data; expect(requestData).toHaveProperty('plan', 'am3_business'); From c92b9b581340e4ee5cf971c2763e4b78b742d929 Mon Sep 17 00:00:00 2001 From: Brendan Hy Date: Tue, 27 May 2025 14:04:39 -0700 Subject: [PATCH 3/3] add tests --- .../components/changePlanAction.spec.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/static/gsAdmin/components/changePlanAction.spec.tsx b/static/gsAdmin/components/changePlanAction.spec.tsx index 9cc3b444f2af63..92bc6503b49fd4 100644 --- a/static/gsAdmin/components/changePlanAction.spec.tsx +++ b/static/gsAdmin/components/changePlanAction.spec.tsx @@ -355,4 +355,69 @@ describe('ChangePlanAction', () => { expect(requestData).toHaveProperty('reservedErrors', 50000); expect(requestData).toHaveProperty('reservedTransactions', 25000); }); + + describe('Seer Budget', () => { + beforeEach(() => { + mockOrg.features = ['seer-billing']; + jest.clearAllMocks(); + MockApiClient.clearMockResponses(); + + const user = UserFixture(); + user.permissions = new Set(['billing.provision']); + ConfigStore.set('user', user); + SubscriptionStore.set(mockOrg.slug, subscription); + + // Set up default subscription response + MockApiClient.addMockResponse({ + url: `/subscriptions/${mockOrg.slug}/`, + body: subscription, + }); + + MockApiClient.addMockResponse({ + url: `/customers/${mockOrg.slug}/billing-config/?tier=all`, + body: BILLING_CONFIG, + }); + }); + + it('shows Seer budget checkbox for AM tiers', async () => { + openAndLoadModal(); + + await waitFor(() => { + expect(screen.getByRole('tab', {name: 'AM3'})).toBeInTheDocument(); + }); + await userEvent.click(screen.getAllByRole('radio')[0] as HTMLElement); + + expect(screen.getByText('Seer')).toBeInTheDocument(); + + const am2Tab = screen.getByRole('tab', {name: 'AM2'}); + await userEvent.click(am2Tab); + await userEvent.click(screen.getAllByRole('radio')[0] as HTMLElement); + + expect(screen.getByText('Seer')).toBeInTheDocument(); + + const am1Tab = screen.getByRole('tab', {name: 'AM1'}); + await userEvent.click(am1Tab); + await userEvent.click(screen.getAllByRole('radio')[0] as HTMLElement); + + expect(screen.getByText('Seer')).toBeInTheDocument(); + }); + it('hides Seer budget checkbox for MM2 tier', async () => { + openAndLoadModal(); + + await waitFor(() => { + expect(screen.getByRole('tab', {name: 'AM3'})).toBeInTheDocument(); + }); + + const mm2Tab = screen.getByRole('tab', {name: 'MM2'}); + await userEvent.click(mm2Tab); + + await userEvent.click(screen.getAllByRole('radio')[0] as HTMLElement); + + expect( + screen.queryByRole('checkbox', { + name: 'Seer Budget', + }) + ).not.toBeInTheDocument(); + }); + }); });