Browse Source

feat(discover): multi y axis checkbox dropdown (#28509)

Added multi y-axis checkbox selector in discover results chart.
edwardgou-sentry 3 years ago
parent
commit
63f29414d8

+ 46 - 26
static/app/components/charts/eventsChart.tsx

@@ -22,6 +22,7 @@ import {Series} from 'app/types/echarts';
 import {defined} from 'app/utils';
 import {axisLabelFormatter, tooltipFormatter} from 'app/utils/discover/charts';
 import {aggregateMultiPlotType, getEquation, isEquation} from 'app/utils/discover/fields';
+import {decodeList} from 'app/utils/queryString';
 import {Theme} from 'app/utils/theme';
 
 import EventsRequest from './eventsRequest';
@@ -38,10 +39,10 @@ type ChartProps = {
     xAxis?: EChartOption.XAxis;
     yAxis?: EChartOption.YAxis;
   };
-  currentSeriesName?: string;
+  currentSeriesNames: string[];
   releaseSeries?: Series[];
+  previousSeriesNames: string[];
   previousTimeseriesData?: Series | null;
-  previousSeriesName?: string;
   /**
    * A callback to allow for post-processing of the series data.
    * Can be used to rename series or even insert a new series.
@@ -159,8 +160,8 @@ class Chart extends React.Component<ChartProps, State> {
       showLegend,
       legendOptions,
       chartOptions: chartOptionsProp,
-      currentSeriesName,
-      previousSeriesName,
+      currentSeriesNames,
+      previousSeriesNames,
       seriesTransformer,
       previousSeriesTransformer,
       colors,
@@ -171,7 +172,10 @@ class Chart extends React.Component<ChartProps, State> {
     } = this.props;
     const {seriesSelection} = this.state;
 
-    const data = [currentSeriesName ?? t('Current'), previousSeriesName ?? t('Previous')];
+    const data = [
+      ...(currentSeriesNames.length > 0 ? currentSeriesNames : [t('Current')]),
+      ...(previousSeriesNames.length > 0 ? previousSeriesNames : [t('Previous')]),
+    ];
 
     const releasesLegend = t('Releases');
 
@@ -254,7 +258,6 @@ class Chart extends React.Component<ChartProps, State> {
     };
 
     const Component = this.getChartComponent();
-
     return (
       <Component
         {...props}
@@ -291,7 +294,7 @@ export type EventsChartProps = {
   /**
    * The aggregate/metric to plot.
    */
-  yAxis: string;
+  yAxis: string | string[];
   /**
    * Relative datetime expression. eg. 14d
    */
@@ -360,10 +363,16 @@ export type EventsChartProps = {
    * Whether or not to zerofill results
    */
   withoutZerofill?: boolean;
+  /**
+   * Name of the series
+   */
+  currentSeriesName?: string;
+  /**
+   * Name of the previous series
+   */
+  previousSeriesName?: string;
 } & Pick<
   ChartProps,
-  | 'currentSeriesName'
-  | 'previousSeriesName'
   | 'seriesTransformer'
   | 'previousSeriesTransformer'
   | 'showLegend'
@@ -389,7 +398,11 @@ type ChartDataProps = {
 
 class EventsChart extends React.Component<EventsChartProps> {
   isStacked() {
-    return typeof this.props.topEvents === 'number' && this.props.topEvents > 0;
+    const {topEvents, yAxis} = this.props;
+    return (
+      (typeof topEvents === 'number' && topEvents > 0) ||
+      (Array.isArray(yAxis) && yAxis.length > 1)
+    );
   }
 
   render() {
@@ -431,16 +444,23 @@ class EventsChart extends React.Component<EventsChartProps> {
       withoutZerofill,
       ...props
     } = this.props;
+
     // Include previous only on relative dates (defaults to relative if no start and end)
     const includePrevious = !disablePrevious && !start && !end;
 
-    let yAxisLabel = yAxis && isEquation(yAxis) ? getEquation(yAxis) : yAxis;
-    if (yAxisLabel && yAxisLabel.length > 60) {
-      yAxisLabel = yAxisLabel.substr(0, 60) + '...';
-    }
-    const previousSeriesName =
-      previousName ?? (yAxisLabel ? t('previous %s', yAxisLabel) : undefined);
-    const currentSeriesName = currentName ?? yAxisLabel;
+    const yAxisArray = decodeList(yAxis);
+    const yAxisSeriesNames = yAxisArray.map(name => {
+      let yAxisLabel = name && isEquation(name) ? getEquation(name) : name;
+      if (yAxisLabel && yAxisLabel.length > 60) {
+        yAxisLabel = yAxisLabel.substr(0, 60) + '...';
+      }
+      return yAxisLabel;
+    });
+
+    const previousSeriesName = previousName
+      ? [previousName]
+      : yAxisSeriesNames.map(name => t('previous %s', name));
+    const currentSeriesName = currentName ? [currentName] : yAxisSeriesNames;
 
     const intervalVal = showDaily ? '1d' : interval || getInterval(this.props, 'high');
 
@@ -482,12 +502,12 @@ class EventsChart extends React.Component<EventsChartProps> {
             releaseSeries={releaseSeries || []}
             timeseriesData={seriesData ?? []}
             previousTimeseriesData={previousTimeseriesData}
-            currentSeriesName={currentSeriesName}
-            previousSeriesName={previousSeriesName}
+            currentSeriesNames={currentSeriesName}
+            previousSeriesNames={previousSeriesName}
             seriesTransformer={seriesTransformer}
             previousSeriesTransformer={previousSeriesTransformer}
             stacked={this.isStacked()}
-            yAxis={yAxis}
+            yAxis={yAxisArray[0]}
             showDaily={showDaily}
             colors={colors}
             legendOptions={legendOptions}
@@ -543,8 +563,8 @@ class EventsChart extends React.Component<EventsChartProps> {
             interval={intervalVal}
             query={query}
             includePrevious={includePrevious}
-            currentSeriesName={currentSeriesName}
-            previousSeriesName={previousSeriesName}
+            currentSeriesName={currentSeriesName[0]}
+            previousSeriesName={previousSeriesName[0]}
             yAxis={yAxis}
             field={field}
             orderby={orderby}
@@ -554,12 +574,12 @@ class EventsChart extends React.Component<EventsChartProps> {
             // Cannot do interpolation when stacking series
             withoutZerofill={withoutZerofill && !this.isStacked()}
           >
-            {eventData =>
-              chartImplementation({
+            {eventData => {
+              return chartImplementation({
                 ...eventData,
                 zoomRenderProps,
-              })
-            }
+              });
+            }}
           </EventsRequest>
         )}
       </ChartZoom>

+ 206 - 0
static/app/components/charts/optionCheckboxSelector.tsx

@@ -0,0 +1,206 @@
+import {Component, createRef, Fragment} from 'react';
+import styled from '@emotion/styled';
+import isEqual from 'lodash/isEqual';
+
+import {InlineContainer, SectionHeading} from 'app/components/charts/styles';
+import CheckboxFancy from 'app/components/checkboxFancy/checkboxFancy';
+import DropdownBubble from 'app/components/dropdownBubble';
+import DropdownButton from 'app/components/dropdownButton';
+import {DropdownItem} from 'app/components/dropdownControl';
+import DropdownMenu from 'app/components/dropdownMenu';
+import Tooltip from 'app/components/tooltip';
+import Truncate from 'app/components/truncate';
+import {t} from 'app/locale';
+import overflowEllipsis from 'app/styles/overflowEllipsis';
+import space from 'app/styles/space';
+import {SelectValue} from 'app/types';
+
+const defaultProps = {
+  menuWidth: 'auto',
+};
+
+type Props = {
+  options: SelectValue<string>[];
+  selected: string[];
+  onChange: (value: string[]) => void;
+  title: string;
+} & typeof defaultProps;
+
+type State = {
+  menuContainerWidth?: number;
+};
+
+class OptionCheckboxSelector extends Component<Props, State> {
+  static defaultProps = defaultProps;
+
+  state: State = {};
+
+  componentDidMount() {
+    this.setMenuContainerWidth();
+  }
+
+  shouldComponentUpdate(nextProps: Props, nextState: State) {
+    return !isEqual(nextProps, this.props) || !isEqual(nextState, this.state);
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (prevProps.selected !== this.props.selected) {
+      this.setMenuContainerWidth();
+    }
+  }
+
+  setMenuContainerWidth() {
+    const menuContainerWidth = this.menuContainerRef?.current?.offsetWidth;
+    if (menuContainerWidth) {
+      this.setState({menuContainerWidth});
+    }
+  }
+
+  constructNewSelected(value: string) {
+    const {selected} = this.props;
+    // Cannot have no option selected
+    if (selected.length === 1 && selected[0] === value) {
+      return selected;
+    }
+    // Check if the value is already selected.
+    // Return a new updated array with the value either selected or deselected depending on previous selected state.
+    if (selected.includes(value)) {
+      return selected.filter(selectedValue => selectedValue !== value);
+    }
+    return [...selected, value];
+  }
+
+  menuContainerRef = createRef<HTMLDivElement>();
+
+  render() {
+    const {menuContainerWidth} = this.state;
+    const {options, onChange, selected, title, menuWidth} = this.props;
+    const selectedOptionLabel =
+      options
+        .filter(opt => selected.includes(opt.value))
+        .map(({label}) => label)
+        .join(', ') || 'None';
+
+    return (
+      <InlineContainer>
+        <SectionHeading>{title}</SectionHeading>
+        <MenuContainer ref={this.menuContainerRef}>
+          <DropdownMenu alwaysRenderMenu={false} keepMenuOpen>
+            {({isOpen, getMenuProps, getActorProps}) => (
+              <Fragment>
+                <StyledDropdownButton {...getActorProps()} size="zero" isOpen={isOpen}>
+                  <TruncatedLabel>{String(selectedOptionLabel)}</TruncatedLabel>
+                </StyledDropdownButton>
+                <StyledDropdownBubble
+                  {...getMenuProps()}
+                  alignMenu="right"
+                  width={menuWidth}
+                  minWidth={menuContainerWidth}
+                  isOpen={isOpen}
+                  blendWithActor={false}
+                  blendCorner
+                >
+                  {options.map(opt => {
+                    // Y-Axis is capped at 3 fields
+                    const disabled = selected.length > 2 && !selected.includes(opt.value);
+                    return (
+                      <StyledDropdownItem
+                        key={opt.value}
+                        onSelect={eventKey =>
+                          onChange(this.constructNewSelected(eventKey))
+                        }
+                        eventKey={opt.value}
+                        disabled={disabled}
+                        data-test-id={`option-${opt.value}`}
+                        isChecked={selected.includes(opt.value)}
+                      >
+                        <StyledTooltip
+                          title={
+                            disabled
+                              ? t(
+                                  'Only a maximum of 3 fields can be displayed on the Y-Axis at a time'
+                                )
+                              : undefined
+                          }
+                        >
+                          <StyledTruncate
+                            isActive={false}
+                            value={String(opt.label)}
+                            maxLength={60}
+                            expandDirection="left"
+                          />
+                        </StyledTooltip>
+                        <CheckboxFancy isChecked={selected.includes(opt.value)} />
+                      </StyledDropdownItem>
+                    );
+                  })}
+                </StyledDropdownBubble>
+              </Fragment>
+            )}
+          </DropdownMenu>
+        </MenuContainer>
+      </InlineContainer>
+    );
+  }
+}
+
+const TruncatedLabel = styled('span')`
+  ${overflowEllipsis};
+  max-width: 400px;
+`;
+
+const StyledTruncate = styled(Truncate)<{
+  isActive: boolean;
+}>`
+  flex: auto;
+  padding-right: ${space(1)};
+  & span {
+    ${p =>
+      p.isActive &&
+      `
+      color: ${p.theme.white};
+      background: ${p.theme.active};
+      border: none;
+    `}
+  }
+`;
+
+const MenuContainer = styled('div')`
+  display: inline-block;
+  position: relative;
+`;
+
+const StyledDropdownButton = styled(DropdownButton)`
+  padding: ${space(1)} ${space(2)};
+  font-weight: normal;
+  z-index: ${p => (p.isOpen ? p.theme.zIndex.dropdownAutocomplete.actor : 'auto')};
+`;
+
+const StyledDropdownBubble = styled(DropdownBubble)<{
+  isOpen: boolean;
+  minWidth?: number;
+}>`
+  display: ${p => (p.isOpen ? 'block' : 'none')};
+  overflow: visible;
+  ${p =>
+    p.minWidth && p.width === 'auto' && `min-width: calc(${p.minWidth}px + ${space(3)})`};
+`;
+
+const StyledDropdownItem = styled(DropdownItem)<{isChecked?: boolean}>`
+  line-height: ${p => p.theme.text.lineHeightBody};
+  white-space: nowrap;
+  ${CheckboxFancy} {
+    opacity: ${p => (p.isChecked ? 1 : 0.3)};
+  }
+
+  &:hover ${CheckboxFancy} {
+    opacity: 1;
+  }
+`;
+
+const StyledTooltip = styled(Tooltip)`
+  flex: auto;
+  margin-right: ${space(2)};
+`;
+
+export default OptionCheckboxSelector;

+ 33 - 9
static/app/views/eventsV2/chartFooter.tsx

@@ -1,5 +1,7 @@
 import * as React from 'react';
 
+import Feature from 'app/components/acl/feature';
+import OptionCheckboxSelector from 'app/components/charts/optionCheckboxSelector';
 import OptionSelector from 'app/components/charts/optionSelector';
 import {
   ChartControls,
@@ -8,19 +10,21 @@ import {
   SectionValue,
 } from 'app/components/charts/styles';
 import {t} from 'app/locale';
-import {SelectValue} from 'app/types';
+import {Organization, SelectValue} from 'app/types';
 
 type Props = {
+  organization: Organization;
   total: number | null;
-  yAxisValue: string;
+  yAxisValue: string[];
   yAxisOptions: SelectValue<string>[];
-  onAxisChange: (value: string) => void;
+  onAxisChange: (value: string[]) => void;
   displayMode: string;
   displayOptions: SelectValue<string>[];
   onDisplayChange: (value: string) => void;
 };
 
 export default function ChartFooter({
+  organization,
   total,
   yAxisValue,
   yAxisOptions,
@@ -53,12 +57,32 @@ export default function ChartFooter({
           onChange={onDisplayChange}
           menuWidth="170px"
         />
-        <OptionSelector
-          title={t('Y-Axis')}
-          selected={yAxisValue}
-          options={yAxisOptions}
-          onChange={onAxisChange}
-        />
+        <Feature
+          organization={organization}
+          features={['connect-discover-and-dashboards']}
+        >
+          {({hasFeature}) => {
+            if (hasFeature) {
+              return (
+                <OptionCheckboxSelector
+                  title={t('Y-Axis')}
+                  selected={yAxisValue}
+                  options={yAxisOptions}
+                  onChange={onAxisChange}
+                />
+              );
+            } else {
+              return (
+                <OptionSelector
+                  title={t('Y-Axis')}
+                  selected={yAxisValue[0]}
+                  options={yAxisOptions}
+                  onChange={value => onAxisChange([value])}
+                />
+              );
+            }
+          }}
+        </Feature>
       </InlineContainer>
     </ChartControls>
   );

+ 31 - 2
static/app/views/eventsV2/results.tsx

@@ -32,8 +32,9 @@ import {defined, generateQueryWithTag} from 'app/utils';
 import {trackAnalyticsEvent} from 'app/utils/analytics';
 import EventView, {isAPIPayloadSimilar} from 'app/utils/discover/eventView';
 import {generateAggregateFields} from 'app/utils/discover/fields';
+import {CHART_AXIS_OPTIONS, DisplayModes} from 'app/utils/discover/types';
 import localStorage from 'app/utils/localStorage';
-import {decodeScalar} from 'app/utils/queryString';
+import {decodeList, decodeScalar} from 'app/utils/queryString';
 import withApi from 'app/utils/withApi';
 import withGlobalSelection from 'app/utils/withGlobalSelection';
 import withOrganization from 'app/utils/withOrganization';
@@ -109,6 +110,9 @@ class Results extends React.Component<Props, State> {
     if (defined(location.query.id)) {
       updateSavedQueryVisit(organization.slug, location.query.id);
     }
+    if (organization.features.includes('connect-discover-and-dashboards')) {
+      this.defaultYAxis();
+    }
   }
 
   componentDidUpdate(prevProps: Props, prevState: State) {
@@ -284,12 +288,23 @@ class Results extends React.Component<Props, State> {
     });
   };
 
-  handleYAxisChange = (value: string) => {
+  handleYAxisChange = (value: string[]) => {
     const {router, location} = this.props;
+    const isDisplayMultiYAxisSupported = [
+      DisplayModes.DEFAULT,
+      DisplayModes.DAILY,
+    ].includes(location.query.display as DisplayModes);
 
     const newQuery = {
       ...location.query,
       yAxis: value,
+      // If using Multi Y-axis and not in a supported display, change to the default display mode
+      display:
+        value.length > 1 && !isDisplayMultiYAxisSupported
+          ? location.query.display === DisplayModes.DAILYTOP5
+            ? DisplayModes.DAILY
+            : DisplayModes.DEFAULT
+          : location.query.display,
     };
 
     router.push({
@@ -390,6 +405,18 @@ class Results extends React.Component<Props, State> {
     this.setState({incompatibleAlertNotice});
   };
 
+  defaultYAxis() {
+    const {location} = this.props;
+    const yAxisArray = decodeList(location.query.yAxis);
+    // Default Y-Axis to count() if none is selected
+    if (yAxisArray.length === 0) {
+      browserHistory.replace({
+        ...location,
+        query: {...location.query, yAxis: [CHART_AXIS_OPTIONS[0].value]},
+      });
+    }
+  }
+
   renderError(error: string) {
     if (!error) {
       return null;
@@ -421,6 +448,7 @@ class Results extends React.Component<Props, State> {
       : eventView.fields;
     const query = eventView.query;
     const title = this.getDocumentTitle();
+    const yAxisArray = decodeList(location.query.yAxis);
 
     return (
       <SentryDocumentTitle title={title} orgSlug={organization.slug}>
@@ -455,6 +483,7 @@ class Results extends React.Component<Props, State> {
                   onDisplayChange={this.handleDisplayChange}
                   total={totalValues}
                   confirmedQuery={confirmedQuery}
+                  yAxis={yAxisArray}
                 />
               </Top>
               <Layout.Main fullWidth={!showTags}>

+ 80 - 25
static/app/views/eventsV2/resultsChart.tsx

@@ -5,10 +5,12 @@ import {Location} from 'history';
 import isEqual from 'lodash/isEqual';
 
 import {Client} from 'app/api';
+import AreaChart from 'app/components/charts/areaChart';
 import EventsChart from 'app/components/charts/eventsChart';
 import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
 import {Panel} from 'app/components/panels';
 import Placeholder from 'app/components/placeholder';
+import {t} from 'app/locale';
 import {Organization} from 'app/types';
 import {getUtcToLocalDateObject} from 'app/utils/dates';
 import EventView from 'app/utils/discover/eventView';
@@ -26,6 +28,7 @@ type ResultsChartProps = {
   eventView: EventView;
   location: Location;
   confirmedQuery: boolean;
+  yAxisValue: string[];
 };
 
 class ResultsChart extends Component<ResultsChartProps> {
@@ -41,13 +44,15 @@ class ResultsChart extends Component<ResultsChartProps> {
   }
 
   render() {
-    const {api, eventView, location, organization, router, confirmedQuery} = this.props;
+    const {api, eventView, location, organization, router, confirmedQuery, yAxisValue} =
+      this.props;
 
     const hasPerformanceChartInterpolation = organization.features.includes(
       'performance-chart-interpolation'
     );
-
-    const yAxisValue = eventView.getYAxis();
+    const hasConnectDiscoverAndDashboards = organization.features.includes(
+      'connect-discover-and-dashboards'
+    );
 
     const globalSelection = eventView.getGlobalSelection();
     const start = globalSelection.datetime.start
@@ -93,6 +98,9 @@ class ResultsChart extends Component<ResultsChartProps> {
               utc={utc === 'true'}
               confirmedQuery={confirmedQuery}
               withoutZerofill={hasPerformanceChartInterpolation}
+              chartComponent={
+                hasConnectDiscoverAndDashboards && !isDaily ? AreaChart : undefined
+              }
             />
           ),
           fixed: <Placeholder height="200px" testId="skeleton-ui" />,
@@ -109,10 +117,11 @@ type ContainerProps = {
   location: Location;
   organization: Organization;
   confirmedQuery: boolean;
+  yAxis: string[];
 
   // chart footer props
   total: number | null;
-  onAxisChange: (value: string) => void;
+  onAxisChange: (value: string[]) => void;
   onDisplayChange: (value: string) => void;
 };
 
@@ -142,34 +151,63 @@ class ResultsChartContainer extends Component<ContainerProps> {
       onDisplayChange,
       organization,
       confirmedQuery,
+      yAxis,
     } = this.props;
 
-    const yAxisValue = eventView.getYAxis();
     const hasQueryFeature = organization.features.includes('discover-query');
-    const displayOptions = eventView.getDisplayOptions().filter(opt => {
-      // top5 modes are only available with larger packages in saas.
-      // We remove instead of disable here as showing tooltips in dropdown
-      // menus is clunky.
-      if (
-        [DisplayModes.TOP5, DisplayModes.DAILYTOP5].includes(opt.value as DisplayModes) &&
-        !hasQueryFeature
-      ) {
-        return false;
-      }
-      return true;
-    });
+    const hasConnectDiscoverAndDashboards = organization.features.includes(
+      'connect-discover-and-dashboards'
+    );
+    const displayOptions = eventView
+      .getDisplayOptions()
+      .filter(opt => {
+        // top5 modes are only available with larger packages in saas.
+        // We remove instead of disable here as showing tooltips in dropdown
+        // menus is clunky.
+        if (
+          [DisplayModes.TOP5, DisplayModes.DAILYTOP5].includes(
+            opt.value as DisplayModes
+          ) &&
+          !hasQueryFeature
+        ) {
+          return false;
+        }
+        return true;
+      })
+      .map(opt => {
+        // Can only use default display or total daily with multi y axis
+        if (
+          yAxis.length > 1 &&
+          ![DisplayModes.DEFAULT, DisplayModes.DAILY].includes(opt.value as DisplayModes)
+        ) {
+          return {
+            ...opt,
+            disabled: true,
+            tooltip: t(
+              'Change the Y-Axis dropdown to display only 1 function to use this view.'
+            ),
+          };
+        }
+        return opt;
+      });
+
+    const yAxisValue = hasConnectDiscoverAndDashboards ? yAxis : [eventView.getYAxis()];
 
     return (
       <StyledPanel>
-        <ResultsChart
-          api={api}
-          eventView={eventView}
-          location={location}
-          organization={organization}
-          router={router}
-          confirmedQuery={confirmedQuery}
-        />
+        {(yAxisValue.length > 0 && (
+          <ResultsChart
+            api={api}
+            eventView={eventView}
+            location={location}
+            organization={organization}
+            router={router}
+            confirmedQuery={confirmedQuery}
+            yAxisValue={yAxisValue}
+          />
+        )) || <NoChartContainer>{t('No Y-Axis selected.')}</NoChartContainer>}
         <ChartFooter
+          organization={organization}
           total={total}
           yAxisValue={yAxisValue}
           yAxisOptions={eventView.getYAxisOptions()}
@@ -190,3 +228,20 @@ const StyledPanel = styled(Panel)`
     margin: 0;
   }
 `;
+
+const NoChartContainer = styled('div')<{height?: string}>`
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+
+  flex: 1;
+  flex-shrink: 0;
+  overflow: hidden;
+  height: ${p => p.height || '200px'};
+  position: relative;
+  border-color: transparent;
+  margin-bottom: 0;
+  color: ${p => p.theme.gray300};
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+`;

+ 11 - 2
static/app/views/eventsV2/table/tableView.tsx

@@ -33,6 +33,7 @@ import {
 } from 'app/utils/discover/fields';
 import {DisplayModes, TOP_N} from 'app/utils/discover/types';
 import {eventDetailsRouteWithEventView, generateEventSlug} from 'app/utils/discover/urls';
+import {decodeList} from 'app/utils/queryString';
 import {MutableSearch} from 'app/utils/tokenizeSearch';
 import withProjects from 'app/utils/withProjects';
 import {getTraceDetailsUrl} from 'app/views/performance/traceDetails/utils';
@@ -193,6 +194,8 @@ class TableView extends React.Component<TableViewProps> {
 
       const nextEventView = eventView.sortOnField(field, tableMeta);
       const queryStringObject = nextEventView.generateQueryStringObject();
+      // Need to pull yAxis from location since eventView only stores 1 yAxis field at time
+      queryStringObject.yAxis = decodeList(location.query.yAxis);
 
       return {
         ...location,
@@ -427,7 +430,7 @@ class TableView extends React.Component<TableViewProps> {
   };
 
   handleUpdateColumns = (columns: Column[]): void => {
-    const {organization, eventView} = this.props;
+    const {organization, eventView, location} = this.props;
 
     // metrics
     trackAnalyticsEvent({
@@ -437,7 +440,13 @@ class TableView extends React.Component<TableViewProps> {
     });
 
     const nextView = eventView.withColumns(columns);
-    browserHistory.push(nextView.getResultsViewUrlTarget(organization.slug));
+    const resultsViewUrlTarget = nextView.getResultsViewUrlTarget(organization.slug);
+    // Need to pull yAxis from location since eventView only stores 1 yAxis field at time
+    const previousYAxis = decodeList(location.query.yAxis);
+    resultsViewUrlTarget.query.yAxis = previousYAxis.filter(yAxis =>
+      nextView.getYAxisOptions().find(({value}) => value === yAxis)
+    );
+    browserHistory.push(resultsViewUrlTarget);
   };
 
   renderHeaderButtons = () => {

+ 1 - 0
static/app/views/projectDetail/charts/projectBaseEventsChart.tsx

@@ -24,6 +24,7 @@ type Props = Omit<
   onTotalValuesChange: (value: number | null) => void;
   theme: Theme;
   help?: string;
+  yAxis: string;
 };
 
 class ProjectBaseEventsChart extends Component<Props> {

+ 110 - 0
tests/js/spec/components/charts/optionCheckboxSelector.spec.tsx

@@ -0,0 +1,110 @@
+import {mountWithTheme} from 'sentry-test/enzyme';
+import {initializeOrg} from 'sentry-test/initializeOrg';
+
+import OptionCheckboxSelector from 'app/components/charts/optionCheckboxSelector';
+import {t} from 'app/locale';
+
+describe('EventsV2 > OptionCheckboxSelector', function () {
+  const features = ['discover-basic'];
+  const yAxisValue = ['count()', 'failure_count()'];
+  const yAxisOptions = [
+    {label: 'count()', value: 'count()'},
+    {label: 'failure_count()', value: 'failure_count()'},
+    {label: 'count_unique(user)', value: 'count_unique(user)'},
+    {label: 'avg(transaction.duration)', value: 'avg(transaction.duration)'},
+  ];
+  let organization, initialData, selected, wrapper, onChangeStub, dropdownItem;
+
+  beforeEach(() => {
+    // @ts-expect-error
+    organization = TestStubs.Organization({
+      features: [...features, 'connect-discover-and-dashboards'],
+    });
+
+    // Start off with an invalid view (empty is invalid)
+    initialData = initializeOrg({
+      organization,
+      router: {
+        location: {query: {query: 'tag:value'}},
+      },
+      project: 1,
+      projects: [],
+    });
+    selected = [...yAxisValue];
+    wrapper = mountWithTheme(
+      <OptionCheckboxSelector
+        title={t('Y-Axis')}
+        selected={selected}
+        options={yAxisOptions}
+        onChange={() => undefined}
+      />,
+      initialData.routerContext
+    );
+    // Parent component usually handles the new selected state but we don't have one in this test so we update props ourselves
+    onChangeStub = jest.fn(newSelected => wrapper.setProps({selected: newSelected}));
+    wrapper.setProps({onChange: onChangeStub});
+
+    dropdownItem = wrapper.find('StyledDropdownItem');
+  });
+
+  it('renders yAxisOptions with yAxisValue selected', function () {
+    expect(dropdownItem.at(0).find('span').last().children().html()).toEqual('count()');
+    expect(dropdownItem.at(1).find('span').last().children().html()).toEqual(
+      'failure_count()'
+    );
+    expect(dropdownItem.at(2).find('span').last().children().html()).toEqual(
+      'count_unique(user)'
+    );
+    expect(dropdownItem.at(0).props().isChecked).toEqual(true);
+    expect(dropdownItem.at(1).props().isChecked).toEqual(true);
+    expect(dropdownItem.at(2).props().isChecked).toEqual(false);
+  });
+
+  it('calls onChange prop with new checkbox option state', function () {
+    dropdownItem.at(0).find('span').first().simulate('click');
+    expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']);
+    dropdownItem.at(0).find('span').first().simulate('click');
+    expect(onChangeStub).toHaveBeenCalledWith(['failure_count()', 'count()']);
+    dropdownItem.at(1).find('span').first().simulate('click');
+    expect(onChangeStub).toHaveBeenCalledWith(['count()']);
+    dropdownItem.at(1).find('span').first().simulate('click');
+    expect(onChangeStub).toHaveBeenCalledWith(['count()', 'failure_count()']);
+    dropdownItem.at(2).find('span').first().simulate('click');
+    expect(onChangeStub).toHaveBeenCalledWith([
+      'count()',
+      'failure_count()',
+      'count_unique(user)',
+    ]);
+  });
+
+  it('does not uncheck options when clicked if only one option is currently selected', function () {
+    dropdownItem.at(0).find('span').first().simulate('click');
+    expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']);
+    dropdownItem.at(1).find('span').first().simulate('click');
+    expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']);
+  });
+
+  it('only allows up to 3 options to be checked at one time', function () {
+    dropdownItem.at(2).find('span').first().simulate('click');
+    expect(onChangeStub).toHaveBeenCalledWith([
+      'count()',
+      'failure_count()',
+      'count_unique(user)',
+    ]);
+    dropdownItem.at(3).find('span').first().simulate('click');
+    expect(onChangeStub).not.toHaveBeenCalledWith([
+      'count()',
+      'failure_count()',
+      'count_unique(user)',
+      'avg(transaction.duration)',
+    ]);
+    dropdownItem.at(2).find('span').first().simulate('click');
+    expect(onChangeStub).toHaveBeenCalledWith(['count()', 'failure_count()']);
+    dropdownItem.at(3).find('span').first().simulate('click');
+    expect(onChangeStub).toHaveBeenCalledWith([
+      'count()',
+      'failure_count()',
+      'avg(transaction.duration)',
+    ]);
+  });
+});

+ 97 - 0
tests/js/spec/views/eventsV2/chartFooter.spec.tsx

@@ -0,0 +1,97 @@
+import {mountWithTheme} from 'sentry-test/enzyme';
+import {initializeOrg} from 'sentry-test/initializeOrg';
+
+import {t} from 'app/locale';
+import {DisplayModes} from 'app/utils/discover/types';
+import ChartFooter from 'app/views/eventsV2/chartFooter';
+
+describe('EventsV2 > ChartFooter', function () {
+  const features = ['discover-basic'];
+  const yAxisValue = ['count()', 'failure_count()'];
+  const yAxisOptions = [
+    {label: 'count()', value: 'count()'},
+    {label: 'failure_count()', value: 'failure_count()'},
+  ];
+
+  afterEach(function () {});
+
+  it('renders yAxis option using OptionSelector using only the first yAxisValue without feature flag', async function () {
+    // @ts-expect-error
+    const organization = TestStubs.Organization({
+      features,
+      // @ts-expect-error
+      projects: [TestStubs.Project()],
+    });
+
+    // Start off with an invalid view (empty is invalid)
+    const initialData = initializeOrg({
+      organization,
+      router: {
+        location: {query: {query: 'tag:value'}},
+      },
+      project: 1,
+      projects: [],
+    });
+
+    const wrapper = mountWithTheme(
+      <ChartFooter
+        organization={organization}
+        total={100}
+        yAxisValue={yAxisValue}
+        yAxisOptions={yAxisOptions}
+        onAxisChange={() => undefined}
+        displayMode={DisplayModes.DEFAULT}
+        displayOptions={[{label: DisplayModes.DEFAULT, value: DisplayModes.DEFAULT}]}
+        onDisplayChange={() => undefined}
+      />,
+      initialData.routerContext
+    );
+
+    // @ts-expect-error
+    await tick();
+    wrapper.update();
+
+    const optionSelector = wrapper.find('OptionSelector').last();
+    expect(optionSelector.props().title).toEqual(t('Y-Axis'));
+    expect(optionSelector.props().selected).toEqual(yAxisValue[0]);
+  });
+
+  it('renders yAxis option using OptionCheckboxSelector using entire yAxisValue with feature flag', async function () {
+    // @ts-expect-error
+    const organization = TestStubs.Organization({
+      features: [...features, 'connect-discover-and-dashboards'],
+    });
+
+    // Start off with an invalid view (empty is invalid)
+    const initialData = initializeOrg({
+      organization,
+      router: {
+        location: {query: {query: 'tag:value'}},
+      },
+      project: 1,
+      projects: [],
+    });
+
+    const wrapper = mountWithTheme(
+      <ChartFooter
+        organization={organization}
+        total={100}
+        yAxisValue={yAxisValue}
+        yAxisOptions={yAxisOptions}
+        onAxisChange={() => undefined}
+        displayMode={DisplayModes.DEFAULT}
+        displayOptions={[{label: DisplayModes.DEFAULT, value: DisplayModes.DEFAULT}]}
+        onDisplayChange={() => undefined}
+      />,
+      initialData.routerContext
+    );
+
+    // @ts-expect-error
+    await tick();
+    wrapper.update();
+
+    const optionCheckboxSelector = wrapper.find('OptionCheckboxSelector').last();
+    expect(optionCheckboxSelector.props().title).toEqual(t('Y-Axis'));
+    expect(optionCheckboxSelector.props().selected).toEqual(yAxisValue);
+  });
+});

+ 11 - 11
tests/js/spec/views/eventsV2/results.spec.jsx

@@ -339,7 +339,7 @@ describe('EventsV2 > Results', function () {
     wrapper.update();
 
     const eventsRequest = wrapper.find('EventsChart');
-    expect(eventsRequest.props().yAxis).toEqual('count()');
+    expect(eventsRequest.props().yAxis).toEqual(['count()']);
     wrapper.unmount();
   });
 
@@ -352,7 +352,7 @@ describe('EventsV2 > Results', function () {
     const initialData = initializeOrg({
       organization,
       router: {
-        location: {query: {...generateFields(), display: 'previoux'}},
+        location: {query: {...generateFields(), display: 'default', yAxis: 'count'}},
       },
     });
 
@@ -398,7 +398,7 @@ describe('EventsV2 > Results', function () {
     const initialData = initializeOrg({
       organization,
       router: {
-        location: {query: {...generateFields(), display: 'previoux'}},
+        location: {query: {...generateFields(), display: 'previous'}},
       },
     });
 
@@ -706,7 +706,7 @@ describe('EventsV2 > Results', function () {
       expect.objectContaining({
         query: expect.objectContaining({
           statsPeriod: '14d',
-          yAxis: 'count()',
+          yAxis: ['count()'],
         }),
       })
     );
@@ -728,7 +728,7 @@ describe('EventsV2 > Results', function () {
       expect.objectContaining({
         query: expect.objectContaining({
           statsPeriod: '14d',
-          yAxis: 'count_unique(user)',
+          yAxis: ['count_unique(user)'],
         }),
       })
     );
@@ -744,7 +744,7 @@ describe('EventsV2 > Results', function () {
     const initialData = initializeOrg({
       organization,
       router: {
-        location: {query: {...generateFields(), display: 'default'}},
+        location: {query: {...generateFields(), display: 'default', yAxis: 'count()'}},
       },
     });
 
@@ -769,7 +769,7 @@ describe('EventsV2 > Results', function () {
       expect.objectContaining({
         query: expect.objectContaining({
           statsPeriod: '14d',
-          yAxis: 'count()',
+          yAxis: ['count()'],
         }),
       })
     );
@@ -777,7 +777,7 @@ describe('EventsV2 > Results', function () {
     // Update location simulating a browser back button action
     wrapper.setProps({
       location: {
-        query: {...generateFields(), display: 'previous'},
+        query: {...generateFields(), display: 'previous', yAxis: 'count()'},
       },
     });
     await tick();
@@ -791,7 +791,7 @@ describe('EventsV2 > Results', function () {
       expect.objectContaining({
         query: expect.objectContaining({
           statsPeriod: '28d',
-          yAxis: 'count()',
+          yAxis: ['count()'],
         }),
       })
     );
@@ -832,7 +832,7 @@ describe('EventsV2 > Results', function () {
       expect.objectContaining({
         query: expect.objectContaining({
           statsPeriod: '14d',
-          yAxis: 'count()',
+          yAxis: ['count()'],
         }),
       })
     );
@@ -854,7 +854,7 @@ describe('EventsV2 > Results', function () {
       expect.objectContaining({
         query: expect.objectContaining({
           statsPeriod: '28d',
-          yAxis: 'count_unique(user)',
+          yAxis: ['count_unique(user)'],
         }),
       })
     );

Some files were not shown because too many files changed in this diff