wip groupBy per panel

This commit is contained in:
Victor Marin
2025-11-03 18:18:26 +02:00
parent 55a4361f17
commit e4289dedc6
@@ -1,8 +1,8 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { useState, useCallback } from 'react'; import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { lastValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2, fuzzySearch } from '@grafana/data';
import { t, Trans } from '@grafana/i18n'; import { t, Trans } from '@grafana/i18n';
import { import {
GroupByVariable, GroupByVariable,
@@ -12,11 +12,13 @@ import {
VariableValueOption, VariableValueOption,
VizPanel, VizPanel,
} from '@grafana/scenes'; } 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 { export class PanelGroupByAction extends SceneObjectBase {
static Component = PanelGroupByActionRenderer; static Component = PanelGroupByActionRenderer;
private _groupByVariable: GroupByVariable | undefined;
constructor() { constructor() {
super({}); super({});
this.addActivationHandler(this.onActivate); this.addActivationHandler(this.onActivate);
@@ -27,10 +29,14 @@ export class PanelGroupByAction extends SceneObjectBase {
if (!panel || !(panel instanceof VizPanel)) { if (!panel || !(panel instanceof VizPanel)) {
throw new Error('PanelGroupByAction can be used only for 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() { public getGroupByVariable() {
return sceneGraph.getVariables(this).state.variables.find((variable) => variable instanceof GroupByVariable); return this._groupByVariable;
} }
} }
@@ -40,132 +46,203 @@ function PanelGroupByActionRenderer({ model }: SceneComponentProps<PanelGroupByA
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
// Track temporary selections (not applied yet) // Track temporary selections (not applied yet)
const [tempSelectedItems, setTempSelectedItems] = useState<VariableValueOption[]>([]); const [selectedItems, setSelectedItems] = useState<VariableValueOption[]>([]);
// Track applied selections
const [appliedSelectedItems, setAppliedSelectedItems] = useState<VariableValueOption[]>([]);
// Track search input // Track search input
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
// Track loading state // Track loading state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Track dropdown open state
const [isOpen, setIsOpen] = useState(false);
// Refs for positioning and click outside detection
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Check if an option is selected in temp state // Check if an option is selected in temp state
const isOptionSelected = useCallback( const isOptionSelected = useCallback(
(item: VariableValueOption) => tempSelectedItems.some((opt) => opt.value === item.value), (item: VariableValueOption) => selectedItems.some((opt) => opt.value === item.value),
[tempSelectedItems] [selectedItems]
); );
// Toggle selection in temp state // Toggle selection in temp state
const handleItemClick = useCallback( const handleItemClick = useCallback(
(item: VariableValueOption) => { (item: VariableValueOption) => {
if (isOptionSelected(item)) { if (isOptionSelected(item)) {
setTempSelectedItems(tempSelectedItems.filter((opt) => opt.value !== item.value)); setSelectedItems(selectedItems.filter((opt) => opt.value !== item.value));
} else { } else {
setTempSelectedItems([...tempSelectedItems, item]); setSelectedItems([...selectedItems, item]);
} }
}, },
[isOptionSelected, tempSelectedItems] [isOptionSelected, selectedItems]
); );
// Apply selections and close dropdown // Apply selections and close dropdown
const handleApply = () => { 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 // Cancel and close dropdown
const handleOpenChange = async (open: boolean) => { const handleCancel = () => {
if (open) { setIsOpen(false);
};
// Toggle dropdown open/close
const handleToggle = async () => {
const newOpenState = !isOpen;
setIsOpen(newOpenState);
if (newOpenState) {
// If groupBy has no options, fetch them // If groupBy has no options, fetch them
if (groupByVariable && (!groupByState?.options || groupByState.options.length === 0)) { if (groupByVariable && (!groupByState?.options || groupByState.options.length === 0)) {
setIsLoading(true); 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(''); 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) { if (!groupByState) {
return null; return null;
} }
const menu = ( // Calculate menu position based on button position
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ const getMenuPosition = () => {
<div className={styles.menuContainer} onClick={(e) => e.stopPropagation()}> if (!buttonRef.current) {
<div className={styles.searchContainer}> return { top: 0, left: 0 };
<Input }
prefix={<Icon name="search" />} const rect = buttonRef.current.getBoundingClientRect();
placeholder={t('panel-group-by.search-placeholder', 'Search...')} return {
value={searchValue} top: rect.bottom + 4,
onChange={(e) => setSearchValue(e.currentTarget.value)} left: rect.left,
/> };
</div> };
<div className={styles.listContainer}>
{isLoading ? ( const menuPosition = getMenuPosition();
<div className={styles.emptyMessage}>
<Trans i18nKey="panel-group-by.loading">Loading options...</Trans>
</div>
) : groupByState.options.length === 0 ? (
<div className={styles.emptyMessage}>
<Trans i18nKey="panel-group-by.no-options">No options found</Trans>
</div>
) : (
groupByState.options.map((option) => {
const isSelected = isOptionSelected(option);
return (
<div
key={option.value as string}
className={cx(styles.option)}
onClick={(e) => {
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}
>
<Checkbox value={isSelected} onChange={() => handleItemClick(option)} />
<span className={styles.optionLabel}>{option.label}</span>
</div>
);
})
)}
</div>
<div className={styles.applyContainer}>
<Button
onClick={(e) => {
e.stopPropagation();
handleApply();
}}
>
<Trans i18nKey="panel-group-by.apply">Apply</Trans>
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
}}
variant="secondary"
fill="outline"
>
<Trans i18nKey="panel-group-by.cancel">Cancel</Trans>
</Button>
</div>
</div>
);
return ( return (
<Dropdown overlay={menu} onVisibleChange={handleOpenChange} placement="bottom-start"> <>
<Button variant="secondary" size="sm"> <Button variant="secondary" size="sm" ref={buttonRef} onClick={handleToggle}>
<Trans i18nKey="panel-group-by.button">Group by</Trans> <Trans i18nKey="panel-group-by.button">Group by</Trans>
<Icon name="angle-down" /> <Icon name="angle-down" />
</Button> </Button>
</Dropdown>
{isOpen && (
<Portal>
<div
ref={menuRef}
className={styles.menuContainer}
style={{
position: 'fixed',
top: menuPosition.top,
left: menuPosition.left,
zIndex: 1060,
}}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div className={styles.searchContainer} onClick={(e) => e.stopPropagation()}>
<Input
prefix={<Icon name="search" />}
placeholder={t('panel-group-by.search-placeholder', 'Search...')}
value={searchValue}
onChange={(e) => setSearchValue(e.currentTarget.value)}
/>
</div>
<div className={styles.listContainer}>
{isLoading ? (
<div className={styles.emptyMessage}>
<Trans i18nKey="panel-group-by.loading">Loading options...</Trans>
</div>
) : filteredOptions.length === 0 ? (
<div className={styles.emptyMessage}>
<Trans i18nKey="panel-group-by.no-options">No options found</Trans>
</div>
) : (
filteredOptions.map((option) => {
const isSelected = isOptionSelected(option);
return (
<div
key={option.value as string}
className={cx(styles.option)}
onClick={(e) => {
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}
>
<Checkbox value={isSelected} onChange={() => handleItemClick(option)} />
<span className={styles.optionLabel}>{option.label}</span>
</div>
);
})
)}
</div>
<div className={styles.applyContainer}>
<Button onClick={handleApply}>
<Trans i18nKey="panel-group-by.apply">Apply</Trans>
</Button>
<Button onClick={handleCancel} variant="secondary" fill="outline">
<Trans i18nKey="panel-group-by.cancel">Cancel</Trans>
</Button>
</div>
</div>
</Portal>
)}
</>
); );
} }
@@ -201,6 +278,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
'&:hover': { '&:hover': {
background: theme.colors.background.secondary, background: theme.colors.background.secondary,
}, },
'&:focus-visible': {
outline: `2px solid ${theme.colors.primary.border}`,
outlineOffset: '-2px',
},
}), }),
optionLabel: css({ optionLabel: css({
flex: 1, flex: 1,