Browse Source

ref(ui): Convert usage chart to fc (#69268)

Scott Cooper 10 months ago
parent
commit
d759688a24
1 changed files with 306 additions and 316 deletions
  1. 306 316
      static/app/views/organizationStats/usageChart/index.tsx

+ 306 - 316
static/app/views/organizationStats/usageChart/index.tsx

@@ -1,6 +1,4 @@
-import {Component, Fragment} from 'react';
-import type {Theme} from '@emotion/react';
-import {withTheme} from '@emotion/react';
+import {type Theme, useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 import Color from 'color';
 import type {
@@ -22,9 +20,8 @@ import {DATA_CATEGORY_INFO} from 'sentry/constants';
 import {IconWarning} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import type {DataCategoryInfo, IntervalPeriod, SelectValue} from 'sentry/types';
+import type {DataCategoryInfo, IntervalPeriod, SelectValue} from 'sentry/types/core';
 import {parsePeriodToHours, statsPeriodToDays} from 'sentry/utils/dates';
-import getDynamicText from 'sentry/utils/getDynamicText';
 import commonTheme from 'sentry/utils/theme';
 
 import {formatUsageWithUnits} from '../utils';
@@ -32,7 +29,6 @@ import {formatUsageWithUnits} from '../utils';
 import {getTooltipFormatter, getXAxisDates, getXAxisLabelInterval} from './utils';
 
 const GIGABYTE = 10 ** 9;
-type ChartProps = React.ComponentProps<typeof BaseChart>;
 
 const COLOR_ERRORS = Color(commonTheme.dataCategory.errors).lighten(0.25).string();
 const COLOR_TRANSACTIONS = Color(commonTheme.dataCategory.transactions)
@@ -116,38 +112,10 @@ export enum SeriesTypes {
   FILTERED = 'Filtered',
 }
 
-type DefaultProps = {
-  /**
-   * Config for category dropdown options
-   */
-  categoryOptions: CategoryOption[];
-  /**
-   * Modify the usageStats using the transformation method selected.
-   * 1. This must be a pure function!
-   * 2. If the parent component will handle the data transformation, you should
-   *    replace this prop with "(s) => {return s}"
-   */
-  handleDataTransformation: (
-    stats: ChartStats,
-    transform: ChartDataTransform
-  ) => ChartStats;
-
-  /**
-   * Intervals between the x-axis values
-   */
-  usageDateInterval: IntervalPeriod;
-
-  /**
-   * Display datetime in UTC
-   */
-  usageDateShowUtc: boolean;
-};
-
-export type UsageChartProps = DefaultProps & {
+export type UsageChartProps = {
   dataCategory: DataCategoryInfo['plural'];
 
   dataTransform: ChartDataTransform;
-  theme: Theme;
   usageDateEnd: string;
 
   usageDateStart: string;
@@ -161,27 +129,76 @@ export type UsageChartProps = DefaultProps & {
    */
   categoryColors?: string[];
 
+  /**
+   * Config for category dropdown options
+   */
+  categoryOptions?: CategoryOption[];
   /**
    * Additional data to draw on the chart alongside usage
    */
   chartSeries?: SeriesOption[];
+
   /**
    * Replace default tooltip
    */
   chartTooltip?: TooltipComponentOption;
-
   errors?: Record<string, Error>;
-  footer?: React.ReactNode;
 
-  isError?: boolean;
+  /**
+   * Modify the usageStats using the transformation method selected.
+   * If the parent component will handle the data transformation, you should
+   *    replace this prop with "(s) => {return s}"
+   */
+  handleDataTransformation?: (
+    stats: Readonly<ChartStats>,
+    transform: Readonly<ChartDataTransform>
+  ) => ChartStats;
 
+  isError?: boolean;
   isLoading?: boolean;
 
-  title?: React.ReactNode;
+  /**
+   * Intervals between the x-axis values
+   */
+  usageDateInterval?: IntervalPeriod;
+
+  /**
+   * Display datetime in UTC
+   */
+  usageDateShowUtc?: boolean;
 };
 
-type State = {
-  xAxisDates: string[];
+/**
+ * When the data transformation is set to cumulative, the chart will display
+ * the total sum of the data points up to that point.
+ */
+const cumulativeTotalDataTransformation: UsageChartProps['handleDataTransformation'] = (
+  stats,
+  transform
+) => {
+  const chartData: ChartStats = {
+    accepted: [],
+    dropped: [],
+    projected: [],
+    filtered: [],
+  };
+  const isCumulative = transform === ChartDataTransform.CUMULATIVE;
+
+  Object.keys(stats).forEach(k => {
+    let count = 0;
+
+    chartData[k] = stats[k].map((stat: any) => {
+      const [x, y] = stat.value;
+      count = isCumulative ? count + y : y;
+
+      return {
+        ...stat,
+        value: [x, count],
+      };
+    });
+  });
+
+  return chartData;
 };
 
 export type ChartStats = {
@@ -191,41 +208,75 @@ export type ChartStats = {
   filtered?: NonNullable<BarSeriesOption['data']>;
 };
 
-export class UsageChart extends Component<UsageChartProps, State> {
-  static defaultProps: DefaultProps = {
-    categoryOptions: CHART_OPTIONS_DATACATEGORY,
-    usageDateShowUtc: true,
-    usageDateInterval: '1d',
-    handleDataTransformation: (stats, transform) => {
-      const chartData: ChartStats = {
-        accepted: [],
-        dropped: [],
-        projected: [],
-        filtered: [],
-      };
-      const isCumulative = transform === ChartDataTransform.CUMULATIVE;
+function chartMetadata({
+  categoryOptions,
+  dataCategory,
+  usageStats,
+  dataTransform,
+  usageDateStart,
+  usageDateEnd,
+  usageDateInterval,
+  usageDateShowUtc,
+  handleDataTransformation,
+}: Required<
+  Pick<
+    UsageChartProps,
+    | 'categoryOptions'
+    | 'dataCategory'
+    | 'handleDataTransformation'
+    | 'usageStats'
+    | 'dataTransform'
+    | 'usageDateStart'
+    | 'usageDateEnd'
+    | 'usageDateInterval'
+    | 'usageDateShowUtc'
+  >
+>): {
+  chartData: ChartStats;
+  chartLabel: React.ReactNode;
+  tooltipValueFormatter: (val?: number) => string;
+  xAxisData: string[];
+  xAxisLabelInterval: number;
+  xAxisTickInterval: number;
+  yAxisFormatter: (val: number) => string;
+  yAxisMinInterval: number;
+} {
+  const selectDataCategory = categoryOptions.find(o => o.value === dataCategory);
+  if (!selectDataCategory) {
+    throw new Error('Selected item is not supported');
+  }
 
-      Object.keys(stats).forEach(k => {
-        let count = 0;
+  // Do not assume that handleDataTransformation is a pure function
+  const chartData: ChartStats = {
+    ...handleDataTransformation(usageStats, dataTransform),
+  };
 
-        chartData[k] = stats[k].map(stat => {
-          const [x, y] = stat.value;
-          count = isCumulative ? count + y : y;
+  Object.keys(chartData).forEach(k => {
+    const isProjected = k === SeriesTypes.PROJECTED;
 
-          return {
-            ...stat,
-            value: [x, count],
-          };
-        });
-      });
+    // Map the array and destructure elements to avoid side-effects
+    chartData[k] = chartData[k]?.map((stat: any) => {
+      return {
+        ...stat,
+        tooltip: {show: false},
+        itemStyle: {opacity: isProjected ? 0.6 : 1},
+      };
+    });
+  });
 
-      return chartData;
-    },
-  };
+  // Use hours as common units
+  const dataPeriod = statsPeriodToDays(undefined, usageDateStart, usageDateEnd) * 24;
+  const barPeriod = parsePeriodToHours(usageDateInterval);
+  if (dataPeriod < 0 || barPeriod < 0) {
+    throw new Error('UsageChart: Unable to parse data time period');
+  }
 
-  state: State = {
-    xAxisDates: [],
-  };
+  const {xAxisTickInterval, xAxisLabelInterval} = getXAxisLabelInterval(
+    dataPeriod,
+    dataPeriod / barPeriod
+  );
+
+  const {label, yAxisMinInterval} = selectDataCategory;
 
   /**
    * UsageChart needs to generate the X-Axis dates as props.usageStats may
@@ -234,157 +285,103 @@ export class UsageChart extends Component<UsageChartProps, State> {
    * E.g. usageStats.accepted covers day 1-15 of a month, usageStats.projected
    * either covers day 16-30 or may not be available at all.
    */
-  static getDerivedStateFromProps(
-    nextProps: Readonly<UsageChartProps>,
-    prevState: State
-  ): State {
-    const {usageDateStart, usageDateEnd, usageDateShowUtc, usageDateInterval} = nextProps;
-
-    return {
-      ...prevState,
-      xAxisDates: getXAxisDates(
-        usageDateStart,
-        usageDateEnd,
-        usageDateShowUtc,
-        usageDateInterval
-      ),
-    };
-  }
-
-  get chartColors() {
-    const {dataCategory, theme} = this.props;
-    const COLOR_PROJECTED = theme.chartOther;
-
-    if (dataCategory === DATA_CATEGORY_INFO.error.plural) {
-      return [COLOR_ERRORS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
-    }
+  const xAxisDates = getXAxisDates(
+    usageDateStart,
+    usageDateEnd,
+    usageDateShowUtc,
+    usageDateInterval
+  );
+
+  return {
+    chartLabel: label,
+    chartData,
+    xAxisData: xAxisDates,
+    xAxisTickInterval,
+    xAxisLabelInterval,
+    yAxisMinInterval,
+    yAxisFormatter: (val: number) =>
+      formatUsageWithUnits(val, dataCategory, {
+        isAbbreviated: true,
+        useUnitScaling: true,
+      }),
+    tooltipValueFormatter: getTooltipFormatter(dataCategory),
+  };
+}
 
-    if (dataCategory === DATA_CATEGORY_INFO.attachment.plural) {
-      return [COLOR_ATTACHMENTS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
-    }
+function chartColors(theme: Theme, dataCategory: UsageChartProps['dataCategory']) {
+  const COLOR_PROJECTED = theme.chartOther;
 
-    return [COLOR_TRANSACTIONS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
+  if (dataCategory === DATA_CATEGORY_INFO.error.plural) {
+    return [COLOR_ERRORS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
   }
 
-  get chartMetadata(): {
-    chartData: ChartStats;
-    chartLabel: React.ReactNode;
-    tooltipValueFormatter: (val?: number) => string;
-    xAxisData: string[];
-    xAxisLabelInterval: number;
-    xAxisTickInterval: number;
-    yAxisFormatter: (val: number) => string;
-    yAxisMinInterval: number;
-  } {
-    const {categoryOptions, usageDateStart, usageDateEnd} = this.props;
-    const {
-      usageDateInterval,
-      usageStats,
-      dataCategory,
-      dataTransform,
-      handleDataTransformation,
-    } = this.props;
-    const {xAxisDates} = this.state;
-
-    const selectDataCategory = categoryOptions.find(o => o.value === dataCategory);
-    if (!selectDataCategory) {
-      throw new Error('Selected item is not supported');
-    }
-
-    // Do not assume that handleDataTransformation is a pure function
-    const chartData: ChartStats = {
-      ...handleDataTransformation(usageStats, dataTransform),
-    };
-
-    Object.keys(chartData).forEach(k => {
-      const isProjected = k === SeriesTypes.PROJECTED;
-
-      // Map the array and destructure elements to avoid side-effects
-      chartData[k] = chartData[k].map(stat => {
-        return {
-          ...stat,
-          tooltip: {show: false},
-          itemStyle: {opacity: isProjected ? 0.6 : 1},
-        };
-      });
-    });
+  if (dataCategory === DATA_CATEGORY_INFO.attachment.plural) {
+    return [COLOR_ATTACHMENTS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
+  }
 
-    // Use hours as common units
-    const dataPeriod = statsPeriodToDays(undefined, usageDateStart, usageDateEnd) * 24;
-    const barPeriod = parsePeriodToHours(usageDateInterval);
-    if (dataPeriod < 0 || barPeriod < 0) {
-      throw new Error('UsageChart: Unable to parse data time period');
-    }
+  return [COLOR_TRANSACTIONS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
+}
 
-    const {xAxisTickInterval, xAxisLabelInterval} = getXAxisLabelInterval(
-      dataPeriod,
-      dataPeriod / barPeriod
+function UsageChartBody({
+  usageDateStart,
+  usageDateEnd,
+  usageStats,
+  dataCategory,
+  dataTransform,
+  chartSeries,
+  chartTooltip,
+  categoryColors,
+  isLoading,
+  isError,
+  errors,
+  categoryOptions = CHART_OPTIONS_DATACATEGORY,
+  usageDateInterval = '1d',
+  usageDateShowUtc = true,
+  handleDataTransformation = cumulativeTotalDataTransformation,
+}: UsageChartProps) {
+  const theme = useTheme();
+
+  if (isLoading) {
+    return (
+      <Placeholder height="200px">
+        <LoadingIndicator mini />
+      </Placeholder>
     );
-
-    const {label, yAxisMinInterval} = selectDataCategory;
-
-    return {
-      chartLabel: label,
-      chartData,
-      xAxisData: xAxisDates,
-      xAxisTickInterval,
-      xAxisLabelInterval,
-      yAxisMinInterval,
-      yAxisFormatter: (val: number) =>
-        formatUsageWithUnits(val, dataCategory, {
-          isAbbreviated: true,
-          useUnitScaling: true,
-        }),
-      tooltipValueFormatter: getTooltipFormatter(dataCategory),
-    };
   }
 
-  get chartSeries() {
-    const {chartSeries} = this.props;
-    const {chartData} = this.chartMetadata;
-
-    let series: SeriesOption[] = [
-      barSeries({
-        name: SeriesTypes.ACCEPTED,
-        data: chartData.accepted,
-        barMinHeight: 1,
-        stack: 'usage',
-        legendHoverLink: false,
-      }),
-      barSeries({
-        name: SeriesTypes.FILTERED,
-        data: chartData.filtered,
-        barMinHeight: 1,
-        stack: 'usage',
-        legendHoverLink: false,
-      }),
-      barSeries({
-        name: SeriesTypes.DROPPED,
-        data: chartData.dropped,
-        stack: 'usage',
-        legendHoverLink: false,
-      }),
-      barSeries({
-        name: SeriesTypes.PROJECTED,
-        data: chartData.projected,
-        barMinHeight: 1,
-        stack: 'usage',
-        legendHoverLink: false,
-      }),
-    ];
-
-    // Additional series passed by parent component
-    if (chartSeries) {
-      series = series.concat(chartSeries as SeriesOption[]);
-    }
-
-    return series;
+  if (isError) {
+    return (
+      <Placeholder height="200px">
+        <IconWarning size="sm" />
+        <ErrorMessages data-test-id="error-messages">
+          {errors &&
+            Object.keys(errors).map(k => <span key={k}>{errors[k]?.message}</span>)}
+        </ErrorMessages>
+      </Placeholder>
+    );
   }
 
-  get chartLegendData() {
-    const {chartSeries} = this.props;
-    const {chartData} = this.chartMetadata;
-
+  const {
+    chartData,
+    tooltipValueFormatter,
+    xAxisData,
+    xAxisTickInterval,
+    xAxisLabelInterval,
+    yAxisMinInterval,
+    yAxisFormatter,
+  } = chartMetadata({
+    categoryOptions,
+    dataCategory,
+    handleDataTransformation: handleDataTransformation!,
+    usageStats,
+    dataTransform,
+    usageDateStart,
+    usageDateEnd,
+    usageDateInterval,
+    usageDateShowUtc,
+  });
+
+  function chartLegendData() {
     const legend: LegendComponentOption['data'] = [
       {
         name: SeriesTypes.ACCEPTED,
@@ -418,116 +415,109 @@ export class UsageChart extends Component<UsageChartProps, State> {
     return legend;
   }
 
-  get chartTooltip(): ChartProps['tooltip'] {
-    const {chartTooltip} = this.props;
-
-    if (chartTooltip) {
-      return chartTooltip;
-    }
-
-    const {tooltipValueFormatter} = this.chartMetadata;
-
-    return {
-      // Trigger to axis prevents tooltip from redrawing when hovering
-      // over individual bars
-      trigger: 'axis',
-      valueFormatter: tooltipValueFormatter,
-    };
-  }
-
-  renderChart() {
-    const {categoryColors, theme, title, isLoading, isError, errors} = this.props;
-    if (isLoading) {
-      return (
-        <Placeholder height="200px">
-          <LoadingIndicator mini />
-        </Placeholder>
-      );
-    }
-
-    if (isError) {
-      return (
-        <Placeholder height="200px">
-          <IconWarning size="sm" />
-          <ErrorMessages data-test-id="error-messages">
-            {errors &&
-              Object.keys(errors).map(k => <span key={k}>{errors[k]?.message}</span>)}
-          </ErrorMessages>
-        </Placeholder>
-      );
-    }
-
-    const {
-      xAxisData,
-      xAxisTickInterval,
-      xAxisLabelInterval,
-      yAxisMinInterval,
-      yAxisFormatter,
-    } = this.chartMetadata;
+  const colors = categoryColors?.length
+    ? categoryColors
+    : chartColors(theme, dataCategory);
+
+  const series: SeriesOption[] = [
+    barSeries({
+      name: SeriesTypes.ACCEPTED,
+      data: chartData.accepted,
+      barMinHeight: 1,
+      stack: 'usage',
+      legendHoverLink: false,
+    }),
+    barSeries({
+      name: SeriesTypes.FILTERED,
+      data: chartData.filtered,
+      barMinHeight: 1,
+      stack: 'usage',
+      legendHoverLink: false,
+    }),
+    barSeries({
+      name: SeriesTypes.DROPPED,
+      data: chartData.dropped,
+      stack: 'usage',
+      legendHoverLink: false,
+    }),
+    barSeries({
+      name: SeriesTypes.PROJECTED,
+      data: chartData.projected,
+      barMinHeight: 1,
+      stack: 'usage',
+      legendHoverLink: false,
+    }),
+    // Additional series passed by parent component
+    ...(chartSeries || []),
+  ];
+
+  return (
+    <BaseChart
+      colors={colors}
+      grid={{bottom: '3px', left: '0px', right: '10px', top: '40px'}}
+      xAxis={xAxis({
+        show: true,
+        type: 'category',
+        name: 'Date',
+        data: xAxisData,
+        axisTick: {
+          interval: xAxisTickInterval,
+          alignWithLabel: true,
+        },
+        axisLabel: {
+          interval: xAxisLabelInterval,
+          formatter: (label: string) => label.slice(0, 6), // Limit label to 6 chars
+        },
+        theme,
+      })}
+      yAxis={{
+        min: 0,
+        minInterval: yAxisMinInterval,
+        axisLabel: {
+          formatter: yAxisFormatter,
+          color: theme.chartLabel,
+        },
+      }}
+      series={series}
+      tooltip={
+        chartTooltip
+          ? chartTooltip
+          : {
+              // Trigger to axis prevents tooltip from redrawing when hovering
+              // over individual bars
+              trigger: 'axis',
+              valueFormatter: tooltipValueFormatter,
+            }
+      }
+      onLegendSelectChanged={() => {}}
+      legend={Legend({
+        right: 10,
+        top: 5,
+        data: chartLegendData(),
+        theme,
+      })}
+    />
+  );
+}
 
-    const colors = categoryColors?.length ? categoryColors : this.chartColors;
+interface UsageChartPanelProps extends UsageChartProps {
+  footer?: React.ReactNode;
+  title?: React.ReactNode;
+}
 
-    return (
-      <Fragment>
+function UsageChart({title, footer, ...props}: UsageChartPanelProps) {
+  return (
+    <Panel id="usage-chart" data-test-id="usage-chart">
+      <ChartContainer>
         <HeaderTitleLegend>{title || t('Current Usage Period')}</HeaderTitleLegend>
-        {getDynamicText({
-          value: (
-            <BaseChart
-              colors={colors}
-              grid={{bottom: '3px', left: '0px', right: '10px', top: '40px'}}
-              xAxis={xAxis({
-                show: true,
-                type: 'category',
-                name: 'Date',
-                data: xAxisData,
-                axisTick: {
-                  interval: xAxisTickInterval,
-                  alignWithLabel: true,
-                },
-                axisLabel: {
-                  interval: xAxisLabelInterval,
-                  formatter: (label: string) => label.slice(0, 6), // Limit label to 6 chars
-                },
-                theme,
-              })}
-              yAxis={{
-                min: 0,
-                minInterval: yAxisMinInterval,
-                axisLabel: {
-                  formatter: yAxisFormatter,
-                  color: theme.chartLabel,
-                },
-              }}
-              series={this.chartSeries}
-              tooltip={this.chartTooltip}
-              onLegendSelectChanged={() => {}}
-              legend={Legend({
-                right: 10,
-                top: 5,
-                data: this.chartLegendData,
-                theme,
-              })}
-            />
-          ),
-          fixed: <Placeholder height="200px" />,
-        })}
-      </Fragment>
-    );
-  }
-
-  render() {
-    const {footer} = this.props;
-
-    return (
-      <Panel id="usage-chart" data-test-id="usage-chart">
-        <ChartContainer>{this.renderChart()}</ChartContainer>
-        {footer}
-      </Panel>
-    );
-  }
+        <UsageChartBody {...props} />
+      </ChartContainer>
+      {footer}
+    </Panel>
+  );
 }
 
-export default withTheme(UsageChart);
+export default UsageChart;
 
 const ErrorMessages = styled('div')`
   display: flex;