From d3d294c99eee0755e6f24e3dd057834dbf6050c5 Mon Sep 17 00:00:00 2001 From: Victor Marin Date: Fri, 28 Nov 2025 15:03:11 +0200 Subject: [PATCH] Dashboards: Per panel GroupBy action (#113445) * wip per panel group by * wip groupBy per panel * wip groupBy per panel * groupBy per panel action tests * fix * fix * fix * fix * CR mods * switch to dropdown * adjust apply * optimise action logic to avoid unnecessary triggers * canary scenes * wip (cherry picked from commit 51a00db93d0805f481a9e48213382468f1eb2986) * optimise action logic to avoid unnecessary triggers (cherry picked from commit c4de2dfff88c02c5aef61d1cbee070b5f6e5ccbc) * refactor * refactor * memoize values/ refactor * refactor * refactor components - do not make async call unless queries/groupByOptions change * canary scenes * fix test * Optimise handlers * Reset options if they are not applied * refactor subscriptions * refactor * scenes bump * fixes * properly deactivate header actions on panel edit * list * refactor showing menu using css, remove header deactivation code from panel-edit * cleanup * cleanup * cleanup + action redesign * i18n * pr mods * translations * fix * fix * fix design --------- Co-authored-by: Sergej-Vlasov Co-authored-by: Dominik Prokop --- .../src/types/featureToggles.gen.ts | 4 + .../src/selectors/components.ts | 3 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 13 ++ .../scene/VizPanelHeaderActions.test.tsx | 154 +++++++++++++++++ .../scene/VizPanelHeaderActions.tsx | 136 +++++++++++++++ .../scene/VizPanelSubHeader.tsx | 5 +- .../PanelGroupByAction/PanelGroupByAction.tsx | 150 +++++++++++++++++ .../PanelGroupByActionPopover.tsx | 156 ++++++++++++++++++ .../serialization/layoutSerializers/utils.ts | 7 + .../transformSaveModelToScene.ts | 4 + .../features/dashboard-scene/utils/utils.ts | 4 + public/locales/en-US/grafana.json | 6 + 14 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/VizPanelHeaderActions.test.tsx create mode 100644 public/app/features/dashboard-scene/scene/VizPanelHeaderActions.tsx create mode 100644 public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByAction.tsx create mode 100644 public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByActionPopover.tsx diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 35cccd78375..d853a64395a 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -377,6 +377,10 @@ export interface FeatureToggles { */ perPanelNonApplicableDrilldowns?: 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/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 98fa6053d11..36d0088b258 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -443,6 +443,9 @@ export const versionedComponents = { PanelDataErrorMessage: { '10.4.0': 'data-testid Panel data error message', }, + PanelGroupByHeaderAction: { + '12.4.0': 'data-testid Panel group by header action', + }, }, Visualization: { Graph: { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 950476d1a8f..c112e0b77a0 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -607,6 +607,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 a7423d583ab..1382204ce0f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -84,6 +84,7 @@ kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true perPanelNonApplicableDrilldowns,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.json b/pkg/services/featuremgmt/toggles_gen.json index ea80af0831e..8451faca5d0 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2512,6 +2512,19 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "panelGroupBy", + "resourceVersion": "1764257043719", + "creationTimestamp": "2025-11-27T15:24:03Z" + }, + "spec": { + "description": "Enabled a group by action per panel", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, { "metadata": { "name": "panelTimeSettings", diff --git a/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.test.tsx b/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.test.tsx new file mode 100644 index 00000000000..24b83e853fe --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.test.tsx @@ -0,0 +1,154 @@ +import { of } from 'rxjs'; + +import { DataQueryRequest, DataSourceApi, LoadingState } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { + GroupByVariable, + SceneDataTransformer, + SceneQueryRunner, + SceneVariableSet, + VizPanel, + VizPanelState, +} from '@grafana/scenes'; + +import { activateFullSceneTree } from '../utils/test-utils'; + +import { DashboardScene } from './DashboardScene'; +import { VizPanelHeaderActions } from './VizPanelHeaderActions'; +import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; + +const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { + return of({ + state: LoadingState.Loading, + series: [], + timeRange: request.range, + }); +}); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { + return runRequestMock(ds, request); + }, + getDataSourceSrv: () => ({ + get: jest.fn().mockResolvedValue({ + getRef: () => ({ uid: 'ds-1', type: 'test' }), + }), + getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds-1', type: 'test' }), + }), + getPluginImportUtils: () => ({ + getPanelPluginFromCache: jest.fn(() => undefined), + }), +})); + +setPluginImportUtils({ + importPanelPlugin: () => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: () => undefined, +}); + +describe('VizPanelHeaderActions', () => { + it('renders PanelGroupByAction when the group by variable applies to the panel', async () => { + const { headerActions } = await buildScene(); + + expect(headerActions.state.supportsApplicability).toBe(true); + }); + + it('does not set when applicability is disabled', async () => { + const { headerActions } = await buildScene({ applicabilityEnabled: false }); + + expect(headerActions.state.supportsApplicability).toBe(false); + }); + + it('does not set when the datasource uid does not match', async () => { + const { headerActions } = await buildScene({ + variableDatasourceUid: 'other-ds', + }); + + expect(headerActions.state.supportsApplicability).toBe(false); + }); + + it('does not set if variable ds changes to a different type', async () => { + const { headerActions, groupByVariable } = await buildScene(); + + expect(headerActions.state.supportsApplicability).toBe(true); + + groupByVariable.setState({ datasource: { uid: 'ds-2' } }); + + expect(headerActions.state.supportsApplicability).toBe(false); + }); + + it('does not set if variable applicability becomes disabled', async () => { + const { headerActions, groupByVariable } = await buildScene(); + + expect(headerActions.state.supportsApplicability).toBe(true); + + groupByVariable.setState({ applicabilityEnabled: false }); + + expect(headerActions.state.supportsApplicability).toBe(false); + }); + + it('sdoes not set if queryRunner changes datasource to different one than vars', async () => { + const { headerActions, queryRunner } = await buildScene(); + + expect(headerActions.state.supportsApplicability).toBe(true); + + queryRunner.setState({ datasource: { uid: 'ds-2' } }); + + expect(headerActions.state.supportsApplicability).toBe(false); + }); +}); + +interface BuildSceneOptions { + applicabilityEnabled?: boolean; + variableDatasourceUid?: string; +} + +async function buildScene(options?: BuildSceneOptions) { + const headerActions = new VizPanelHeaderActions({}); + + const queryRunner = new SceneQueryRunner({ + datasource: { uid: 'ds-1' }, + queries: [{ refId: 'A', datasource: { uid: 'ds-1' } }], + }); + + const groupByVariable = new GroupByVariable({ + name: 'group', + label: 'group', + value: [], + text: [], + options: [], + applicabilityEnabled: options?.applicabilityEnabled ?? true, + datasource: { uid: options?.variableDatasourceUid ?? 'ds-1' }, + }); + + const dataProvider = new SceneDataTransformer({ + $data: queryRunner, + transformations: [], + }); + + const panelState: VizPanelState = { + key: 'panel-1', + title: 'Panel A', + pluginId: 'timeseries', + headerActions, + $data: dataProvider, + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + }; + + const panel = new VizPanel(panelState); + + const scene = new DashboardScene({ + $variables: new SceneVariableSet({ + variables: [groupByVariable], + }), + body: DefaultGridLayoutManager.fromVizPanels([panel]), + }); + + activateFullSceneTree(scene); + + await new Promise((r) => setTimeout(r, 1)); + + return { headerActions, panel, groupByVariable, queryRunner }; +} diff --git a/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.tsx b/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.tsx new file mode 100644 index 00000000000..3130d746c94 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.tsx @@ -0,0 +1,136 @@ +import { Unsubscribable } from 'rxjs'; + +import { + GroupByVariable, + SceneComponentProps, + sceneGraph, + SceneObjectBase, + SceneObjectState, + SceneQueryRunner, + VizPanel, +} from '@grafana/scenes'; +import { DataSourceRef } from '@grafana/schema'; + +import { verifyDrilldownApplicability } from '../utils/drilldownUtils'; + +import { PanelGroupByAction } from './panel-actions/PanelGroupByAction/PanelGroupByAction'; + +export interface VizPanelHeaderActionsState extends SceneObjectState { + hideGroupByAction?: boolean; + supportsApplicability?: boolean; +} + +export class VizPanelHeaderActions extends SceneObjectBase { + static Component = VizPanelHeaderActionsRenderer; + + private _groupByVar?: GroupByVariable; + private _groupBySub?: Unsubscribable; + private _queryRunnerDatasource?: DataSourceRef; + + constructor(state: Partial) { + super({ + hideGroupByAction: state.hideGroupByAction ?? false, + ...state, + }); + + this.addActivationHandler(this._onActivate); + } + + private _onActivate = () => { + if (!this.parent || !(this.parent instanceof VizPanel)) { + throw new Error('VizPanelHeaderActions must be a child of a VizPanel'); + } + + if (!this.state.hideGroupByAction) { + this.subscribeToGroupByChanges(); + } + + return () => { + this._groupBySub?.unsubscribe(); + }; + }; + + private setAplicabilitySupport(groupByDs?: DataSourceRef | null, groupByApplicability?: boolean) { + this.setState({ + supportsApplicability: verifyDrilldownApplicability( + this, + this._queryRunnerDatasource, + groupByDs ?? this._groupByVar?.state.datasource ?? null, + groupByApplicability ?? this._groupByVar?.state.applicabilityEnabled ?? false + ), + }); + } + + private subscribeToGroupByChanges() { + const vars = sceneGraph.getVariables(this); + const queryRunner = this.getQueryRunner(); + + this._groupByVar = vars.state.variables.find((variable) => variable instanceof GroupByVariable); + this._queryRunnerDatasource = queryRunner?.state.datasource; + + this.setAplicabilitySupport(); + + // check when var set updates and search for groupBy var + this._subs.add( + vars.subscribeToState((n) => { + this._groupByVar = n.variables.find((variable) => variable instanceof GroupByVariable); + + if (this._groupByVar) { + this._groupBySub?.unsubscribe(); + this._groupBySub = this._groupByVar?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { + this.setAplicabilitySupport(n.datasource, n.applicabilityEnabled); + } + }); + } + }) + ); + + // update query runner datasource changes + this._subs.add( + queryRunner?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource) { + this._queryRunnerDatasource = n.datasource; + + this.setAplicabilitySupport(); + } + }) + ); + + this._groupBySub = this._groupByVar?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { + this.setAplicabilitySupport(n.datasource, n.applicabilityEnabled); + } + }); + } + + public getQueryRunner() { + const panel = this.parent; + const dataObject = panel ? sceneGraph.getData(panel) : undefined; + const queryRunner = dataObject?.state.$data; + + if (!queryRunner || !(queryRunner instanceof SceneQueryRunner)) { + return null; + } + + return queryRunner; + } +} + +export function VizPanelHeaderActionsRenderer({ model }: SceneComponentProps) { + const { hideGroupByAction, supportsApplicability } = model.useState(); + const variables = sceneGraph.getVariables(model); + const groupByVariable = variables.state.variables.find((variable) => variable instanceof GroupByVariable); + const queryRunner = model.getQueryRunner(); + const queries = queryRunner?.state.data?.request?.targets ?? []; + + return ( + <> + {!hideGroupByAction && supportsApplicability && ( +
+ +
+ )} + + ); +} diff --git a/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx b/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx index f2247661e04..ff6f6c0712e 100644 --- a/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx +++ b/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx @@ -116,7 +116,7 @@ export class VizPanelSubHeader extends SceneObjectBase { private refreshDrilldownVarsSubscriptions() { if (this._groupByVar) { this._groupBySub?.unsubscribe(); - this._groupByVar?.subscribeToState((n, p) => { + this._groupBySub = this._groupByVar?.subscribeToState((n, p) => { if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { this.setDrilldownApplicabilitySupportHelper(undefined, { datasource: n.datasource, @@ -127,7 +127,8 @@ export class VizPanelSubHeader extends SceneObjectBase { } if (this._adHocVar) { - this._adHocVar?.subscribeToState((n, p) => { + this._adHocSub?.unsubscribe(); + this._adHocSub = this._adHocVar?.subscribeToState((n, p) => { if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { this.setDrilldownApplicabilitySupportHelper({ datasource: n.datasource, diff --git a/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByAction.tsx b/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByAction.tsx new file mode 100644 index 00000000000..b12cc52cbe3 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByAction.tsx @@ -0,0 +1,150 @@ +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import { lastValueFrom } from 'rxjs'; + +import { fuzzySearch } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { Trans } from '@grafana/i18n'; +import { GroupByVariable, SceneDataQuery, VariableValueOption, VariableValueSingle } from '@grafana/scenes'; +import { Button, Icon, Popover } from '@grafana/ui'; + +import { PanelGroupByActionPopover } from './PanelGroupByActionPopover'; + +interface Props { + groupByVariable: GroupByVariable; + queries: SceneDataQuery[]; +} + +export function PanelGroupByAction({ groupByVariable, queries }: Props) { + const { options: groupByOptions } = groupByVariable.useState(); + + const [options, setOptions] = useState([]); + const [selectedValues, setSelectedValues] = useState([]); + const [searchValue, setSearchValue] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [reloadOptions, setReloadOptions] = useState(false); + const [isPopoverVisible, setPopoverVisible] = useState(false); + + const ref = useRef(null); + + const fetchOptions = useCallback(async () => { + if (!groupByVariable || !reloadOptions) { + return; + } + + setIsLoading(true); + try { + if (groupByOptions.length === 0) { + await lastValueFrom(groupByVariable.validateAndUpdate()); + const options = await getApplicableGroupByOptions(groupByVariable, groupByVariable.state.options, queries); + + setSelectedValues(getGroupByValue(groupByVariable)); + setOptions(options); + return; + } + + const options = await getApplicableGroupByOptions(groupByVariable, groupByOptions, queries); + + setSelectedValues(getGroupByValue(groupByVariable)); + setOptions(options); + } catch (error) { + setSelectedValues([]); + setOptions([]); + } finally { + setIsLoading(false); + setReloadOptions(false); + } + }, [groupByOptions, groupByVariable, queries, reloadOptions]); + + useEffect(() => { + setReloadOptions(true); + }, [groupByOptions, queries]); + + useEffect(() => { + if (isPopoverVisible) { + fetchOptions(); + } + }, [fetchOptions, isPopoverVisible]); + + const filteredOptions = useMemo(() => { + if (!searchValue) { + return options; + } + + const haystack = options.map((option) => option.label); + const indices = fuzzySearch(haystack, searchValue); + return indices.map((idx) => options[idx]); + }, [options, searchValue]); + + const onCancel = () => { + setSearchValue(''); + setPopoverVisible(false); + }; + + const openPopover = () => { + // Reset checked state to match current variable value when opening + setSelectedValues(getGroupByValue(groupByVariable)); + setPopoverVisible(true); + }; + + return ( + + ); +} + +function getGroupByValue(groupByVariable: GroupByVariable) { + return Array.isArray(groupByVariable.state.value) + ? groupByVariable.state.value + : groupByVariable.state.value + ? [groupByVariable.state.value] + : []; +} + +async function getApplicableGroupByOptions( + groupByVariable: GroupByVariable, + options: VariableValueOption[], + queries: SceneDataQuery[] +) { + const values = options.map((option) => option.value); + const applicability = await groupByVariable.getGroupByApplicabilityForQueries(values, queries); + + return applicability + ? applicability.filter((item) => item.applicable).map((item) => ({ label: item.key, value: item.key })) + : options; +} diff --git a/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByActionPopover.tsx b/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByActionPopover.tsx new file mode 100644 index 00000000000..06191e1f7bf --- /dev/null +++ b/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByActionPopover.tsx @@ -0,0 +1,156 @@ +import { css, cx } from '@emotion/css'; +import { useCallback } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; +import { GroupByVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes'; +import { Button, Checkbox, ClickOutsideWrapper, Icon, Input, Stack, useStyles2 } from '@grafana/ui'; + +interface Props { + groupByVariable: GroupByVariable; + onCancel: () => void; + isLoading: boolean; + searchValue: string; + setSearchValue: (value: string) => void; + options: VariableValueOption[]; + values: VariableValueSingle[]; + onValuesChange: (value: VariableValueSingle[]) => void; +} + +export function PanelGroupByActionPopover({ + groupByVariable, + onCancel, + isLoading, + searchValue, + setSearchValue, + options, + values, + onValuesChange, +}: Props) { + const styles = useStyles2(getStyles); + + const onCheckedChanged = useCallback( + (option: VariableValueOption) => (event: React.FormEvent) => { + const newValues = event.currentTarget.checked + ? values.concat(option.value) + : values.filter((c) => c !== option.value); + + onValuesChange(newValues); + }, + [onValuesChange, values] + ); + + const isChecked = (option: VariableValueOption) => { + return values.includes(option.value); + }; + + const handleApply = useCallback(() => { + if (!values.length) { + return; + } + + groupByVariable.changeValueTo(values, values.map(String), true); + onCancel(); + }, [groupByVariable, onCancel, values]); + + const isAnyOptionChecked = () => { + if (!values.length) { + return false; + } + + return values.some((value) => options.find((option) => option.value === value)); + }; + + return ( + + {/* This is just blocking click events from bubbeling and should not have a keyboard interaction. */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} +
ev.stopPropagation()}> + +
+ } + placeholder={t('panel-group-by.search-placeholder', 'Search')} + value={searchValue} + onChange={(e) => setSearchValue(e.currentTarget.value)} + /> +
+ +
+ {isLoading ? ( +
+ Loading options +
+ ) : options.length === 0 ? ( +
+ No options found +
+ ) : ( + options.map((option) => { + return ( +
+ +
+ ); + }) + )} +
+ + + + + +
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + menuContainer: css({ + display: 'flex', + flexDirection: 'column', + background: theme.colors.background.elevated, + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + boxShadow: theme.shadows.z3, + padding: theme.spacing(2), + }), + searchContainer: css({ + width: '100%', + paddingBottom: theme.spacing(1), + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + listContainer: css({ + flex: 1, + overflow: 'auto', + minHeight: '100px', + maxHeight: '300px', + padding: theme.spacing(0.5), + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + 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, + }, + '&:focus-visible': { + outline: `2px solid ${theme.colors.primary.border}`, + outlineOffset: '-2px', + }, + }), + emptyMessage: css({ + padding: theme.spacing(2), + textAlign: 'center', + color: theme.colors.text.secondary, + }), +}); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index 512631961d3..788dd53d11a 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -31,6 +31,7 @@ import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks'; import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior'; import { PanelNotices } from '../../scene/PanelNotices'; +import { VizPanelHeaderActions } from '../../scene/VizPanelHeaderActions'; import { VizPanelSubHeader } from '../../scene/VizPanelSubHeader'; import { AutoGridItem } from '../../scene/layout-auto-grid/AutoGridItem'; import { DashboardGridItem } from '../../scene/layout-default/DashboardGridItem'; @@ -71,6 +72,9 @@ export function buildVizPanel(panel: PanelKind, id?: number): VizPanel { seriesLimit: config.panelSeriesLimit, $data: createPanelDataProvider(panel), titleItems, + headerActions: new VizPanelHeaderActions({ + hideGroupByAction: !config.featureToggles.panelGroupBy, + }), subHeader: new VizPanelSubHeader({ hideNonApplicableDrilldowns: !config.featureToggles.perPanelNonApplicableDrilldowns, }), @@ -121,6 +125,9 @@ export function buildLibraryPanel(panel: LibraryPanelKind, id?: number): VizPane }), ], extendPanelContext: setDashboardPanelContext, + headerActions: new VizPanelHeaderActions({ + hideGroupByAction: !config.featureToggles.panelGroupBy, + }), pluginId: LibraryPanelBehavior.LOADING_VIZ_PANEL_PLUGIN_ID, title: panel.spec.title, options: {}, diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 9194adf6a7b..3c334b022c1 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -46,6 +46,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { PanelNotices } from '../scene/PanelNotices'; +import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions'; import { VizPanelSubHeader } from '../scene/VizPanelSubHeader'; import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; @@ -442,6 +443,9 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem { hoverHeaderOffset: 0, $data: createPanelDataProvider(panel), titleItems, + headerActions: new VizPanelHeaderActions({ + hideGroupByAction: !config.featureToggles.panelGroupBy, + }), subHeader: new VizPanelSubHeader({ hideNonApplicableDrilldowns: !config.featureToggles.perPanelNonApplicableDrilldowns, }), diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 7188756b055..6c486021e62 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -24,6 +24,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel'; +import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions'; import { VizPanelSubHeader } from '../scene/VizPanelSubHeader'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; @@ -283,6 +284,9 @@ export function getDefaultVizPanel(): VizPanel { menu: new VizPanelMenu({ $behaviors: [panelMenuBehavior], }), + headerActions: new VizPanelHeaderActions({ + hideGroupByAction: !config.featureToggles.panelGroupBy, + }), $data: new SceneDataTransformer({ $data: new SceneQueryRunner({ queries: [{ refId: 'A' }], diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6230117bcf2..d9efaadc4d6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11094,6 +11094,12 @@ "could-anything-matching-query": "Could not find anything matching your query" } }, + "panel-group-by": { + "button": "Group by", + "loading": "Loading options", + "no-options": "No options found", + "search-placeholder": "Search" + }, "panel-type-filter": { "clear-button": "Clear types", "select-aria-label": "Panel type filter",