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