11import React , { useCallback , useEffect , useMemo , useState } from 'react' ;
2+ import { useTheme } from '@emotion/react' ;
23import styled from '@emotion/styled' ;
34
45import { Tag } from 'sentry/components/core/badge/tag' ;
@@ -25,6 +26,7 @@ import {defined} from 'sentry/utils';
2526import getDaysSinceDate from 'sentry/utils/getDaysSinceDate' ;
2627import { toTitleCase } from 'sentry/utils/string/toTitleCase' ;
2728import { useLocation } from 'sentry/utils/useLocation' ;
29+ import useMedia from 'sentry/utils/useMedia' ;
2830import { useNavigate } from 'sentry/utils/useNavigate' ;
2931import { useNavContext } from 'sentry/views/nav/context' ;
3032import { 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+
7581function 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
764822export default UsageOverview ;
765823
766- const StyledButton = styled ( Button ) `
767- padding: 0;
768- ` ;
769-
770824const Bar = styled ( 'div' ) < {
771825 fillPercentage : number ;
772826 hasLeftBorderRadius ?: boolean ;
0 commit comments