-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat(usage overview): Add breakdown into panel #104141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+524
−10
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
ecf4694
feat(usage overview): Introduce util functions and hook
isabellaenriquez a9fdc2d
fix test + knip
isabellaenriquez 28f2d3e
add this too
isabellaenriquez 6b0f5aa
feat(usage overview): Update buttons
isabellaenriquez a6efc5d
update tests + tweak style
isabellaenriquez f7623fe
also add this
isabellaenriquez 485be78
feat(usage overview): Extract drawer charts into component
isabellaenriquez 145d051
rm unused exported type
isabellaenriquez ab127d3
update tests
isabellaenriquez 87a9f70
feat(usage overview): Introduce new panel
isabellaenriquez eec3fb6
split out breakdownInfo
isabellaenriquez bd9804f
feat(usage overview): Add breakdown into panel
isabellaenriquez eb07afa
fix types
isabellaenriquez 6d701b6
add padding
isabellaenriquez 1e485b8
Merge branch 'master' into isabella/uo-v2-pt-5
isabellaenriquez File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
301 changes: 301 additions & 0 deletions
301
static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,301 @@ | ||
| import {Fragment} from 'react'; | ||
|
|
||
| import {Flex, Grid} from '@sentry/scraps/layout'; | ||
| import {Text} from '@sentry/scraps/text'; | ||
|
|
||
| import QuestionTooltip from 'sentry/components/questionTooltip'; | ||
| import {t, tct} from 'sentry/locale'; | ||
| 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 type { | ||
| BillingMetricHistory, | ||
| Plan, | ||
| ProductTrial, | ||
| ReservedBudget, | ||
| Subscription, | ||
| } from 'getsentry/types'; | ||
| import { | ||
| displayBudgetName, | ||
| formatReservedWithUnits, | ||
| getSoftCapType, | ||
| isTrialPlan, | ||
| supportsPayg, | ||
| } from 'getsentry/utils/billing'; | ||
| import {displayPriceWithCents} from 'getsentry/views/amCheckout/utils'; | ||
|
|
||
| interface BaseProps { | ||
| activeProductTrial: ProductTrial | null; | ||
| subscription: Subscription; | ||
| } | ||
|
|
||
| interface UsageBreakdownInfoProps extends BaseProps { | ||
| formattedAdditionalReserved: React.ReactNode | null; | ||
| formattedGifted: React.ReactNode | null; | ||
| formattedPlatformReserved: React.ReactNode | null; | ||
| formattedSoftCapType: React.ReactNode | null; | ||
| paygCategoryBudget: number | null; | ||
| paygSpend: number; | ||
| plan: Plan; | ||
| platformReservedField: React.ReactNode; | ||
| productCanUsePayg: boolean; | ||
| recurringReservedSpend: number | null; | ||
| } | ||
|
|
||
| interface DataCategoryUsageBreakdownInfoProps extends BaseProps { | ||
| category: DataCategory; | ||
| metricHistory: BillingMetricHistory; | ||
| } | ||
|
|
||
| interface ReservedBudgetUsageBreakdownInfoProps extends BaseProps { | ||
| reservedBudget: ReservedBudget; | ||
| } | ||
|
|
||
| function UsageBreakdownField({ | ||
| field, | ||
| value, | ||
| help, | ||
| }: { | ||
| field: React.ReactNode; | ||
| value: React.ReactNode; | ||
| help?: React.ReactNode; | ||
| }) { | ||
| return ( | ||
| <Flex direction="column" gap="sm"> | ||
| <Flex gap="sm"> | ||
| <Text variant="muted" bold uppercase size="sm"> | ||
| {field} | ||
| </Text> | ||
| {help && <QuestionTooltip title={help} size="xs" />} | ||
| </Flex> | ||
| <Text size="lg">{value}</Text> | ||
| </Flex> | ||
| ); | ||
| } | ||
|
|
||
| function UsageBreakdownInfo({ | ||
| subscription, | ||
| plan, | ||
| platformReservedField, | ||
| formattedPlatformReserved, | ||
| formattedAdditionalReserved, | ||
| formattedGifted, | ||
| paygSpend, | ||
| paygCategoryBudget, | ||
| recurringReservedSpend, | ||
| productCanUsePayg, | ||
| activeProductTrial, | ||
| formattedSoftCapType, | ||
| }: UsageBreakdownInfoProps) { | ||
| const canUsePayg = productCanUsePayg && supportsPayg(subscription); | ||
| const shouldShowIncludedVolume = | ||
| !!activeProductTrial || | ||
| !!formattedPlatformReserved || | ||
| !!formattedAdditionalReserved || | ||
| !!formattedGifted; | ||
| const shouldShowReservedSpend = | ||
| defined(recurringReservedSpend) && subscription.canSelfServe; | ||
| const shouldShowAdditionalSpend = | ||
| shouldShowReservedSpend || canUsePayg || defined(formattedSoftCapType); | ||
|
|
||
| if (!shouldShowIncludedVolume && !shouldShowAdditionalSpend) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <Grid columns="repeat(2, 1fr)" gap="md lg" padding="xl"> | ||
| {shouldShowIncludedVolume && ( | ||
| <Flex direction="column" gap="lg"> | ||
| <Text bold>{t('Included volume')}</Text> | ||
| {activeProductTrial && ( | ||
| <UsageBreakdownField field={t('Trial')} value={UNLIMITED} /> | ||
| )} | ||
| {formattedPlatformReserved && ( | ||
| <UsageBreakdownField | ||
| field={platformReservedField} | ||
| value={formattedPlatformReserved} | ||
| /> | ||
| )} | ||
| {formattedAdditionalReserved && ( | ||
| <UsageBreakdownField | ||
| field={t('Additional reserved')} | ||
| value={formattedAdditionalReserved} | ||
| /> | ||
| )} | ||
| {formattedGifted && ( | ||
| <UsageBreakdownField field={t('Gifted')} value={formattedGifted} /> | ||
| )} | ||
| </Flex> | ||
| )} | ||
| {shouldShowAdditionalSpend && ( | ||
| <Flex direction="column" gap="lg"> | ||
| <Text bold>{t('Additional spend')}</Text> | ||
| {formattedSoftCapType && ( | ||
| <UsageBreakdownField | ||
| field={t('Soft cap type')} | ||
| value={formattedSoftCapType} | ||
| /> | ||
| )} | ||
| {canUsePayg && ( | ||
| <UsageBreakdownField | ||
| field={displayBudgetName(plan, {title: true})} | ||
| value={ | ||
| <Fragment> | ||
| {displayPriceWithCents({cents: paygSpend})} | ||
| {!!paygCategoryBudget && ( | ||
| <Fragment> | ||
| {' / '} | ||
| <Text variant="muted"> | ||
| {displayPriceWithCents({cents: paygCategoryBudget})} | ||
| </Text> | ||
| </Fragment> | ||
| )} | ||
| </Fragment> | ||
| } | ||
| help={tct( | ||
| "The amount of [budgetTerm] you've used so far on this product in the current month.", | ||
| { | ||
| budgetTerm: displayBudgetName(plan), | ||
| } | ||
| )} | ||
| /> | ||
| )} | ||
| {shouldShowReservedSpend && ( | ||
| <UsageBreakdownField | ||
| field={t('Reserved spend')} | ||
| value={displayPriceWithCents({cents: recurringReservedSpend})} | ||
| help={t( | ||
| 'The amount you spend on additional reserved volume for this product per billing cycle.' | ||
| )} | ||
| /> | ||
| )} | ||
| </Flex> | ||
| )} | ||
| </Grid> | ||
| ); | ||
| } | ||
|
|
||
| function DataCategoryUsageBreakdownInfo({ | ||
| subscription, | ||
| category, | ||
| metricHistory, | ||
| activeProductTrial, | ||
| }: DataCategoryUsageBreakdownInfoProps) { | ||
| const {planDetails: plan} = subscription; | ||
| const productCanUsePayg = plan.onDemandCategories.includes(category); | ||
| const platformReserved = | ||
| plan.planCategories[category]?.find( | ||
| bucket => bucket.price === 0 && bucket.events >= 0 | ||
| )?.events ?? 0; | ||
| const platformReservedField = tct('[planName] plan', {planName: plan.name}); | ||
| const reserved = metricHistory.reserved ?? 0; | ||
| const isUnlimited = reserved === UNLIMITED_RESERVED; | ||
|
|
||
| const addOnDataCategories = Object.values(plan.addOnCategories).flatMap( | ||
| addOn => addOn.dataCategories | ||
| ); | ||
| 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 formattedAdditionalReserved = shouldShowAdditionalReserved | ||
| ? formatReservedWithUnits(additionalReserved, category) | ||
| : null; | ||
|
|
||
| const gifted = metricHistory.free ?? 0; | ||
| const formattedGifted = isAddOnChildCategory | ||
| ? null | ||
| : formatReservedWithUnits(gifted, category); | ||
|
|
||
| const paygSpend = metricHistory.onDemandSpendUsed ?? 0; | ||
| const paygCategoryBudget = metricHistory.onDemandBudget ?? 0; | ||
|
|
||
| const recurringReservedSpend = isAddOnChildCategory | ||
| ? null | ||
| : (plan.planCategories[category]?.find(bucket => bucket.events === reserved)?.price ?? | ||
| 0); | ||
|
|
||
| return ( | ||
| <UsageBreakdownInfo | ||
| subscription={subscription} | ||
| plan={plan} | ||
| platformReservedField={platformReservedField} | ||
| formattedPlatformReserved={formattedPlatformReserved} | ||
| formattedAdditionalReserved={formattedAdditionalReserved} | ||
| formattedGifted={formattedGifted} | ||
| paygSpend={paygSpend} | ||
| paygCategoryBudget={paygCategoryBudget} | ||
| recurringReservedSpend={recurringReservedSpend} | ||
| productCanUsePayg={productCanUsePayg} | ||
| activeProductTrial={activeProductTrial} | ||
| formattedSoftCapType={getSoftCapType(metricHistory)} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| function ReservedBudgetUsageBreakdownInfo({ | ||
| subscription, | ||
| reservedBudget, | ||
| activeProductTrial, | ||
| }: ReservedBudgetUsageBreakdownInfoProps) { | ||
| const {planDetails: plan, categories: metricHistories} = subscription; | ||
| const productCanUsePayg = reservedBudget.dataCategories.every(category => | ||
| plan.onDemandCategories.includes(category) | ||
| ); | ||
| const onTrialOrSponsored = isTrialPlan(subscription.plan) || subscription.isSponsored; | ||
|
|
||
| const platformReservedField = onTrialOrSponsored | ||
| ? tct('[planName] plan', {planName: plan.name}) | ||
| : tct('[productName] monthly credits', { | ||
| productName: toTitleCase(reservedBudget.productName, { | ||
| allowInnerUpperCase: true, | ||
| }), | ||
| }); | ||
| const formattedPlatformReserved = displayPriceWithCents({ | ||
| cents: reservedBudget.reservedBudget, | ||
| }); | ||
|
|
||
| const formattedAdditionalReserved = null; | ||
| const formattedGifted = displayPriceWithCents({cents: reservedBudget.freeBudget}); | ||
| const paygSpend = reservedBudget.dataCategories.reduce((acc, category) => { | ||
| return acc + (metricHistories[category]?.onDemandSpendUsed ?? 0); | ||
| }, 0); | ||
|
|
||
| const billedCategory = reservedBudget.dataCategories[0]!; | ||
| const metricHistory = subscription.categories[billedCategory]; | ||
| if (!metricHistory) { | ||
| return null; | ||
| } | ||
| const recurringReservedSpend = | ||
| plan.planCategories[billedCategory]?.find( | ||
| bucket => bucket.events === metricHistory.reserved | ||
| )?.price ?? 0; | ||
|
|
||
| return ( | ||
| <UsageBreakdownInfo | ||
| subscription={subscription} | ||
| plan={plan} | ||
| platformReservedField={platformReservedField} | ||
| formattedPlatformReserved={formattedPlatformReserved} | ||
| formattedAdditionalReserved={formattedAdditionalReserved} | ||
| formattedGifted={formattedGifted} | ||
| paygSpend={paygSpend} | ||
| paygCategoryBudget={null} | ||
| recurringReservedSpend={recurringReservedSpend} | ||
| productCanUsePayg={productCanUsePayg} | ||
| activeProductTrial={activeProductTrial} | ||
| formattedSoftCapType={null} // Reserved budgets don't have soft caps, the individual categories in the budget may | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export {DataCategoryUsageBreakdownInfo, ReservedBudgetUsageBreakdownInfo}; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Gifted field displays zero values unnecessarily
The
formattedGiftedvariable is always computed regardless of whetherfreeBudgetorfreeis zero. SincedisplayPriceWithCents({cents: 0})returns"$0.00"andformatReservedWithUnits(0, category)returns"0"(both truthy strings), the conditional{formattedGifted && ...}will render the "Gifted" field showing "$0.00" or "0" when there's actually no gifted amount. The tests explicitly set non-zero gifted values, suggesting this zero case wasn't intended to display. Compare this toformattedAdditionalReservedwhich correctly uses a conditional check before formatting.Additional Locations (1)
static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx#L213-L217There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this was intentional however since this is not GA'd yet it's possible we will provide the same treatment as we do for additional reserved or vice versa later on