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 <leeoniya@gmail.com>
This commit is contained in:
co-authored by
Leon Sorokin
parent
89cd2284cb
commit
2174a84b36
@@ -1079,4 +1079,8 @@ export interface FeatureToggles {
|
||||
* @default false
|
||||
*/
|
||||
alertmanagerRemoteSecondaryWithRemoteState?: boolean;
|
||||
/**
|
||||
* Enable adhoc filter buttons in visualization tooltips
|
||||
*/
|
||||
adhocFiltersInTooltips?: boolean;
|
||||
}
|
||||
|
||||
@@ -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<LinkModel<Field>>;
|
||||
actions?: Array<ActionModel<Field>>;
|
||||
adHocFilters?: AdHocFilterModel[];
|
||||
annotate?: () => void;
|
||||
}
|
||||
|
||||
@@ -41,7 +47,7 @@ function makeRenderLinksOrActions<T extends LinkModel | ActionModel>(
|
||||
|
||||
if (oneClickItem != null) {
|
||||
return (
|
||||
<div className={styles.dataLinks}>
|
||||
<div className={styles.footerSection}>
|
||||
<Stack direction="column" justifyContent="flex-start" gap={0.5}>
|
||||
<span className={styles.oneClickWrapper}>
|
||||
<Icon name="info-circle" size="lg" className={styles.infoIcon} />
|
||||
@@ -53,7 +59,7 @@ function makeRenderLinksOrActions<T extends LinkModel | ActionModel>(
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.dataLinks}>
|
||||
<div className={styles.footerSection}>
|
||||
<Stack direction="column" justifyContent="flex-start" gap={itemGap}>
|
||||
{items.map((item, i) => renderItem(item, i, styles))}
|
||||
</Stack>
|
||||
@@ -79,7 +85,7 @@ const renderActions = makeRenderLinksOrActions<ActionModel>(
|
||||
(item, i) => <ActionButton key={i} action={item} variant="secondary" />
|
||||
);
|
||||
|
||||
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
|
||||
<div className={styles.wrapper}>
|
||||
{!hasOneClickAction && renderDataLinks(dataLinks, styles)}
|
||||
{!hasOneClickLink && renderActions(actions, styles)}
|
||||
{!hasOneClickLink && !hasOneClickAction && adHocFilters.length > 0 && (
|
||||
<div className={styles.footerSection}>
|
||||
{adHocFilters.map((item, index) => (
|
||||
<Button key={index} icon="filter" variant="secondary" size="sm" onClick={item.onClick}>
|
||||
<Trans i18nKey="grafana-ui.viz-tooltip.footer-filter-for-value">
|
||||
Filter for '{{ value: item.value }}'
|
||||
</Trans>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!hasOneClickLink && !hasOneClickAction && annotate != null && (
|
||||
<div className={styles.addAnnotations}>
|
||||
<div className={styles.footerSection}>
|
||||
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
|
||||
<Trans i18nKey="grafana-ui.viz-tooltip.footer-add-annotation">Add annotation</Trans>
|
||||
</Button>
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const portalRoot = useRef<HTMLElement | null>(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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Options>) => {
|
||||
// const { dataLinkPostProcessor } = usePanelContext();
|
||||
|
||||
const theme = useTheme2();
|
||||
const { onAddAdHocFilter } = usePanelContext();
|
||||
|
||||
const {
|
||||
barWidth,
|
||||
@@ -160,7 +162,40 @@ export const BarChartPanel = (props: PanelProps<Options>) => {
|
||||
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 (
|
||||
<TimeSeriesTooltip
|
||||
series={vizSeries[0]}
|
||||
@@ -173,6 +208,7 @@ export const BarChartPanel = (props: PanelProps<Options>) => {
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
replaceVariables={replaceVariables}
|
||||
dataLinks={dataLinks}
|
||||
adHocFilters={adHocFilters}
|
||||
hideZeros={options.tooltip.hideZeros}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 = <VizTooltipFooter dataLinks={dataLinks} actions={actions} annotate={annotate} />;
|
||||
footer = (
|
||||
<VizTooltipFooter dataLinks={dataLinks} actions={actions} annotate={annotate} adHocFilters={adHocFilters} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user