From 2174a84b36b9a086018972654c5ec661c19937a1 Mon Sep 17 00:00:00 2001 From: Sam Jewell <2903904+samjewell@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:53:43 +0100 Subject: [PATCH] BarChart: Add ad-hoc filter button to tooltip (#107874) **What is this feature?** Allow AdHoc filters to be set from Tooltips when clicking on bars in the BarChart panel. **Why do we need this feature?** We're trying to implement a "Panel to Panel" filtering behaviour, similar to what is possible in Tableau and Looker. We're using AdHoc filters, as these scale well across panels and across dimensions in the data. We expect this PR will work well in tandem with #108011 to allow panel-to-panel filtering of data coming from BigQuery. --------- Co-authored-by: Leon Sorokin --- .../src/types/featureToggles.gen.ts | 4 ++ .../VizTooltip/VizTooltipFooter.tsx | 31 +++++++++---- .../uPlot/plugins/TooltipPlugin2.tsx | 17 ++++++- packages/grafana-ui/src/internal/index.ts | 2 +- pkg/services/featuremgmt/registry.go | 7 +++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++ pkg/services/featuremgmt/toggles_gen.json | 13 ++++++ .../plugins/panel/barchart/BarChartPanel.tsx | 44 +++++++++++++++++-- .../panel/timeseries/TimeSeriesTooltip.tsx | 7 ++- public/locales/en-US/grafana.json | 1 + 11 files changed, 114 insertions(+), 17 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c82fffa8ea5..6f908a114a4 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1079,4 +1079,8 @@ export interface FeatureToggles { * @default false */ alertmanagerRemoteSecondaryWithRemoteState?: boolean; + /** + * Enable adhoc filter buttons in visualization tooltips + */ + adhocFiltersInTooltips?: boolean; } diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index db5d1e88c37..b4eccaee66a 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -11,10 +11,16 @@ import { DataLinkButton } from '../DataLinks/DataLinkButton'; import { Icon } from '../Icon/Icon'; import { Stack } from '../Layout/Stack/Stack'; import { ResponsiveProp } from '../Layout/utils/responsiveness'; +import { AdHocFilterItem } from '../Table/TableNG/types'; + +export interface AdHocFilterModel extends AdHocFilterItem { + onClick: () => void; +} interface VizTooltipFooterProps { dataLinks: Array>; actions?: Array>; + adHocFilters?: AdHocFilterModel[]; annotate?: () => void; } @@ -41,7 +47,7 @@ function makeRenderLinksOrActions( if (oneClickItem != null) { return ( -
+
@@ -53,7 +59,7 @@ function makeRenderLinksOrActions( } return ( -
+
{items.map((item, i) => renderItem(item, i, styles))} @@ -79,7 +85,7 @@ const renderActions = makeRenderLinksOrActions( (item, i) => ); -export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizTooltipFooterProps) => { +export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilters = [] }: VizTooltipFooterProps) => { const styles = useStyles2(getStyles); const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]); const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]); @@ -88,8 +94,19 @@ export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizToolt
{!hasOneClickAction && renderDataLinks(dataLinks, styles)} {!hasOneClickLink && renderActions(actions, styles)} + {!hasOneClickLink && !hasOneClickAction && adHocFilters.length > 0 && ( +
+ {adHocFilters.map((item, index) => ( + + ))} +
+ )} {!hasOneClickLink && !hasOneClickAction && annotate != null && ( -
+
@@ -106,11 +123,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ flex: 1, padding: theme.spacing(0), }), - dataLinks: css({ - borderTop: `1px solid ${theme.colors.border.medium}`, - padding: theme.spacing(1), - }), - addAnnotations: css({ + footerSection: css({ borderTop: `1px solid ${theme.colors.border.medium}`, padding: theme.spacing(1), }), diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index e19a6230cec..703b6a85ed1 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -7,6 +7,7 @@ import uPlot from 'uplot'; import { GrafanaTheme2, LinkModel } from '@grafana/data'; import { DashboardCursorSync } from '@grafana/schema'; +import { AdHocFilterModel } from '../../../internal'; import { useStyles2 } from '../../../themes/ThemeContext'; import { RangeSelection1D, RangeSelection2D, OnSelectRangeCallback } from '../../PanelChrome'; import { getPortalContainer } from '../../Portal/Portal'; @@ -28,6 +29,7 @@ export const enum TooltipHoverMode { } type GetDataLinksCallback = (seriesIdx: number, dataIdx: number) => LinkModel[]; +type GetAdHocFiltersCallback = (seriesIdx: number, dataIdx: number) => AdHocFilterModel[]; interface TooltipPlugin2Props { config: UPlotConfigBuilder; @@ -43,6 +45,7 @@ interface TooltipPlugin2Props { onSelectRange?: OnSelectRangeCallback; getDataLinks?: GetDataLinksCallback; + getAdHocFilters?: GetAdHocFiltersCallback; render: ( u: uPlot, @@ -53,7 +56,8 @@ interface TooltipPlugin2Props { // selected time range (for annotation triggering) timeRange: TimeRange2 | null, viaSync: boolean, - dataLinks: LinkModel[] + dataLinks: LinkModel[], + adHocFilters: AdHocFilterModel[] ) => React.ReactNode; maxWidth?: number; @@ -107,6 +111,7 @@ const MIN_ZOOM_DIST = 5; const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e.metaKey; const getDataLinksFallback: GetDataLinksCallback = () => []; +const getAdHocFiltersFallback: GetAdHocFiltersCallback = () => []; const userAgentIsMobile = /Android|iPhone|iPad/i.test(navigator.userAgent); @@ -124,6 +129,7 @@ export const TooltipPlugin2 = ({ syncMode = DashboardCursorSync.Off, syncScope = 'global', // eventsScope getDataLinks = getDataLinksFallback, + getAdHocFilters = getAdHocFiltersFallback, }: TooltipPlugin2Props) => { const domRef = useRef(null); const portalRoot = useRef(null); @@ -143,6 +149,9 @@ export const TooltipPlugin2 = ({ const getLinksRef = useRef(getDataLinks); getLinksRef.current = getDataLinks; + const getAdHocFiltersRef = useRef(getAdHocFilters); + getAdHocFiltersRef.current = getAdHocFilters; + useLayoutEffect(() => { sizeRef.current?.observer.disconnect(); @@ -202,6 +211,7 @@ export const TooltipPlugin2 = ({ let closestSeriesIdx: number | null = null; let viaSync = false; let dataLinks: LinkModel[] = []; + let adHocFilters: AdHocFilterModel[] = []; // for onceClick link rendering during mousemoves we use these pre-generated first links or actions // these will be wrong if the titles have interpolation using the hovered *value* @@ -273,7 +283,8 @@ export const TooltipPlugin2 = ({ dismiss, selectedRange, viaSync, - _isPinned ? dataLinks : closestSeriesIdx != null ? persistentLinks[closestSeriesIdx] : [] + _isPinned ? dataLinks : closestSeriesIdx != null ? persistentLinks[closestSeriesIdx] : [], + _isPinned ? adHocFilters : [] ) : null, dismiss, @@ -293,6 +304,7 @@ export const TooltipPlugin2 = ({ _isHovering = false; _plot!.setCursor({ left: -10, top: -10 }); dataLinks = []; + adHocFilters = []; scheduleRender(prevIsPinned); }; @@ -351,6 +363,7 @@ export const TooltipPlugin2 = ({ // if tooltip visible, not pinned, and within proximity to a series/point else if (_isHovering && !_isPinned && closestSeriesIdx != null) { dataLinks = getLinksRef.current(closestSeriesIdx, seriesIdxs[closestSeriesIdx]!); + adHocFilters = getAdHocFiltersRef.current(closestSeriesIdx, seriesIdxs[closestSeriesIdx]!); const oneClickLink = dataLinks.find((dataLink) => dataLink.oneClick === true); if (oneClickLink != null) { diff --git a/packages/grafana-ui/src/internal/index.ts b/packages/grafana-ui/src/internal/index.ts index 54bf72d16b0..0a549922564 100644 --- a/packages/grafana-ui/src/internal/index.ts +++ b/packages/grafana-ui/src/internal/index.ts @@ -83,7 +83,7 @@ export { ConfirmContent, type ConfirmContentProps } from '../components/ConfirmM export { EmotionPerfTest } from '../components/ThemeDemos/EmotionPerfTest'; export { VizTooltipContent } from '../components/VizTooltip/VizTooltipContent'; -export { VizTooltipFooter } from '../components/VizTooltip/VizTooltipFooter'; +export { VizTooltipFooter, type AdHocFilterModel } from '../components/VizTooltip/VizTooltipFooter'; export { VizTooltipHeader } from '../components/VizTooltip/VizTooltipHeader'; export { VizTooltipWrapper } from '../components/VizTooltip/VizTooltipWrapper'; export { VizTooltipRow } from '../components/VizTooltip/VizTooltipRow'; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b7ee60b6e55..bef148ef73c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1868,6 +1868,13 @@ var ( HideFromDocs: true, Expression: "false", }, + { + Name: "adhocFiltersInTooltips", + Description: "Enable adhoc filter buttons in visualization tooltips", + Stage: FeatureStageExperimental, + Owner: grafanaDataProSquad, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index d22d7e5b728..720d9a00434 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -241,3 +241,4 @@ pluginAssetProvider,experimental,@grafana/plugins-platform-backend,false,true,fa unifiedStorageSearchDualReaderEnabled,experimental,@grafana/search-and-storage,false,false,false dashboardDsAdHocFiltering,experimental,@grafana/datapro,false,false,true alertmanagerRemoteSecondaryWithRemoteState,experimental,@grafana/alerting-squad,false,false,false +adhocFiltersInTooltips,experimental,@grafana/datapro,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 7def9808abb..6f82a45302d 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -974,4 +974,8 @@ const ( // FlagAlertmanagerRemoteSecondaryWithRemoteState // Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications. FlagAlertmanagerRemoteSecondaryWithRemoteState = "alertmanagerRemoteSecondaryWithRemoteState" + + // FlagAdhocFiltersInTooltips + // Enable adhoc filter buttons in visualization tooltips + FlagAdhocFiltersInTooltips = "adhocFiltersInTooltips" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 6d738596653..ac1e306cd71 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -17,6 +17,19 @@ "expression": "true" } }, + { + "metadata": { + "name": "adhocFiltersInTooltips", + "resourceVersion": "1753712755564", + "creationTimestamp": "2025-07-28T14:25:55Z" + }, + "spec": { + "description": "Enable adhoc filter buttons in visualization tooltips", + "stage": "experimental", + "codeowner": "@grafana/datapro", + "frontend": true + } + }, { "metadata": { "name": "aiGeneratedDashboardChanges", diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index fdb05575741..3b54eed78d4 100644 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -1,18 +1,19 @@ import { useMemo } from 'react'; import { PanelProps, VizOrientation } from '@grafana/data'; -import { PanelDataErrorView } from '@grafana/runtime'; +import { config, PanelDataErrorView } from '@grafana/runtime'; import { + AdHocFilterItem, TooltipDisplayMode, TooltipPlugin2, UPLOT_AXIS_FONT_SIZE, UPlotChart, VizLayout, measureText, - // usePanelContext, + usePanelContext, useTheme2, } from '@grafana/ui'; -import { TooltipHoverMode } from '@grafana/ui/internal'; +import { AdHocFilterModel, FILTER_FOR_OPERATOR, TooltipHoverMode } from '@grafana/ui/internal'; import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; @@ -30,6 +31,7 @@ export const BarChartPanel = (props: PanelProps) => { // const { dataLinkPostProcessor } = usePanelContext(); const theme = useTheme2(); + const { onAddAdHocFilter } = usePanelContext(); const { barWidth, @@ -160,7 +162,40 @@ export const BarChartPanel = (props: PanelProps) => { getDataLinks={(seriesIdx, dataIdx) => vizSeries[0].fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? [] } - render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { + getAdHocFilters={(_seriesIdx, dataIdx) => { + const xField = vizSeries[0].fields[0]; + + // Check if the field supports filtering + // We only show filters on filterable fields (xField.config.filterable). + // Fields will have been marked as filterable by the data source if that data source supports adhoc filtering + // (eg. Prom or Loki) and the field types support adhoc filtering (eg. string or number - depending on the data source). + // Fields may later be marked as not filterable. For example, fields created from Grafana Transforms that + // are derived from a data source, but are not present in the data source. + // We choose `xField` here because it contains the label-value pair, rather than `field` which is the numeric Value. + if ( + config.featureToggles.adhocFiltersInTooltips && + xField.config.filterable && + onAddAdHocFilter != null + ) { + const adHocFilterItem: AdHocFilterItem = { + key: xField.name, + operator: FILTER_FOR_OPERATOR, + value: String(xField.values[dataIdx]), + }; + + const adHocFilters: AdHocFilterModel[] = [ + { + ...adHocFilterItem, + onClick: () => onAddAdHocFilter(adHocFilterItem), + }, + ]; + + return adHocFilters; + } + + return []; + }} + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks, adHocFilters) => { return ( ) => { maxHeight={options.tooltip.maxHeight} replaceVariables={replaceVariables} dataLinks={dataLinks} + adHocFilters={adHocFilters} hideZeros={options.tooltip.hideZeros} /> ); diff --git a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx index 48d7cc1665a..36cf73bb58a 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx @@ -9,6 +9,7 @@ import { VizTooltipWrapper, getContentItems, VizTooltipItem, + AdHocFilterModel, } from '@grafana/ui/internal'; import { getFieldActions } from '../status-history/utils'; @@ -41,6 +42,7 @@ export interface TimeSeriesTooltipProps { replaceVariables?: InterpolateFunction; dataLinks: LinkModel[]; hideZeros?: boolean; + adHocFilters?: AdHocFilterModel[]; } export const TimeSeriesTooltip = ({ @@ -56,6 +58,7 @@ export const TimeSeriesTooltip = ({ replaceVariables = (str) => str, dataLinks, hideZeros, + adHocFilters, }: TimeSeriesTooltipProps) => { const xField = series.fields[0]; const xVal = formattedValueToString(xField.display!(xField.values[dataIdxs[0]!])); @@ -90,7 +93,9 @@ export const TimeSeriesTooltip = ({ const dataIdx = dataIdxs[seriesIdx]!; const actions = getFieldActions(series, field, replaceVariables, dataIdx); - footer = ; + footer = ( + + ); } } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a2596532803..c3763c9a59a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8729,6 +8729,7 @@ "footer-add-annotation": "Add annotation", "footer-click-to-action": "Click to {{actionTitle}}", "footer-click-to-navigate": "Click to open {{linkTitle}}", + "footer-filter-for-value": "Filter for '{{value}}'", "timestamp": "Timestamp" }, "week-start-picker": {