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 { 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<PanelGroupByA
const styles = useStyles2(getStyles);
// Track temporary selections (not applied yet)
const [tempSelectedItems, setTempSelectedItems] = useState<VariableValueOption[]>([]);
// Track applied selections
const [appliedSelectedItems, setAppliedSelectedItems] = useState<VariableValueOption[]>([]);
const [selectedItems, setSelectedItems] = useState<VariableValueOption[]>([]);
// 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<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(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 */
<div className={styles.menuContainer} onClick={(e) => e.stopPropagation()}>
<div className={styles.searchContainer}>
<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>
) : 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>
);
// 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 (
<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>
<Icon name="angle-down" />
</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': {
background: theme.colors.background.secondary,
},
'&:focus-visible': {
outline: `2px solid ${theme.colors.primary.border}`,
outlineOffset: '-2px',
},
}),
optionLabel: css({
flex: 1,