From 579057d450d593da22dfc1fdb0494470c2e8f852 Mon Sep 17 00:00:00 2001 From: Victor Marin Date: Mon, 16 Feb 2026 16:54:30 +0200 Subject: [PATCH 1/2] PoC - unify groupBy into adhocs UI --- packages/scenes/src/index.ts | 2 +- .../AdHocFiltersCombobox.tsx | 40 ++- .../AdHocFiltersComboboxRenderer.tsx | 16 +- .../AdHocFiltersCombobox/GroupByPill.tsx | 332 ++++++++++++++++++ .../adhoc/AdHocFiltersRecommendations.tsx | 125 +++++-- .../variables/adhoc/AdHocFiltersVariable.tsx | 40 ++- .../controller/AdHocFiltersController.ts | 9 + .../AdHocFiltersVariableController.ts | 66 ++++ 8 files changed, 602 insertions(+), 28 deletions(-) create mode 100644 packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/GroupByPill.tsx diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts index 24529c1cc..89afb26c1 100644 --- a/packages/scenes/src/index.ts +++ b/packages/scenes/src/index.ts @@ -78,7 +78,7 @@ 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, GROUP_BY_OPERATOR_VALUE } from './variables/adhoc/AdHocFiltersVariable'; export type { AdHocFilterWithLabels } from './variables/adhoc/AdHocFiltersVariable'; export type { AdHocFiltersController, diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersCombobox.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersCombobox.tsx index d7ee5f114..26f57f657 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 { @@ -459,6 +465,21 @@ export const AdHocCombobox = forwardRef(function AdHocCombobox( } const selectedItem = filteredDropDownItems[activeIndex]; + // Handle "Group by" operator selection + if (filterInputType === 'operator' && selectedItem.value === GROUP_BY_OPERATOR_VALUE) { + if (!isAlwaysWip && filter) { + // Editing an existing filter: remove it first + controller.removeFilter(filter); + } + controller.addGroupByValue?.(filter!.key, filter!.keyLabel); + handleResetWip(); + handleChangeViewMode?.(); + setOpen(false); + setInputValue(''); + focusOnWipInputRef?.(); + return; + } + if (multiValueEdit) { handleLocalMultiValueChange(selectedItem); setInputValue(''); @@ -508,8 +529,10 @@ export const AdHocCombobox = forwardRef(function AdHocCombobox( controller, filter, filterInputType, + isAlwaysWip, populateInputOnEdit, handleChangeViewMode, + handleResetWip, refs.domReference, isLastFilter, focusOnWipInputRef, @@ -776,6 +799,21 @@ 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) { + controller.removeFilter(filter); + } + 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..e236ffd40 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx @@ -7,6 +7,7 @@ import { useMeasure } from 'react-use'; import { AdHocFiltersController } from '../controller/AdHocFiltersController'; import { AdHocFilterPill } from './AdHocFilterPill'; import { AdHocFiltersAlwaysWipCombobox } from './AdHocFiltersAlwaysWipCombobox'; +import { GroupByPill } from './GroupByPill'; const MAX_VISIBLE_FILTERS = 5; @@ -15,7 +16,9 @@ interface Props { } export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRenderer({ controller }: Props) { - const { originFilters, filters, readOnly, collapsible, valueRecommendations } = controller.useState(); + const { originFilters, filters, readOnly, collapsible, valueRecommendations, forceEditGroupBy } = + controller.useState(); + const groupByVariable = controller.getGroupByVariable?.(); const styles = useStyles2(getStyles); const theme = useTheme2(); const [collapsed, setCollapsed] = useState(true); @@ -85,6 +88,17 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe {valueRecommendations && } + {/* Single GroupBy pill showing all group-by values — rendered first */} + {groupByVariable && ( + + )} + {filtersToRender.map((filter, index) => ( void; + forceEdit?: boolean; +} + +/** + * A single pill that displays all GroupBy values. Click to enter edit mode + * where each value is shown as a removable chip (similar to multi-value filter editing). + */ +export function GroupByPill({ + groupByVariable, + controller, + readOnly, + focusOnWipInputRef, + forceEdit, +}: GroupByPillProps) { + const { value, text, keysApplicability } = groupByVariable.useState(); + const styles = useStyles2(getStyles); + const [editMode, setEditMode] = useState(false); + const pillWrapperRef = useRef(null); + + const handleEnterEditMode = useCallback(() => { + if (readOnly) { + return; + } + setEditMode(true); + }, [readOnly]); + + const handleExitEditMode = useCallback(() => { + setEditMode(false); + }, []); + + // Enter edit mode when triggered by keyboard backspace navigation + useEffect(() => { + if (forceEdit) { + setEditMode(true); + controller.clearForceEditGroupBy?.(); + } + }, [forceEdit, controller]); + + const values = isArray(value) ? value.map(String).filter((v) => v !== '') : value ? [String(value)] : []; + const texts = isArray(text) ? text.map(String) : text ? [String(text)] : []; + + // Don't render if there are no GroupBy values + if (values.length === 0) { + return null; + } + + const handleRemoveValue = (key: string) => { + controller.removeGroupByValue?.(key); + }; + + // Build the display text for view mode + const displayText = values.map((v, i) => texts[i] ?? v).join(', '); + const pillTextContent = `Group by: ${displayText}`; + + if (editMode) { + return ( + + ); + } + + const pillText = {pillTextContent}; + + return ( +
{ + e.stopPropagation(); + handleEnterEditMode(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleEnterEditMode(); + } + }} + role={readOnly ? undefined : 'button'} + aria-label={t('grafana-scenes.components.group-by-pill.edit-group-by', 'Edit group by values')} + tabIndex={0} + ref={pillWrapperRef} + > + {pillTextContent.length < LABEL_MAX_VISIBLE_LENGTH ? ( + pillText + ) : ( + {pillTextContent}
} placement="top"> + {pillText} + + )} + + {!readOnly && ( + { + e.stopPropagation(); + // Clear all GroupBy values + values.forEach((v) => controller.removeGroupByValue?.(v)); + setTimeout(() => focusOnWipInputRef?.()); + }} + name="times" + size="md" + className={styles.pillIcon} + tooltip={t('grafana-scenes.components.group-by-pill.clear-all', 'Clear all group by values')} + /> + )} + + ); +} + +/** + * Edit mode for the GroupBy pill - shows each value as a removable chip. + * Backspace removes the last chip. Tab exits and focuses the WIP input + * so the user can start adding a new key (filter or groupBy). + */ +function GroupByPillEditMode({ + values, + texts, + keysApplicability, + onRemoveValue, + onExitEditMode, + onFocusWipInput, + readOnly, +}: { + values: string[]; + texts: string[]; + keysApplicability?: DrilldownsApplicability[]; + onRemoveValue: (key: string) => void; + onExitEditMode: () => void; + onFocusWipInput?: () => void; + readOnly?: boolean; +}) { + const styles = useStyles2(getStyles); + const wrapperRef = useRef(null); + const focusRef = useRef(null); + + // Focus on mount so keyboard events are captured + useEffect(() => { + focusRef.current?.focus(); + }, []); + + // Close edit mode when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + onExitEditMode(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onExitEditMode]); + + // Close if all values removed + useEffect(() => { + if (values.length === 0) { + onExitEditMode(); + setTimeout(() => onFocusWipInput?.()); + } + }, [values.length, onExitEditMode, onFocusWipInput]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && values.length > 0) { + e.preventDefault(); + onRemoveValue(values[values.length - 1]); + } else if (e.key === 'Escape') { + onExitEditMode(); + } else if (e.key === 'Tab') { + e.preventDefault(); + onExitEditMode(); + setTimeout(() => onFocusWipInput?.()); + } + }, + [values, onRemoveValue, onExitEditMode, onFocusWipInput] + ); + + return ( +
{ + e.stopPropagation(); + focusRef.current?.focus(); + }} + > +
+ {t('grafana-scenes.components.group-by-pill.prefix', 'Group by:')} + {values.map((val, idx) => { + const label = texts[idx] ?? val; + const applicability = keysApplicability?.find((item: DrilldownsApplicability) => item.key === val); + const isNonApplicable = applicability && !applicability.applicable; + + return ( +
+ {label} + {!readOnly && ( + { + e.stopPropagation(); + onRemoveValue(val); + focusRef.current?.focus(); + }} + name="times" + size="sm" + className={styles.chipCloseIcon} + tooltip={t('grafana-scenes.components.group-by-pill.remove-value', 'Remove {{label}}', { label })} + /> + )} +
+ ); + })} +
+
+ ); +} + +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: 'pointer', + + '&:hover': { + background: theme.colors.action.hover, + }, + }), + readOnlyPill: css({ + paddingRight: theme.spacing(1), + cursor: 'text', + '&:hover': { + background: theme.colors.action.selected, + }, + }), + groupByPrefix: css({ + ...theme.typography.bodySmall, + fontWeight: theme.typography.fontWeightBold, + color: theme.colors.text.secondary, + marginRight: theme.spacing(0.5), + whiteSpace: 'nowrap', + flexShrink: 0, + }), + pillIcon: css({ + marginInline: theme.spacing(0.5), + cursor: 'pointer', + '&:hover': { + color: theme.colors.text.primary, + }, + }), + pillText: css({ + maxWidth: '300px', + width: '100%', + textOverflow: 'ellipsis', + overflow: 'hidden', + }), + tooltipText: css({ + textAlign: 'center', + }), + editModeWrapper: css({ + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + background: theme.colors.action.selected, + borderRadius: theme.shape.radius.default, + border: `1px solid ${theme.colors.border.weak}`, + padding: theme.spacing(0.25, 0.5), + minHeight: theme.spacing(2.75), + }), + valueChip: css({ + display: 'flex', + alignItems: 'center', + background: theme.colors.background.secondary, + borderRadius: theme.shape.radius.pill, + border: `1px solid ${theme.colors.border.weak}`, + padding: theme.spacing(0, 0.5, 0, 1), + ...theme.typography.bodySmall, + fontWeight: theme.typography.fontWeightBold, + whiteSpace: 'nowrap', + }), + chipText: css({ + marginRight: theme.spacing(0.25), + }), + chipCloseIcon: css({ + cursor: 'pointer', + color: theme.colors.text.secondary, + '&:hover': { + color: theme.colors.text.primary, + }, + }), + focusTrap: css({ + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + outline: 'none', + }), + ...getNonApplicablePillStyles(theme), +}); diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.tsx index c2cf60146..841c70216 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,8 @@ export const getRecentFiltersKey = (datasourceUid: string | undefined) => export interface AdHocFiltersRecommendationsState extends SceneObjectState { recentFilters?: AdHocFilterWithLabels[]; recommendedFilters?: AdHocFilterWithLabels[]; + recentGroupings?: Array>; + recommendedGroupings?: Array>; } export class AdHocFiltersRecommendations extends SceneObjectBase { @@ -65,6 +70,7 @@ export class AdHocFiltersRecommendations extends SceneObjectBase { + this.setState({ + recentGroupings: newState.recentGrouping, + recommendedGroupings: newState.recommendedGrouping, + }); + })) + ); + } + } + return () => { scopesSubscription?.unsubscribe(); adHocSubscription?.unsubscribe(); + groupByRecsSubscription?.unsubscribe(); }; }; @@ -193,28 +220,80 @@ 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); - } - }, - })); + const { recentFilters, recommendedFilters, recentGroupings, recommendedGroupings } = model.useState(); + const { filters, groupByVariable } = model._adHocFilter.useState(); + + 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); + } + }, + })) ?? []; + + 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 + ); + } + }, + })) + : []; + + const recentDrilldowns = [...recentFilterPills, ...recentGroupByPills]; + const recommendedDrilldowns = [...recommendedFilterPills, ...recommendedGroupByPills]; - return ; + 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..0d4429031 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx @@ -38,6 +38,8 @@ 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 { t } from '@grafana/i18n'; +import type { GroupByVariable } from '../groupby/GroupByVariable'; export interface AdHocFilterWithLabels = {}> extends AdHocVariableFilter { keyLabel?: string; @@ -166,6 +168,12 @@ export interface AdHocFiltersVariableState extends SceneVariableState { * enables drilldown recommendations */ drilldownRecommendationsEnabled?: boolean; + + /** Optional reference to a GroupByVariable for unified UI. */ + groupByVariable?: GroupByVariable; + + /** @internal Flag to trigger edit mode on the GroupBy pill via keyboard navigation */ + _forceEditGroupBy?: boolean; } export type AdHocVariableExpressionBuilderFn = (filters: AdHocFilterWithLabels[]) => string; @@ -194,6 +202,8 @@ export type OperatorDefinition = { isRegex?: Boolean; }; +export const GROUP_BY_OPERATOR_VALUE = '__groupBy__'; + export const OPERATORS: OperatorDefinition[] = [ { value: '=', @@ -489,6 +499,16 @@ export class AdHocFiltersVariable // Clear all user-added filters this.setState({ filters: [] }); + + // 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 { @@ -653,6 +673,9 @@ export class AdHocFiltersVariable return [...acc, f]; }, []), }); + } else if (this.state.groupByVariable && filter === this.state._wip) { + // No filters or origin filters left — navigate into GroupBy pill + this.setState({ _forceEditGroupBy: true }); } } @@ -846,9 +869,9 @@ export class AdHocFiltersVariable } 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 +884,19 @@ export class AdHocFiltersVariable value, description, })); + + 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; } } diff --git a/packages/scenes/src/variables/adhoc/controller/AdHocFiltersController.ts b/packages/scenes/src/variables/adhoc/controller/AdHocFiltersController.ts index c9848dac1..60dcaf033 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,7 @@ export interface AdHocFiltersControllerState { collapsible?: boolean; valueRecommendations?: AdHocFiltersRecommendations; drilldownRecommendationsEnabled?: boolean; + forceEditGroupBy?: boolean; } /** @@ -125,4 +127,11 @@ export interface AdHocFiltersController { * Optional: Stop tracking the current interaction. */ stopInteraction?(): void; + + getGroupByVariable?(): GroupByVariable | undefined; + addGroupByValue?(key: string, keyLabel?: string): void; + removeGroupByValue?(key: string): void; + + /** Clear the forceEditGroupBy flag after the GroupBy pill has consumed it */ + clearForceEditGroupBy?(): void; } diff --git a/packages/scenes/src/variables/adhoc/controller/AdHocFiltersVariableController.ts b/packages/scenes/src/variables/adhoc/controller/AdHocFiltersVariableController.ts index 9f7c630fa..5141d8165 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 { isArray } from 'lodash'; import { AdHocFilterWithLabels, AdHocFiltersVariable } 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, + forceEditGroupBy: state._forceEditGroupBy, }; } @@ -96,4 +99,67 @@ export class AdHocFiltersVariableController implements AdHocFiltersController { const interactionTracker = getInteractionTracker(this.model); interactionTracker?.stopInteraction(); } + + public getGroupByVariable(): GroupByVariable | undefined { + return this.model.state.groupByVariable; + } + + public clearForceEditGroupBy(): void { + this.model.setState({ _forceEditGroupBy: false }); + } + + 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); + 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); + groupBy.changeValueTo(newValues, newTexts, true); + this._syncGroupByAfterChange(groupBy, newValues); + } + } + + private _syncGroupByAfterChange(groupBy: GroupByVariable, newValues: string[]): void { + if (groupBy.state.defaultValue) { + const restorable = groupBy.checkIfRestorable(newValues); + if (restorable !== groupBy.state.restorable) { + groupBy.setState({ restorable }); + } + } + groupBy._verifyApplicabilityAndStoreRecentGrouping(); + } } From 1e185076a2ecf05173e0d05169ac0ff2f8bbfa18 Mon Sep 17 00:00:00 2001 From: Victor Marin Date: Tue, 17 Feb 2026 14:54:22 +0200 Subject: [PATCH 2/2] fixes --- packages/scenes/src/locales/en-US/grafana-scenes.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/scenes/src/locales/en-US/grafana-scenes.json b/packages/scenes/src/locales/en-US/grafana-scenes.json index 4adef081c..947d6d8f1 100644 --- a/packages/scenes/src/locales/en-US/grafana-scenes.json +++ b/packages/scenes/src/locales/en-US/grafana-scenes.json @@ -23,6 +23,12 @@ "subTitle": "The url did not match any page", "title": "Not found" }, + "group-by-pill": { + "clear-all": "Clear all group by values", + "edit-group-by": "Edit group by values", + "prefix": "Group by:", + "remove-value": "Remove {{label}}" + }, "lazy-loader": { "placeholder": "\u00a0" }, @@ -100,6 +106,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"