From e4289dedc662c5cad1fb01e05c162bdca97146ce Mon Sep 17 00:00:00 2001 From: Victor Marin Date: Mon, 3 Nov 2025 18:18:26 +0200 Subject: [PATCH] wip groupBy per panel --- .../scene/PanelGroupByAction.tsx | 265 ++++++++++++------ 1 file changed, 173 insertions(+), 92 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/PanelGroupByAction.tsx b/public/app/features/dashboard-scene/scene/PanelGroupByAction.tsx index 6cedfd62247..19f92a4e477 100644 --- a/public/app/features/dashboard-scene/scene/PanelGroupByAction.tsx +++ b/public/app/features/dashboard-scene/scene/PanelGroupByAction.tsx @@ -1,8 +1,8 @@ import { css, cx } from '@emotion/css'; -import { useState, useCallback } from 'react'; -import { lastValueFrom } from 'rxjs'; +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { firstValueFrom } from 'rxjs'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, fuzzySearch } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; import { GroupByVariable, @@ -12,11 +12,13 @@ import { VariableValueOption, VizPanel, } from '@grafana/scenes'; -import { Button, Dropdown, Icon, Input, useStyles2, Checkbox } from '@grafana/ui'; +import { Button, Icon, Input, useStyles2, Checkbox, Portal } from '@grafana/ui'; export class PanelGroupByAction extends SceneObjectBase { static Component = PanelGroupByActionRenderer; + private _groupByVariable: GroupByVariable | undefined; + constructor() { super({}); this.addActivationHandler(this.onActivate); @@ -27,10 +29,14 @@ export class PanelGroupByAction extends SceneObjectBase { if (!panel || !(panel instanceof VizPanel)) { throw new Error('PanelGroupByAction can be used only for VizPanel'); } + + this._groupByVariable = sceneGraph + .getVariables(this) + .state.variables.find((variable) => variable instanceof GroupByVariable); }; public getGroupByVariable() { - return sceneGraph.getVariables(this).state.variables.find((variable) => variable instanceof GroupByVariable); + return this._groupByVariable; } } @@ -40,132 +46,203 @@ function PanelGroupByActionRenderer({ model }: SceneComponentProps([]); - // Track applied selections - const [appliedSelectedItems, setAppliedSelectedItems] = useState([]); + const [selectedItems, setSelectedItems] = useState([]); // Track search input const [searchValue, setSearchValue] = useState(''); // Track loading state const [isLoading, setIsLoading] = useState(false); + // Track dropdown open state + const [isOpen, setIsOpen] = useState(false); + + // Refs for positioning and click outside detection + const buttonRef = useRef(null); + const menuRef = useRef(null); // Check if an option is selected in temp state const isOptionSelected = useCallback( - (item: VariableValueOption) => tempSelectedItems.some((opt) => opt.value === item.value), - [tempSelectedItems] + (item: VariableValueOption) => selectedItems.some((opt) => opt.value === item.value), + [selectedItems] ); // Toggle selection in temp state const handleItemClick = useCallback( (item: VariableValueOption) => { if (isOptionSelected(item)) { - setTempSelectedItems(tempSelectedItems.filter((opt) => opt.value !== item.value)); + setSelectedItems(selectedItems.filter((opt) => opt.value !== item.value)); } else { - setTempSelectedItems([...tempSelectedItems, item]); + setSelectedItems([...selectedItems, item]); } }, - [isOptionSelected, tempSelectedItems] + [isOptionSelected, selectedItems] ); // Apply selections and close dropdown const handleApply = () => { - setAppliedSelectedItems(tempSelectedItems); + const groupByVariable = model.getGroupByVariable(); + const selectedValues = selectedItems.map((item) => item.value); + if (groupByVariable) { + groupByVariable.changeValueTo(selectedValues, selectedValues, true); + } + setIsOpen(false); }; - // When dropdown opens, fetch options if needed and sync temp state with applied state - const handleOpenChange = async (open: boolean) => { - if (open) { + // Cancel and close dropdown + const handleCancel = () => { + setIsOpen(false); + }; + + // Toggle dropdown open/close + const handleToggle = async () => { + const newOpenState = !isOpen; + setIsOpen(newOpenState); + + if (newOpenState) { // If groupBy has no options, fetch them if (groupByVariable && (!groupByState?.options || groupByState.options.length === 0)) { setIsLoading(true); - await lastValueFrom(groupByVariable.validateAndUpdate()); + try { + await firstValueFrom(groupByVariable.validateAndUpdate()); + } catch (error) { + console.error('Failed to fetch group by options:', error); + } finally { + setIsLoading(false); + } } - setTempSelectedItems(appliedSelectedItems); setSearchValue(''); } }; + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + isOpen && + menuRef.current && + buttonRef.current && + !menuRef.current.contains(event.target as Node) && + !buttonRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Filter options based on search using fuzzy search + const filteredOptions = useMemo(() => { + if (!groupByState?.options) { + return []; + } + + if (!searchValue) { + return groupByState.options; + } + + const haystack = groupByState.options.map((option) => option.label); + const indices = fuzzySearch(haystack, searchValue); + return indices.map((idx) => groupByState.options[idx]); + }, [groupByState?.options, searchValue]); + if (!groupByState) { return null; } - const menu = ( - /* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ -
e.stopPropagation()}> -
- } - placeholder={t('panel-group-by.search-placeholder', 'Search...')} - value={searchValue} - onChange={(e) => setSearchValue(e.currentTarget.value)} - /> -
-
- {isLoading ? ( -
- Loading options... -
- ) : groupByState.options.length === 0 ? ( -
- No options found -
- ) : ( - groupByState.options.map((option) => { - const isSelected = isOptionSelected(option); - return ( -
{ - e.stopPropagation(); - handleItemClick(option); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - handleItemClick(option); - } - }} - role="button" - tabIndex={0} - aria-pressed={isSelected} - > - handleItemClick(option)} /> - {option.label} -
- ); - }) - )} -
-
- - -
-
- ); + // Calculate menu position based on button position + const getMenuPosition = () => { + if (!buttonRef.current) { + return { top: 0, left: 0 }; + } + const rect = buttonRef.current.getBoundingClientRect(); + return { + top: rect.bottom + 4, + left: rect.left, + }; + }; + + const menuPosition = getMenuPosition(); return ( - - - + + {isOpen && ( + +
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} +
e.stopPropagation()}> + } + placeholder={t('panel-group-by.search-placeholder', 'Search...')} + value={searchValue} + onChange={(e) => setSearchValue(e.currentTarget.value)} + /> +
+
+ {isLoading ? ( +
+ Loading options... +
+ ) : filteredOptions.length === 0 ? ( +
+ No options found +
+ ) : ( + filteredOptions.map((option) => { + const isSelected = isOptionSelected(option); + return ( +
{ + e.stopPropagation(); + handleItemClick(option); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + handleItemClick(option); + } + }} + role="button" + tabIndex={0} + aria-pressed={isSelected} + > + handleItemClick(option)} /> + {option.label} +
+ ); + }) + )} +
+
+ + +
+
+
+ )} + ); } @@ -201,6 +278,10 @@ const getStyles = (theme: GrafanaTheme2) => ({ '&:hover': { background: theme.colors.background.secondary, }, + '&:focus-visible': { + outline: `2px solid ${theme.colors.primary.border}`, + outlineOffset: '-2px', + }, }), optionLabel: css({ flex: 1,