diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 332ae53c3ed757..443df350734d85 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -609,6 +609,23 @@ export function supportsPayg(subscription: Subscription) { return subscription.planDetails.allowOnDemand && subscription.supportsOnDemand; } +/** + * Whether the category can use PAYG on the subscription given existing budgets. + * Does not check if there is PAYG left. + */ +export function hasPaygBudgetForCategory( + subscription: Subscription, + category: DataCategory +) { + if (!subscription.onDemandBudgets) { + return false; + } + if (subscription.onDemandBudgets.budgetMode === OnDemandBudgetMode.PER_CATEGORY) { + return (subscription.onDemandBudgets.budgets?.[category] ?? 0) > 0; + } + return subscription.onDemandBudgets.sharedMaxBudget > 0; +} + /** * Returns true if the current user has billing perms. */ diff --git a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx b/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx deleted file mode 100644 index 172342be26dbad..00000000000000 --- a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import {Container, Flex} from 'sentry/components/core/layout'; -import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components'; -import useOrganization from 'sentry/utils/useOrganization'; - -import {useProductBillingMetadata} from 'getsentry/hooks/useProductBillingMetadata'; -import { - type BillingMetricHistory, - type CustomerUsage, - type Subscription, -} from 'getsentry/types'; -import UsageCharts from 'getsentry/views/subscriptionPage/usageOverview/components/charts'; - -interface CategoryUsageDrawerProps { - categoryInfo: BillingMetricHistory; - subscription: Subscription; - usageData: CustomerUsage; -} - -function CategoryUsageDrawer({ - categoryInfo, - subscription, - usageData, -}: CategoryUsageDrawerProps) { - const organization = useOrganization(); - const {category} = categoryInfo; - - // XXX(isabella): using this to make knip happy til the hook is used in other places - const {displayName} = useProductBillingMetadata(subscription, category); - - return ( - - - {displayName} - - - - - - ); -} - -export default CategoryUsageDrawer; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx index 638d9ba7032905..82e11d155dfb3f 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx @@ -9,7 +9,7 @@ import type {DataCategory} from 'sentry/types/core'; import {defined} from 'sentry/utils'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; -import {UNLIMITED, UNLIMITED_RESERVED} from 'getsentry/constants'; +import {UNLIMITED_RESERVED} from 'getsentry/constants'; import type { BillingMetricHistory, Plan, @@ -21,6 +21,7 @@ import { displayBudgetName, formatReservedWithUnits, getSoftCapType, + hasPaygBudgetForCategory, isTrialPlan, supportsPayg, } from 'getsentry/utils/billing'; @@ -96,7 +97,9 @@ function UsageBreakdownInfo({ !!formattedAdditionalReserved || !!formattedGifted; const shouldShowReservedSpend = - defined(recurringReservedSpend) && subscription.canSelfServe; + defined(recurringReservedSpend) && + recurringReservedSpend > 0 && + subscription.canSelfServe; const shouldShowAdditionalSpend = shouldShowReservedSpend || canUsePayg || defined(formattedSoftCapType); @@ -104,13 +107,15 @@ function UsageBreakdownInfo({ return null; } + const interval = plan.contractInterval === 'monthly' ? t('month') : t('year'); + return ( {shouldShowIncludedVolume && ( {t('Included volume')} {activeProductTrial && ( - + )} {formattedPlatformReserved && ( bucket.price === 0 && bucket.events >= 0 @@ -198,23 +205,26 @@ function DataCategoryUsageBreakdownInfo({ ); const isAddOnChildCategory = addOnDataCategories.includes(category) && !isUnlimited; - const shouldShowAdditionalReserved = - !isAddOnChildCategory && !isUnlimited && subscription.canSelfServe; - const formattedPlatformReserved = isAddOnChildCategory - ? null - : formatReservedWithUnits( - shouldShowAdditionalReserved ? platformReserved : reserved, - category - ); const additionalReserved = Math.max(0, reserved - platformReserved); + const shouldShowAdditionalReserved = + !isAddOnChildCategory && + !isUnlimited && + subscription.canSelfServe && + additionalReserved > 0; const formattedAdditionalReserved = shouldShowAdditionalReserved ? formatReservedWithUnits(additionalReserved, category) : null; + const formattedPlatformReserved = + isAddOnChildCategory || !reserved + ? null + : formatReservedWithUnits( + shouldShowAdditionalReserved ? platformReserved : reserved, + category + ); const gifted = metricHistory.free ?? 0; - const formattedGifted = isAddOnChildCategory - ? null - : formatReservedWithUnits(gifted, category); + const formattedGifted = + isAddOnChildCategory || !gifted ? null : formatReservedWithUnits(gifted, category); const paygSpend = metricHistory.onDemandSpendUsed ?? 0; const paygCategoryBudget = metricHistory.onDemandBudget ?? 0; @@ -248,8 +258,10 @@ function ReservedBudgetUsageBreakdownInfo({ activeProductTrial, }: ReservedBudgetUsageBreakdownInfoProps) { const {planDetails: plan, categories: metricHistories} = subscription; - const productCanUsePayg = reservedBudget.dataCategories.every(category => - plan.onDemandCategories.includes(category) + const productCanUsePayg = reservedBudget.dataCategories.every( + category => + plan.onDemandCategories.includes(category) && + hasPaygBudgetForCategory(subscription, category) ); const onTrialOrSponsored = isTrialPlan(subscription.plan) || subscription.isSponsored; @@ -265,7 +277,10 @@ function ReservedBudgetUsageBreakdownInfo({ }); const formattedAdditionalReserved = null; - const formattedGifted = displayPriceWithCents({cents: reservedBudget.freeBudget}); + const formattedGifted = + reservedBudget.freeBudget > 0 + ? displayPriceWithCents({cents: reservedBudget.freeBudget}) + : null; const paygSpend = reservedBudget.dataCategories.reduce((acc, category) => { return acc + (metricHistories[category]?.onDemandSpendUsed ?? 0); }, 0); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/charts.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/charts.tsx index 6be7d0143bc605..1cdcac59a3fef5 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/charts.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/charts.tsx @@ -142,5 +142,4 @@ function UsageCharts({ ); } - export default UsageCharts; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx index c1d16e7ad0d8c1..3478c4c002f50b 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx @@ -67,7 +67,7 @@ describe('ProductBreakdownPanel', () => { expect(screen.getByText('Pay-as-you-go')).toBeInTheDocument(); expect(screen.getByText('$10.00')).toBeInTheDocument(); expect(screen.getByText('Reserved spend')).toBeInTheDocument(); - expect(screen.getByText('$245.00')).toBeInTheDocument(); + expect(screen.getByText('$245.00 / month')).toBeInTheDocument(); }); it('renders for data category with per-category PAYG set', async () => { @@ -111,13 +111,19 @@ describe('ProductBreakdownPanel', () => { expect(screen.getByText('$10.00 /')).toBeInTheDocument(); expect(screen.getByText('$100.00')).toBeInTheDocument(); // shows per-category individual budget expect(screen.getByText('Reserved spend')).toBeInTheDocument(); - expect(screen.getByText('$245.00')).toBeInTheDocument(); + expect(screen.getByText('$245.00 / month')).toBeInTheDocument(); }); it('renders for reserved budget add-on', async () => { const legacySeerSubscription = SubscriptionWithLegacySeerFixture({ organization, plan: 'am3_business', + onDemandBudgets: { + enabled: true, + budgetMode: OnDemandBudgetMode.SHARED, + sharedMaxBudget: 100_00, + onDemandSpendUsed: 0, + }, }); legacySeerSubscription.reservedBudgets![0] = { ...legacySeerSubscription.reservedBudgets![0]!, @@ -153,13 +159,19 @@ describe('ProductBreakdownPanel', () => { expect(screen.getByText('Pay-as-you-go')).toBeInTheDocument(); expect(screen.getByText('$2.00')).toBeInTheDocument(); expect(screen.getByText('Reserved spend')).toBeInTheDocument(); - expect(screen.getByText('$20.00')).toBeInTheDocument(); + expect(screen.getByText('$20.00 / month')).toBeInTheDocument(); }); it('renders for reserved budget add-on data category', async () => { const legacySeerSubscription = SubscriptionWithLegacySeerFixture({ organization, plan: 'am3_business', + onDemandBudgets: { + enabled: true, + budgetMode: OnDemandBudgetMode.SHARED, + sharedMaxBudget: 100_00, + onDemandSpendUsed: 0, + }, }); legacySeerSubscription.reservedBudgets![0] = { ...legacySeerSubscription.reservedBudgets![0]!, @@ -300,4 +312,24 @@ describe('ProductBreakdownPanel', () => { await screen.findByRole('heading', {name: 'Errors'}); expect(screen.getByText('Pay-as-you-go limit reached')).toBeInTheDocument(); }); + + it('hides irrelevant breakdown fields', async () => { + render( + + ); + await screen.findByRole('heading', {name: 'Errors'}); + expect(screen.getByText('Included volume')).toBeInTheDocument(); + expect(screen.getByText('Business plan')).toBeInTheDocument(); + expect(screen.getByText('50,000')).toBeInTheDocument(); + expect(screen.queryByText('Additional reserved')).not.toBeInTheDocument(); + expect(screen.queryByText('Gifted')).not.toBeInTheDocument(); + expect(screen.queryByText('Additional spend')).not.toBeInTheDocument(); + expect(screen.queryByText('Pay-as-you-go')).not.toBeInTheDocument(); + expect(screen.queryByText('Reserved spend')).not.toBeInTheDocument(); + }); }); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx index 051709b4a679ea..9c8594947aef07 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx @@ -25,12 +25,14 @@ import { ProductTrialCta, UpgradeCta, } from 'getsentry/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta'; +import {USAGE_OVERVIEW_PANEL_HEADER_HEIGHT} from 'getsentry/views/subscriptionPage/usageOverview/constants'; import type {BreakdownPanelProps} from 'getsentry/views/subscriptionPage/usageOverview/types'; function PanelHeader({ selectedProduct, subscription, -}: Pick) { + isInline, +}: Pick) { const {onDemandBudgets: paygBudgets} = subscription; const { @@ -75,9 +77,19 @@ function PanelHeader({ ) : null; + if (isInline && !status) { + return null; + } + return ( - - {displayName} + + {!isInline && {displayName}} {status} ); @@ -141,7 +153,7 @@ function ProductBreakdownPanel({ ); } - const isEmpty = !potentialProductTrial && !isEnabled; + const shouldShowUpgradeCta = !potentialProductTrial && !isEnabled; return ( - + {potentialProductTrial && ( )} - {isEmpty && } + {shouldShowUpgradeCta && ( + + )} ); } diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx index a7c4255528c101..bec366a4f5a2c5 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx @@ -9,7 +9,7 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {DataCategory} from 'sentry/types/core'; -import {GIGABYTE, UNLIMITED, UNLIMITED_RESERVED} from 'getsentry/constants'; +import {GIGABYTE, UNLIMITED_RESERVED} from 'getsentry/constants'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import UsageOverviewTable from 'getsentry/views/subscriptionPage/usageOverview/components/table'; @@ -163,7 +163,7 @@ describe('UsageOverviewTable', () => { await screen.findByRole('columnheader', {name: 'Feature'}); // Errors usage and gifted units - expect(screen.getByRole('cell', {name: '6K / 51K'})).toBeInTheDocument(); + expect(screen.getByRole('cell', {name: '6K / 51K (1K gifted)'})).toBeInTheDocument(); expect(screen.getByRole('cell', {name: '$10.00'})).toBeInTheDocument(); // Attachments usage should be in the correct unit + above platform volume @@ -175,7 +175,7 @@ describe('UsageOverviewTable', () => { expect(screen.getByRole('cell', {name: '$32.00'})).toBeInTheDocument(); // Unlimited usage for Replays - expect(screen.getByRole('cell', {name: `500K / ${UNLIMITED}`})).toBeInTheDocument(); + expect(screen.getByRole('cell', {name: 'Unlimited'})).toBeInTheDocument(); }); it('renders table based on add-on state', async () => { @@ -226,7 +226,7 @@ describe('UsageOverviewTable', () => { // issue fixes is unlimited expect(screen.getByRole('cell', {name: 'Issue Fixes'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: `0 / ${UNLIMITED}`})).toBeInTheDocument(); + expect(screen.getByRole('cell', {name: 'Unlimited'})).toBeInTheDocument(); // issue scans is 0 so is not rendered expect(screen.queryByRole('cell', {name: 'Issue Scans'})).not.toBeInTheDocument(); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx index 7d488b88e4a8fa..a0cbc67dd7933c 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx @@ -144,9 +144,9 @@ const Table = styled('table')` width: 100%; margin: 0; - thead, - tbody, - tr { + & > thead, + & > tbody, + & > * > tr { display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx index f5f83578125eb0..e082eb85b3d8a1 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx @@ -1,8 +1,10 @@ import {Fragment, useState} from 'react'; -import {css, useTheme} from '@emotion/react'; +import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; -import {Flex} from 'sentry/components/core/layout'; +import {Tag} from '@sentry/scraps/badge'; + +import {Container, Flex} from 'sentry/components/core/layout'; import {Text} from 'sentry/components/core/text'; import ProgressRing from 'sentry/components/progressRing'; import {IconLock, IconWarning} from 'sentry/icons'; @@ -68,7 +70,7 @@ function UsageOverviewTableRow({ usageExceeded, activeProductTrial, potentialProductTrial, - } = useProductBillingMetadata(subscription, product, parentProduct, isChildProduct); + } = useProductBillingMetadata(subscription, product, parentProduct); if (!billedCategory) { return null; } @@ -86,6 +88,7 @@ function UsageOverviewTableRow({ let percentUsed = 0; let formattedUsage = ''; let formattedPrepaid = null; + let formattedFree = null; let paygSpend = 0; let isUnlimited = false; @@ -101,6 +104,7 @@ function UsageOverviewTableRow({ const reservedBudget = subscription.reservedBudgets?.find( budget => budget.apiName === reservedBudgetCategory ); + const free = reservedBudget?.freeBudget ?? 0; percentUsed = reservedBudget ? getPercentage(reservedBudget.totalReservedSpend, reservedBudget.reservedBudget) : 0; @@ -119,6 +123,7 @@ function UsageOverviewTableRow({ formattedPrepaid = formatReservedWithUnits(UNLIMITED_RESERVED, billedCategory); } else if (reservedBudget) { formattedPrepaid = displayPriceWithCents({cents: reservedBudget.reservedBudget}); + formattedFree = free ? displayPriceWithCents({cents: free}) : null; } paygSpend = isChildProduct @@ -128,7 +133,7 @@ function UsageOverviewTableRow({ }, 0); } else { // convert prepaid amount to the same unit as usage to accurately calculate percent used - const {prepaid} = metricHistory; + const {prepaid, free} = metricHistory; isUnlimited = prepaid === UNLIMITED_RESERVED || !!activeProductTrial; const rawPrepaid = isUnlimited ? prepaid @@ -147,6 +152,12 @@ function UsageOverviewTableRow({ useUnitScaling: true, isAbbreviated: true, }); + formattedFree = free + ? formatReservedWithUnits(free, billedCategory, { + useUnitScaling: true, + isAbbreviated: true, + }) + : null; paygSpend = subscription.categories[billedCategory]?.onDemandSpendUsed ?? 0; } @@ -168,19 +179,17 @@ function UsageOverviewTableRow({ const isPaygOnly = !isAddOn && supportsPayg(subscription) && metricHistory.reserved === 0; - const isClickable = !!potentialProductTrial || isEnabled; const isSelected = selectedProduct === product; return ( isClickable && setIsHovered(true)} - onMouseLeave={() => isClickable && setIsHovered(false)} - isClickable={isClickable} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} isSelected={isSelected} - onClick={() => (isClickable ? onRowClick(product) : undefined)} + onClick={() => onRowClick(product)} onKeyDown={e => { - if ((e.key === 'Enter' || e.key === ' ') && isClickable) { + if (e.key === 'Enter' || e.key === ' ') { onRowClick(product); } }} @@ -197,10 +206,10 @@ function UsageOverviewTableRow({ {usageExceeded ? ( - + + + ) : isPaygOnly || isChildProduct || isUnlimited ? null : ( - + + + )} - {isPaygOnly || isChildProduct || !formattedPrepaid - ? formattedUsage - : `${formattedUsage} / ${formattedPrepaid}`} + {isUnlimited ? ( + {t('Unlimited')} + ) : isPaygOnly || isChildProduct || !formattedPrepaid ? ( + formattedUsage + ) : ( + `${formattedUsage} / ${formattedPrepaid}` + )} + {formattedFree && ` (${formattedFree} gifted)`} @@ -268,7 +287,7 @@ function UsageOverviewTableRow({ export default UsageOverviewTableRow; -const Row = styled('tr')<{isClickable: boolean; isSelected: boolean}>` +const Row = styled('tr')<{isSelected: boolean}>` position: relative; background: ${p => (p.isSelected ? p.theme.backgroundSecondary : p.theme.background)}; padding: ${p => p.theme.space.xl}; @@ -282,17 +301,9 @@ const Row = styled('tr')<{isClickable: boolean; isSelected: boolean}>` border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius}; } - cursor: default; - - ${p => - p.isClickable && - css` - cursor: pointer; - - &:hover { - background: ${p.theme.backgroundSecondary}; - } - `} + &:hover { + background: ${p => p.theme.backgroundSecondary}; + } `; const MobilePanelContainer = styled('td')` diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx index 69df6962d2a95d..14e8409b387d14 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx @@ -1,16 +1,14 @@ import {Fragment, useState} from 'react'; import {LinkButton} from '@sentry/scraps/button/linkButton'; -import {Flex, Grid} from '@sentry/scraps/layout'; +import {Container, Flex} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; -import {IconLightning, IconOpen} from 'sentry/icons'; +import {IconLightning, IconLock, IconOpen} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; -import {useNavContext} from 'sentry/views/nav/context'; -import {NavLayout} from 'sentry/views/nav/types'; import StartTrialButton from 'getsentry/components/startTrialButton'; import { @@ -23,6 +21,7 @@ import {checkIsAddOn, getBilledCategory} from 'getsentry/utils/billing'; import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; function Cta({ + icon, title, subtitle, buttons, @@ -32,40 +31,39 @@ function Cta({ subtitle: React.ReactNode; title: React.ReactNode; buttons?: React.ReactNode; + icon?: React.ReactNode; }) { - const {isCollapsed: navIsCollapsed, layout: navLayout} = useNavContext(); - const isMobile = navLayout === NavLayout.MOBILE; - return ( - - - + + {icon && ( + + {icon} + + )} + {title} - - {subtitle} - + + + {subtitle} + + {buttons && ( - + {buttons} )} - + ); } @@ -174,6 +172,7 @@ function UpgradeCta({ return ( } title={t('Upgrade required')} subtitle={tct('You currently do not have access to this feature. [action]', { action: subscription.canSelfServe diff --git a/static/gsApp/views/subscriptionPage/usageOverview/constants.tsx b/static/gsApp/views/subscriptionPage/usageOverview/constants.tsx index 61d8a301bf88c1..e8b2935d025697 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/constants.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/constants.tsx @@ -3,3 +3,8 @@ * At smaller screen sizes, the panel is shown inline. */ export const SIDE_PANEL_MIN_SCREEN_BREAKPOINT = 'lg'; + +/** + * The height of the panel header for both the table and side panel. + */ +export const USAGE_OVERVIEW_PANEL_HEADER_HEIGHT = '60px'; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx index 7a1e2e0d4e1ad1..053473923bcc50 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx @@ -1,32 +1,29 @@ -import moment from 'moment-timezone'; -import {LocationFixture} from 'sentry-fixture/locationFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {BillingHistoryFixture} from 'getsentry-test/fixtures/billingHistory'; import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage'; -import { - SubscriptionFixture, - SubscriptionWithLegacySeerFixture, -} from 'getsentry-test/fixtures/subscription'; +import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; -import {resetMockDate, setMockDate} from 'sentry-test/utils'; import {DataCategory} from 'sentry/types/core'; +import * as useMedia from 'sentry/utils/useMedia'; -import {GIGABYTE, UNLIMITED, UNLIMITED_RESERVED} from 'getsentry/constants'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; -import Overview from 'getsentry/views/subscriptionPage/overview'; import UsageOverview from 'getsentry/views/subscriptionPage/usageOverview'; describe('UsageOverview', () => { const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({organization, plan: 'am3_business'}); + const subscription = SubscriptionFixture({ + organization, + plan: 'am3_business', + onDemandPeriodStart: '2021-05-02', + onDemandPeriodEnd: '2021-06-01', + }); const usageData = CustomerUsageFixture(); beforeEach(() => { + jest.restoreAllMocks(); organization.features = ['subscriptions-v3', 'seer-billing']; - setMockDate(new Date('2021-05-07')); - MockApiClient.clearMockResponses(); organization.access = ['org:billing']; SubscriptionStore.set(organization.slug, subscription); MockApiClient.addMockResponse({ @@ -36,11 +33,7 @@ describe('UsageOverview', () => { }); }); - afterEach(() => { - resetMockDate(); - }); - - it('renders columns and buttons for billing users', () => { + it('renders actions for billing users', async () => { render( { usageData={usageData} /> ); - expect(screen.getAllByRole('columnheader')).toHaveLength(6); - expect(screen.getByRole('columnheader', {name: 'Product'})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'Total usage'})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'Reserved'})).toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: 'Reserved spend'}) - ).toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: 'Pay-as-you-go spend'}) - ).toBeInTheDocument(); + + await screen.findByRole('heading', {name: 'Usage: May 2 - Jun 1, 2021'}); expect(screen.getByRole('button', {name: 'View all usage'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Download as CSV'})).toBeInTheDocument(); - expect(screen.getAllByRole('row', {name: /^View .+ usage$/i}).length).toBeGreaterThan( - 0 - ); }); - it('renders columns for non-billing users', () => { + it('does not render actions for non-billing users', async () => { organization.access = []; render( { usageData={usageData} /> ); - expect(screen.getAllByRole('columnheader')).toHaveLength(6); - expect(screen.getByRole('columnheader', {name: 'Product'})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'Total usage'})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'Reserved'})).toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: 'Reserved spend'}) - ).toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: 'Pay-as-you-go spend'}) - ).toBeInTheDocument(); + + await screen.findByRole('heading', {name: 'Usage: May 2 - Jun 1, 2021'}); expect( screen.queryByRole('button', {name: 'View all usage'}) ).not.toBeInTheDocument(); expect( screen.queryByRole('button', {name: 'Download as CSV'}) ).not.toBeInTheDocument(); - expect(screen.getAllByRole('row', {name: /^View .+ usage$/i}).length).toBeGreaterThan( - 0 - ); - }); - - it('renders some spend columns for non-self-serve with PAYG support', () => { - const newSubscription = SubscriptionFixture({ - organization, - plan: 'am3_business', - supportsOnDemand: true, - canSelfServe: false, - }); - SubscriptionStore.set(organization.slug, newSubscription); - render( - - ); - expect(screen.getAllByRole('columnheader')).toHaveLength(5); - expect( - screen.queryByRole('columnheader', {name: 'Reserved spend'}) - ).not.toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: 'Pay-as-you-go spend'}) - ).toBeInTheDocument(); }); - it('does not render spend columns for non-self-serve without PAYG support', () => { - const newSubscription = SubscriptionFixture({ - organization, - plan: 'am3_business', - supportsOnDemand: false, - canSelfServe: false, - }); - SubscriptionStore.set(organization.slug, newSubscription); - render( - - ); - expect(screen.getAllByRole('columnheader')).toHaveLength(4); - expect( - screen.queryByRole('columnheader', {name: 'Reserved spend'}) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('columnheader', {name: 'Pay-as-you-go spend'}) - ).not.toBeInTheDocument(); - }); - - it('renders table based on subscription state', () => { - subscription.onDemandPeriodStart = '2025-05-02'; - subscription.onDemandPeriodEnd = '2025-06-01'; - subscription.onDemandMaxSpend = 100_00; - subscription.productTrials = [ - { - category: DataCategory.PROFILE_DURATION, - isStarted: false, - reasonCode: 1001, - startDate: undefined, - endDate: moment().utc().add(20, 'years').format(), - }, - { - category: DataCategory.REPLAYS, - isStarted: true, - reasonCode: 1001, - startDate: moment().utc().subtract(10, 'days').format(), - endDate: moment().utc().add(20, 'days').format(), - }, - ]; - subscription.categories.errors = { - ...subscription.categories.errors!, - free: 1000, - usage: 6000, - onDemandSpendUsed: 10_00, - }; - subscription.categories.attachments = { - ...subscription.categories.attachments!, - reserved: 25, // 25 GB - prepaid: 25, // 25 GB - free: 0, - usage: GIGABYTE / 2, - }; - subscription.categories.spans = { - ...subscription.categories.spans!, - reserved: 20_000_000, - }; - subscription.categories.replays = { - ...subscription.categories.replays!, - reserved: UNLIMITED_RESERVED, - usage: 500_000, - }; - + it('opens panel based with no query params', async () => { + jest + .spyOn(useMedia, 'default') + .mockImplementation(query => query.includes('min-width')); render( { /> ); - expect(screen.getByText('May 2 - Jun 1, 2025')).toBeInTheDocument(); - - // Continuous profile hours product trial available - expect( - screen.getByRole('button', { - name: 'Start 14 day free Continuous Profile Hours trial', - }) - ).toBeInTheDocument(); - expect(screen.queryByText('Trial available')).not.toBeInTheDocument(); - - // Replays product trial active - expect(screen.getByRole('cell', {name: '20 days left'})).toBeInTheDocument(); - - // Errors usage and gifted units - expect(screen.getByRole('cell', {name: '6,000'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: '$10.00'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: '12% of 51,000'})).toBeInTheDocument(); - - // Attachments usage should be in the correct unit + above platform volume - expect(screen.getByRole('cell', {name: '500 MB'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: '2% of 25 GB'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: '$6.00'})).toBeInTheDocument(); - - // Reserved spans above platform volume - expect(screen.getAllByRole('cell', {name: '0'}).length).toBeGreaterThan(0); - expect(screen.getByRole('cell', {name: '$32.00'})).toBeInTheDocument(); - - // Unlimited usage for Replays - expect(screen.getAllByRole('cell', {name: UNLIMITED})).toHaveLength(2); + await screen.findByRole('heading', {name: 'Errors'}); }); - it('renders table based on add-on state', () => { - organization.features.push('seer-user-billing'); - const subWithSeer = SubscriptionWithLegacySeerFixture({organization}); - SubscriptionStore.set(organization.slug, subWithSeer); + it('opens panel based on query params', async () => { + jest + .spyOn(useMedia, 'default') + .mockImplementation(query => query.includes('min-width')); render( + />, + { + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/subscription/usage-overview', + query: {product: DataCategory.REPLAYS}, + }, + }, + } ); - // Org has Seer user flag but did not buy Seer add on, only legacy add-on - expect(screen.getAllByRole('cell', {name: 'Seer'})).toHaveLength(1); - expect(screen.getAllByRole('row', {name: 'Collapse Seer details'})).toHaveLength(1); - expect(screen.getByRole('cell', {name: 'Issue Fixes'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: 'Issue Scans'})).toBeInTheDocument(); - // We test it this way to ensure we don't show the cell with the proper display name or the raw DataCategory - expect(screen.queryByRole('cell', {name: /Seer*Users/})).not.toBeInTheDocument(); - expect(screen.queryByRole('cell', {name: /Prevent*Reviews/})).not.toBeInTheDocument(); + await screen.findByRole('heading', {name: 'Replays'}); + expect(screen.queryByRole('heading', {name: 'Errors'})).not.toBeInTheDocument(); }); - it('renders add-on sub-categories if unlimited', () => { - const sub = SubscriptionFixture({organization}); - sub.categories.seerAutofix = { - ...sub.categories.seerAutofix!, - reserved: UNLIMITED_RESERVED, - }; - + it('defaults to last selected when query param is invalid', async () => { + jest + .spyOn(useMedia, 'default') + .mockImplementation(query => query.includes('min-width')); render( + />, + { + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/subscription/usage-overview', + query: {product: 'transactions'}, + }, + }, + } ); - // issue fixes is unlimited - expect(screen.getByRole('cell', {name: 'Issue Fixes'})).toBeInTheDocument(); - expect(screen.getAllByRole('cell', {name: UNLIMITED})).toHaveLength(2); - // issue scans is 0 - expect(screen.getByRole('cell', {name: 'Issue Scans'})).toBeInTheDocument(); - expect(screen.getAllByRole('cell', {name: '0'}).length).toBeGreaterThan(0); - - // add-on is not rendered since at least one of its sub-categories is unlimited - expect(screen.queryByRole('cell', {name: 'Seer'})).not.toBeInTheDocument(); - expect( - screen.queryByRole('row', {name: 'Expand Seer details'}) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('row', {name: 'Collapse Seer details'}) - ).not.toBeInTheDocument(); - - expect(screen.getByRole('cell', {name: 'Issue Scans'})).toBeInTheDocument(); + await screen.findByRole('heading', {name: 'Errors'}); + expect(screen.queryByRole('heading', {name: 'Transactions'})).not.toBeInTheDocument(); }); - it('can open drawer for data categories but not add ons', async () => { - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/usage/`, - method: 'GET', - body: CustomerUsageFixture(), - }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/billing-details/`, - method: 'GET', - }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/subscription/next-bill/`, - method: 'GET', - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/promotions/trigger-check/`, - method: 'POST', - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/prompts-activity/`, - body: {}, - }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/recurring-credits/`, - method: 'GET', - body: [], - }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/history/`, - method: 'GET', - }); - const subWithSeer = SubscriptionWithLegacySeerFixture({organization}); - const mockLocation = LocationFixture(); - SubscriptionStore.set(organization.slug, subWithSeer); - - // use Overview component here so we can test the drawers - render(, {organization}); - - await screen.findByText('Usage Overview'); - expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('row', {name: 'View Errors usage'})); - expect( - screen.getByRole('complementary', {name: 'Usage for Errors'}) - ).toBeInTheDocument(); - - // cannot open drawer for seat-based categories - expect( - screen.queryByRole('row', {name: 'View Cron Monitors usage'}) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('row', {name: 'View Uptime monitors usage'}) - ).not.toBeInTheDocument(); - - // cannot open drawer for add-ons, but can open for an add-on's data categories - expect(screen.queryByRole('row', {name: 'View Seer usage'})).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('row', {name: 'View Issue Fixes usage'})); - expect( - screen.getByRole('complementary', {name: 'Usage for Issue Fixes'}) - ).toBeInTheDocument(); - expect( - screen.queryByRole('complementary', {name: 'Usage for Errors'}) - ).not.toBeInTheDocument(); - }); - - it('renders PAYG tags only if PAYG is supported', () => { - const sub = SubscriptionFixture({ - plan: 'am3_f', - organization, - supportsOnDemand: true, - }); - + it('can switch panel by clicking table rows', async () => { + jest + .spyOn(useMedia, 'default') + .mockImplementation(query => query.includes('min-width')); render( ); - expect( - screen.getAllByRole('cell', {name: 'Pay-as-you-go only'}).length - ).toBeGreaterThan(0); - }); - it('does not render PAYG tags if PAYG is not supported', () => { - const sub = SubscriptionFixture({ - plan: 'am3_f', - organization, - supportsOnDemand: false, - }); - - render( - - ); - expect( - screen.queryByRole('cell', {name: 'Pay-as-you-go only'}) - ).not.toBeInTheDocument(); + await screen.findByRole('heading', {name: 'Errors'}); + expect(screen.queryByRole('heading', {name: 'Replays'})).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'View Replays usage'})); + await screen.findByRole('heading', {name: 'Replays'}); + expect(screen.queryByRole('heading', {name: 'Errors'})).not.toBeInTheDocument(); }); }); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx index 592768604dd989..638bce9c2b4bbb 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -1,879 +1,155 @@ -import React, {Fragment, useCallback, useEffect, useMemo, useState} from 'react'; +import {useEffect, useState} from 'react'; import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; import moment from 'moment-timezone'; -import {Tag} from 'sentry/components/core/badge/tag'; -import {Button} from 'sentry/components/core/button'; -import {Container, Flex} from 'sentry/components/core/layout'; -import {Heading, Text} from 'sentry/components/core/text'; -import type {TextProps} from 'sentry/components/core/text/text'; -import useDrawer from 'sentry/components/globalDrawer'; -import QuestionTooltip from 'sentry/components/questionTooltip'; -import type {GridColumnOrder} from 'sentry/components/tables/gridEditable'; -import GridEditable from 'sentry/components/tables/gridEditable'; -import {IconChevron, IconLightning, IconLock} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; +import {Container, Flex, Grid} from 'sentry/components/core/layout'; +import {Heading} from 'sentry/components/core/text'; +import {tct} from 'sentry/locale'; import {DataCategory} from 'sentry/types/core'; -import type {Organization} from 'sentry/types/organization'; -import {defined} from 'sentry/utils'; -import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; -import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import {useLocation} from 'sentry/utils/useLocation'; import useMedia from 'sentry/utils/useMedia'; import {useNavigate} from 'sentry/utils/useNavigate'; -import {useNavContext} from 'sentry/views/nav/context'; -import {NavLayout} from 'sentry/views/nav/types'; -import ProductTrialTag from 'getsentry/components/productTrial/productTrialTag'; -import StartTrialButton from 'getsentry/components/startTrialButton'; -import { - GIGABYTE, - RESERVED_BUDGET_QUOTA, - UNLIMITED, - UNLIMITED_RESERVED, -} from 'getsentry/constants'; -import { - AddOnCategory, - OnDemandBudgetMode, - type CustomerUsage, - type EventBucket, - type Subscription, -} from 'getsentry/types'; -import { - displayBudgetName, - formatReservedWithUnits, - formatUsageWithUnits, - getActiveProductTrial, - getPercentage, - getPotentialProductTrial, - getReservedBudgetCategoryForAddOn, - MILLISECONDS_IN_HOUR, - supportsPayg, -} from 'getsentry/utils/billing'; -import { - getCategoryInfoFromPlural, - getPlanCategoryName, - isByteCategory, - isContinuousProfiling, - sortCategories, -} from 'getsentry/utils/dataCategory'; +import {AddOnCategory, OnDemandBudgetMode} from 'getsentry/types'; +import {checkIsAddOn, getActiveProductTrial} from 'getsentry/utils/billing'; import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; -import {displayPriceWithCents, getBucket} from 'getsentry/views/amCheckout/utils'; -import CategoryUsageDrawer from 'getsentry/views/subscriptionPage/components/categoryUsageDrawer'; import UsageOverviewActions from 'getsentry/views/subscriptionPage/usageOverview/components/actions'; import ProductBreakdownPanel from 'getsentry/views/subscriptionPage/usageOverview/components/panel'; import UsageOverviewTable from 'getsentry/views/subscriptionPage/usageOverview/components/table'; +import { + SIDE_PANEL_MIN_SCREEN_BREAKPOINT, + USAGE_OVERVIEW_PANEL_HEADER_HEIGHT, +} from 'getsentry/views/subscriptionPage/usageOverview/constants'; +import type {UsageOverviewProps} from 'getsentry/views/subscriptionPage/usageOverview/types'; -interface UsageOverviewProps { - organization: Organization; - subscription: Subscription; - usageData: CustomerUsage; -} - -// XXX: This is a hack to ensure that the grid rows don't change height -// when hovering over the row (due to buttons that appear) -const MIN_CONTENT_HEIGHT = '28px'; - -function CurrencyCell({ - children, - bold, - variant, -}: { - children: React.ReactNode; - bold?: boolean; - variant?: TextProps<'span'>['variant']; -}) { - return ( - - {children} - - ); -} - -function ReservedUsageBar({percentUsed}: {percentUsed: number}) { - if (percentUsed === 0 || percentUsed === 1) { - return ; - } - const filledWidth = percentUsed * 90; - const unfilledWidth = 90 - filledWidth; - return ( - - - - +function UsageOverview({subscription, organization, usageData}: UsageOverviewProps) { + const [selectedProduct, setSelectedProduct] = useState( + DataCategory.ERRORS ); -} - -function LegacyUsageOverviewTable({ - subscription, - organization, - usageData, -}: UsageOverviewProps) { const navigate = useNavigate(); const location = useLocation(); - const [openState, setOpenState] = useState>>({}); - const [hoverState, setHoverState] = useState>>( - {} - ); - const {isDrawerOpen, openDrawer} = useDrawer(); - const [highlightedRow, setHighlightedRow] = useState(undefined); - const [trialButtonBusyState, setTrialButtonBusyState] = useState< - Partial> - >({}); const theme = useTheme(); - const isXlScreen = useMedia(`(min-width: ${theme.breakpoints.xl})`); - - const handleOpenDrawer = useCallback( - (dataCategory: DataCategory) => { - trackGetsentryAnalytics('subscription_page.usage_overview.row_clicked', { - organization, - subscription, - dataCategory, - }); - navigate( - { - pathname: location.pathname, - query: {...location.query, drawer: dataCategory}, - }, - { - replace: true, - } - ); - }, - [navigate, location.query, location.pathname, organization, subscription] + const showSidePanel = useMedia( + `(min-width: ${theme.breakpoints[SIDE_PANEL_MIN_SCREEN_BREAKPOINT]})` ); - const handleCloseDrawer = useCallback(() => { - navigate( - { - pathname: location.pathname, - query: { - ...location.query, - drawer: undefined, - }, - }, - {replace: true} - ); - }, [navigate, location.query, location.pathname]); - - useEffect(() => { - Object.entries(subscription.addOns ?? {}) - .filter( - // only show add-on data categories if the add-on is enabled - ([_, addOnInfo]) => - !addOnInfo.billingFlag || - (organization.features.includes(addOnInfo.billingFlag) && addOnInfo.enabled) - ) - .forEach(([apiName, _]) => { - setOpenState(prev => ({...prev, [apiName]: true})); - }); - }, [subscription.addOns, organization.features]); + const startDate = moment(subscription.onDemandPeriodStart); + const endDate = moment(subscription.onDemandPeriodEnd); + const startsAndEndsSameYear = startDate.year() === endDate.year(); useEffect(() => { - if (!isDrawerOpen && location.query.drawer) { - const dataCategory = location.query.drawer as DataCategory; - const categoryInfo = subscription.categories[dataCategory]; - const productName = getPlanCategoryName({ - plan: subscription.planDetails, - category: dataCategory, - title: true, - }); - if (!categoryInfo) { - handleCloseDrawer(); - return; - } - openDrawer( - () => ( - - ), - { - ariaLabel: t('Usage for %s', productName), - drawerKey: 'usage-overview-drawer', - resizable: false, - onClose: () => handleCloseDrawer(), - drawerWidth: '650px', + const productFromQuery = location.query.product as string; + if (productFromQuery) { + const isAddOn = checkIsAddOn(productFromQuery); + if (selectedProduct !== productFromQuery) { + const isSelectable = isAddOn + ? (subscription.addOns?.[productFromQuery as AddOnCategory]?.enabled ?? false) + : (subscription.categories[productFromQuery as DataCategory]?.reserved ?? 0) > + 0 || + !!getActiveProductTrial( + subscription.productTrials ?? null, + productFromQuery as DataCategory + ) || + (subscription.onDemandBudgets?.budgetMode === OnDemandBudgetMode.SHARED + ? subscription.onDemandBudgets.sharedMaxBudget + : (subscription.onDemandBudgets?.budgets?.[ + productFromQuery as DataCategory + ] ?? 0)) > 0; + if (isSelectable) { + setSelectedProduct( + isAddOn + ? (productFromQuery as AddOnCategory) + : (productFromQuery as DataCategory) + ); + } else { + // if the query is an unselectable product, reset the query to the existing selected product + navigate( + { + pathname: location.pathname, + query: { + ...location.query, + product: selectedProduct, + }, + }, + { + replace: true, + } + ); } - ); + } } }, [ - isDrawerOpen, - location.query.drawer, - usageData, - subscription, - openDrawer, - handleCloseDrawer, + location.query.product, + selectedProduct, + location.pathname, + location.query, + navigate, + subscription.addOns, + subscription.categories, + subscription.onDemandBudgets, + subscription.productTrials, ]); - const allAddOnDataCategories = useMemo( - () => - Object.values(subscription.planDetails.addOnCategories) - .filter( - // filter out data categories that are part of add-on that have at least one unlimited sub-category - addOn => - !addOn.dataCategories.some( - category => - subscription.categories[category]?.reserved === UNLIMITED_RESERVED - ) - ) - .flatMap(addOn => addOn.dataCategories), - [subscription.planDetails.addOnCategories, subscription.categories] - ); - - const addOnsToShow = useMemo( - () => - Object.entries(subscription.addOns ?? {}).filter( - // show add-ons regardless of whether they're enabled - // as long as they're launched for the org - // and none of their sub-categories are unlimited - // Also do not show Seer if the legacy Seer add-on is enabled - ([_, addOnInfo]) => - (!addOnInfo.billingFlag || - organization.features.includes(addOnInfo.billingFlag)) && - !addOnInfo.dataCategories.some( - category => subscription.categories[category]?.reserved === UNLIMITED_RESERVED - ) && - (addOnInfo.apiName !== AddOnCategory.SEER || - !subscription.addOns?.[AddOnCategory.LEGACY_SEER]?.enabled) - ), - [subscription.addOns, organization.features, subscription.categories] - ); - - const columnOrder: GridColumnOrder[] = useMemo(() => { - const hasAnyPotentialOrActiveProductTrial = subscription.productTrials?.some( - trial => - !trial.isStarted || - (trial.isStarted && trial.endDate && getDaysSinceDate(trial.endDate) <= 0) - ); - return [ - {key: 'product', name: t('Product'), width: 250}, - {key: 'totalUsage', name: t('Total usage'), width: 200}, - {key: 'reservedUsage', name: t('Reserved'), width: 300}, - {key: 'reservedSpend', name: t('Reserved spend'), width: isXlScreen ? 200 : 150}, - { - key: 'budgetSpend', - name: t('%s spend', displayBudgetName(subscription.planDetails, {title: true})), - width: isXlScreen ? 200 : 150, - }, - { - key: 'trialInfo', - name: '', - width: 200, - }, - { - key: 'drawerButton', - name: '', - }, - ].filter( - column => - (subscription.canSelfServe || - !column.key.endsWith('Spend') || - (supportsPayg(subscription) && column.key === 'budgetSpend')) && - (hasAnyPotentialOrActiveProductTrial || column.key !== 'trialInfo') - ); - }, [subscription, isXlScreen]); - - // TODO(isabella): refactor this to have better types - const productData: Array<{ - budgetSpend: number; - hasAccess: boolean; - isClickable: boolean; - isPaygOnly: boolean; - isUnlimited: boolean; - product: string; - totalUsage: number; - addOnCategory?: AddOnCategory; - ariaLabel?: string; - dataCategory?: DataCategory; - free?: number; - isChildProduct?: boolean; - isOpen?: boolean; - percentUsed?: number; - productTrialCategory?: DataCategory; - reserved?: number; - reservedSpend?: number; - softCapType?: 'ON_DEMAND' | 'TRUE_FORWARD'; - toggleKey?: DataCategory | AddOnCategory; - }> = useMemo(() => { - return [ - ...sortCategories(subscription.categories) - .filter(metricHistory => !allAddOnDataCategories.includes(metricHistory.category)) - .map(metricHistory => { - const category = metricHistory.category; - const categoryInfo = getCategoryInfoFromPlural(category); - const productName = getPlanCategoryName({ - plan: subscription.planDetails, - category, - title: true, - }); - const reserved = metricHistory.reserved ?? 0; - const free = metricHistory.free ?? 0; - const prepaid = metricHistory.prepaid ?? 0; - const total = metricHistory.usage; - const paygTotal = metricHistory.onDemandSpendUsed; - const softCapType = - metricHistory.softCapType ?? - (metricHistory.trueForward ? 'TRUE_FORWARD' : undefined); - const activeProductTrial = getActiveProductTrial( - subscription.productTrials ?? [], - category - ); - - const isPaygOnly = reserved === 0 && subscription.supportsOnDemand; - const hasAccess = activeProductTrial - ? true - : isPaygOnly - ? subscription.onDemandBudgets?.budgetMode === - OnDemandBudgetMode.PER_CATEGORY - ? metricHistory.onDemandBudget > 0 - : subscription.onDemandMaxSpend > 0 - : reserved > 0 || reserved === UNLIMITED_RESERVED; - - const bucket = getBucket({ - events: reserved, // buckets use the converted unit reserved amount (ie. in GB for byte categories) - buckets: subscription.planDetails.planCategories[category], - }); - const recurringReservedSpend = bucket.price ?? 0; - // convert prepaid amount to the same unit as usage to accurately calculate percent used - const reservedTotal = isByteCategory(category) - ? prepaid * GIGABYTE - : isContinuousProfiling(category) - ? prepaid * MILLISECONDS_IN_HOUR - : prepaid; - const percentUsed = reservedTotal - ? getPercentage(total, reservedTotal) - : undefined; - - return { - dataCategory: category, - hasAccess, - isPaygOnly, - free, - reserved, - isUnlimited: !!activeProductTrial || reserved === UNLIMITED_RESERVED, - softCapType: softCapType ?? undefined, - product: productName, - totalUsage: total, - percentUsed, - reservedSpend: recurringReservedSpend, - budgetSpend: paygTotal, - productTrialCategory: category, - isClickable: categoryInfo?.tallyType === 'usage', - }; - }), - ...addOnsToShow.flatMap(([apiName, addOnInfo]) => { - const addOnName = toTitleCase(addOnInfo.productName, { - allowInnerUpperCase: true, - }); - - const paygTotal = addOnInfo.dataCategories.reduce((acc, category) => { - return acc + (subscription.categories[category]?.onDemandSpendUsed ?? 0); - }, 0); - const addOnDataCategories = addOnInfo.dataCategories; - const reservedBudgetCategory = getReservedBudgetCategoryForAddOn( - apiName as AddOnCategory - ); - const reservedBudget = reservedBudgetCategory - ? subscription.reservedBudgets?.find( - budget => (budget.apiName as string) === reservedBudgetCategory - ) - : undefined; - const percentUsed = reservedBudget?.reservedBudget - ? getPercentage( - reservedBudget?.totalReservedSpend, - reservedBudget?.reservedBudget - ) - : undefined; - const activeProductTrial = getActiveProductTrial( - subscription.productTrials ?? [], - addOnDataCategories[0] as DataCategory - ); - const hasAccess = addOnInfo.enabled; - - let bucket: EventBucket | undefined = undefined; - if (hasAccess) { - // NOTE: this only works for reserved budget add-ons, - // returning the first sub-category bucket that has a price - // for the reserved budget tier (RESERVED_BUDGET_QUOTA) - let i = 0; - while (!bucket?.price && i < addOnDataCategories.length) { - bucket = getBucket({ - buckets: subscription.planDetails.planCategories[addOnDataCategories[i]!], - events: RESERVED_BUDGET_QUOTA, - }); - i++; - } - } - const recurringReservedSpend = bucket?.price ?? 0; - - // Only show child categories if the add-on is open and enabled - const childCategoriesData = - openState[apiName as AddOnCategory] && hasAccess - ? addOnInfo.dataCategories.map(addOnDataCategory => { - const categoryInfo = getCategoryInfoFromPlural(addOnDataCategory); - const childSpend = - reservedBudget?.categories[addOnDataCategory]?.reservedSpend ?? 0; - const childPaygTotal = - subscription.categories[addOnDataCategory]?.onDemandSpendUsed ?? 0; - const childProductName = getPlanCategoryName({ - plan: subscription.planDetails, - category: addOnDataCategory, - title: true, - }); - const metricHistory = subscription.categories[addOnDataCategory]; - const softCapType = - metricHistory?.softCapType ?? - (metricHistory?.trueForward ? 'TRUE_FORWARD' : undefined); - return { - addOnCategory: apiName as AddOnCategory, - dataCategory: addOnDataCategory, - isChildProduct: true, - isOpen: openState[apiName as AddOnCategory], - hasAccess: true, - isPaygOnly: false, - isUnlimited: !!activeProductTrial, - softCapType: softCapType ?? undefined, - budgetSpend: childPaygTotal, - totalUsage: (childSpend ?? 0) + childPaygTotal, - product: childProductName, - isClickable: categoryInfo?.tallyType === 'usage', - }; - }) - : null; - - return [ - { - addOnCategory: apiName as AddOnCategory, - hasAccess, - free: reservedBudget?.freeBudget ?? 0, - reserved: reservedBudget?.reservedBudget ?? 0, - isPaygOnly: !reservedBudget, - isOpen: openState[apiName as AddOnCategory], - toggleKey: hasAccess ? (apiName as AddOnCategory) : undefined, - isUnlimited: !!activeProductTrial, - productTrialCategory: addOnDataCategories[0] as DataCategory, - product: addOnName, - totalUsage: (reservedBudget?.totalReservedSpend ?? 0) + paygTotal, - percentUsed, - reservedSpend: recurringReservedSpend, - budgetSpend: paygTotal, - isClickable: hasAccess, - }, - ...(childCategoriesData ?? []), - ]; - }), - ]; - }, [subscription, allAddOnDataCategories, openState, addOnsToShow]); - return ( - { - const isSpendColumn = column.key.toString().toLowerCase().endsWith('spend'); - return ( - - {column.name} - - ); - }, - renderBodyCell: (column, row) => { - const { - totalUsage, - hasAccess, - isPaygOnly, - isUnlimited, - product, - addOnCategory, - dataCategory, - free, - isChildProduct, - isOpen, - reserved, - percentUsed, - softCapType, - toggleKey, - productTrialCategory, - isClickable, - } = row; - - const productTrial = productTrialCategory - ? (getActiveProductTrial( - subscription.productTrials ?? [], - productTrialCategory - ) ?? - getPotentialProductTrial( - subscription.productTrials ?? [], - productTrialCategory - )) - : undefined; - - if (defined(isOpen) && !isOpen && isChildProduct) { - return null; - } - - switch (column.key) { - case 'product': { - const title = ( - - {!hasAccess && } {product} - {softCapType && - ` (${toTitleCase(softCapType.replace(/_/g, ' ').toLocaleLowerCase())})`}{' '} - - ); - - if (toggleKey) { - return ( - - - {title} - - ); - } - return ( - - {title} - - ); - } - case 'totalUsage': { - const formattedTotal = addOnCategory - ? displayPriceWithCents({cents: totalUsage}) - : dataCategory - ? formatUsageWithUnits(totalUsage, dataCategory, { - useUnitScaling: true, - }) - : totalUsage; - - return ( - - - {isUnlimited ? UNLIMITED : formattedTotal}{' '} - - - ); - } - case 'reservedUsage': { - if (isPaygOnly) { - return ( - - - {tct('[budgetTerm] only', { - budgetTerm: displayBudgetName(subscription.planDetails, { - title: true, - }), - })} - - - ); - } - - if (isUnlimited) { - return {UNLIMITED}; - } - - if (defined(percentUsed)) { - const formattedReserved = addOnCategory - ? displayPriceWithCents({cents: reserved ?? 0}) - : dataCategory - ? formatReservedWithUnits(reserved ?? 0, dataCategory, { - useUnitScaling: true, - }) - : (reserved ?? 0); - const formattedFree = addOnCategory - ? displayPriceWithCents({cents: free ?? 0}) - : dataCategory - ? formatReservedWithUnits(free ?? 0, dataCategory, { - useUnitScaling: true, - }) - : (free ?? 0); - const formattedReservedTotal = addOnCategory - ? displayPriceWithCents({cents: (reserved ?? 0) + (free ?? 0)}) - : dataCategory - ? formatReservedWithUnits( - (reserved ?? 0) + (free ?? 0), - dataCategory, - { - useUnitScaling: true, - } - ) - : (reserved ?? 0) + (free ?? 0); - - return ( - - - - {tct('[percent]% of [formattedReservedTotal]', { - percent: percentUsed.toFixed(0), - formattedReservedTotal, - })} - - { - - } - - ); - } - return
; - } - case 'reservedSpend': - case 'budgetSpend': { - const spend = row[column.key as keyof typeof row]; - const formattedSpend = spend - ? displayPriceWithCents({cents: spend as number}) - : '-'; - return {formattedSpend}; - } - case 'trialInfo': { - if (productTrial) { - return ( - - {productTrial.isStarted ? ( - - - - ) : ( - { - setTrialButtonBusyState(prev => ({ - ...prev, - [productTrial.category]: true, - })); - }} - onTrialStarted={() => { - setTrialButtonBusyState(prev => ({ - ...prev, - [productTrial.category]: true, - })); - }} - onTrialFailed={() => { - setTrialButtonBusyState(prev => ({ - ...prev, - [productTrial.category]: false, - })); - }} - busy={trialButtonBusyState[productTrial.category]} - disabled={trialButtonBusyState[productTrial.category]} - size="xs" - > - - - {t('Start 14 day free trial')} - - - )} - - ); - } - return
; - } - case 'drawerButton': { - if (isClickable && dataCategory && hoverState[dataCategory]) { - return ( - -