From 60f547949a16d2d552065c81fe09b3d0cf21e0f2 Mon Sep 17 00:00:00 2001 From: Victor Marin Date: Mon, 16 Feb 2026 15:03:25 +0200 Subject: [PATCH 1/2] PoC - unify groupBy into adhocs UI --- packages/scenes/src/index.ts | 7 +- .../AdHocFiltersCombobox.tsx | 57 ++++- .../AdHocFiltersComboboxRenderer.tsx | 154 +++++++++++- .../AdHocFiltersCombobox/GroupByPill.tsx | 151 +++++++++++ .../AdHocFiltersCombobox/GroupByPills.tsx | 57 +++++ .../adhoc/AdHocFiltersRecommendations.tsx | 135 ++++++++-- .../variables/adhoc/AdHocFiltersVariable.tsx | 123 ++++++++- .../controller/AdHocFiltersController.ts | 42 ++++ .../AdHocFiltersVariableController.ts | 234 +++++++++++++++++- 9 files changed, 918 insertions(+), 42 deletions(-) create mode 100644 packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/GroupByPill.tsx create mode 100644 packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/GroupByPills.tsx 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/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..d63cc9a7e 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(); + } } From e71726cb25aab104afa4f034ea510b6f19739e66 Mon Sep 17 00:00:00 2001 From: Victor Marin Date: Tue, 17 Feb 2026 14:53:42 +0200 Subject: [PATCH 2/2] fixes --- packages/scenes/src/locales/en-US/grafana-scenes.json | 9 +++++++++ .../AdHocFiltersComboboxRenderer.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) 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/AdHocFiltersComboboxRenderer.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx index d63cc9a7e..949d101b8 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx @@ -117,7 +117,7 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe visibleFilters={visibleFilters} pillOrder={pillOrder} readOnly={readOnly} - shouldCollapse={shouldCollapse} + shouldCollapse={shouldCollapse ?? false} focusOnWipInputRef={focusOnWipInputRef.current} /> ) : (