Browse Source

ref(perf): Add `percent_change` support to `MetricReadout` (#69672)

- Extract `PercentChange` component
- Add `percent_change` unit to `MetricReadout`

This is the last step to using `MetricReadout` in all our metrics
ribbons! Also as a side benefit, it lets you specify the preferred
polarity. e.g., you can tell it that a negative value is actually _good_
and should be green!
George Gritsouk 10 months ago
parent
commit
5ea6250435

+ 29 - 0
static/app/components/percentChange.spec.tsx

@@ -0,0 +1,29 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {PercentChange} from 'sentry/components/percentChange';
+
+describe('PercentChange', function () {
+  it('renders negative percent change', () => {
+    render(<PercentChange value={-0.2352} />);
+
+    expect(screen.getByText('-23.52%')).toBeInTheDocument();
+  });
+
+  it('renders positive percent change', () => {
+    render(<PercentChange value={0.0552} />);
+
+    expect(screen.getByText('+5.52%')).toBeInTheDocument();
+  });
+
+  it('respects preferred negative polarity', () => {
+    render(<PercentChange value={0.0552} preferredPolarity="-" />);
+
+    expect(screen.getByText('+5.52%')).toHaveAttribute('data-rating', 'bad');
+  });
+
+  it('respects preferred positive polarity', () => {
+    render(<PercentChange value={0.0552} preferredPolarity="+" />);
+
+    expect(screen.getByText('+5.52%')).toHaveAttribute('data-rating', 'good');
+  });
+});

+ 70 - 0
static/app/components/percentChange.tsx

@@ -0,0 +1,70 @@
+import styled from '@emotion/styled';
+
+import {NumberContainer} from 'sentry/utils/discover/styles';
+import {formatPercentage} from 'sentry/utils/formatters';
+
+interface Props extends React.HTMLAttributes<HTMLSpanElement> {
+  value: number;
+  colorize?: boolean;
+  minimumValue?: number;
+  preferredPolarity?: Polarity;
+}
+
+type Polarity = '+' | '-' | '';
+
+type Rating = 'good' | 'bad' | 'neutral';
+
+export function PercentChange({
+  value,
+  colorize = true,
+  preferredPolarity = '+',
+  minimumValue,
+  ...props
+}: Props) {
+  const polarity = getPolarity(value);
+  const rating = getPolarityRating(polarity, preferredPolarity);
+
+  return (
+    <NumberContainer {...props}>
+      <ColorizedRating rating={colorize ? rating : 'neutral'} data-rating={rating}>
+        {polarity}
+        {formatPercentage(Math.abs(value), 2, {minimumValue})}
+      </ColorizedRating>
+    </NumberContainer>
+  );
+}
+
+function getPolarity(value: number): Polarity {
+  if (value > 0) {
+    return '+';
+  }
+
+  if (value < 0) {
+    return '-';
+  }
+
+  return '';
+}
+
+function getPolarityRating(polarity: Polarity, preferredPolarity: Polarity): Rating {
+  if (polarity === preferredPolarity) {
+    return 'good';
+  }
+
+  if (polarity !== preferredPolarity) {
+    return 'bad';
+  }
+
+  return 'neutral';
+}
+
+const ColorizedRating = styled('div')<{
+  rating: Rating;
+}>`
+  color: ${p =>
+    p.rating === 'good'
+      ? p.theme.successText
+      : p.rating === 'bad'
+        ? p.theme.errorText
+        : p.theme.subText};
+`;

+ 2 - 0
static/app/utils/discover/fields.tsx

@@ -125,6 +125,8 @@ export type CountUnit = 'count';
 
 export type PercentageUnit = 'percentage';
 
+export type PercentChangeUnit = 'percent_change';
+
 export enum DurationUnit {
   NANOSECOND = 'nanosecond',
   MICROSECOND = 'microsecond',

+ 18 - 0
static/app/views/performance/metricReadout.spec.tsx

@@ -78,6 +78,24 @@ describe('MetricReadout', function () {
     expect(screen.getByText('<0.01%')).toBeInTheDocument();
   });
 
+  describe('percent_change', () => {
+    it('renders negative percent change', () => {
+      render(
+        <MetricReadout title="% Difference" unit="percent_change" value={-0.2352} />
+      );
+
+      expect(screen.getByRole('heading', {name: '% Difference'})).toBeInTheDocument();
+      expect(screen.getByText('-23.52%')).toBeInTheDocument();
+    });
+
+    it('renders positive percent change', () => {
+      render(<MetricReadout title="% Difference" unit="percent_change" value={0.0552} />);
+
+      expect(screen.getByRole('heading', {name: '% Difference'})).toBeInTheDocument();
+      expect(screen.getByText('+5.52%')).toBeInTheDocument();
+    });
+  });
+
   it('renders counts', () => {
     render(<MetricReadout title="Count" unit="count" value={7800123} />);
 

+ 19 - 2
static/app/views/performance/metricReadout.tsx

@@ -5,9 +5,14 @@ import styled from '@emotion/styled';
 import Duration from 'sentry/components/duration';
 import FileSize from 'sentry/components/fileSize';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {PercentChange} from 'sentry/components/percentChange';
 import {Tooltip} from 'sentry/components/tooltip';
 import {defined} from 'sentry/utils';
-import type {CountUnit, PercentageUnit} from 'sentry/utils/discover/fields';
+import type {
+  CountUnit,
+  PercentageUnit,
+  PercentChangeUnit,
+} from 'sentry/utils/discover/fields';
 import {DurationUnit, RateUnit, SizeUnit} from 'sentry/utils/discover/fields';
 import {
   formatAbbreviatedNumber,
@@ -21,7 +26,8 @@ type Unit =
   | SizeUnit.BYTE
   | RateUnit
   | CountUnit
-  | PercentageUnit;
+  | PercentageUnit
+  | PercentChangeUnit;
 
 interface Props {
   title: string;
@@ -107,6 +113,17 @@ function ReadoutContent({unit, value, tooltip, align = 'right', isLoading}: Prop
     );
   }
 
+  if (unit === 'percent_change') {
+    renderedValue = (
+      <NumberContainer align={align}>
+        <PercentChange
+          value={typeof value === 'string' ? parseFloat(value) : value}
+          minimumValue={MINIMUM_PERCENTAGE_VALUE}
+        />
+      </NumberContainer>
+    );
+  }
+
   if (tooltip) {
     return (
       <NumberContainer align={align}>

+ 2 - 25
static/app/views/starfish/components/tableCells/percentChangeCell.tsx

@@ -1,7 +1,5 @@
-import styled from '@emotion/styled';
-
+import {PercentChange} from 'sentry/components/percentChange';
 import {NumberContainer} from 'sentry/utils/discover/styles';
-import {formatPercentage} from 'sentry/utils/formatters';
 
 type PercentChangeCellProps = {
   deltaValue: number;
@@ -9,30 +7,9 @@ type PercentChangeCellProps = {
 };
 
 export function PercentChangeCell({deltaValue, colorize = true}: PercentChangeCellProps) {
-  const sign = deltaValue >= 0 ? '+' : '-';
-  const delta = formatPercentage(Math.abs(deltaValue), 2);
-  const trendDirection = deltaValue < 0 ? 'good' : deltaValue > 0 ? 'bad' : 'neutral';
-
   return (
     <NumberContainer>
-      <Colorized trendDirection={colorize ? trendDirection : 'neutral'}>
-        {sign}
-        {delta}
-      </Colorized>
+      <PercentChange value={deltaValue} colorize={colorize} />
     </NumberContainer>
   );
 }
-
-type ColorizedProps = {
-  children: React.ReactNode;
-  trendDirection: 'good' | 'bad' | 'neutral';
-};
-
-const Colorized = styled('div')<ColorizedProps>`
-  color: ${p =>
-    p.trendDirection === 'good'
-      ? p.theme.successText
-      : p.trendDirection === 'bad'
-        ? p.theme.errorText
-        : p.theme.subText};
-`;