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 [ - '
', - `
${truncatedValue}
`, - '
', - ``, - '
', - ].join(''); + const escapedTruncatedValue = escape(truncatedValue); + return ` +
+
+ ${escapedTruncatedValue} + ${pct} +
+
+ `.trim(); }, []); + const tooltipActionsHtmlRenderer = useCallback( + (value: string) => { + if (!value) return ''; + + const escapedAttributeName = escape(attributeDistribution.name); + const escapedValue = escape(value); + const actionBackground = theme.gray200; + return [ + '', + ' ', + ' Group by attribute', + ' ', + ' ', + ' Add value to filter', + ' ', + ' ', + ' Exclude value from filter', + ' ', + ' ', + ' Copy value to clipboard', + ' ', + '', + ] + .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 ` +
+
+ ${escapedTruncatedName} + + + + selected + + ${selectedPct} + + + + + baseline + + ${baselinePct} + +
+
+ `.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 [ + '', + ' ', + ' Group by attribute', + ' ', + ' ', + ' Add value to filter', + ' ', + ' ', + ' Exclude value from filter', + ' ', + ' ', + ' Copy value to clipboard', + ' ', + '', + ] + .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; +}