diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index fdf456b8c2e..ef2022e8779 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1287,6 +1287,11 @@ export interface FeatureToggles { */ newVizSuggestions?: boolean; /** + * Enable style actions (copy/paste) in the panel editor + * @default false + */ + panelStyleActions?: boolean; + /** * Enable all plugins to supply visualization suggestions (including 3rd party plugins) * @default false */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 4c14af9caa0..568487c4b57 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2024,6 +2024,14 @@ var ( Owner: grafanaDatavizSquad, Expression: "false", }, + { + Name: "panelStyleActions", + Description: "Enable style actions (copy/paste) in the panel editor", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + Expression: "false", + }, { Name: "externalVizSuggestions", Description: "Enable all plugins to supply visualization suggestions (including 3rd party plugins)", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index f71db344871..9557b7a3486 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -253,6 +253,7 @@ Created,Name,Stage,Owner,requiresDevMode,RequiresRestart,FrontendOnly 2025-10-20,newGauge,preview,@grafana/dataviz-squad,false,false,true 2025-11-12,newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true 2025-12-02,externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true +2026-01-28,panelStyleActions,experimental,@grafana/dataviz-squad,false,false,true 2025-12-18,heatmapRowsAxisOptions,experimental,@grafana/dataviz-squad,false,false,true 2025-10-17,preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true 2025-10-31,jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index eb413b01535..eb2f2f90c55 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3314,6 +3314,20 @@ "expression": "false" } }, + { + "metadata": { + "name": "panelStyleActions", + "resourceVersion": "1769620237787", + "creationTimestamp": "2026-01-28T17:10:37Z" + }, + "spec": { + "description": "Enable style actions (copy/paste) in the panel editor", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "expression": "false" + } + }, { "metadata": { "name": "panelTimeSettings", diff --git a/public/app/core/constants.ts b/public/app/core/constants.ts index 2ae7e63f16a..4bf6e369e78 100644 --- a/public/app/core/constants.ts +++ b/public/app/core/constants.ts @@ -9,6 +9,7 @@ export const DEFAULT_ROW_HEIGHT = 250; export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3; export const LS_PANEL_COPY_KEY = 'panel-copy'; +export const LS_STYLES_COPY_KEY = 'styles-copy'; export const LS_ROW_COPY_KEY = 'row-copy'; export const LS_TAB_COPY_KEY = 'tab-copy'; export const PANEL_BORDER = 2; diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 9a0014ce4a7..ea36adf52d0 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -24,7 +24,7 @@ import { import { Dashboard, DashboardCursorSync, LibraryPanel } from '@grafana/schema'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { appEvents } from 'app/core/app_events'; -import { LS_PANEL_COPY_KEY } from 'app/core/constants'; +import { LS_PANEL_COPY_KEY, LS_STYLES_COPY_KEY } from 'app/core/constants'; import { AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DecoratedRevisionModel } from 'app/features/dashboard/types/revisionModels'; @@ -39,6 +39,7 @@ import { createWorker } from '../saving/createDetectChangesWorker'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { getCloneKey } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; +import { DashboardInteractions } from '../utils/interactions'; import { findVizPanelByKey, getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; import * as utils from '../utils/utils'; @@ -632,6 +633,196 @@ describe('DashboardScene', () => { expect(store.exists(LS_PANEL_COPY_KEY)).toBe(false); }); + describe('Copy/Paste panel styles', () => { + const createTimeseriesPanel = () => { + return new VizPanel({ + title: 'Timeseries Panel', + key: `panel-timeseries-${Math.random()}`, + pluginId: 'timeseries', + fieldConfig: { + defaults: { + color: { mode: 'palette-classic' }, + custom: { + lineWidth: 1, + fillOpacity: 10, + }, + }, + overrides: [], + }, + }); + }; + + beforeEach(() => { + store.delete(LS_STYLES_COPY_KEY); + config.featureToggles.panelStyleActions = true; + }); + + afterEach(() => { + config.featureToggles.panelStyleActions = false; + }); + + it('Should copy panel styles when feature flag is enabled', () => { + const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked'); + const timeseriesPanel = createTimeseriesPanel(); + + scene.copyPanelStyles(timeseriesPanel); + + expect(store.exists(LS_STYLES_COPY_KEY)).toBe(true); + const stored = JSON.parse(store.get(LS_STYLES_COPY_KEY) || '{}'); + expect(stored.panelType).toBe('timeseries'); + expect(stored.styles).toBeDefined(); + expect(spy).not.toHaveBeenCalled(); // Analytics only called from menu + }); + + it('Should not copy panel styles when feature flag is disabled', () => { + config.featureToggles.panelStyleActions = false; + const timeseriesPanel = createTimeseriesPanel(); + + scene.copyPanelStyles(timeseriesPanel); + + expect(store.exists(LS_STYLES_COPY_KEY)).toBe(false); + }); + + it('Should not copy styles for non-timeseries panels', () => { + const vizPanel = findVizPanelByKey(scene, 'panel-1')!; + scene.copyPanelStyles(vizPanel); + + expect(store.exists(LS_STYLES_COPY_KEY)).toBe(false); + }); + + it('Should return false for hasPanelStylesToPaste when no styles copied', () => { + expect(DashboardScene.hasPanelStylesToPaste('timeseries')).toBe(false); + }); + + it('Should return false for hasPanelStylesToPaste when feature flag is disabled', () => { + store.set(LS_STYLES_COPY_KEY, JSON.stringify({ panelType: 'timeseries', styles: {} })); + config.featureToggles.panelStyleActions = false; + + expect(DashboardScene.hasPanelStylesToPaste('timeseries')).toBe(false); + }); + + it('Should return true for hasPanelStylesToPaste when styles exist for matching panel type', () => { + store.set(LS_STYLES_COPY_KEY, JSON.stringify({ panelType: 'timeseries', styles: {} })); + + expect(DashboardScene.hasPanelStylesToPaste('timeseries')).toBe(true); + }); + + it('Should return false for hasPanelStylesToPaste for different panel type', () => { + store.set(LS_STYLES_COPY_KEY, JSON.stringify({ panelType: 'timeseries', styles: {} })); + + expect(DashboardScene.hasPanelStylesToPaste('table')).toBe(false); + }); + + it('Should paste panel styles when feature flag is enabled', () => { + const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked'); + const timeseriesPanel = createTimeseriesPanel(); + const mockOnFieldConfigChange = jest.fn(); + timeseriesPanel.onFieldConfigChange = mockOnFieldConfigChange; + + const styles = { + panelType: 'timeseries', + styles: { + fieldConfig: { + defaults: { + color: { mode: 'palette-classic' }, + custom: { + lineWidth: 2, + fillOpacity: 10, + }, + }, + }, + }, + }; + store.set(LS_STYLES_COPY_KEY, JSON.stringify(styles)); + + scene.pastePanelStyles(timeseriesPanel); + + expect(mockOnFieldConfigChange).toHaveBeenCalled(); + expect(store.exists(LS_STYLES_COPY_KEY)).toBe(true); + expect(spy).not.toHaveBeenCalled(); + }); + + it('Should not paste panel styles when feature flag is disabled', () => { + config.featureToggles.panelStyleActions = false; + const timeseriesPanel = createTimeseriesPanel(); + const mockOnFieldConfigChange = jest.fn(); + timeseriesPanel.onFieldConfigChange = mockOnFieldConfigChange; + + const styles = { + panelType: 'timeseries', + styles: { fieldConfig: { defaults: {} } }, + }; + store.set(LS_STYLES_COPY_KEY, JSON.stringify(styles)); + + scene.pastePanelStyles(timeseriesPanel); + + expect(mockOnFieldConfigChange).not.toHaveBeenCalled(); + }); + + it('Should not paste styles when no styles are copied', () => { + const timeseriesPanel = createTimeseriesPanel(); + const mockOnFieldConfigChange = jest.fn(); + timeseriesPanel.onFieldConfigChange = mockOnFieldConfigChange; + + scene.pastePanelStyles(timeseriesPanel); + + expect(mockOnFieldConfigChange).not.toHaveBeenCalled(); + }); + + it('Should not paste styles to different panel type', () => { + const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked'); + const timeseriesPanel = createTimeseriesPanel(); + const mockOnFieldConfigChange = jest.fn(); + timeseriesPanel.onFieldConfigChange = mockOnFieldConfigChange; + + const styles = { + panelType: 'table', + styles: { fieldConfig: { defaults: {} } }, + }; + store.set(LS_STYLES_COPY_KEY, JSON.stringify(styles)); + + scene.pastePanelStyles(timeseriesPanel); + + expect(mockOnFieldConfigChange).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('Should allow pasting styles multiple times', () => { + const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked'); + const timeseriesPanel1 = createTimeseriesPanel(); + const timeseriesPanel2 = createTimeseriesPanel(); + const mockOnFieldConfigChange1 = jest.fn(); + const mockOnFieldConfigChange2 = jest.fn(); + timeseriesPanel1.onFieldConfigChange = mockOnFieldConfigChange1; + timeseriesPanel2.onFieldConfigChange = mockOnFieldConfigChange2; + + const styles = { + panelType: 'timeseries', + styles: { fieldConfig: { defaults: { custom: { lineWidth: 3 } } } }, + }; + store.set(LS_STYLES_COPY_KEY, JSON.stringify(styles)); + + scene.pastePanelStyles(timeseriesPanel1); + expect(mockOnFieldConfigChange1).toHaveBeenCalled(); + expect(store.exists(LS_STYLES_COPY_KEY)).toBe(true); + + scene.pastePanelStyles(timeseriesPanel2); + expect(mockOnFieldConfigChange2).toHaveBeenCalled(); + expect(store.exists(LS_STYLES_COPY_KEY)).toBe(true); + expect(spy).not.toHaveBeenCalled(); + }); + + it('Should report analytics on paste error', () => { + const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked'); + jest.spyOn(console, 'error').mockImplementation(); + + store.set(LS_STYLES_COPY_KEY, 'invalid json'); + scene.pastePanelStyles(createTimeseriesPanel()); + + expect(spy).toHaveBeenCalledWith('paste', 'timeseries', expect.any(Number), true); + }); + }); + it('Should unlink a library panel', () => { const libPanel = new VizPanel({ title: 'Panel B', diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index cfeda73f38c..9d6cff4d5ba 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -1,6 +1,6 @@ import * as H from 'history'; -import { CoreApp, DataQueryRequest, locationUtil, NavIndex, NavModelItem, store } from '@grafana/data'; +import { CoreApp, DataQueryRequest, FieldConfig, locationUtil, NavIndex, NavModelItem, store } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, locationService, RefreshEvent } from '@grafana/runtime'; import { @@ -19,7 +19,7 @@ import { Dashboard, DashboardLink, LibraryPanel } from '@grafana/schema'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { appEvents } from 'app/core/app_events'; import { ScrollRefElement } from 'app/core/components/NativeScrollbar'; -import { LS_PANEL_COPY_KEY } from 'app/core/constants'; +import { LS_PANEL_COPY_KEY, LS_STYLES_COPY_KEY } from 'app/core/constants'; import { getNavModel } from 'app/core/selectors/navModel'; import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; @@ -34,6 +34,7 @@ import { DecoratedRevisionModel } from 'app/features/dashboard/types/revisionMod import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { VariablesChanged } from 'app/features/variables/types'; +import { defaultGraphStyleConfig } from 'app/plugins/panel/timeseries/config'; import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types/dashboard'; import { ShowConfirmModalEvent } from 'app/types/events'; @@ -69,6 +70,7 @@ import { isRepeatCloneOrChildOf } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { getDashboardUrl } from '../utils/getDashboardUrl'; +import { DashboardInteractions } from '../utils/interactions'; import { getClosestVizPanel, getDashboardSceneFor, @@ -98,6 +100,15 @@ export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'gra export const PANEL_SEARCH_VAR = 'systemPanelFilterVar'; export const PANELS_PER_ROW_VAR = 'systemDynamicRowSizeVar'; +type PanelStyles = { + fieldConfig?: { defaults: Partial }; +}; + +type CopiedPanelStyles = { + panelType: string; + styles: PanelStyles; +}; + export interface DashboardSceneState extends SceneObjectState { /** The title */ title: string; @@ -651,6 +662,145 @@ export class DashboardScene extends SceneObjectBase impleme store.delete(LS_PANEL_COPY_KEY); } + /** + * Hardcoded to Timeseries for this PoC + * @internal + */ + private static extractPanelStyles(panel: VizPanel): PanelStyles { + const styles: PanelStyles = {}; + + if (!panel.state.fieldConfig?.defaults) { + return styles; + } + + styles.fieldConfig = { defaults: {} }; + + const defaults = styles.fieldConfig.defaults; + const panelDefaults = panel.state.fieldConfig.defaults; + + // default props (color) + if (defaultGraphStyleConfig.fieldConfig?.defaultsProps) { + for (const key of defaultGraphStyleConfig.fieldConfig.defaultsProps) { + const value = panelDefaults[key]; + if (value !== undefined) { + defaults[key] = value; + } + } + } + + // custom props (lineWidth, fillOpacity, etc.) + if (panel.state.fieldConfig.defaults.custom && defaultGraphStyleConfig.fieldConfig?.defaults) { + const customDefaults: Record = {}; + const panelCustom: Record = panel.state.fieldConfig.defaults.custom; + + for (const key of defaultGraphStyleConfig.fieldConfig.defaults) { + const value = panelCustom[key]; + if (value !== undefined) { + customDefaults[key] = value; + } + } + + defaults.custom = customDefaults; + } + + return styles; + } + + /** @internal */ + public copyPanelStyles(vizPanel: VizPanel) { + if (!config.featureToggles.panelStyleActions) { + return; + } + + const panelType = vizPanel.state.pluginId; + + if (panelType !== 'timeseries') { + return; + } + + const stylesToCopy: CopiedPanelStyles = { + panelType, + styles: DashboardScene.extractPanelStyles(vizPanel), + }; + + store.set(LS_STYLES_COPY_KEY, JSON.stringify(stylesToCopy)); + appEvents.emit('alert-success', ['Panel styles copied.']); + } + + /** @internal */ + public static hasPanelStylesToPaste(panelType: string): boolean { + if (!config.featureToggles.panelStyleActions) { + return false; + } + + const stylesJson = store.get(LS_STYLES_COPY_KEY); + if (!stylesJson) { + return false; + } + + try { + const stylesCopy: CopiedPanelStyles = JSON.parse(stylesJson); + return stylesCopy.panelType === panelType; + } catch (e) { + return false; + } + } + + /** @internal */ + public pastePanelStyles(vizPanel: VizPanel) { + if (!config.featureToggles.panelStyleActions) { + return; + } + + const stylesJson = store.get(LS_STYLES_COPY_KEY); + if (!stylesJson) { + return; + } + + try { + const stylesCopy: CopiedPanelStyles = JSON.parse(stylesJson); + + const panelType = vizPanel.state.pluginId; + + if (stylesCopy.panelType !== panelType) { + return; + } + + if (!stylesCopy.styles.fieldConfig?.defaults) { + return; + } + + const newDefaults = { + ...vizPanel.state.fieldConfig?.defaults, + ...stylesCopy.styles.fieldConfig.defaults, + }; + + if (stylesCopy.styles.fieldConfig.defaults.custom) { + newDefaults.custom = { + ...vizPanel.state.fieldConfig?.defaults?.custom, + ...stylesCopy.styles.fieldConfig.defaults.custom, + }; + } + + const newFieldConfig = { + ...vizPanel.state.fieldConfig, + defaults: newDefaults, + }; + vizPanel.onFieldConfigChange(newFieldConfig); + + appEvents.emit('alert-success', ['Panel styles applied.']); + } catch (e) { + console.error('Error pasting panel styles:', e); + appEvents.emit('alert-error', ['Error pasting panel styles.']); + DashboardInteractions.panelStylesMenuClicked( + 'paste', + vizPanel.state.pluginId, + getPanelIdForVizPanel(vizPanel) ?? -1, + true + ); + } + } + public removePanel(panel: VizPanel) { getLayoutManagerFor(panel).removePanel?.(panel); } diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx index 029cc0c7ccd..09cfd5792de 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx @@ -5,6 +5,7 @@ import { PluginExtensionPanelContext, PluginExtensionTypes, getDefaultTimeRange, + store, toDataFrame, urlUtil, } from '@grafana/data'; @@ -18,6 +19,7 @@ import { VizPanel, VizPanelMenu, } from '@grafana/scenes'; +import { LS_STYLES_COPY_KEY } from 'app/core/constants'; import { contextSrv } from 'app/core/services/context_srv'; import { GetExploreUrlArguments } from 'app/core/utils/explore'; import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; @@ -26,6 +28,7 @@ import * as storeModule from 'app/store/store'; import { AccessControlAction } from 'app/types/accessControl'; import { buildPanelEditScene } from '../panel-edit/PanelEditor'; +import { DashboardInteractions } from '../utils/interactions'; import { DashboardScene } from './DashboardScene'; import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks'; @@ -849,6 +852,75 @@ describe('panelMenuBehavior', () => { jest.restoreAllMocks(); }); }); + + describe('Panel styles menu', () => { + async function buildTimeseriesTestScene() { + const menu = new VizPanelMenu({ $behaviors: [panelMenuBehavior] }); + const panel = new VizPanel({ + title: 'Timeseries Panel', + pluginId: 'timeseries', + key: 'panel-ts', + menu, + }); + + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + new DashboardScene({ + title: 'My dashboard', + uid: 'dash-1', + meta: { canEdit: true }, + body: DefaultGridLayoutManager.fromVizPanels([panel]), + }); + + menu.activate(); + await new Promise((r) => setTimeout(r, 1)); + + return { menu, panel }; + } + + beforeEach(() => { + config.featureToggles.panelStyleActions = true; + }); + + afterEach(() => { + config.featureToggles.panelStyleActions = false; + store.delete(LS_STYLES_COPY_KEY); + }); + + it('should call analytics when copy styles is clicked', async () => { + const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked'); + const { menu } = await buildTimeseriesTestScene(); + + const copyItem = menu.state.items?.find((i) => i.text === 'Styles')?.subMenu?.[0]; + copyItem?.onClick?.({} as never); + + expect(spy).toHaveBeenCalledWith('copy', 'timeseries', expect.any(Number)); + }); + + it('should call analytics when paste styles is clicked', async () => { + store.set(LS_STYLES_COPY_KEY, JSON.stringify({ panelType: 'timeseries', styles: {} })); + const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked'); + const { menu } = await buildTimeseriesTestScene(); + + const pasteItem = menu.state.items?.find((i) => i.text === 'Styles')?.subMenu?.[1]; + pasteItem?.onClick?.({} as never); + + expect(spy).toHaveBeenCalledWith('paste', 'timeseries', expect.any(Number)); + }); + + it('should not show styles menu when feature flag is disabled', async () => { + config.featureToggles.panelStyleActions = false; + const { menu } = await buildTimeseriesTestScene(); + + expect(menu.state.items?.find((i) => i.text === 'Styles')).toBeUndefined(); + }); + + it('should not show styles menu for non-timeseries panels', async () => { + const { menu } = await buildTestScene({}); + + expect(menu.state.items?.find((i) => i.text === 'Styles')).toBeUndefined(); + }); + }); }); interface SceneOptions { diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 2cd8a4f5a54..fb0db6ba8b9 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -347,6 +347,48 @@ export function panelMenuBehavior(menu: VizPanelMenu) { } } + if (panel.state.pluginId === 'timeseries' && config.featureToggles.panelStyleActions) { + const stylesSubMenu: PanelMenuItem[] = []; + + stylesSubMenu.push({ + text: t('panel.header-menu.copy-styles', `Copy styles`), + iconClassName: 'copy', + onClick: () => { + DashboardInteractions.panelStylesMenuClicked( + 'copy', + panel.state.pluginId, + getPanelIdForVizPanel(panel) ?? -1 + ); + dashboard.copyPanelStyles(panel); + }, + }); + + if (DashboardScene.hasPanelStylesToPaste('timeseries')) { + stylesSubMenu.push({ + text: t('panel.header-menu.paste-styles', `Paste styles`), + iconClassName: 'clipboard-alt', + onClick: () => { + DashboardInteractions.panelStylesMenuClicked( + 'paste', + panel.state.pluginId, + getPanelIdForVizPanel(panel) ?? -1 + ); + dashboard.pastePanelStyles(panel); + }, + }); + } + + items.push({ + type: 'submenu', + text: t('panel.header-menu.styles', `Styles`), + iconClassName: 'palette', + subMenu: stylesSubMenu, + onClick: (e) => { + e.preventDefault(); + }, + }); + } + if (moreSubMenu.length) { items.push({ type: 'submenu', diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts b/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts index d5eca1d4e14..a4ba15a4e38 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts +++ b/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts @@ -6,7 +6,7 @@ import { RowsLayoutRowKind, TabsLayoutTabKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { LS_PANEL_COPY_KEY, LS_ROW_COPY_KEY, LS_TAB_COPY_KEY } from 'app/core/constants'; +import { LS_PANEL_COPY_KEY, LS_ROW_COPY_KEY, LS_STYLES_COPY_KEY, LS_TAB_COPY_KEY } from 'app/core/constants'; import { deserializeAutoGridItem } from '../../serialization/layoutSerializers/AutoGridLayoutSerializer'; import { deserializeGridItem } from '../../serialization/layoutSerializers/DefaultGridLayoutSerializer'; @@ -24,6 +24,7 @@ export function clearClipboard() { store.delete(LS_PANEL_COPY_KEY); store.delete(LS_ROW_COPY_KEY); store.delete(LS_TAB_COPY_KEY); + store.delete(LS_STYLES_COPY_KEY); } export interface RowStore { diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index e920ff289a4..b08f8462bfe 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -101,6 +101,11 @@ export const DashboardInteractions = { reportDashboardInteraction('panel_action_clicked', { item, id, source }); }, + // Panel styles copy/paste interactions + panelStylesMenuClicked(action: 'copy' | 'paste', panelType: string, panelId: number, error?: boolean) { + reportDashboardInteraction('panel_styles_menu_clicked', { action, panelType, panelId, error }); + }, + // Dashboard edit item actions // dashboards_edit_action_clicked: when user adds or removes an item in edit mode // props: { item: string } - item is one of: add_panel, group_row, group_tab, ungroup, paste_panel, remove_row, remove_tab diff --git a/public/app/plugins/panel/timeseries/config.ts b/public/app/plugins/panel/timeseries/config.ts index e88fc5b94a2..192651f957e 100644 --- a/public/app/plugins/panel/timeseries/config.ts +++ b/public/app/plugins/panel/timeseries/config.ts @@ -44,6 +44,54 @@ export const defaultGraphConfig: GraphFieldConfig = { showValues: false, }; +/** + * Defines graph style configuration properties. Properties from GraphFieldConfig. + * Temporary config - PoC. + */ +export const defaultGraphStyleConfig = { + fieldConfig: { + defaultsProps: ['color'], + defaults: [ + // Line config + 'lineColor', + 'lineInterpolation', + 'lineStyle', + 'lineWidth', + 'spanNulls', + // Fill config + 'fillBelowTo', + 'fillColor', + 'fillOpacity', + // Points config + 'pointColor', + 'pointSize', + 'pointSymbol', + 'showPoints', + // Axis config + 'axisBorderShow', + 'axisCenteredZero', + 'axisColorMode', + 'axisGridShow', + 'axisLabel', + 'axisPlacement', + 'axisSoftMax', + 'axisSoftMin', + 'axisWidth', + // Graph field config + 'drawStyle', + 'gradientMode', + 'insertNulls', + 'showValues', + // Stacking + 'stacking', + // Bar config + 'barAlignment', + 'barWidthFactor', + 'barMaxWidth', + ], + }, +} as const; + export type NullEditorSettings = { isTime: boolean }; export function getGraphFieldConfig(cfg: GraphFieldConfig, isTime = true): SetFieldConfigOptionsArgs { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index fc37d801997..d44f97fa30e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11290,6 +11290,7 @@ }, "header-menu": { "copy": "Copy", + "copy-styles": "Copy styles", "create-library-panel": "Create library panel", "duplicate": "Duplicate", "edit": "Edit", @@ -11301,11 +11302,13 @@ "inspect-json": "Panel JSON", "more": "More...", "new-alert-rule": "New alert rule", + "paste-styles": "Paste styles", "query": "Query", "remove": "Remove", "replace-library-panel": "Replace library panel", "share": "Share", "show-legend": "Show legend", + "styles": "Styles", "time-settings": "Time settings", "unlink-library-panel": "Unlink library panel", "view": "View"