From 4d5f8cf0864c589afd20896ade85beb0c609eacb Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Wed, 28 Aug 2024 13:30:11 -0400 Subject: [PATCH] feat(dashboards): Allow cancelling a zoom selection (#76626) Closes https://github.com/getsentry/sentry/issues/44723. Pressing the "Escape" key while making a drag selection on a chart will cancel the selection! Makes it possible to change your mind halfway through. --- static/app/components/charts/chartZoom.tsx | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/static/app/components/charts/chartZoom.tsx b/static/app/components/charts/chartZoom.tsx index b41fe879b32cf..4f89995a64b19 100644 --- a/static/app/components/charts/chartZoom.tsx +++ b/static/app/components/charts/chartZoom.tsx @@ -2,6 +2,7 @@ import {Component} from 'react'; import type {InjectedRouter} from 'react-router'; import type { DataZoomComponentOption, + ECharts, InsideDataZoomComponentOption, ToolboxComponentOption, XAXisComponentOption, @@ -100,6 +101,15 @@ class ChartZoom extends Component { this.saveCurrentPeriod(this.props); } + componentWillUnmount(): void { + document.body.removeEventListener('keydown', this.handleKeyDown); + document.body.removeEventListener('mouseup', this.handleMouseUp); + this.$chart?.removeEventListener('mousedown', this.handleMouseDown); + } + + chart?: ECharts; + $chart?: HTMLDivElement; + isCancellingZoom?: boolean; history: Period[]; currentPeriod?: Period; zooming: (() => void) | null = null; @@ -186,6 +196,35 @@ class ChartZoom extends Component { */ handleChartReady = chart => { this.props.onChartReady?.(chart); + + // The DOM element is also available via chart._dom but TypeScript hates that, since + // _dom is technically private. Instead, use `querySelector` to get the element + this.chart = chart; + this.$chart = document.querySelector( + `div[_echarts_instance_="${chart.id}"]` + ) as HTMLDivElement; + + this.$chart.addEventListener('mousedown', this.handleMouseDown); + }; + + handleKeyDown = evt => { + if (!this.chart) { + return; + } + + // This handler only exists if mouse down was caught inside the chart. + // Therefore, no need to check any other state. + if (evt.key === 'Escape') { + evt.stopPropagation(); + // Mark the component as currently cancelling a zoom selection. This allows + // us to prevent "restore" handlers from running + this.isCancellingZoom = true; + + // "restore" removes the current chart zoom selection + this.chart.dispatchAction({ + type: 'restore', + }); + } }; /** @@ -194,6 +233,15 @@ class ChartZoom extends Component { * Updates URL state to reflect initial params */ handleZoomRestore = (evt, chart) => { + if (this.isCancellingZoom) { + // If this restore is caused by a zoom cancel, do not run handlers! + // The regular handler restores to the earliest point in the zoom history + // and we do not want that. We want to cancel the selection and do nothing + // else. Reset `isCancellingZoom` here in case the dispatch was async + this.isCancellingZoom = false; + return; + } + if (!this.history.length) { return; } @@ -206,6 +254,22 @@ class ChartZoom extends Component { this.props.onRestore?.(evt, chart); }; + handleMouseDown = () => { + // Register `mouseup` and `keydown` listeners on mouse down + // This ensures that there is only one live listener at a time + // regardless of how many charts are rendered. NOTE: It's + // important to set `useCapture: true` in the `"keydown"` handler + // otherwise the Escape will close whatever modal or panel the + // chart is in. Those elements register their handlers _earlier_. + document.body.addEventListener('mouseup', this.handleMouseUp); + document.body.addEventListener('keydown', this.handleKeyDown, true); + }; + + handleMouseUp = () => { + document.body.removeEventListener('mouseup', this.handleMouseUp); + document.body.removeEventListener('keydown', this.handleKeyDown, true); + }; + handleDataZoom = (evt, chart) => { const model = chart.getModel(); const {startValue, endValue} = model._payload.batch[0];