wip per panel group by
This commit is contained in:
@@ -408,6 +408,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
unlimitedLayoutsNesting?: boolean;
|
||||
/**
|
||||
* Enabled a group by action per panel
|
||||
*/
|
||||
panelGroupBy?: boolean;
|
||||
/**
|
||||
* Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
||||
*/
|
||||
panelFilterVariable?: boolean;
|
||||
|
||||
@@ -678,6 +678,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "panelGroupBy",
|
||||
Description: "Enabled a group by action per panel",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "panelFilterVariable",
|
||||
Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard",
|
||||
|
||||
@@ -90,6 +90,7 @@ dashboardScene,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,true
|
||||
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
|
||||
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
|
||||
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
||||
|
||||
|
@@ -371,6 +371,10 @@ const (
|
||||
// Enables unlimited dashboard panel grouping
|
||||
FlagUnlimitedLayoutsNesting = "unlimitedLayoutsNesting"
|
||||
|
||||
// FlagPanelGroupBy
|
||||
// Enabled a group by action per panel
|
||||
FlagPanelGroupBy = "panelGroupBy"
|
||||
|
||||
// FlagPanelFilterVariable
|
||||
// Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
||||
FlagPanelFilterVariable = "panelFilterVariable"
|
||||
|
||||
@@ -2884,6 +2884,19 @@
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "panelGroupBy",
|
||||
"resourceVersion": "1762169261546",
|
||||
"creationTimestamp": "2025-11-03T11:27:41Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enabled a group by action per panel",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dashboards-squad",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "panelMonitoring",
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import {
|
||||
GroupByVariable,
|
||||
SceneComponentProps,
|
||||
sceneGraph,
|
||||
SceneObjectBase,
|
||||
VariableValueOption,
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { Button, Dropdown, Icon, Input, useStyles2, Checkbox } from '@grafana/ui';
|
||||
|
||||
export class PanelGroupByAction extends SceneObjectBase {
|
||||
static Component = PanelGroupByActionRenderer;
|
||||
|
||||
constructor() {
|
||||
super({});
|
||||
this.addActivationHandler(this.onActivate);
|
||||
}
|
||||
|
||||
private onActivate = () => {
|
||||
const panel = this.parent;
|
||||
if (!panel || !(panel instanceof VizPanel)) {
|
||||
throw new Error('PanelGroupByAction can be used only for VizPanel');
|
||||
}
|
||||
};
|
||||
|
||||
public getGroupByVariable() {
|
||||
return sceneGraph.getVariables(this).state.variables.find((variable) => variable instanceof GroupByVariable);
|
||||
}
|
||||
}
|
||||
|
||||
function PanelGroupByActionRenderer({ model }: SceneComponentProps<PanelGroupByAction>) {
|
||||
const groupByVariable = model.getGroupByVariable();
|
||||
const groupByState = groupByVariable?.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// Track temporary selections (not applied yet)
|
||||
const [tempSelectedItems, setTempSelectedItems] = useState<VariableValueOption[]>([]);
|
||||
// Track applied selections
|
||||
const [appliedSelectedItems, setAppliedSelectedItems] = useState<VariableValueOption[]>([]);
|
||||
// Track search input
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
// Track loading state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Check if an option is selected in temp state
|
||||
const isOptionSelected = useCallback(
|
||||
(item: VariableValueOption) => tempSelectedItems.some((opt) => opt.value === item.value),
|
||||
[tempSelectedItems]
|
||||
);
|
||||
|
||||
// Toggle selection in temp state
|
||||
const handleItemClick = useCallback(
|
||||
(item: VariableValueOption) => {
|
||||
if (isOptionSelected(item)) {
|
||||
setTempSelectedItems(tempSelectedItems.filter((opt) => opt.value !== item.value));
|
||||
} else {
|
||||
setTempSelectedItems([...tempSelectedItems, item]);
|
||||
}
|
||||
},
|
||||
[isOptionSelected, tempSelectedItems]
|
||||
);
|
||||
|
||||
// Apply selections and close dropdown
|
||||
const handleApply = () => {
|
||||
setAppliedSelectedItems(tempSelectedItems);
|
||||
};
|
||||
|
||||
// When dropdown opens, fetch options if needed and sync temp state with applied state
|
||||
const handleOpenChange = async (open: boolean) => {
|
||||
if (open) {
|
||||
// If groupBy has no options, fetch them
|
||||
if (groupByVariable && (!groupByState?.options || groupByState.options.length === 0)) {
|
||||
setIsLoading(true);
|
||||
await lastValueFrom(groupByVariable.validateAndUpdate());
|
||||
}
|
||||
setTempSelectedItems(appliedSelectedItems);
|
||||
setSearchValue('');
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown overlay={menu} onVisibleChange={handleOpenChange} placement="bottom-start">
|
||||
<Button variant="secondary" size="sm">
|
||||
<Trans i18nKey="panel-group-by.button">Group by</Trans>
|
||||
<Icon name="angle-down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
menuContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '300px',
|
||||
maxHeight: '400px',
|
||||
background: theme.colors.background.primary,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
boxShadow: theme.shadows.z3,
|
||||
}),
|
||||
searchContainer: css({
|
||||
padding: theme.spacing(1),
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
listContainer: css({
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
minHeight: '200px',
|
||||
maxHeight: '300px',
|
||||
padding: theme.spacing(0.5),
|
||||
}),
|
||||
option: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(1),
|
||||
cursor: 'pointer',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
'&:hover': {
|
||||
background: theme.colors.background.secondary,
|
||||
},
|
||||
}),
|
||||
optionLabel: css({
|
||||
flex: 1,
|
||||
}),
|
||||
emptyMessage: css({
|
||||
padding: theme.spacing(2),
|
||||
textAlign: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
applyContainer: css({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(1),
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import { registerDashboardMacro } from '../scene/DashboardMacro';
|
||||
import { DashboardReloadBehavior } from '../scene/DashboardReloadBehavior';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { PanelGroupByAction } from '../scene/PanelGroupByAction';
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
|
||||
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
|
||||
import { PanelNotices } from '../scene/PanelNotices';
|
||||
@@ -437,6 +438,7 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem {
|
||||
hoverHeaderOffset: 0,
|
||||
$data: createPanelDataProvider(panel),
|
||||
titleItems,
|
||||
headerActions: config.featureToggles.panelGroupBy ? [new PanelGroupByAction()] : [],
|
||||
$behaviors: [],
|
||||
extendPanelContext: setDashboardPanelContext,
|
||||
_UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel),
|
||||
|
||||
Reference in New Issue
Block a user