Browse Source

feat(dashboards): synced cursors (#64554)

Ogi 1 year ago
parent
commit
f02f23d2b0

+ 9 - 1
static/app/components/charts/utils.tsx

@@ -7,7 +7,7 @@ import moment from 'moment';
 
 import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
 import type {EventsStats, MultiSeriesEventsStats, PageFilters} from 'sentry/types';
-import type {Series} from 'sentry/types/echarts';
+import type {ReactEchartsRef, Series} from 'sentry/types/echarts';
 import {defined, escape} from 'sentry/utils';
 import {getFormattedDate, parsePeriodToHours} from 'sentry/utils/dates';
 import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
@@ -405,3 +405,11 @@ export function useEchartsAriaLabels(
 export function isEmptySeries(series: Series) {
   return series.data.every(dataPoint => dataPoint.value === 0);
 }
+
+/**
+ * Used to determine which chart in a group is currently hovered.
+ */
+export function isChartHovered(chartRef: ReactEchartsRef | null) {
+  const hoveredEchartElement = document.querySelector('.echarts-for-react:hover');
+  return hoveredEchartElement === chartRef?.ele;
+}

+ 3 - 1
static/app/views/dashboards/dashboard.tsx

@@ -52,7 +52,7 @@ import {
 import SortableWidget from './sortableWidget';
 import type {DashboardDetails, Widget} from './types';
 import {DashboardWidgetSource, WidgetType} from './types';
-import {getDashboardFiltersFromURL} from './utils';
+import {connectDashboardCharts, getDashboardFiltersFromURL} from './utils';
 
 export const DRAG_HANDLE_CLASS = 'widget-drag';
 const DRAG_RESIZE_CLASS = 'widget-resize';
@@ -69,6 +69,7 @@ const BOTTOM_MOBILE_VIEW_POSITION = {
 const MOBILE_BREAKPOINT = parseInt(theme.breakpoints.small, 10);
 const BREAKPOINTS = {[MOBILE]: 0, [DESKTOP]: MOBILE_BREAKPOINT};
 const COLUMNS = {[MOBILE]: NUM_MOBILE_COLS, [DESKTOP]: NUM_DESKTOP_COLS};
+export const DASHBOARD_CHART_GROUP = 'dashboard-group';
 
 type Props = {
   api: Client;
@@ -115,6 +116,7 @@ class Dashboard extends Component<Props, State> {
       },
       windowWidth: window.innerWidth,
     };
+    connectDashboardCharts(DASHBOARD_CHART_GROUP);
   }
 
   static getDerivedStateFromProps(props, state) {

+ 5 - 0
static/app/views/dashboards/utils.tsx

@@ -1,4 +1,5 @@
 import {browserHistory} from 'react-router';
+import {connect} from 'echarts';
 import type {Location, Query} from 'history';
 import cloneDeep from 'lodash/cloneDeep';
 import isEqual from 'lodash/isEqual';
@@ -663,3 +664,7 @@ export function dashboardFiltersToString(
   }
   return dashboardFilterConditions;
 }
+
+export function connectDashboardCharts(groupName: string) {
+  connect?.(groupName);
+}

+ 0 - 13
static/app/views/dashboards/widgetBuilder/widgetBuilderDataset.spec.tsx

@@ -33,19 +33,6 @@ const defaultOrgFeatures = [
   'dashboards-rh-widget',
 ];
 
-// function mockDashboard(dashboard: Partial<DashboardDetails>): DashboardDetails {
-//   return {
-//     id: '1',
-//     title: 'Dashboard',
-//     createdBy: undefined,
-//     dateCreated: '2020-01-01T00:00:00.000Z',
-//     widgets: [],
-//     projects: [],
-//     filters: {},
-//     ...dashboard,
-//   };
-// }
-
 function renderTestComponent({
   dashboard,
   query,

+ 70 - 12
static/app/views/dashboards/widgetCard/chart.tsx

@@ -16,7 +16,7 @@ import {LineChart} from 'sentry/components/charts/lineChart';
 import SimpleTableChart from 'sentry/components/charts/simpleTableChart';
 import TransitionChart from 'sentry/components/charts/transitionChart';
 import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
-import {getSeriesSelection} from 'sentry/components/charts/utils';
+import {getSeriesSelection, isChartHovered} from 'sentry/components/charts/utils';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import type {PlaceholderProps} from 'sentry/components/placeholder';
 import Placeholder from 'sentry/components/placeholder';
@@ -24,7 +24,12 @@ import {Tooltip} from 'sentry/components/tooltip';
 import {IconWarning} from 'sentry/icons';
 import {space} from 'sentry/styles/space';
 import type {Organization, PageFilters} from 'sentry/types';
-import type {EChartDataZoomHandler, EChartEventHandler} from 'sentry/types/echarts';
+import type {
+  EChartDataZoomHandler,
+  EChartEventHandler,
+  ReactEchartsRef,
+  Series,
+} from 'sentry/types/echarts';
 import {
   axisLabelFormatter,
   axisLabelFormatterUsingAggregateOutputType,
@@ -50,6 +55,7 @@ import {
 } from 'sentry/views/dashboards/datasetConfig/metrics';
 import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
 
+import {getFormatter} from '../../../components/charts/components/tooltip';
 import {getDatasetConfig} from '../datasetConfig/base';
 import type {Widget} from '../types';
 import {DisplayType, WidgetType} from '../types';
@@ -82,6 +88,7 @@ type WidgetCardChartProps = Pick<
   selection: PageFilters;
   theme: Theme;
   widget: Widget;
+  chartGroup?: string;
   chartZoomOptions?: DataZoomComponentOption;
   expandNumbers?: boolean;
   isMobile?: boolean;
@@ -257,6 +264,23 @@ class WidgetCardChart extends Component<WidgetCardChartProps, State> {
     });
   }
 
+  chartRef: ReactEchartsRef | null = null;
+
+  handleRef = (chartRef: ReactEchartsRef): void => {
+    if (chartRef && !this.chartRef) {
+      this.chartRef = chartRef;
+      // add chart to the group so that it has synced cursors
+      const instance = chartRef.getEchartsInstance?.();
+      if (instance && !instance.group && this.props.chartGroup) {
+        instance.group = this.props.chartGroup;
+      }
+    }
+
+    if (!chartRef) {
+      this.chartRef = null;
+    }
+  };
+
   chartComponent(chartProps): React.ReactNode {
     const {widget} = this.props;
     const stacked = widget.queries[0]?.columns.length > 0;
@@ -368,6 +392,20 @@ class WidgetCardChart extends Component<WidgetCardChartProps, State> {
     const durationUnit = isDurationChart
       ? timeseriesResults && getDurationUnit(timeseriesResults, legendOptions)
       : undefined;
+    const bucketSize = getBucketSize(timeseriesResults);
+
+    const valueFormatter = (value: number, seriesName?: string) => {
+      if (widget.widgetType === WidgetType.METRICS) {
+        return formatMetricAxisValue(axisField, value);
+      }
+      const aggregateName = seriesName?.split(':').pop()?.trim();
+      if (aggregateName) {
+        return timeseriesResultsTypes
+          ? tooltipFormatter(value, timeseriesResultsTypes[aggregateName])
+          : tooltipFormatter(value, aggregateOutputType(aggregateName));
+      }
+      return tooltipFormatter(value, 'number');
+    };
 
     const chartOptions = {
       autoHeightResize,
@@ -382,17 +420,20 @@ class WidgetCardChart extends Component<WidgetCardChartProps, State> {
       },
       tooltip: {
         trigger: 'axis',
-        valueFormatter: (value: number, seriesName: string) => {
-          if (widget.widgetType === WidgetType.METRICS) {
-            return formatMetricAxisValue(axisField, value);
+        formatter: (params, asyncTicket) => {
+          // tooltip is triggered whenever any chart in the group is hovered,
+          // so we need to check if the mouse is actually over this chart
+          if (!isChartHovered(this.chartRef)) {
+            return '';
           }
-          const aggregateName = seriesName?.split(':').pop()?.trim();
-          if (aggregateName) {
-            return timeseriesResultsTypes
-              ? tooltipFormatter(value, timeseriesResultsTypes[aggregateName])
-              : tooltipFormatter(value, aggregateOutputType(aggregateName));
-          }
-          return tooltipFormatter(value, 'number');
+
+          return getFormatter({
+            valueFormatter,
+            isGroupedByDate: true,
+            bucketSize,
+            addSecondsToTimeFormat: false,
+            showTimeInTooltip: true,
+          })(params, asyncTicket);
         },
       },
       yAxis: {
@@ -415,6 +456,11 @@ class WidgetCardChart extends Component<WidgetCardChartProps, State> {
         },
         minInterval: durationUnit ?? 0,
       },
+      xAxis: {
+        axisPointer: {
+          snap: true,
+        },
+      },
     };
 
     return (
@@ -469,6 +515,9 @@ class WidgetCardChart extends Component<WidgetCardChartProps, State> {
 
           const seriesStart = series[0]?.data[0]?.name;
           const seriesEnd = series[0]?.data[series[0].data.length - 1]?.name;
+
+          const forwardedRef = this.props.chartGroup ? this.handleRef : undefined;
+
           return (
             <TransitionChart loading={loading} reloading={loading}>
               <LoadingScreen loading={loading} />
@@ -490,6 +539,7 @@ class WidgetCardChart extends Component<WidgetCardChartProps, State> {
                     legend,
                     series,
                     onLegendSelectChanged,
+                    forwardedRef,
                   }),
                   fixed: <Placeholder height="200px" testId="skeleton-ui" />,
                 })}
@@ -502,6 +552,14 @@ class WidgetCardChart extends Component<WidgetCardChartProps, State> {
   }
 }
 
+const getBucketSize = (series: Series[] | undefined) => {
+  if (!series || series.length < 2) {
+    return 0;
+  }
+
+  return Number(series[0].data[1]?.name) - Number(series[0].data[0]?.name);
+};
+
 export default withTheme(WidgetCardChart);
 
 const StyledTransparentLoadingMask = styled(props => (

+ 1 - 2
static/app/views/dashboards/widgetCard/index.spec.tsx

@@ -663,10 +663,9 @@ describe('Dashboards > WidgetCard', function () {
     });
     const {tooltip, yAxis} = spy.mock.calls.pop()?.[0] ?? {};
     expect(tooltip).toBeDefined();
+
     expect(yAxis).toBeDefined();
     // @ts-expect-error
-    expect(tooltip.valueFormatter(24, 'p95(measurements.custom)')).toEqual('24.00ms');
-    // @ts-expect-error
     expect(yAxis.axisLabel.formatter(24, 'p95(measurements.custom)')).toEqual('24ms');
   });
 

+ 3 - 0
static/app/views/dashboards/widgetCard/index.tsx

@@ -38,6 +38,7 @@ import withOrganization from 'sentry/utils/withOrganization';
 import withPageFilters from 'sentry/utils/withPageFilters';
 // eslint-disable-next-line no-restricted-imports
 import withSentryRouter from 'sentry/utils/withSentryRouter';
+import {DASHBOARD_CHART_GROUP} from 'sentry/views/dashboards/dashboard';
 import {MetricWidgetCard} from 'sentry/views/dashboards/widgetCard/metricWidgetCard';
 import {Toolbar} from 'sentry/views/dashboards/widgetCard/toolbar';
 
@@ -358,6 +359,7 @@ class WidgetCard extends Component<Props, State> {
                     windowWidth={windowWidth}
                     onDataFetched={this.setData}
                     dashboardFilters={dashboardFilters}
+                    chartGroup={DASHBOARD_CHART_GROUP}
                   />
                 ) : (
                   <LazyLoad once resize height={200}>
@@ -373,6 +375,7 @@ class WidgetCard extends Component<Props, State> {
                       windowWidth={windowWidth}
                       onDataFetched={this.setData}
                       dashboardFilters={dashboardFilters}
+                      chartGroup={DASHBOARD_CHART_GROUP}
                     />
                   </LazyLoad>
                 )}

+ 2 - 0
static/app/views/dashboards/widgetCard/metricWidgetCard/index.tsx

@@ -24,6 +24,7 @@ import {
   toMetricDisplayType,
 } from '../../../../utils/metrics/dashboard';
 import {parseField} from '../../../../utils/metrics/mri';
+import {DASHBOARD_CHART_GROUP} from '../../dashboard';
 import type {DashboardFilters, Widget} from '../../types';
 
 type Props = {
@@ -206,6 +207,7 @@ export function MetricWidgetChartContainer({
       query={extendQuery(metricWidgetQueryParams.query, dashboardFilters)}
       groupBy={metricWidgetQueryParams.groupBy}
       displayType={toMetricDisplayType(metricWidgetQueryParams.displayType)}
+      chartGroup={DASHBOARD_CHART_GROUP}
     />
   );
 }

+ 5 - 0
static/app/views/dashboards/widgetCard/widgetCardChartContainer.tsx

@@ -30,6 +30,7 @@ type Props = {
   organization: Organization;
   selection: PageFilters;
   widget: Widget;
+  chartGroup?: string;
   chartZoomOptions?: DataZoomComponentOption;
   dashboardFilters?: DashboardFilters;
   expandNumbers?: boolean;
@@ -73,6 +74,7 @@ export function WidgetCardChartContainer({
   showSlider,
   noPadding,
   chartZoomOptions,
+  chartGroup,
 }: Props) {
   const location = useLocation();
   const router = useRouter();
@@ -143,6 +145,7 @@ export function WidgetCardChartContainer({
                 showSlider={showSlider}
                 noPadding={noPadding}
                 chartZoomOptions={chartZoomOptions}
+                chartGroup={chartGroup}
               />
             </Fragment>
           );
@@ -185,6 +188,7 @@ export function WidgetCardChartContainer({
                 showSlider={showSlider}
                 noPadding={noPadding}
                 chartZoomOptions={chartZoomOptions}
+                chartGroup={chartGroup}
               />
             </Fragment>
           );
@@ -235,6 +239,7 @@ export function WidgetCardChartContainer({
               noPadding={noPadding}
               chartZoomOptions={chartZoomOptions}
               timeseriesResultsTypes={timeseriesResultsTypes}
+              chartGroup={chartGroup}
             />
           </Fragment>
         );

+ 8 - 10
static/app/views/ddm/chart.tsx

@@ -18,11 +18,11 @@ import {isCumulativeOp} from 'sentry/utils/metrics';
 import {formatMetricsUsingUnitAndOp} from 'sentry/utils/metrics/formatters';
 import {MetricDisplayType} from 'sentry/utils/metrics/types';
 import useRouter from 'sentry/utils/useRouter';
-import {DDM_CHART_GROUP} from 'sentry/views/ddm/constants';
 import type {FocusAreaProps} from 'sentry/views/ddm/context';
 import {useFocusArea} from 'sentry/views/ddm/focusArea';
 
 import {getFormatter} from '../../components/charts/components/tooltip';
+import {isChartHovered} from '../../components/charts/utils';
 
 import {useChartSamples} from './useChartSamples';
 import type {SamplesProps, ScatterSeries as ScatterSeriesType, Series} from './widget';
@@ -32,6 +32,7 @@ type ChartProps = {
   series: Series[];
   widgetIndex: number;
   focusArea?: FocusAreaProps;
+  group?: string;
   height?: number;
   operation?: string;
   scatter?: SamplesProps;
@@ -44,7 +45,7 @@ echarts.use(CanvasRenderer);
 
 export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
   (
-    {series, displayType, operation, widgetIndex, focusArea, height, scatter},
+    {series, displayType, operation, widgetIndex, focusArea, height, scatter, group},
     forwardedRef
   ) => {
     const router = useRouter();
@@ -70,9 +71,12 @@ export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
     });
 
     useEffect(() => {
+      if (!group) {
+        return;
+      }
       const echartsInstance = chartRef?.current?.getEchartsInstance();
       if (echartsInstance && !echartsInstance.group) {
-        echartsInstance.group = DDM_CHART_GROUP;
+        echartsInstance.group = group;
       }
     });
 
@@ -143,13 +147,7 @@ export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
             if (focusAreaBrush.isDrawingRef.current) {
               return '';
             }
-            const hoveredEchartElement = Array.from(
-              document.querySelectorAll(':hover')
-            ).find(element => {
-              return element.classList.contains('echarts-for-react');
-            });
-            const isThisChartHovered = hoveredEchartElement === chartRef?.current?.ele;
-            if (!isThisChartHovered) {
+            if (!isChartHovered(chartRef?.current)) {
               return '';
             }
             if (params.seriesType === 'scatter') {

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