Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions static/gsApp/views/subscriptionPage/usageOverview.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,16 +174,14 @@ describe('UsageOverview', () => {
/>
);
expect(screen.getByRole('cell', {name: 'Seer'})).toBeInTheDocument();
expect(
screen.getByRole('button', {name: 'Collapse Seer details'})
).toBeInTheDocument();
expect(screen.getByRole('row', {name: 'Collapse Seer details'})).toBeInTheDocument();
expect(screen.getByRole('cell', {name: 'Issue Fixes'})).toBeInTheDocument();
expect(screen.getByRole('cell', {name: 'Issue Scans'})).toBeInTheDocument();

// Org has Prevent flag but did not buy Prevent add on
expect(screen.getByRole('cell', {name: 'Prevent'})).toBeInTheDocument();
expect(
screen.queryByRole('button', {name: 'Collapse Prevent details'})
screen.queryByRole('row', {name: 'Collapse Prevent details'})
).not.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: /Prevent*Users/})).not.toBeInTheDocument();
Expand Down
176 changes: 115 additions & 61 deletions static/gsApp/views/subscriptionPage/usageOverview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';

import {Tag} from 'sentry/components/core/badge/tag';
Expand All @@ -25,6 +26,7 @@ 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';
Expand Down Expand Up @@ -72,6 +74,10 @@ interface UsageOverviewProps {
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,
Expand Down Expand Up @@ -106,28 +112,46 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
const hasBillingPerms = organization.access.includes('org:billing');
const navigate = useNavigate();
const location = useLocation();
const [openState, setOpenState] = useState<Record<string, boolean>>({});
const [openState, setOpenState] = useState<Partial<Record<AddOnCategory, boolean>>>({});
const [hoverState, setHoverState] = useState<Partial<Record<DataCategory, boolean>>>(
{}
);
const {isDrawerOpen, openDrawer} = useDrawer();
const [highlightedRow, setHighlightedRow] = useState<number | undefined>(undefined);
const [trialButtonBusyState, setTrialButtonBusyState] = useState<
Partial<Record<DataCategory, boolean>>
>({});
const theme = useTheme();
const isXlScreen = useMedia(`(min-width: ${theme.breakpoints.xl})`);

const handleCloseDrawer = useCallback(
(replace: boolean) => {
const handleOpenDrawer = useCallback(
(dataCategory: DataCategory) => {
navigate(
{
pathname: location.pathname,
query: {
...location.query,
drawer: undefined,
},
query: {...location.query, drawer: dataCategory},
},
{replace}
{
replace: true,
}
);
},
[navigate, location.query, location.pathname]
);

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(
Expand All @@ -151,7 +175,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
title: true,
});
if (!categoryInfo) {
handleCloseDrawer(true);
handleCloseDrawer();
return;
}
openDrawer(
Expand All @@ -170,7 +194,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
ariaLabel: t('Usage for %s', productName),
drawerKey: 'usage-overview-drawer',
resizable: false,
onClose: () => handleCloseDrawer(false),
onClose: () => handleCloseDrawer(),
drawerWidth: '650px',
}
);
Expand All @@ -195,19 +219,23 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
(trial.isStarted && trial.endDate && getDaysSinceDate(trial.endDate) <= 0)
);
return [
{key: 'product', name: t('Product'), width: 300},
{key: 'product', name: t('Product'), width: 250},
{key: 'currentUsage', name: t('Current usage'), width: 200},
{key: 'reservedUsage', name: t('Reserved usage'), width: 200},
{key: 'reservedSpend', name: t('Reserved spend'), width: 200},
{key: 'reservedSpend', name: t('Reserved spend'), width: isXlScreen ? 200 : 150},
{
key: 'budgetSpend',
name: t('%s spend', displayBudgetName(subscription.planDetails, {title: true})),
width: 200,
width: isXlScreen ? 200 : 150,
},
{
key: 'trialInfo',
name: '',
width: 50,
width: 200,
},
{
key: 'drawerButton',
name: '',
},
].filter(
column =>
Expand All @@ -225,13 +253,15 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
subscription.canSelfServe,
subscription.onDemandInvoiced,
subscription.onDemandInvoicedManual,
isXlScreen,
]);

// TODO(isabella): refactor this to have better types
const productData: Array<{
budgetSpend: number;
currentUsage: number;
hasAccess: boolean;
isClickable: boolean;
isPaygOnly: boolean;
isUnlimited: boolean;
product: string;
Expand All @@ -253,6 +283,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
.filter(metricHistory => !allAddOnDataCategories.includes(metricHistory.category))
.map(metricHistory => {
const category = metricHistory.category;
const categoryInfo = getCategoryInfoFromPlural(category);
const productName = getPlanCategoryName({
plan: subscription.planDetails,
category,
Expand Down Expand Up @@ -310,6 +341,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
reservedSpend: recurringReservedSpend,
budgetSpend: paygTotal,
productTrialCategory: category,
isClickable: categoryInfo?.tallyType === 'usage',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would customer be aware of why the row is not clickable?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be noted in the docs but it's the same reason we don't show seat-based categories in the reserved usage chart nor with a breakdown in existing UI

};
}),
...Object.entries(subscription.addOns ?? {})
Expand Down Expand Up @@ -367,8 +399,9 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi

// Only show child categories if the add-on is open and enabled
const childCategoriesData =
openState[apiName] && hasAccess
openState[apiName as AddOnCategory] && hasAccess
? addOnInfo.dataCategories.map(addOnDataCategory => {
const categoryInfo = getCategoryInfoFromPlural(addOnDataCategory);
const childSpend =
reservedBudget?.categories[addOnDataCategory]?.reservedSpend ?? 0;
const childPaygTotal =
Expand All @@ -386,14 +419,15 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
addOnCategory: apiName as AddOnCategory,
dataCategory: addOnDataCategory,
isChildProduct: true,
isOpen: openState[apiName],
isOpen: openState[apiName as AddOnCategory],
hasAccess: true,
isPaygOnly: false,
isUnlimited: !!activeProductTrial,
softCapType: softCapType ?? undefined,
budgetSpend: childPaygTotal,
currentUsage: (childSpend ?? 0) + childPaygTotal,
product: childProductName,
isClickable: categoryInfo?.tallyType === 'usage',
};
})
: null;
Expand All @@ -405,7 +439,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
free: reservedBudget?.freeBudget ?? 0,
reserved: reservedBudget?.reservedBudget ?? 0,
isPaygOnly: !reservedBudget,
isOpen: openState[apiName],
isOpen: openState[apiName as AddOnCategory],
toggleKey: hasAccess ? (apiName as AddOnCategory) : undefined,
isUnlimited: !!activeProductTrial,
productTrialCategory: addOnDataCategories[0] as DataCategory,
Expand All @@ -414,6 +448,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
reservedUsage: percentUsed,
reservedSpend: recurringReservedSpend,
budgetSpend: paygTotal,
isClickable: hasAccess,
},
...(childCategoriesData ?? []),
];
Expand Down Expand Up @@ -456,6 +491,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
softCapType,
toggleKey,
productTrialCategory,
isClickable,
} = row;

const productTrial = productTrialCategory
Expand Down Expand Up @@ -485,34 +521,20 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi

if (toggleKey) {
return (
<Container>
<StyledButton
borderless
icon={
isOpen ? (
<IconChevron direction="up" />
) : (
<IconChevron direction="down" />
)
}
aria-label={
isOpen
? t('Collapse %s details', product)
: t('Expand %s details', product)
}
onClick={() =>
setOpenState(prev => ({...prev, [toggleKey as string]: !isOpen}))
}
>
{title}
</StyledButton>
</Container>
<Flex align="center" gap="sm" minHeight={MIN_CONTENT_HEIGHT}>
<IconChevron direction={isOpen ? 'up' : 'down'} />
{title}
</Flex>
);
}
return (
<Container paddingLeft={isChildProduct ? '2xl' : undefined}>
<Flex
paddingLeft={isChildProduct ? '2xl' : undefined}
minHeight={MIN_CONTENT_HEIGHT}
align="center"
>
{title}
</Container>
</Flex>
);
}
case 'currentUsage': {
Expand Down Expand Up @@ -550,7 +572,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
: `${formattedTotal} / ${formattedReservedTotal}`;

return (
<Flex align="center" gap="sm">
<Flex align="center" gap="sm" width="max-content">
<Text as="div" textWrap="balance">
{isUnlimited ? UNLIMITED : formattedCurrentUsage}{' '}
{!(isPaygOnly || isChildProduct) && (
Expand Down Expand Up @@ -664,31 +686,67 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
}
return <div />;
}
case 'drawerButton': {
if (isClickable && dataCategory && hoverState[dataCategory]) {
return (
<Container alignSelf="end">
<Button
size="xs"
aria-label={t('View %s usage', product)}
icon={<IconChevron direction="right" />}
onClick={() => handleOpenDrawer(dataCategory)}
/>
</Container>
);
}
return <div />;
}
default:
return row[column.key as keyof typeof row];
}
},
}}
isRowClickable={row =>
!!row.dataCategory &&
getCategoryInfoFromPlural(row.dataCategory)?.tallyType === 'usage'
}
onRowClick={row => {
isRowClickable={row => row.isClickable}
onRowMouseOver={(row, key) => {
if (row.isClickable) {
setHighlightedRow(key);
if (row.dataCategory) {
setHoverState(prev => ({...prev, [row.dataCategory as DataCategory]: true}));
}
}
}}
onRowMouseOut={row => {
setHighlightedRow(undefined);
if (row.dataCategory) {
const categoryInfo = getCategoryInfoFromPlural(row.dataCategory);
if (categoryInfo?.tallyType === 'usage') {
navigate({
pathname: location.pathname,
query: {...location.query, drawer: row.dataCategory},
});
setHoverState(prev => ({...prev, [row.dataCategory as DataCategory]: false}));
}
}}
highlightedRowKey={highlightedRow}
onRowClick={row => {
if (row.isClickable) {
if (row.dataCategory) {
handleOpenDrawer(row.dataCategory);
} else if (row.addOnCategory) {
setOpenState(prev => ({
...prev,
[row.addOnCategory as AddOnCategory]:
!prev[row.addOnCategory as AddOnCategory],
}));
}
}
}}
getRowAriaLabel={row => {
if (row.dataCategory) {
const categoryInfo = getCategoryInfoFromPlural(row.dataCategory);
if (categoryInfo?.tallyType === 'usage') {
return t('View %s usage', row.product);
if (row.isClickable) {
if (row.dataCategory) {
const categoryInfo = getCategoryInfoFromPlural(row.dataCategory);
if (categoryInfo?.tallyType === 'usage') {
return t('View %s usage', row.product);
}
} else if (row.addOnCategory) {
const isOpen = openState[row.addOnCategory];
return isOpen
? t('Collapse %s details', row.product)
: t('Expand %s details', row.product);
}
}
return undefined;
Expand Down Expand Up @@ -763,10 +821,6 @@ function UsageOverview({subscription, organization, usageData}: UsageOverviewPro

export default UsageOverview;

const StyledButton = styled(Button)`
padding: 0;
`;

const Bar = styled('div')<{
fillPercentage: number;
hasLeftBorderRadius?: boolean;
Expand Down
Loading