diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 807bde7db74..da0926b2da6 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -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; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 3487cb00d8e..2dab59fbd5a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 6423bfa6bcc..ca3985be4cd 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index caf057a2b08..27de8b36d7f 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 12add4d3d1f..61615611ee7 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -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", diff --git a/public/app/features/dashboard-scene/scene/PanelGroupByAction.tsx b/public/app/features/dashboard-scene/scene/PanelGroupByAction.tsx new file mode 100644 index 00000000000..6cedfd62247 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/PanelGroupByAction.tsx @@ -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) { + const groupByVariable = model.getGroupByVariable(); + const groupByState = groupByVariable?.useState(); + const styles = useStyles2(getStyles); + + // Track temporary selections (not applied yet) + const [tempSelectedItems, setTempSelectedItems] = useState([]); + // Track applied selections + const [appliedSelectedItems, setAppliedSelectedItems] = useState([]); + // 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 */ +
e.stopPropagation()}> +
+ } + placeholder={t('panel-group-by.search-placeholder', 'Search...')} + value={searchValue} + onChange={(e) => setSearchValue(e.currentTarget.value)} + /> +
+
+ {isLoading ? ( +
+ Loading options... +
+ ) : groupByState.options.length === 0 ? ( +
+ No options found +
+ ) : ( + groupByState.options.map((option) => { + const isSelected = isOptionSelected(option); + return ( +
{ + 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} + > + handleItemClick(option)} /> + {option.label} +
+ ); + }) + )} +
+
+ + +
+
+ ); + + return ( + + + + ); +} + +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}`, + }), +}); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 7646b7df68c..39cb50bb515 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -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),