diff --git a/static/app/utils.tsx b/static/app/utils.tsx
index 1df1574fcc3428..c7fae01b270ace 100644
--- a/static/app/utils.tsx
+++ b/static/app/utils.tsx
@@ -23,7 +23,12 @@ export function nl2br(str: string): string {
}
export function escape(str: string): string {
- return str.replace(/&/g, '&').replace(//g, '>');
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
}
export function percent(value: number, totalValue: number): number {
diff --git a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx
index 4bc58343d7f19a..8f89511ee0a71e 100644
--- a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx
+++ b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx
@@ -10,6 +10,11 @@ import {Flex} from 'sentry/components/core/layout';
import {tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {ReactEchartsRef} from 'sentry/types/echarts';
+import {escape} from 'sentry/utils';
+import {
+ Actions,
+ useAttributeBreakdownsTooltip,
+} from 'sentry/views/explore/hooks/useAttributeBreakdownsTooltip';
import type {AttributeDistribution} from './attributeDistributionContent';
@@ -96,15 +101,109 @@ export function Chart({
? `${value.slice(0, TOOLTIP_MAX_VALUE_LENGTH)}...`
: value
: '\u2014';
- return [
- '
',
- ``,
- '',
- ].join('');
+ const escapedTruncatedValue = escape(truncatedValue);
+ return `
+
+ `.trim();
}, []);
+ const tooltipActionsHtmlRenderer = useCallback(
+ (value: string) => {
+ if (!value) return '';
+
+ const escapedAttributeName = escape(attributeDistribution.name);
+ const escapedValue = escape(value);
+ const actionBackground = theme.gray200;
+ return [
+ '',
+ ]
+ .join('\n')
+ .trim();
+ },
+ [theme.gray200, attributeDistribution.name]
+ );
+
+ const tooltipConfig = useAttributeBreakdownsTooltip({
+ chartRef,
+ formatter: toolTipFormatter,
+ chartWidth,
+ actionsHtmlRenderer: tooltipActionsHtmlRenderer,
+ });
+
const chartXAxisLabelFormatter = useCallback(
(value: string): string => {
const labelsCount = seriesData.length > 0 ? seriesData.length : 1;
@@ -166,12 +265,7 @@ export function Chart({
ref={chartRef}
autoHeightResize
isGroupedByDate={false}
- tooltip={{
- appendToBody: true,
- trigger: 'axis',
- renderMode: 'html',
- formatter: toolTipFormatter,
- }}
+ tooltip={tooltipConfig}
grid={{
left: 2,
right: 8,
diff --git a/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx b/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx
index ab3e2ee7460c3c..5d29cff71109f6 100644
--- a/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx
+++ b/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx
@@ -2,7 +2,6 @@ import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
import type {Theme} from '@emotion/react';
import styled from '@emotion/styled';
import type {TooltipComponentFormatterCallbackParams} from 'echarts';
-import type {CallbackDataParams} from 'echarts/types/dist/shared';
import {Tooltip} from '@sentry/scraps/tooltip/tooltip';
@@ -11,7 +10,12 @@ import {Flex} from 'sentry/components/core/layout';
import {tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {ReactEchartsRef} from 'sentry/types/echarts';
+import {escape} from 'sentry/utils';
import type {AttributeBreakdownsComparison} from 'sentry/views/explore/hooks/useAttributeBreakdownComparison';
+import {
+ Actions,
+ useAttributeBreakdownsTooltip,
+} from 'sentry/views/explore/hooks/useAttributeBreakdownsTooltip';
const MAX_BAR_WIDTH = 20;
const HIGH_CARDINALITY_THRESHOLD = 20;
@@ -164,50 +168,132 @@ export function Chart({
[attribute.cohort1, attribute.cohort2, cohort1Total, cohort2Total]
);
- const toolTipValueFormatter = useCallback(
- (_value: number, _label?: string, seriesParams?: CallbackDataParams) => {
- const percentage = Number(seriesParams?.data);
- return percentageFormatter(percentage);
- },
- []
- );
-
- const toolTipFormatAxisLabel = useCallback(
- (
- _value: number,
- _isTimestamp: boolean,
- _utc: boolean,
- _showTimeInTooltip: boolean,
- _addSecondsToTimeFormat: boolean,
- _bucketSize: number | undefined,
- seriesParamsOrParam: TooltipComponentFormatterCallbackParams
- ) => {
- if (!Array.isArray(seriesParamsOrParam)) {
+ const toolTipFormatter = useCallback(
+ (p: TooltipComponentFormatterCallbackParams) => {
+ if (!Array.isArray(p)) {
return '\u2014';
}
- const selectedParam = seriesParamsOrParam.find(
- s => s.seriesName === SELECTED_SERIES_NAME
- );
- const baselineParam = seriesParamsOrParam.find(
- s => s.seriesName === BASELINE_SERIES_NAME
- );
+ const selectedParam = p.find(s => s.seriesName === SELECTED_SERIES_NAME);
+ const baselineParam = p.find(s => s.seriesName === BASELINE_SERIES_NAME);
if (!selectedParam || !baselineParam) {
throw new Error('selectedParam or baselineParam is not defined');
}
- const name = selectedParam?.name ?? baselineParam?.name ?? '';
+ const selectedValue = selectedParam.value;
+ const baselineValue = baselineParam.value;
+ const selectedPct = percentageFormatter(Number(selectedValue));
+ const baselinePct = percentageFormatter(Number(baselineValue));
+
+ const name = selectedParam.name ?? baselineParam.name ?? '';
const truncatedName =
name.length > TOOLTIP_MAX_VALUE_LENGTH
? `${name.slice(0, TOOLTIP_MAX_VALUE_LENGTH)}...`
: name;
+ const escapedTruncatedName = escape(truncatedName);
+
+ return `
+
+ `.trim();
+ },
+ [cohort1Color, cohort2Color]
+ );
- return `${truncatedName}
`;
+ const tooltipActionsHtmlRenderer = useCallback(
+ (value: string) => {
+ if (!value) return '';
+
+ const escapedAttributeName = escape(attribute.attributeName);
+ const escapedValue = escape(value);
+ const actionBackground = theme.gray200;
+ return [
+ '',
+ ]
+ .join('\n')
+ .trim();
},
- []
+ [theme.gray200, attribute.attributeName]
);
+ const tooltipConfig = useAttributeBreakdownsTooltip({
+ chartRef,
+ formatter: toolTipFormatter,
+ chartWidth,
+ actionsHtmlRenderer: tooltipActionsHtmlRenderer,
+ });
+
const chartXAxisLabelFormatter = useCallback(
(value: string): string => {
const selectedSeries = seriesData[SELECTED_SERIES_NAME];
@@ -276,13 +362,7 @@ export function Chart({
ref={chartRef}
autoHeightResize
isGroupedByDate={false}
- tooltip={{
- appendToBody: true,
- trigger: 'axis',
- renderMode: 'html',
- valueFormatter: toolTipValueFormatter,
- formatAxisLabel: toolTipFormatAxisLabel,
- }}
+ tooltip={tooltipConfig}
grid={{
left: 2,
right: 8,
diff --git a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx
new file mode 100644
index 00000000000000..64cde1b347c495
--- /dev/null
+++ b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx
@@ -0,0 +1,214 @@
+import {useEffect, useMemo, useRef, useState} from 'react';
+import type {ECharts, TooltipComponentFormatterCallbackParams} from 'echarts';
+
+import type {TooltipOption} from 'sentry/components/charts/baseChart';
+import type {ReactEchartsRef} from 'sentry/types/echarts';
+import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
+import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode';
+import {
+ useAddSearchFilter,
+ useSetQueryParamsGroupBys,
+} from 'sentry/views/explore/queryParams/context';
+
+const TOOLTIP_POSITION_X_OFFSET = 20;
+const TOOLTIP_POSITION_Y_OFFSET = 5;
+
+type Params = {
+ /**
+ * The ref to the chart component.
+ */
+ chartRef: React.RefObject;
+ /**
+ * The width of the chart. Used to dynamically position the tooltip to mitigate content fron being cut off.
+ */
+ chartWidth: number;
+ /**
+ * The formatter function to format the tooltip content.
+ */
+ formatter: (params: TooltipComponentFormatterCallbackParams) => string;
+ /**
+ * The function to render the HTML for the tooltip actions. The actions are rendered on click, anywhere on the chart.
+ */
+ actionsHtmlRenderer?: (value: string) => string;
+};
+
+export enum Actions {
+ GROUP_BY = 'group_by_attribute',
+ ADD_TO_FILTER = 'add_value_to_filter',
+ EXCLUDE_FROM_FILTER = 'exclude_value_from_filter',
+ COPY_TO_CLIPBOARD = 'copy_value_to_clipboard',
+}
+
+// This hook creates a tooltip configuration for attribute breakdowns charts.
+// Since echarts tooltips do not support actions out of the box, we need to handle them manually.
+// It is used to freeze the tooltip position and show the tooltip actions on click, anywhere on the chart.
+// So that users can intuitively enter the tooltip and click on the actions.
+export function useAttributeBreakdownsTooltip({
+ chartRef,
+ formatter,
+ chartWidth,
+ actionsHtmlRenderer,
+}: Params): TooltipOption {
+ const [frozenPosition, setFrozenPosition] = useState<[number, number] | null>(null);
+ const tooltipParamsRef = useRef(null);
+
+ const addSearchFilter = useAddSearchFilter();
+ const copyToClipboard = useCopyToClipboard();
+ const setGroupBys = useSetQueryParamsGroupBys();
+
+ // This effect runs on load and when the frozen position changes.
+ // - If frozen position is set, trigger a re-render of the tooltip with frozen position to show the
+ // tooltip actions (if passed in).
+ // - Sets up the listener for clicks anywhere on the chart to toggle the frozen tooltip state.
+ useEffect(() => {
+ const chartInstance: ECharts | undefined = chartRef.current?.getEchartsInstance();
+ if (!chartInstance) return;
+
+ if (frozenPosition) {
+ chartInstance.dispatchAction({
+ type: 'showTip',
+ x: frozenPosition[0],
+ y: frozenPosition[1],
+ });
+ }
+
+ const dom = chartInstance.getDom();
+
+ const handleClickAnywhere = (event: MouseEvent) => {
+ event.preventDefault();
+ const pixelPoint: [number, number] = [event.offsetX, event.offsetY];
+
+ // If the tooltip is frozen, toggle the frozen state and reset the tooltip params.
+ if (frozenPosition) {
+ setFrozenPosition(null);
+ tooltipParamsRef.current = null;
+ } else {
+ // If the tooltip is not frozen, set the frozen position to the current pixel point.
+ setFrozenPosition(pixelPoint);
+ }
+ };
+
+ dom.addEventListener('click', handleClickAnywhere);
+
+ // eslint-disable-next-line consistent-return
+ return () => {
+ dom.removeEventListener('click', handleClickAnywhere);
+ };
+ }, [chartRef, frozenPosition]);
+
+ // This effect sets up the on click listeners for the tooltip actions.
+ // e-charts tooltips do not support actions out of the box, so we need to handle them manually.
+ useEffect(() => {
+ if (!frozenPosition) return;
+
+ const handleClickActions = (event: MouseEvent) => {
+ event.preventDefault();
+
+ const target = event.target as HTMLElement;
+ const action = target.getAttribute('data-tooltip-action');
+ const key = target.getAttribute('data-tooltip-action-key');
+ const value = target.getAttribute('data-tooltip-action-value');
+
+ if (action && value && key) {
+ switch (action) {
+ case Actions.GROUP_BY:
+ setGroupBys([key], Mode.AGGREGATE);
+ break;
+ case Actions.ADD_TO_FILTER:
+ addSearchFilter({
+ key,
+ value,
+ });
+ break;
+ case Actions.EXCLUDE_FROM_FILTER:
+ addSearchFilter({
+ key,
+ value,
+ negated: true,
+ });
+ break;
+ case Actions.COPY_TO_CLIPBOARD:
+ copyToClipboard.copy(value);
+ break;
+ default:
+ throw new Error(`Unknown attribute breakdowns tooltip action: ${action}`);
+ }
+ }
+ };
+
+ // TODO Abdullah Khan: For now, attaching the listener to document.body
+ // Tried using a more specific selector causes weird race conditions, will need to investigate.
+ document.addEventListener('click', handleClickActions);
+
+ // eslint-disable-next-line consistent-return
+ return () => {
+ document.removeEventListener('click', handleClickActions);
+ };
+ }, [frozenPosition, copyToClipboard, addSearchFilter, setGroupBys]);
+
+ const tooltipConfig: TooltipOption = useMemo(
+ () => ({
+ trigger: 'axis',
+ appendToBody: true,
+ renderMode: 'html',
+ enterable: true,
+ formatter: (params: TooltipComponentFormatterCallbackParams) => {
+ // If the tooltip is NOT frozen, set the tooltip params and return the formatted content,
+ // including the tooltip actions placeholder.
+ if (!frozenPosition) {
+ tooltipParamsRef.current = params;
+
+ const actionsPlaceholder = `
+
+ `.trim();
+
+ return formatter(params) + actionsPlaceholder;
+ }
+
+ if (!tooltipParamsRef.current) {
+ return '\u2014';
+ }
+
+ // If the tooltip is frozen, use the cached tooltip params and
+ // return the formatted content, including the tooltip actions.
+ const p = tooltipParamsRef.current;
+ const value = (Array.isArray(p) ? p[0]?.name : p.name) ?? '';
+ return formatter(p) + (actionsHtmlRenderer?.(value) ?? '');
+ },
+ position(
+ point: [number, number],
+ _params: TooltipComponentFormatterCallbackParams,
+ el: any
+ ) {
+ const dom = el as HTMLDivElement;
+ const tooltipWidth = dom?.offsetWidth ?? 0;
+ const [rawX = 0, rawY = 0] = frozenPosition ?? point;
+
+ // Adding offsets to mitigate users accidentally entering the tooltip,
+ // when trying to hover over the chart for values.
+ let x = rawX + TOOLTIP_POSITION_X_OFFSET;
+ const y = rawY + TOOLTIP_POSITION_Y_OFFSET;
+
+ // Flip left if it overflows chart width. Mitigates the content from being cut off.
+ if (x + tooltipWidth > chartWidth) {
+ x = rawX - tooltipWidth - TOOLTIP_POSITION_X_OFFSET;
+ }
+
+ return [x, y];
+ },
+ }),
+ [frozenPosition, chartWidth, formatter, actionsHtmlRenderer, tooltipParamsRef]
+ );
+
+ return tooltipConfig;
+}