wip groupBy per panel
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user