diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts
index 24529c1cc..bb943a8e3 100644
--- a/packages/scenes/src/index.ts
+++ b/packages/scenes/src/index.ts
@@ -78,7 +78,12 @@ export {
} from './variables/variants/MultiValueVariable';
export { LocalValueVariable } from './variables/variants/LocalValueVariable';
export { IntervalVariable } from './variables/variants/IntervalVariable';
-export { AdHocFiltersVariable } from './variables/adhoc/AdHocFiltersVariable';
+export {
+ AdHocFiltersVariable,
+ OPERATORS,
+ GROUP_BY_OPERATOR_VALUE,
+ type OperatorDefinition,
+} from './variables/adhoc/AdHocFiltersVariable';
export type { AdHocFilterWithLabels } from './variables/adhoc/AdHocFiltersVariable';
export type {
AdHocFiltersController,
diff --git a/packages/scenes/src/locales/en-US/grafana-scenes.json b/packages/scenes/src/locales/en-US/grafana-scenes.json
index 4adef081c..4090b78cf 100644
--- a/packages/scenes/src/locales/en-US/grafana-scenes.json
+++ b/packages/scenes/src/locales/en-US/grafana-scenes.json
@@ -23,6 +23,11 @@
"subTitle": "The url did not match any page",
"title": "Not found"
},
+ "group-by-pill": {
+ "group-by-key": "Group by {{label}}",
+ "non-applicable": "Group by is not applicable",
+ "remove-group-by": "Remove group by {{label}}"
+ },
"lazy-loader": {
"placeholder": "\u00a0"
},
@@ -100,6 +105,10 @@
"placeholder-select-value": "Select value"
}
},
+ "adhoc": {
+ "group-by-operator-description": "Group by this label instead of filtering",
+ "group-by-operator-label": "Group by"
+ },
"adhoc-filters-combobox-renderer": {
"collapse": "Collapse",
"collapse-filters": "Collapse filters"
diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersCombobox.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersCombobox.tsx
index d7ee5f114..85dcf97bd 100644
--- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersCombobox.tsx
+++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersCombobox.tsx
@@ -14,7 +14,13 @@ import { FloatingFocusManager, FloatingPortal, UseFloatingOptions } from '@float
import { Spinner, Text, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { css, cx } from '@emotion/css';
-import { AdHocFilterWithLabels, isFilterComplete, isMultiValueOperator, OPERATORS } from '../AdHocFiltersVariable';
+import {
+ AdHocFilterWithLabels,
+ GROUP_BY_OPERATOR_VALUE,
+ isFilterComplete,
+ isMultiValueOperator,
+ OPERATORS,
+} from '../AdHocFiltersVariable';
import { AdHocFiltersController } from '../controller/AdHocFiltersController';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
@@ -379,6 +385,18 @@ export const AdHocCombobox = forwardRef(function AdHocCombobox(
return;
}
+ // Check if the last pill is a GroupBy -- if so, backspace into it
+ // by removing it and pre-filling the WIP with its key at the operator step
+ if (isAlwaysWip && controller.getLastPillType?.() === 'groupby') {
+ const removed = controller.popLastGroupByValue?.();
+ if (removed) {
+ controller.updateFilter(filter!, { key: removed.key, keyLabel: removed.keyLabel });
+ setInputValue('');
+ switchInputType('operator', setInputType, undefined, refs.domReference.current);
+ return;
+ }
+ }
+
// focus back on alway wip input when you delete filter with backspace
focusOnWipInputRef?.();
@@ -459,6 +477,23 @@ export const AdHocCombobox = forwardRef(function AdHocCombobox(
}
const selectedItem = filteredDropDownItems[activeIndex];
+ // Handle "Group by" operator selection: short-circuit the pill flow
+ if (filterInputType === 'operator' && selectedItem.value === GROUP_BY_OPERATOR_VALUE) {
+ if (!isAlwaysWip && filter) {
+ // Editing an existing filter: convert it to a groupBy (replaces in-place in pillOrder)
+ controller.convertFilterToGroupBy?.(filter);
+ } else {
+ // WIP filter: just add the groupBy
+ controller.addGroupByValue?.(filter!.key, filter!.keyLabel);
+ }
+ handleResetWip();
+ handleChangeViewMode?.();
+ setOpen(false);
+ setInputValue('');
+ focusOnWipInputRef?.();
+ return;
+ }
+
if (multiValueEdit) {
handleLocalMultiValueChange(selectedItem);
setInputValue('');
@@ -508,8 +543,10 @@ export const AdHocCombobox = forwardRef(function AdHocCombobox(
controller,
filter,
filterInputType,
+ isAlwaysWip,
populateInputOnEdit,
handleChangeViewMode,
+ handleResetWip,
refs.domReference,
isLastFilter,
focusOnWipInputRef,
@@ -776,6 +813,24 @@ export const AdHocCombobox = forwardRef(function AdHocCombobox(
event.stopPropagation();
}
+ // Handle "Group by" operator selection via click
+ if (filterInputType === 'operator' && item.value === GROUP_BY_OPERATOR_VALUE) {
+ event.stopPropagation();
+ if (!isAlwaysWip && filter) {
+ // Editing an existing filter: convert it to a groupBy
+ controller.convertFilterToGroupBy?.(filter);
+ } else {
+ // WIP filter: just add the groupBy
+ controller.addGroupByValue?.(filter!.key, filter!.keyLabel);
+ }
+ handleResetWip();
+ handleChangeViewMode?.();
+ setOpen(false);
+ setInputValue('');
+ focusOnWipInputRef?.();
+ return;
+ }
+
if (isMultiValueEdit) {
event.preventDefault();
event.stopPropagation();
diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx
index 7fade50ec..949d101b8 100644
--- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx
+++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx
@@ -1,21 +1,34 @@
import { css, cx } from '@emotion/css';
-import { GrafanaTheme2 } from '@grafana/data';
+import {
+ GrafanaTheme2,
+ // @ts-expect-error (temporary till we update grafana/data)
+ DrilldownsApplicability,
+} from '@grafana/data';
import { Button, Icon, useStyles2, useTheme2 } from '@grafana/ui';
import { t } from '@grafana/i18n';
-import React, { memo, useRef, useState, useEffect } from 'react';
+import React, { memo, useMemo, useRef, useState, useEffect } from 'react';
+import { isArray } from 'lodash';
import { useMeasure } from 'react-use';
import { AdHocFiltersController } from '../controller/AdHocFiltersController';
import { AdHocFilterPill } from './AdHocFilterPill';
import { AdHocFiltersAlwaysWipCombobox } from './AdHocFiltersAlwaysWipCombobox';
+import { GroupByPill } from './GroupByPill';
+import type { GroupByVariable } from '../../groupby/GroupByVariable';
+import type { AdHocFilterWithLabels } from '../AdHocFiltersVariable';
const MAX_VISIBLE_FILTERS = 5;
+type OrderedPill =
+ | { type: 'filter'; filter: AdHocFilterWithLabels; index: number }
+ | { type: 'groupby'; value: string; label: string; applicability?: DrilldownsApplicability };
+
interface Props {
controller: AdHocFiltersController;
}
export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRenderer({ controller }: Props) {
- const { originFilters, filters, readOnly, collapsible, valueRecommendations } = controller.useState();
+ const { originFilters, filters, readOnly, collapsible, valueRecommendations, pillOrder } = controller.useState();
+ const groupByVariable = controller.getGroupByVariable?.();
const styles = useStyles2(getStyles);
const theme = useTheme2();
const [collapsed, setCollapsed] = useState(true);
@@ -52,14 +65,14 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe
}
};
- // Combine all visible filters into one array
+ // Origin filters are always rendered first
const visibleOriginFilters = originFilters?.filter((f) => f.origin) ?? [];
const visibleFilters = filters.filter((f) => !f.hidden);
- const allFilters = [...visibleOriginFilters, ...visibleFilters];
- const totalFiltersCount = allFilters.length;
+
+ // Total count includes origin filters + user filters + groupBy count (for collapse logic)
+ const totalFiltersCount = visibleOriginFilters.length + visibleFilters.length;
const shouldCollapse = collapsible && collapsed && totalFiltersCount > 0;
- const filtersToRender = shouldCollapse ? allFilters.slice(0, MAX_VISIBLE_FILTERS) : allFilters;
// Reset collapsed state when there are no filters (only when collapsible)
useEffect(() => {
@@ -85,9 +98,10 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe
{valueRecommendations && }
- {filtersToRender.map((filter, index) => (
+ {/* Origin filters always render first */}
+ {visibleOriginFilters.map((filter, index) => (
))}
+ {/* Interleaved user filters and groupBy pills, driven by pillOrder */}
+ {groupByVariable ? (
+
+ ) : (
+ // No groupBy linked -- render filters only (original behavior)
+ visibleFilters.map((filter, index) => (
+
+ ))
+ )}
+
{!readOnly && !shouldCollapse ? (
) : null}
@@ -134,6 +172,104 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe
);
});
+/**
+ * Separate component that subscribes to GroupByVariable state to build the
+ * interleaved pill list. This avoids calling useState() conditionally.
+ */
+function InterleavedPills({
+ groupByVariable,
+ controller,
+ visibleFilters,
+ pillOrder,
+ readOnly,
+ shouldCollapse,
+ focusOnWipInputRef,
+}: {
+ groupByVariable: GroupByVariable;
+ controller: AdHocFiltersController;
+ visibleFilters: AdHocFilterWithLabels[];
+ pillOrder?: Array<'filter' | 'groupby'>;
+ readOnly?: boolean;
+ shouldCollapse: boolean;
+ focusOnWipInputRef?: () => void;
+}) {
+ const { value, text, keysApplicability } = groupByVariable.useState();
+
+ const orderedPills = useMemo(() => {
+ const groupByValues = isArray(value) ? value.map(String).filter((v) => v !== '') : value ? [String(value)] : [];
+ const groupByTexts = isArray(text) ? text.map(String) : text ? [String(text)] : [];
+
+ const result: OrderedPill[] = [];
+ let filterIdx = 0;
+ let groupByIdx = 0;
+
+ // Fallback: when no pillOrder exists, show filters first then groupBys
+ const sequence =
+ pillOrder && pillOrder.length > 0
+ ? pillOrder
+ : [...visibleFilters.map(() => 'filter' as const), ...groupByValues.map(() => 'groupby' as const)];
+
+ for (const type of sequence) {
+ if (type === 'filter' && filterIdx < visibleFilters.length) {
+ result.push({ type: 'filter', filter: visibleFilters[filterIdx], index: filterIdx });
+ filterIdx++;
+ } else if (type === 'groupby' && groupByIdx < groupByValues.length) {
+ const val = groupByValues[groupByIdx];
+ const lbl = groupByTexts[groupByIdx] ?? val;
+ const applicability = keysApplicability?.find((item: DrilldownsApplicability) => item.key === val);
+ result.push({ type: 'groupby', value: val, label: lbl, applicability });
+ groupByIdx++;
+ }
+ }
+
+ // Append any remaining items not covered by pillOrder (safety net)
+ while (filterIdx < visibleFilters.length) {
+ result.push({ type: 'filter', filter: visibleFilters[filterIdx], index: filterIdx });
+ filterIdx++;
+ }
+ while (groupByIdx < groupByValues.length) {
+ const val = groupByValues[groupByIdx];
+ const lbl = groupByTexts[groupByIdx] ?? val;
+ const applicability = keysApplicability?.find((item: DrilldownsApplicability) => item.key === val);
+ result.push({ type: 'groupby', value: val, label: lbl, applicability });
+ groupByIdx++;
+ }
+
+ return result;
+ }, [visibleFilters, value, text, keysApplicability, pillOrder]);
+
+ const pillsToRender = shouldCollapse ? orderedPills.slice(0, MAX_VISIBLE_FILTERS) : orderedPills;
+
+ const handleRemoveGroupBy = (key: string) => {
+ controller.removeGroupByValue?.(key);
+ };
+
+ return (
+ <>
+ {pillsToRender.map((pill, i) =>
+ pill.type === 'filter' ? (
+
+ ) : (
+
+ )
+ )}
+ >
+ );
+}
+
const getStyles = (theme: GrafanaTheme2) => ({
comboboxWrapper: css({
display: 'flex',
diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/GroupByPill.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/GroupByPill.tsx
new file mode 100644
index 000000000..3dec48ae9
--- /dev/null
+++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/GroupByPill.tsx
@@ -0,0 +1,151 @@
+import { css, cx } from '@emotion/css';
+import {
+ GrafanaTheme2,
+ // @ts-expect-error (temporary till we update grafana/data)
+ DrilldownsApplicability,
+} from '@grafana/data';
+import { useStyles2, IconButton, Tooltip, Icon } from '@grafana/ui';
+import { t } from '@grafana/i18n';
+import React from 'react';
+import { getNonApplicablePillStyles } from '../../utils';
+
+const LABEL_MAX_VISIBLE_LENGTH = 20;
+
+interface GroupByPillProps {
+ /** The groupBy key value (e.g., "hostname") */
+ value: string;
+ /** The display label for the key */
+ label: string;
+ /** Whether this pill is read-only */
+ readOnly?: boolean;
+ /** Applicability info for this key */
+ applicability?: DrilldownsApplicability;
+ /** Callback to remove this groupBy value */
+ onRemove?: (key: string) => void;
+}
+
+export function GroupByPill({ value, label, readOnly, applicability, onRemove }: GroupByPillProps) {
+ const styles = useStyles2(getStyles);
+
+ const isNonApplicable = applicability && !applicability.applicable;
+ const nonApplicableReason = applicability?.reason;
+
+ const pillTextContent = label;
+ const pillText = (
+ {pillTextContent}
+ );
+
+ return (
+
+
+
+ {pillTextContent.length < LABEL_MAX_VISIBLE_LENGTH ? (
+ pillText
+ ) : (
+ {pillTextContent}
} placement="top">
+ {pillText}
+
+ )}
+
+ {!readOnly && onRemove ? (
+ {
+ e.stopPropagation();
+ onRemove(value);
+ }}
+ onKeyDownCapture={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ onRemove(value);
+ }
+ }}
+ name="times"
+ size="md"
+ className={cx(styles.pillIcon, isNonApplicable && styles.disabledPillIcon)}
+ tooltip={t('grafana-scenes.components.group-by-pill.remove-group-by', 'Remove group by {{label}}', { label })}
+ />
+ ) : null}
+
+ {isNonApplicable && (
+
+
+
+ )}
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ groupByPill: css({
+ display: 'flex',
+ alignItems: 'center',
+ background: theme.colors.action.selected,
+ borderRadius: theme.shape.radius.default,
+ border: `1px solid ${theme.colors.border.weak}`,
+ padding: theme.spacing(0.125, 0, 0.125, 1),
+ color: theme.colors.text.primary,
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ minHeight: theme.spacing(2.75),
+ ...theme.typography.bodySmall,
+ fontWeight: theme.typography.fontWeightBold,
+ cursor: 'default',
+
+ '&:hover': {
+ background: theme.colors.action.hover,
+ },
+ }),
+ readOnlyPill: css({
+ paddingRight: theme.spacing(1),
+ cursor: 'text',
+ '&:hover': {
+ background: theme.colors.action.selected,
+ },
+ }),
+ groupByIcon: css({
+ color: theme.colors.text.secondary,
+ marginRight: theme.spacing(0.5),
+ flexShrink: 0,
+ }),
+ pillIcon: css({
+ marginInline: theme.spacing(0.5),
+ cursor: 'pointer',
+ '&:hover': {
+ color: theme.colors.text.primary,
+ },
+ }),
+ pillText: css({
+ maxWidth: '200px',
+ width: '100%',
+ textOverflow: 'ellipsis',
+ overflow: 'hidden',
+ }),
+ tooltipText: css({
+ textAlign: 'center',
+ }),
+ infoPillIcon: css({
+ marginInline: theme.spacing(0.5),
+ cursor: 'pointer',
+ }),
+ disabledPillIcon: css({
+ marginInline: theme.spacing(0.5),
+ cursor: 'pointer',
+ color: theme.colors.text.disabled,
+ '&:hover': {
+ color: theme.colors.text.disabled,
+ },
+ }),
+ ...getNonApplicablePillStyles(theme),
+});
diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/GroupByPills.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/GroupByPills.tsx
new file mode 100644
index 000000000..f23470778
--- /dev/null
+++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/GroupByPills.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { isArray } from 'lodash';
+import { GroupByPill } from './GroupByPill';
+import type { GroupByVariable } from '../../groupby/GroupByVariable';
+import type { AdHocFiltersController } from '../controller/AdHocFiltersController';
+
+// @ts-expect-error (temporary till we update grafana/data)
+import type { DrilldownsApplicability } from '@grafana/data';
+
+interface GroupByPillsProps {
+ groupByVariable: GroupByVariable;
+ controller: AdHocFiltersController;
+ readOnly?: boolean;
+}
+
+/**
+ * Renders GroupBy values as pills inside the AdHoc combobox renderer.
+ * This is a separate component so we can safely call useState() on GroupByVariable
+ * without violating the React hooks rules (no conditional hooks).
+ */
+export function GroupByPills({ groupByVariable, controller, readOnly }: GroupByPillsProps) {
+ const { value, text, keysApplicability } = groupByVariable.useState();
+
+ const values = isArray(value) ? value.map(String) : value ? [String(value)] : [];
+ const texts = isArray(text) ? text.map(String) : text ? [String(text)] : [];
+
+ // Don't render anything if there are no GroupBy values
+ if (values.length === 0 || (values.length === 1 && values[0] === '')) {
+ return null;
+ }
+
+ const handleRemove = (key: string) => {
+ controller.removeGroupByValue?.(key);
+ };
+
+ return (
+ <>
+ {values
+ .filter((v) => v !== '')
+ .map((val, idx) => {
+ const label = texts[idx] ?? val;
+ const applicability = keysApplicability?.find((item: DrilldownsApplicability) => item.key === val);
+
+ return (
+
+ );
+ })}
+ >
+ );
+}
diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.tsx
index c2cf60146..cfa111e37 100644
--- a/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.tsx
+++ b/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import {
// @ts-expect-error (temporary till we update grafana/data)
DrilldownsApplicability,
+ SelectableValue,
store,
} from '@grafana/data';
import { sceneGraph } from '../../core/sceneGraph';
@@ -16,6 +17,8 @@ import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneComponentProps, SceneObjectState } from '../../core/types';
import { wrapInSafeSerializableSceneObject } from '../../utils/wrapInSafeSerializableSceneObject';
import { Unsubscribable } from 'rxjs';
+import { VariableValueSingle } from '../types';
+import { isArray } from 'lodash';
export const MAX_RECENT_DRILLDOWNS = 3;
export const MAX_STORED_RECENT_DRILLDOWNS = 10;
@@ -26,6 +29,10 @@ export const getRecentFiltersKey = (datasourceUid: string | undefined) =>
export interface AdHocFiltersRecommendationsState extends SceneObjectState {
recentFilters?: AdHocFilterWithLabels[];
recommendedFilters?: AdHocFilterWithLabels[];
+ /** GroupBy recent groupings, mirrored from GroupByRecommendations state */
+ recentGroupings?: Array>;
+ /** GroupBy recommended groupings, mirrored from GroupByRecommendations state */
+ recommendedGroupings?: Array>;
}
export class AdHocFiltersRecommendations extends SceneObjectBase {
@@ -65,6 +72,7 @@ export class AdHocFiltersRecommendations extends SceneObjectBase {
+ this.setState({
+ recentGroupings: newState.recentGrouping,
+ recommendedGroupings: newState.recommendedGrouping,
+ });
+ }))
+ );
+ }
+ }
+
return () => {
scopesSubscription?.unsubscribe();
adHocSubscription?.unsubscribe();
+ groupByRecsSubscription?.unsubscribe();
};
};
@@ -193,28 +225,83 @@ export class AdHocFiltersRecommendations extends SceneObjectBase) {
- const { recentFilters, recommendedFilters } = model.useState();
- const { filters } = model._adHocFilter.useState();
-
- const recentDrilldowns: DrilldownPill[] | undefined = recentFilters?.map((filter) => ({
- label: `${filter.key} ${filter.operator} ${filter.value}`,
- onClick: () => {
- const exists = filters.some((f) => f.key === filter.key && f.value === filter.value);
- if (!exists) {
- model.addFilterToParent(filter);
- }
- },
- }));
-
- const recommendedDrilldowns: DrilldownPill[] | undefined = recommendedFilters?.map((filter) => ({
- label: `${filter.key} ${filter.operator} ${filter.value}`,
- onClick: () => {
- const exists = filters.some((f) => f.key === filter.key && f.value === filter.value);
- if (!exists) {
- model.addFilterToParent(filter);
- }
- },
- }));
-
- return ;
+ const { recentFilters, recommendedFilters, recentGroupings, recommendedGroupings } = model.useState();
+ const { filters, groupByVariable } = model._adHocFilter.useState();
+
+ // Build filter drilldown pills
+ const recentFilterPills: DrilldownPill[] =
+ recentFilters?.map((filter) => ({
+ label: `${filter.key} ${filter.operator} ${filter.value}`,
+ onClick: () => {
+ const exists = filters.some((f) => f.key === filter.key && f.value === filter.value);
+ if (!exists) {
+ model.addFilterToParent(filter);
+ }
+ },
+ })) ?? [];
+
+ const recommendedFilterPills: DrilldownPill[] =
+ recommendedFilters?.map((filter) => ({
+ label: `${filter.key} ${filter.operator} ${filter.value}`,
+ onClick: () => {
+ const exists = filters.some((f) => f.key === filter.key && f.value === filter.value);
+ if (!exists) {
+ model.addFilterToParent(filter);
+ }
+ },
+ })) ?? [];
+
+ // Build GroupBy drilldown pills (if linked)
+ const recentGroupByPills: DrilldownPill[] =
+ groupByVariable && recentGroupings
+ ? recentGroupings.map((grouping) => ({
+ label: `↗ ${grouping.value}`,
+ onClick: () => {
+ const currentValues = isArray(groupByVariable.state.value)
+ ? groupByVariable.state.value.map(String)
+ : groupByVariable.state.value
+ ? [String(groupByVariable.state.value)]
+ : [];
+ if (!currentValues.includes(String(grouping.value))) {
+ groupByVariable.changeValueTo(
+ [...currentValues.filter((v) => v !== ''), grouping.value!],
+ undefined,
+ true
+ );
+ }
+ },
+ }))
+ : [];
+
+ const recommendedGroupByPills: DrilldownPill[] =
+ groupByVariable && recommendedGroupings
+ ? recommendedGroupings.map((grouping) => ({
+ label: `↗ ${grouping.value}`,
+ onClick: () => {
+ const currentValues = isArray(groupByVariable.state.value)
+ ? groupByVariable.state.value.map(String)
+ : groupByVariable.state.value
+ ? [String(groupByVariable.state.value)]
+ : [];
+ if (!currentValues.includes(String(grouping.value))) {
+ groupByVariable.changeValueTo(
+ [...currentValues.filter((v) => v !== ''), grouping.value!],
+ undefined,
+ true
+ );
+ }
+ },
+ }))
+ : [];
+
+ // Combine filter and GroupBy pills
+ const recentDrilldowns = [...recentFilterPills, ...recentGroupByPills];
+ const recommendedDrilldowns = [...recommendedFilterPills, ...recommendedGroupByPills];
+
+ return (
+ 0 ? recentDrilldowns : undefined}
+ recommendedDrilldowns={recommendedDrilldowns.length > 0 ? recommendedDrilldowns : undefined}
+ />
+ );
}
diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx
index 68e5468c2..199151df9 100644
--- a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx
+++ b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx
@@ -28,6 +28,7 @@ import { AdHocFilterRenderer } from './AdHocFilterRenderer';
import { getDataSourceSrv } from '@grafana/runtime';
import { AdHocFiltersVariableUrlSyncHandler, toArray } from './AdHocFiltersVariableUrlSyncHandler';
import { css } from '@emotion/css';
+import { t } from '@grafana/i18n';
import { getEnrichedFiltersRequest } from '../getEnrichedFiltersRequest';
import { AdHocFiltersComboboxRenderer } from './AdHocFiltersCombobox/AdHocFiltersComboboxRenderer';
import { wrapInSafeSerializableSceneObject } from '../../utils/wrapInSafeSerializableSceneObject';
@@ -38,6 +39,7 @@ import { getQueryController } from '../../core/sceneGraph/getQueryController';
import { FILTER_REMOVED_INTERACTION, FILTER_RESTORED_INTERACTION } from '../../performance/interactionConstants';
import { AdHocFiltersVariableController } from './controller/AdHocFiltersVariableController';
import { AdHocFiltersRecommendations } from './AdHocFiltersRecommendations';
+import type { GroupByVariable } from '../groupby/GroupByVariable';
export interface AdHocFilterWithLabels = {}> extends AdHocVariableFilter {
keyLabel?: string;
@@ -166,6 +168,22 @@ export interface AdHocFiltersVariableState extends SceneVariableState {
* enables drilldown recommendations
*/
drilldownRecommendationsEnabled?: boolean;
+
+ /**
+ * Optional reference to a GroupByVariable. When set, the operator dropdown will include
+ * a "Group by" option, and selecting it will add the key to this GroupByVariable instead
+ * of creating a filter. GroupBy pills will be rendered alongside filter pills in the UI.
+ */
+ groupByVariable?: GroupByVariable;
+
+ /**
+ * Tracks the insertion order of user-added filters and groupBy entries.
+ * Each element is either 'filter' or 'groupby'. The Nth 'filter' maps to filters[N],
+ * the Nth 'groupby' maps to the Nth GroupBy value. This allows the UI to render pills
+ * in the order they were added rather than grouping by type.
+ * @internal
+ */
+ pillOrder?: Array<'filter' | 'groupby'>;
}
export type AdHocVariableExpressionBuilderFn = (filters: AdHocFilterWithLabels[]) => string;
@@ -194,6 +212,13 @@ export type OperatorDefinition = {
isRegex?: Boolean;
};
+/**
+ * Special operator value used to indicate a "Group by" selection in the operator dropdown.
+ * When selected, the pill-building flow is short-circuited: the key is added to the linked
+ * GroupByVariable instead of creating a filter with operator + value.
+ */
+export const GROUP_BY_OPERATOR_VALUE = '__groupBy__';
+
export const OPERATORS: OperatorDefinition[] = [
{
value: '=',
@@ -477,7 +502,8 @@ export class AdHocFiltersVariable
}
/**
- * Clear all user-added filters and restore origin filters to their original values.
+ * Clear all user-added filters, restore origin filters to their original values,
+ * and clear the linked GroupByVariable if present.
*/
public clearAll(): void {
// Restore all restorable origin filters to their original values
@@ -487,8 +513,18 @@ export class AdHocFiltersVariable
}
});
- // Clear all user-added filters
- this.setState({ filters: [] });
+ // Clear all user-added filters and reset pill order
+ this.setState({ filters: [], pillOrder: [] });
+
+ // Clear GroupBy values if linked
+ if (this.state.groupByVariable) {
+ const groupBy = this.state.groupByVariable;
+ if (groupBy.state.defaultValue) {
+ groupBy.restoreDefaultValues();
+ } else {
+ groupBy.changeValueTo([], [], true);
+ }
+ }
}
public getValue(fieldPath?: string): VariableValue | undefined {
@@ -537,9 +573,11 @@ export class AdHocFiltersVariable
if (filter === _wip) {
// If we set value we are done with this "work in progress" filter and we can add it
if ('value' in update && update['value'] !== '') {
+ const basePillOrder = this._getOrInitPillOrder();
this.setState({
filters: [...filters, { ..._wip, ...update }],
_wip: undefined,
+ pillOrder: [...basePillOrder, 'filter'],
});
this.verifyApplicabilityAndStoreRecentFilter({ ..._wip, ...update });
} else {
@@ -580,7 +618,14 @@ export class AdHocFiltersVariable
const queryController = getQueryController(this);
queryController?.startProfile(FILTER_REMOVED_INTERACTION);
- this.setState({ filters: this.state.filters.filter((f) => f !== filter) });
+ const filterIndex = this.state.filters.indexOf(filter);
+ const basePillOrder = this._getOrInitPillOrder();
+ const updatedPillOrder = removeNthOccurrence(basePillOrder, 'filter', filterIndex);
+
+ this.setState({
+ filters: this.state.filters.filter((f) => f !== filter),
+ pillOrder: updatedPillOrder,
+ });
this._debouncedVerifyApplicability();
}
@@ -845,10 +890,39 @@ export class AdHocFiltersVariable
});
}
+ /**
+ * Returns the current pillOrder, initializing it from existing state if needed.
+ * When pillOrder is undefined (e.g. dashboard just loaded), this seeds it with
+ * entries for all existing filters and groupBy values so new items are appended
+ * in the correct position.
+ */
+ public _getOrInitPillOrder(): Array<'filter' | 'groupby'> {
+ if (this.state.pillOrder && this.state.pillOrder.length > 0) {
+ return this.state.pillOrder;
+ }
+
+ const filterEntries: Array<'filter' | 'groupby'> = this.state.filters
+ .filter((f) => !f.hidden)
+ .map(() => 'filter' as const);
+
+ const groupBy = this.state.groupByVariable;
+ let groupByEntries: Array<'filter' | 'groupby'> = [];
+ if (groupBy) {
+ const values = Array.isArray(groupBy.state.value)
+ ? groupBy.state.value.map(String).filter((v) => v !== '')
+ : groupBy.state.value
+ ? [String(groupBy.state.value)]
+ : [];
+ groupByEntries = values.map(() => 'groupby' as const);
+ }
+
+ return [...filterEntries, ...groupByEntries];
+ }
+
public _getOperators() {
- const { supportsMultiValueOperators, allowCustomValue = true } = this.state;
+ const { supportsMultiValueOperators, allowCustomValue = true, groupByVariable } = this.state;
- return OPERATORS.filter(({ isMulti, isRegex }) => {
+ const operators = OPERATORS.filter(({ isMulti, isRegex }) => {
if (!supportsMultiValueOperators && isMulti) {
return false;
}
@@ -861,6 +935,20 @@ export class AdHocFiltersVariable
value,
description,
}));
+
+ // Add "Group by" option when a GroupByVariable is linked
+ if (groupByVariable) {
+ operators.unshift({
+ label: t('grafana-scenes.variables.adhoc.group-by-operator-label', 'Group by'),
+ value: GROUP_BY_OPERATOR_VALUE,
+ description: t(
+ 'grafana-scenes.variables.adhoc.group-by-operator-description',
+ 'Group by this label instead of filtering'
+ ),
+ });
+ }
+
+ return operators;
}
}
@@ -950,3 +1038,26 @@ export function isMultiValueOperator(operatorValue: string): boolean {
}
return Boolean(operator.isMulti);
}
+
+/**
+ * Removes the Nth occurrence of `target` from a pill order array.
+ * Used to keep pillOrder in sync when a filter or groupBy is removed.
+ */
+export function removeNthOccurrence(
+ pillOrder: Array<'filter' | 'groupby'>,
+ target: 'filter' | 'groupby',
+ n: number
+): Array<'filter' | 'groupby'> {
+ const result = [...pillOrder];
+ let count = 0;
+ for (let i = 0; i < result.length; i++) {
+ if (result[i] === target) {
+ if (count === n) {
+ result.splice(i, 1);
+ return result;
+ }
+ count++;
+ }
+ }
+ return result;
+}
diff --git a/packages/scenes/src/variables/adhoc/controller/AdHocFiltersController.ts b/packages/scenes/src/variables/adhoc/controller/AdHocFiltersController.ts
index c9848dac1..4a616e3f6 100644
--- a/packages/scenes/src/variables/adhoc/controller/AdHocFiltersController.ts
+++ b/packages/scenes/src/variables/adhoc/controller/AdHocFiltersController.ts
@@ -1,6 +1,7 @@
import { SelectableValue } from '@grafana/data';
import { AdHocFilterWithLabels, OnAddCustomValueFn } from '../AdHocFiltersVariable';
import { AdHocFiltersRecommendations } from '../AdHocFiltersRecommendations';
+import type { GroupByVariable } from '../../groupby/GroupByVariable';
/**
* Controller state returned by useState hook
@@ -20,6 +21,10 @@ export interface AdHocFiltersControllerState {
collapsible?: boolean;
valueRecommendations?: AdHocFiltersRecommendations;
drilldownRecommendationsEnabled?: boolean;
+ /**
+ * Tracks insertion order of user-added filters and groupBy entries.
+ */
+ pillOrder?: Array<'filter' | 'groupby'>;
}
/**
@@ -125,4 +130,41 @@ export interface AdHocFiltersController {
* Optional: Stop tracking the current interaction.
*/
stopInteraction?(): void;
+
+ /**
+ * Get the linked GroupByVariable, if any.
+ */
+ getGroupByVariable?(): GroupByVariable | undefined;
+
+ /**
+ * Add a key to the linked GroupByVariable.
+ * @param key - The key to add as a group-by dimension
+ * @param keyLabel - Optional display label for the key
+ */
+ addGroupByValue?(key: string, keyLabel?: string): void;
+
+ /**
+ * Remove a key from the linked GroupByVariable.
+ * @param key - The key to remove
+ */
+ removeGroupByValue?(key: string): void;
+
+ /**
+ * Convert an existing filter into a GroupBy entry.
+ * Removes the filter, replaces its pillOrder position with 'groupby',
+ * and adds the key to the GroupByVariable.
+ */
+ convertFilterToGroupBy?(filter: AdHocFilterWithLabels): void;
+
+ /**
+ * Returns the type of the last pill in pillOrder ('filter' | 'groupby'),
+ * or undefined if there are no pills.
+ */
+ getLastPillType?(): 'filter' | 'groupby' | undefined;
+
+ /**
+ * Removes the last GroupBy value and returns its key and label.
+ * Used when backspacing into a GroupBy pill from the WIP input.
+ */
+ popLastGroupByValue?(): { key: string; keyLabel: string } | undefined;
}
diff --git a/packages/scenes/src/variables/adhoc/controller/AdHocFiltersVariableController.ts b/packages/scenes/src/variables/adhoc/controller/AdHocFiltersVariableController.ts
index 9f7c630fa..00b5f933e 100644
--- a/packages/scenes/src/variables/adhoc/controller/AdHocFiltersVariableController.ts
+++ b/packages/scenes/src/variables/adhoc/controller/AdHocFiltersVariableController.ts
@@ -1,8 +1,10 @@
import { SelectableValue } from '@grafana/data';
-import { AdHocFilterWithLabels, AdHocFiltersVariable } from '../AdHocFiltersVariable';
+import { isArray } from 'lodash';
+import { AdHocFilterWithLabels, AdHocFiltersVariable, removeNthOccurrence } from '../AdHocFiltersVariable';
import { AdHocFiltersController, AdHocFiltersControllerState } from './AdHocFiltersController';
import { getQueryController } from '../../../core/sceneGraph/getQueryController';
import { getInteractionTracker } from '../../../core/sceneGraph/getInteractionTracker';
+import type { GroupByVariable } from '../../groupby/GroupByVariable';
/**
* Adapter that wraps AdHocFiltersVariable to implement the AdHocFiltersController interface.
@@ -25,6 +27,7 @@ export class AdHocFiltersVariableController implements AdHocFiltersController {
collapsible: state.collapsible,
valueRecommendations: this.model.getRecommendations(),
drilldownRecommendationsEnabled: state.drilldownRecommendationsEnabled,
+ pillOrder: state.pillOrder,
};
}
@@ -96,4 +99,233 @@ export class AdHocFiltersVariableController implements AdHocFiltersController {
const interactionTracker = getInteractionTracker(this.model);
interactionTracker?.stopInteraction();
}
+
+ public getGroupByVariable(): GroupByVariable | undefined {
+ return this.model.state.groupByVariable;
+ }
+
+ public addGroupByValue(key: string, keyLabel?: string): void {
+ const groupBy = this.model.state.groupByVariable;
+ if (!groupBy) {
+ return;
+ }
+
+ const currentValues = isArray(groupBy.state.value)
+ ? groupBy.state.value.map(String)
+ : groupBy.state.value
+ ? [String(groupBy.state.value)]
+ : [];
+ const currentTexts = isArray(groupBy.state.text)
+ ? groupBy.state.text.map(String)
+ : groupBy.state.text
+ ? [String(groupBy.state.text)]
+ : [];
+
+ if (!currentValues.includes(key)) {
+ const newValues = [...currentValues.filter((v) => v !== ''), key];
+ const newTexts = [...currentTexts.filter((t) => t !== ''), keyLabel ?? key];
+
+ groupBy.changeValueTo(newValues, newTexts, true);
+
+ const basePillOrder = this._getOrInitPillOrder(groupBy);
+ this.model.setState({ pillOrder: [...basePillOrder, 'groupby'] });
+
+ this._syncGroupByAfterChange(groupBy, newValues);
+ }
+ }
+
+ public removeGroupByValue(key: string): void {
+ const groupBy = this.model.state.groupByVariable;
+ if (!groupBy) {
+ return;
+ }
+
+ const currentValues = isArray(groupBy.state.value) ? groupBy.state.value.map(String) : [];
+ const currentTexts = isArray(groupBy.state.text) ? groupBy.state.text.map(String) : [];
+
+ const idx = currentValues.indexOf(key);
+ if (idx >= 0) {
+ const newValues = [...currentValues];
+ const newTexts = [...currentTexts];
+ newValues.splice(idx, 1);
+ newTexts.splice(idx, 1);
+
+ const basePillOrder = this._getOrInitPillOrder(groupBy);
+ const updatedPillOrder = removeNthOccurrence(basePillOrder, 'groupby', idx);
+ groupBy.changeValueTo(newValues, newTexts, true);
+ this.model.setState({ pillOrder: updatedPillOrder });
+ this._syncGroupByAfterChange(groupBy, newValues);
+ }
+ }
+
+ public convertFilterToGroupBy(filter: AdHocFilterWithLabels): void {
+ const groupBy = this.model.state.groupByVariable;
+ if (!groupBy) {
+ return;
+ }
+
+ const filterIndex = this.model.state.filters.indexOf(filter);
+ if (filterIndex < 0) {
+ return;
+ }
+
+ // Replace the 'filter' entry at this position with 'groupby' in pillOrder,
+ // and count how many 'groupby' entries precede it to determine the correct
+ // insertion index in the GroupByVariable's values array.
+ const basePillOrder = this._getOrInitPillOrder(groupBy);
+ const updatedPillOrder = [...basePillOrder];
+ let filterCount = 0;
+ let groupByInsertIndex = 0;
+ for (let i = 0; i < updatedPillOrder.length; i++) {
+ if (updatedPillOrder[i] === 'filter') {
+ if (filterCount === filterIndex) {
+ updatedPillOrder[i] = 'groupby';
+ break;
+ }
+ filterCount++;
+ } else if (updatedPillOrder[i] === 'groupby') {
+ groupByInsertIndex++;
+ }
+ }
+
+ // Remove the filter
+ const updatedFilters = this.model.state.filters.filter((f) => f !== filter);
+
+ // Insert the key into GroupBy at the correct position (not appended at end)
+ const currentValues = isArray(groupBy.state.value)
+ ? groupBy.state.value.map(String)
+ : groupBy.state.value
+ ? [String(groupBy.state.value)]
+ : [];
+ const currentTexts = isArray(groupBy.state.text)
+ ? groupBy.state.text.map(String)
+ : groupBy.state.text
+ ? [String(groupBy.state.text)]
+ : [];
+
+ if (!currentValues.includes(filter.key)) {
+ const cleanValues = currentValues.filter((v) => v !== '');
+ const cleanTexts = currentTexts.filter((t) => t !== '');
+ const newValues = [...cleanValues];
+ const newTexts = [...cleanTexts];
+ newValues.splice(groupByInsertIndex, 0, filter.key);
+ newTexts.splice(groupByInsertIndex, 0, filter.keyLabel ?? filter.key);
+ groupBy.changeValueTo(newValues, newTexts, true);
+ this._syncGroupByAfterChange(groupBy, newValues);
+ }
+
+ this.model.updateFilters(updatedFilters);
+ this.model.setState({ pillOrder: updatedPillOrder });
+ }
+
+ /**
+ * Returns the current pillOrder, initializing it from existing state if needed.
+ * This ensures pre-existing filters and groupBy values get entries in pillOrder
+ * before new items are appended.
+ */
+ private _getOrInitPillOrder(groupBy: GroupByVariable): Array<'filter' | 'groupby'> {
+ if (this.model.state.pillOrder && this.model.state.pillOrder.length > 0) {
+ return this.model.state.pillOrder;
+ }
+
+ const groupByValues = isArray(groupBy.state.value)
+ ? groupBy.state.value.map(String).filter((v) => v !== '')
+ : groupBy.state.value
+ ? [String(groupBy.state.value)]
+ : [];
+
+ const filterEntries: Array<'filter' | 'groupby'> = this.model.state.filters
+ .filter((f) => !f.hidden)
+ .map(() => 'filter' as const);
+ const groupByEntries: Array<'filter' | 'groupby'> = groupByValues.map(() => 'groupby' as const);
+
+ return [...filterEntries, ...groupByEntries];
+ }
+
+ public getLastPillType(): 'filter' | 'groupby' | undefined {
+ const groupBy = this.model.state.groupByVariable;
+ if (!groupBy) {
+ return this.model.state.filters.length > 0 ? 'filter' : undefined;
+ }
+
+ const pillOrder = this._getOrInitPillOrder(groupBy);
+ if (pillOrder.length === 0) {
+ return undefined;
+ }
+ return pillOrder[pillOrder.length - 1];
+ }
+
+ public popLastGroupByValue(): { key: string; keyLabel: string } | undefined {
+ const groupBy = this.model.state.groupByVariable;
+ if (!groupBy) {
+ return undefined;
+ }
+
+ const currentValues = isArray(groupBy.state.value) ? groupBy.state.value.map(String) : [];
+ const currentTexts = isArray(groupBy.state.text) ? groupBy.state.text.map(String) : [];
+
+ if (currentValues.length === 0 || (currentValues.length === 1 && currentValues[0] === '')) {
+ return undefined;
+ }
+
+ // Find the index of the last 'groupby' in pillOrder
+ const basePillOrder = this._getOrInitPillOrder(groupBy);
+ let lastGroupByPillIndex = -1;
+ let groupByCount = 0;
+ for (let i = basePillOrder.length - 1; i >= 0; i--) {
+ if (basePillOrder[i] === 'groupby') {
+ lastGroupByPillIndex = i;
+ // Count how many 'groupby' entries are BEFORE this one to find value index
+ groupByCount = 0;
+ for (let j = 0; j < i; j++) {
+ if (basePillOrder[j] === 'groupby') {
+ groupByCount++;
+ }
+ }
+ break;
+ }
+ }
+
+ if (lastGroupByPillIndex < 0) {
+ return undefined;
+ }
+
+ const valueIndex = groupByCount;
+ const key = currentValues[valueIndex];
+ const keyLabel = currentTexts[valueIndex] ?? key;
+
+ // Remove from GroupBy values
+ const newValues = [...currentValues];
+ const newTexts = [...currentTexts];
+ newValues.splice(valueIndex, 1);
+ newTexts.splice(valueIndex, 1);
+
+ // Remove from pillOrder
+ const updatedPillOrder = [...basePillOrder];
+ updatedPillOrder.splice(lastGroupByPillIndex, 1);
+
+ groupBy.changeValueTo(newValues, newTexts, true);
+ this.model.setState({ pillOrder: updatedPillOrder });
+ this._syncGroupByAfterChange(groupBy, newValues);
+
+ return { key, keyLabel };
+ }
+
+ /**
+ * After changing GroupBy values through the AdHoc UI, we need to sync the
+ * restorable flag (for URL sync with defaultValue) and trigger applicability
+ * verification + recent grouping storage (normally done onBlur in the GroupBy renderer).
+ */
+ private _syncGroupByAfterChange(groupBy: GroupByVariable, newValues: string[]): void {
+ // Update the restorable flag so URL sync correctly handles defaultValue
+ if (groupBy.state.defaultValue) {
+ const restorable = groupBy.checkIfRestorable(newValues);
+ if (restorable !== groupBy.state.restorable) {
+ groupBy.setState({ restorable });
+ }
+ }
+
+ // Trigger applicability check and recent grouping storage
+ groupBy._verifyApplicabilityAndStoreRecentGrouping();
+ }
}