Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/scenes/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/scenes/src/locales/en-US/grafana-scenes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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?.();

Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -508,8 +543,10 @@ export const AdHocCombobox = forwardRef(function AdHocCombobox(
controller,
filter,
filterInputType,
isAlwaysWip,
populateInputOnEdit,
handleChangeViewMode,
handleResetWip,
refs.domReference,
isLastFilter,
focusOnWipInputRef,
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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(() => {
Expand All @@ -85,16 +98,41 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe

{valueRecommendations && <valueRecommendations.Component model={valueRecommendations} />}

{filtersToRender.map((filter, index) => (
{/* Origin filters always render first */}
{visibleOriginFilters.map((filter, index) => (
<AdHocFilterPill
key={`${filter.origin ? 'origin-' : ''}${index}-${filter.key}`}
key={`origin-${index}-${filter.key}`}
filter={filter}
controller={controller}
readOnly={readOnly || filter.readOnly}
focusOnWipInputRef={focusOnWipInputRef.current}
/>
))}

{/* Interleaved user filters and groupBy pills, driven by pillOrder */}
{groupByVariable ? (
<InterleavedPills
groupByVariable={groupByVariable}
controller={controller}
visibleFilters={visibleFilters}
pillOrder={pillOrder}
readOnly={readOnly}
shouldCollapse={shouldCollapse ?? false}
focusOnWipInputRef={focusOnWipInputRef.current}
/>
) : (
// No groupBy linked -- render filters only (original behavior)
visibleFilters.map((filter, index) => (
<AdHocFilterPill
key={`${index}-${filter.key}`}
filter={filter}
controller={controller}
readOnly={readOnly || filter.readOnly}
focusOnWipInputRef={focusOnWipInputRef.current}
/>
))
)}

{!readOnly && !shouldCollapse ? (
<AdHocFiltersAlwaysWipCombobox controller={controller} ref={focusOnWipInputRef} />
) : null}
Expand Down Expand Up @@ -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' ? (
<AdHocFilterPill
key={`filter-${pill.index}-${pill.filter.key}`}
filter={pill.filter}
controller={controller}
readOnly={readOnly || pill.filter.readOnly}
focusOnWipInputRef={focusOnWipInputRef}
/>
) : (
<GroupByPill
key={`groupby-${pill.value}`}
value={pill.value}
label={pill.label}
readOnly={readOnly}
applicability={pill.applicability}
onRemove={handleRemoveGroupBy}
/>
)
)}
</>
);
}

const getStyles = (theme: GrafanaTheme2) => ({
comboboxWrapper: css({
display: 'flex',
Expand Down
Loading