mirror of
https://github.com/grafana/grafana.git
synced 2026-02-03 20:49:50 -05:00
PoC: Copy/Paste panel styles (#116786)
This commit is contained in:
parent
5cef2a02f7
commit
cf2481b1c5
13 changed files with 545 additions and 4 deletions
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
|
|
@ -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
|
||||
|
|
|
|||
|
14
pkg/services/featuremgmt/toggles_gen.json
generated
14
pkg/services/featuremgmt/toggles_gen.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<FieldConfig> };
|
||||
};
|
||||
|
||||
type CopiedPanelStyles = {
|
||||
panelType: string;
|
||||
styles: PanelStyles;
|
||||
};
|
||||
|
||||
export interface DashboardSceneState extends SceneObjectState {
|
||||
/** The title */
|
||||
title: string;
|
||||
|
|
@ -651,6 +662,145 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> 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<string, unknown> = {};
|
||||
const panelCustom: Record<string, unknown> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<GraphFieldConfig> {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue