Skip to content

Commit eb11092

Browse files
fix(sub v3): Make row clickability more obvious (#101467)
Tweaks the Usage Overview table to make it more obvious when you can click a row. Changes: - changes row background on hover - surfaces button on hover - add-on dropdown behavior now occurs on whole row, as opposed to just on a button in the first column for that add-on - adjusted some column widths for larger screens - opening a new drawer should replace the browser URL instead of adding to the browser history https://github.com/user-attachments/assets/a9f2087e-f751-4733-a4c8-df63e5478bd9 Closes BIL-1671 and BIL-1679
1 parent 1d5d202 commit eb11092

File tree

2 files changed

+117
-65
lines changed

2 files changed

+117
-65
lines changed

static/gsApp/views/subscriptionPage/usageOverview.spec.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,16 +174,14 @@ describe('UsageOverview', () => {
174174
/>
175175
);
176176
expect(screen.getByRole('cell', {name: 'Seer'})).toBeInTheDocument();
177-
expect(
178-
screen.getByRole('button', {name: 'Collapse Seer details'})
179-
).toBeInTheDocument();
177+
expect(screen.getByRole('row', {name: 'Collapse Seer details'})).toBeInTheDocument();
180178
expect(screen.getByRole('cell', {name: 'Issue Fixes'})).toBeInTheDocument();
181179
expect(screen.getByRole('cell', {name: 'Issue Scans'})).toBeInTheDocument();
182180

183181
// Org has Prevent flag but did not buy Prevent add on
184182
expect(screen.getByRole('cell', {name: 'Prevent'})).toBeInTheDocument();
185183
expect(
186-
screen.queryByRole('button', {name: 'Collapse Prevent details'})
184+
screen.queryByRole('row', {name: 'Collapse Prevent details'})
187185
).not.toBeInTheDocument();
188186
// We test it this way to ensure we don't show the cell with the proper display name or the raw DataCategory
189187
expect(screen.queryByRole('cell', {name: /Prevent*Users/})).not.toBeInTheDocument();

static/gsApp/views/subscriptionPage/usageOverview.tsx

Lines changed: 115 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, {useCallback, useEffect, useMemo, useState} from 'react';
2+
import {useTheme} from '@emotion/react';
23
import styled from '@emotion/styled';
34

45
import {Tag} from 'sentry/components/core/badge/tag';
@@ -25,6 +26,7 @@ import {defined} from 'sentry/utils';
2526
import getDaysSinceDate from 'sentry/utils/getDaysSinceDate';
2627
import {toTitleCase} from 'sentry/utils/string/toTitleCase';
2728
import {useLocation} from 'sentry/utils/useLocation';
29+
import useMedia from 'sentry/utils/useMedia';
2830
import {useNavigate} from 'sentry/utils/useNavigate';
2931
import {useNavContext} from 'sentry/views/nav/context';
3032
import {NavLayout} from 'sentry/views/nav/types';
@@ -72,6 +74,10 @@ interface UsageOverviewProps {
7274
usageData: CustomerUsage;
7375
}
7476

77+
// XXX: This is a hack to ensure that the grid rows don't change height
78+
// when hovering over the row (due to buttons that appear)
79+
const MIN_CONTENT_HEIGHT = '28px';
80+
7581
function CurrencyCell({
7682
children,
7783
bold,
@@ -106,28 +112,46 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
106112
const hasBillingPerms = organization.access.includes('org:billing');
107113
const navigate = useNavigate();
108114
const location = useLocation();
109-
const [openState, setOpenState] = useState<Record<string, boolean>>({});
115+
const [openState, setOpenState] = useState<Partial<Record<AddOnCategory, boolean>>>({});
116+
const [hoverState, setHoverState] = useState<Partial<Record<DataCategory, boolean>>>(
117+
{}
118+
);
110119
const {isDrawerOpen, openDrawer} = useDrawer();
120+
const [highlightedRow, setHighlightedRow] = useState<number | undefined>(undefined);
111121
const [trialButtonBusyState, setTrialButtonBusyState] = useState<
112122
Partial<Record<DataCategory, boolean>>
113123
>({});
124+
const theme = useTheme();
125+
const isXlScreen = useMedia(`(min-width: ${theme.breakpoints.xl})`);
114126

115-
const handleCloseDrawer = useCallback(
116-
(replace: boolean) => {
127+
const handleOpenDrawer = useCallback(
128+
(dataCategory: DataCategory) => {
117129
navigate(
118130
{
119131
pathname: location.pathname,
120-
query: {
121-
...location.query,
122-
drawer: undefined,
123-
},
132+
query: {...location.query, drawer: dataCategory},
124133
},
125-
{replace}
134+
{
135+
replace: true,
136+
}
126137
);
127138
},
128139
[navigate, location.query, location.pathname]
129140
);
130141

142+
const handleCloseDrawer = useCallback(() => {
143+
navigate(
144+
{
145+
pathname: location.pathname,
146+
query: {
147+
...location.query,
148+
drawer: undefined,
149+
},
150+
},
151+
{replace: true}
152+
);
153+
}, [navigate, location.query, location.pathname]);
154+
131155
useEffect(() => {
132156
Object.entries(subscription.addOns ?? {})
133157
.filter(
@@ -151,7 +175,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
151175
title: true,
152176
});
153177
if (!categoryInfo) {
154-
handleCloseDrawer(true);
178+
handleCloseDrawer();
155179
return;
156180
}
157181
openDrawer(
@@ -170,7 +194,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
170194
ariaLabel: t('Usage for %s', productName),
171195
drawerKey: 'usage-overview-drawer',
172196
resizable: false,
173-
onClose: () => handleCloseDrawer(false),
197+
onClose: () => handleCloseDrawer(),
174198
drawerWidth: '650px',
175199
}
176200
);
@@ -195,19 +219,23 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
195219
(trial.isStarted && trial.endDate && getDaysSinceDate(trial.endDate) <= 0)
196220
);
197221
return [
198-
{key: 'product', name: t('Product'), width: 300},
222+
{key: 'product', name: t('Product'), width: 250},
199223
{key: 'currentUsage', name: t('Current usage'), width: 200},
200224
{key: 'reservedUsage', name: t('Reserved usage'), width: 200},
201-
{key: 'reservedSpend', name: t('Reserved spend'), width: 200},
225+
{key: 'reservedSpend', name: t('Reserved spend'), width: isXlScreen ? 200 : 150},
202226
{
203227
key: 'budgetSpend',
204228
name: t('%s spend', displayBudgetName(subscription.planDetails, {title: true})),
205-
width: 200,
229+
width: isXlScreen ? 200 : 150,
206230
},
207231
{
208232
key: 'trialInfo',
209233
name: '',
210-
width: 50,
234+
width: 200,
235+
},
236+
{
237+
key: 'drawerButton',
238+
name: '',
211239
},
212240
].filter(
213241
column =>
@@ -225,13 +253,15 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
225253
subscription.canSelfServe,
226254
subscription.onDemandInvoiced,
227255
subscription.onDemandInvoicedManual,
256+
isXlScreen,
228257
]);
229258

230259
// TODO(isabella): refactor this to have better types
231260
const productData: Array<{
232261
budgetSpend: number;
233262
currentUsage: number;
234263
hasAccess: boolean;
264+
isClickable: boolean;
235265
isPaygOnly: boolean;
236266
isUnlimited: boolean;
237267
product: string;
@@ -253,6 +283,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
253283
.filter(metricHistory => !allAddOnDataCategories.includes(metricHistory.category))
254284
.map(metricHistory => {
255285
const category = metricHistory.category;
286+
const categoryInfo = getCategoryInfoFromPlural(category);
256287
const productName = getPlanCategoryName({
257288
plan: subscription.planDetails,
258289
category,
@@ -310,6 +341,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
310341
reservedSpend: recurringReservedSpend,
311342
budgetSpend: paygTotal,
312343
productTrialCategory: category,
344+
isClickable: categoryInfo?.tallyType === 'usage',
313345
};
314346
}),
315347
...Object.entries(subscription.addOns ?? {})
@@ -367,8 +399,9 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
367399

368400
// Only show child categories if the add-on is open and enabled
369401
const childCategoriesData =
370-
openState[apiName] && hasAccess
402+
openState[apiName as AddOnCategory] && hasAccess
371403
? addOnInfo.dataCategories.map(addOnDataCategory => {
404+
const categoryInfo = getCategoryInfoFromPlural(addOnDataCategory);
372405
const childSpend =
373406
reservedBudget?.categories[addOnDataCategory]?.reservedSpend ?? 0;
374407
const childPaygTotal =
@@ -386,14 +419,15 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
386419
addOnCategory: apiName as AddOnCategory,
387420
dataCategory: addOnDataCategory,
388421
isChildProduct: true,
389-
isOpen: openState[apiName],
422+
isOpen: openState[apiName as AddOnCategory],
390423
hasAccess: true,
391424
isPaygOnly: false,
392425
isUnlimited: !!activeProductTrial,
393426
softCapType: softCapType ?? undefined,
394427
budgetSpend: childPaygTotal,
395428
currentUsage: (childSpend ?? 0) + childPaygTotal,
396429
product: childProductName,
430+
isClickable: categoryInfo?.tallyType === 'usage',
397431
};
398432
})
399433
: null;
@@ -405,7 +439,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
405439
free: reservedBudget?.freeBudget ?? 0,
406440
reserved: reservedBudget?.reservedBudget ?? 0,
407441
isPaygOnly: !reservedBudget,
408-
isOpen: openState[apiName],
442+
isOpen: openState[apiName as AddOnCategory],
409443
toggleKey: hasAccess ? (apiName as AddOnCategory) : undefined,
410444
isUnlimited: !!activeProductTrial,
411445
productTrialCategory: addOnDataCategories[0] as DataCategory,
@@ -414,6 +448,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
414448
reservedUsage: percentUsed,
415449
reservedSpend: recurringReservedSpend,
416450
budgetSpend: paygTotal,
451+
isClickable: hasAccess,
417452
},
418453
...(childCategoriesData ?? []),
419454
];
@@ -456,6 +491,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
456491
softCapType,
457492
toggleKey,
458493
productTrialCategory,
494+
isClickable,
459495
} = row;
460496

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

486522
if (toggleKey) {
487523
return (
488-
<Container>
489-
<StyledButton
490-
borderless
491-
icon={
492-
isOpen ? (
493-
<IconChevron direction="up" />
494-
) : (
495-
<IconChevron direction="down" />
496-
)
497-
}
498-
aria-label={
499-
isOpen
500-
? t('Collapse %s details', product)
501-
: t('Expand %s details', product)
502-
}
503-
onClick={() =>
504-
setOpenState(prev => ({...prev, [toggleKey as string]: !isOpen}))
505-
}
506-
>
507-
{title}
508-
</StyledButton>
509-
</Container>
524+
<Flex align="center" gap="sm" minHeight={MIN_CONTENT_HEIGHT}>
525+
<IconChevron direction={isOpen ? 'up' : 'down'} />
526+
{title}
527+
</Flex>
510528
);
511529
}
512530
return (
513-
<Container paddingLeft={isChildProduct ? '2xl' : undefined}>
531+
<Flex
532+
paddingLeft={isChildProduct ? '2xl' : undefined}
533+
minHeight={MIN_CONTENT_HEIGHT}
534+
align="center"
535+
>
514536
{title}
515-
</Container>
537+
</Flex>
516538
);
517539
}
518540
case 'currentUsage': {
@@ -550,7 +572,7 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
550572
: `${formattedTotal} / ${formattedReservedTotal}`;
551573

552574
return (
553-
<Flex align="center" gap="sm">
575+
<Flex align="center" gap="sm" width="max-content">
554576
<Text as="div" textWrap="balance">
555577
{isUnlimited ? UNLIMITED : formattedCurrentUsage}{' '}
556578
{!(isPaygOnly || isChildProduct) && (
@@ -664,31 +686,67 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi
664686
}
665687
return <div />;
666688
}
689+
case 'drawerButton': {
690+
if (isClickable && dataCategory && hoverState[dataCategory]) {
691+
return (
692+
<Container alignSelf="end">
693+
<Button
694+
size="xs"
695+
aria-label={t('View %s usage', product)}
696+
icon={<IconChevron direction="right" />}
697+
onClick={() => handleOpenDrawer(dataCategory)}
698+
/>
699+
</Container>
700+
);
701+
}
702+
return <div />;
703+
}
667704
default:
668705
return row[column.key as keyof typeof row];
669706
}
670707
},
671708
}}
672-
isRowClickable={row =>
673-
!!row.dataCategory &&
674-
getCategoryInfoFromPlural(row.dataCategory)?.tallyType === 'usage'
675-
}
676-
onRowClick={row => {
709+
isRowClickable={row => row.isClickable}
710+
onRowMouseOver={(row, key) => {
711+
if (row.isClickable) {
712+
setHighlightedRow(key);
713+
if (row.dataCategory) {
714+
setHoverState(prev => ({...prev, [row.dataCategory as DataCategory]: true}));
715+
}
716+
}
717+
}}
718+
onRowMouseOut={row => {
719+
setHighlightedRow(undefined);
677720
if (row.dataCategory) {
678-
const categoryInfo = getCategoryInfoFromPlural(row.dataCategory);
679-
if (categoryInfo?.tallyType === 'usage') {
680-
navigate({
681-
pathname: location.pathname,
682-
query: {...location.query, drawer: row.dataCategory},
683-
});
721+
setHoverState(prev => ({...prev, [row.dataCategory as DataCategory]: false}));
722+
}
723+
}}
724+
highlightedRowKey={highlightedRow}
725+
onRowClick={row => {
726+
if (row.isClickable) {
727+
if (row.dataCategory) {
728+
handleOpenDrawer(row.dataCategory);
729+
} else if (row.addOnCategory) {
730+
setOpenState(prev => ({
731+
...prev,
732+
[row.addOnCategory as AddOnCategory]:
733+
!prev[row.addOnCategory as AddOnCategory],
734+
}));
684735
}
685736
}
686737
}}
687738
getRowAriaLabel={row => {
688-
if (row.dataCategory) {
689-
const categoryInfo = getCategoryInfoFromPlural(row.dataCategory);
690-
if (categoryInfo?.tallyType === 'usage') {
691-
return t('View %s usage', row.product);
739+
if (row.isClickable) {
740+
if (row.dataCategory) {
741+
const categoryInfo = getCategoryInfoFromPlural(row.dataCategory);
742+
if (categoryInfo?.tallyType === 'usage') {
743+
return t('View %s usage', row.product);
744+
}
745+
} else if (row.addOnCategory) {
746+
const isOpen = openState[row.addOnCategory];
747+
return isOpen
748+
? t('Collapse %s details', row.product)
749+
: t('Expand %s details', row.product);
692750
}
693751
}
694752
return undefined;
@@ -763,10 +821,6 @@ function UsageOverview({subscription, organization, usageData}: UsageOverviewPro
763821

764822
export default UsageOverview;
765823

766-
const StyledButton = styled(Button)`
767-
padding: 0;
768-
`;
769-
770824
const Bar = styled('div')<{
771825
fillPercentage: number;
772826
hasLeftBorderRadius?: boolean;

0 commit comments

Comments
 (0)