DashboardScene: Discard panel changes disabled/enabled depending of changes (#87137)

---------

Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Oscar Kilhed 2024-05-09 17:48:20 +02:00 committed by GitHub
parent 588abbb177
commit c3936bbae2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 561 additions and 37 deletions

View File

@ -1,6 +1,8 @@
import { map, of } from 'rxjs';
import { DataQueryRequest, DataSourceApi, DataSourceInstanceSettings, LoadingState, PanelData } from '@grafana/data';
import { calculateFieldTransformer } from '@grafana/data/src/transformations/transformers/calculateField';
import { mockTransformationsRegistry } from '@grafana/data/src/utils/tests/mockTransformationsRegistry';
import { config, locationService } from '@grafana/runtime';
import { SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema';
@ -178,12 +180,17 @@ jest.mock('@grafana/runtime', () => ({
},
}));
mockTransformationsRegistry([calculateFieldTransformer]);
jest.useFakeTimers();
describe('VizPanelManager', () => {
describe('When changing plugin', () => {
it('Should successfully change from one viz type to another', () => {
const { vizPanelManager } = setupTest('panel-1');
expect(vizPanelManager.state.panel.state.pluginId).toBe('timeseries');
vizPanelManager.changePluginType('table');
expect(vizPanelManager.state.panel.state.pluginId).toBe('table');
});
@ -405,6 +412,8 @@ describe('VizPanelManager', () => {
datasourceUid: 'gdev-prometheus',
});
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect(vizPanelManager.state.datasource).toEqual(ds2Mock);
expect(vizPanelManager.state.dsSettings).toEqual(instance2SettingsMock);
});
@ -472,6 +481,8 @@ describe('VizPanelManager', () => {
},
});
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('2h');
});
@ -515,7 +526,7 @@ describe('VizPanelManager', () => {
});
describe('max data points and interval', () => {
it('max data points', async () => {
it('should update max data points', async () => {
const { vizPanelManager } = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
@ -534,10 +545,12 @@ describe('VizPanelManager', () => {
maxDataPoints: 100,
});
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect(dataObj.state.maxDataPoints).toBe(100);
});
it('max data points', async () => {
it('should update min interval', async () => {
const { vizPanelManager } = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
@ -556,6 +569,8 @@ describe('VizPanelManager', () => {
minInterval: '1s',
});
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect(dataObj.state.minInterval).toBe('1s');
});
});
@ -579,6 +594,8 @@ describe('VizPanelManager', () => {
queries: [],
});
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect(dataObj.state.cacheTimeout).toBe('60');
expect(dataObj.state.queryCachingTTL).toBe(200000);
});
@ -616,6 +633,8 @@ describe('VizPanelManager', () => {
},
} as DataSourceInstanceSettings);
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect(vizPanelManager.queryRunner.state.datasource).toEqual({
uid: 'gdev-prometheus',
type: 'grafana-prometheus-datasource',
@ -643,6 +662,8 @@ describe('VizPanelManager', () => {
},
} as DataSourceInstanceSettings);
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect(vizPanelManager.queryRunner.state.datasource).toEqual({
uid: SHARED_DASHBOARD_QUERY,
type: 'datasource',
@ -670,6 +691,8 @@ describe('VizPanelManager', () => {
},
} as DataSourceInstanceSettings);
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect(vizPanelManager.queryRunner.state.datasource).toEqual({
uid: 'gdev-prometheus',
type: 'grafana-prometheus-datasource',
@ -691,6 +714,8 @@ describe('VizPanelManager', () => {
vizPanelManager.dataTransformer.reprocessTransformations = reprocessMock;
vizPanelManager.changeTransformations([{ id: 'calculateField', options: {} }]);
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect(reprocessMock).toHaveBeenCalledTimes(1);
expect(vizPanelManager.dataTransformer.state.transformations).toEqual([{ id: 'calculateField', options: {} }]);
});
@ -716,6 +741,8 @@ describe('VizPanelManager', () => {
},
]);
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect(vizPanelManager.queryRunner.state.queries).toEqual([
{
datasource: {
@ -763,6 +790,8 @@ describe('VizPanelManager', () => {
},
]);
jest.runAllTimers(); // The detect panel changes is debounced
expect(vizPanelManager.state.isDirty).toBe(true);
expect(vizPanelManager.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id);
});
});

View File

@ -1,4 +1,5 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import React, { useEffect } from 'react';
import {
@ -20,6 +21,7 @@ import {
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
SceneObjectStateChangedEvent,
SceneQueryRunner,
VizPanel,
sceneUtils,
@ -34,10 +36,12 @@ import { updateQueries } from 'app/features/query/state/updateQueries';
import { GrafanaQuery } from 'app/plugins/datasource/grafana/types';
import { QueryGroupOptions } from 'app/types';
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
import { getPanelChanges } from '../saving/getDashboardChanges';
import { DashboardGridItem, RepeatDirection } from '../scene/DashboardGridItem';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange';
import { gridItemToPanel } from '../serialization/transformSceneToSaveModel';
import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
export interface VizPanelManagerState extends SceneObjectState {
@ -49,6 +53,7 @@ export interface VizPanelManagerState extends SceneObjectState {
repeat?: string;
repeatDirection?: RepeatDirection;
maxPerRow?: number;
isDirty?: boolean;
}
export enum DisplayMode {
@ -95,8 +100,27 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
private _onActivate() {
this.loadDataSource();
const changesSub = this.subscribeToEvent(SceneObjectStateChangedEvent, this._handleStateChange);
return () => {
changesSub.unsubscribe();
};
}
private _detectPanelModelChanges = debounce(() => {
const { hasChanges } = getPanelChanges(
vizPanelToPanel(this.state.sourcePanel.resolve()),
vizPanelToPanel(this.state.panel)
);
this.setState({ isDirty: hasChanges });
}, 250);
private _handleStateChange = (event: SceneObjectStateChangedEvent) => {
if (DashboardSceneChangeTracker.isUpdatingPersistedState(event)) {
this._detectPanelModelChanges();
}
};
private async loadDataSource() {
const dataObj = this.state.panel.state.$data;

View File

@ -36,10 +36,10 @@ export class DashboardSceneChangeTracker {
this._dashboard = dashboard;
}
private onStateChanged({ payload }: SceneObjectStateChangedEvent) {
static isUpdatingPersistedState({ payload }: SceneObjectStateChangedEvent) {
// If there are no changes in the state, the check is not needed
if (Object.keys(payload.partialUpdate).length === 0) {
return;
return false;
}
// Any change in the panel should trigger a change detection
@ -50,7 +50,7 @@ export class DashboardSceneChangeTracker {
payload.changedObject instanceof DashboardGridItem ||
payload.changedObject instanceof PanelTimeRange
) {
return this.detectSaveModelChanges();
return true;
}
// VizPanelManager includes the repeat configuration
if (payload.changedObject instanceof VizPanelManager) {
@ -59,27 +59,27 @@ export class DashboardSceneChangeTracker {
Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'repeatDirection') ||
Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'maxPerRow')
) {
return this.detectSaveModelChanges();
return true;
}
}
// SceneQueryRunner includes the DS configuration
if (payload.changedObject instanceof SceneQueryRunner) {
if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) {
return this.detectSaveModelChanges();
return true;
}
}
// SceneDataTransformer includes the transformation configuration
if (payload.changedObject instanceof SceneDataTransformer) {
if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) {
return this.detectSaveModelChanges();
return true;
}
}
if (payload.changedObject instanceof VizPanelLinks) {
return this.detectSaveModelChanges();
return true;
}
if (payload.changedObject instanceof LibraryVizPanel) {
if (Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'name')) {
return this.detectSaveModelChanges();
return true;
}
}
if (payload.changedObject instanceof SceneRefreshPicker) {
@ -87,47 +87,54 @@ export class DashboardSceneChangeTracker {
Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'intervals') ||
Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'refresh')
) {
return this.detectSaveModelChanges();
return true;
}
}
if (payload.changedObject instanceof behaviors.CursorSync) {
return this.detectSaveModelChanges();
return true;
}
if (payload.changedObject instanceof SceneDataLayerSet) {
return this.detectSaveModelChanges();
return true;
}
if (payload.changedObject instanceof DashboardGridItem) {
return this.detectSaveModelChanges();
return true;
}
if (payload.changedObject instanceof SceneGridLayout) {
return this.detectSaveModelChanges();
return true;
}
if (payload.changedObject instanceof DashboardScene) {
if (Object.keys(payload.partialUpdate).some((key) => PERSISTED_PROPS.includes(key))) {
return this.detectSaveModelChanges();
return true;
}
}
if (payload.changedObject instanceof SceneTimeRange) {
return this.detectSaveModelChanges();
return true;
}
if (payload.changedObject instanceof DashboardControls) {
if (Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'hideTimeControls')) {
return this.detectSaveModelChanges();
return true;
}
}
if (payload.changedObject instanceof SceneVariableSet) {
return this.detectSaveModelChanges();
return true;
}
if (payload.changedObject instanceof DashboardAnnotationsDataLayer) {
if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) {
return this.detectSaveModelChanges();
return true;
}
}
if (payload.changedObject instanceof behaviors.LiveNowTimer) {
return this.detectSaveModelChanges();
return true;
}
if (isSceneVariableInstance(payload.changedObject)) {
return this.detectSaveModelChanges();
return true;
}
return false;
}
private onStateChanged(event: SceneObjectStateChangedEvent) {
if (DashboardSceneChangeTracker.isUpdatingPersistedState(event)) {
this.detectSaveModelChanges();
}
}

View File

@ -0,0 +1,423 @@
import { Dashboard, Panel } from '@grafana/schema';
import { getDashboardChanges, getPanelChanges } from './getDashboardChanges';
describe('getDashboardChanges', () => {
const initial: Dashboard = {
id: 1,
title: 'Dashboard 1',
time: {
from: 'now-7d',
to: 'now',
},
refresh: '1h',
version: 1,
schemaVersion: 1,
templating: {
list: [
{
name: 'var1',
type: 'query',
query: 'query1',
current: {
value: 'value1',
text: 'text1',
},
options: [],
},
],
},
};
it('should return the correct result when no changes', () => {
const changed = { ...initial };
const expectedChanges = {
initialSaveModel: {
...initial,
},
changedSaveModel: {
...changed,
},
diffs: {},
diffCount: 0,
hasChanges: false,
hasTimeChanges: false,
isNew: false,
hasVariableValueChanges: false,
hasRefreshChange: false,
};
const result = getDashboardChanges(initial, changed, false, false, false);
expect(result).toEqual(expectedChanges);
});
it('should return the correct result when is new', () => {
const newDashInitial = {
...initial,
version: 0,
};
const changed = {
...newDashInitial,
version: 0,
};
const expectedChanges = {
changedSaveModel: {
...newDashInitial,
},
initialSaveModel: {
...changed,
},
diffs: {},
diffCount: 0,
hasChanges: false,
hasTimeChanges: false,
isNew: true,
hasVariableValueChanges: false,
hasRefreshChange: false,
};
const result = getDashboardChanges(newDashInitial, changed, false, false, false);
expect(result).toEqual(expectedChanges);
});
it('should return the correct result when the time changes but they are not preserved', () => {
const changed = {
...initial,
time: {
from: 'now-1d',
to: 'now',
},
};
const expectedChanges = {
initialSaveModel: {
...initial,
},
changedSaveModel: {
...initial,
},
diffs: {},
diffCount: 0,
hasChanges: false,
hasTimeChanges: true,
isNew: false,
hasVariableValueChanges: false,
hasRefreshChange: false,
};
const result = getDashboardChanges(initial, changed, false, false, false);
expect(result).toEqual(expectedChanges);
});
it('should return the correct result when the time changes and they are preserved', () => {
const changed = {
...initial,
time: {
from: 'now-1d',
to: 'now',
},
};
const expectedChanges = {
initialSaveModel: {
...initial,
},
changedSaveModel: {
...changed,
},
diffs: {
time: [
{
endLineNumber: expect.any(Number),
op: 'replace',
originalValue: 'now-7d',
path: ['time', 'from'],
startLineNumber: expect.any(Number),
value: 'now-1d',
},
],
},
diffCount: 1,
hasChanges: true,
hasTimeChanges: true,
isNew: false,
hasVariableValueChanges: false,
hasRefreshChange: false,
};
const result = getDashboardChanges(initial, changed, true, false, false);
expect(result).toEqual(expectedChanges);
});
it('should return the correct result when the refresh changes but it is not preserved', () => {
const changed = {
...initial,
refresh: '2h',
};
const expectedChanges = {
initialSaveModel: {
...initial,
},
changedSaveModel: {
...initial,
},
diffs: {},
diffCount: 0,
hasChanges: false,
hasTimeChanges: false,
isNew: false,
hasVariableValueChanges: false,
hasRefreshChange: true,
};
const result = getDashboardChanges(initial, changed, false, false, false);
expect(result).toEqual(expectedChanges);
});
it('should return the correct result when the refresh changes and it is preserved', () => {
const changed = {
...initial,
refresh: '2h',
};
const expectedChanges = {
initialSaveModel: {
...initial,
},
changedSaveModel: {
...changed,
},
diffs: {
refresh: [
{
endLineNumber: expect.any(Number),
op: 'replace',
originalValue: '1h',
path: ['refresh'],
startLineNumber: expect.any(Number),
value: '2h',
},
],
},
diffCount: 1,
hasChanges: true,
hasTimeChanges: false,
isNew: false,
hasVariableValueChanges: false,
hasRefreshChange: true,
};
const result = getDashboardChanges(initial, changed, false, false, true);
expect(result).toEqual(expectedChanges);
});
it('should return the correct result when the variable value changes but it is not preserved', () => {
const changed = {
...initial,
templating: {
list: [
{
name: 'var1',
type: 'query',
query: 'query1',
current: {
value: 'value2',
text: 'text1',
},
options: [],
},
],
},
} as Dashboard;
const expectedChanges = {
initialSaveModel: {
...initial,
},
changedSaveModel: {
...initial,
},
diffs: {},
diffCount: 0,
hasChanges: false,
hasTimeChanges: false,
isNew: false,
hasVariableValueChanges: true,
hasRefreshChange: false,
};
const result = getDashboardChanges(initial, changed, false, false, false);
expect(result).toEqual(expectedChanges);
});
it('should return the correct result when the variable value changes', () => {
const changed = {
...initial,
templating: {
list: [
{
name: 'var1',
type: 'query',
query: 'query1',
current: {
value: 'value2',
text: 'text1',
},
options: [],
},
],
},
} as Dashboard;
const expectedChanges = {
initialSaveModel: {
...initial,
},
changedSaveModel: {
...changed,
},
diffs: {
templating: [
{
endLineNumber: 17,
op: 'replace',
originalValue: 'value1',
path: ['templating', 'list', '0', 'current', 'value'],
startLineNumber: 17,
value: 'value2',
},
],
},
diffCount: 1,
hasChanges: true,
hasTimeChanges: false,
isNew: false,
hasVariableValueChanges: true,
hasRefreshChange: false,
};
const result = getDashboardChanges(initial, changed, false, true, false);
expect(result).toEqual(expectedChanges);
});
});
describe('getPanelChanges', () => {
const initial: Panel = {
id: 1,
type: 'graph',
title: 'Panel 1',
gridPos: {
x: 0,
y: 0,
w: 12,
h: 8,
},
targets: [
{
refId: 'A',
query: 'query1',
},
],
};
it('should return the correct result when no changes', () => {
const changed = { ...initial };
const expectedChanges = {
initialSaveModel: {
...initial,
},
changedSaveModel: {
...changed,
},
diffs: {},
diffCount: 0,
hasChanges: false,
};
expect(getPanelChanges(initial, changed)).toEqual(expectedChanges);
});
it('should return the correct result when there is some changes', () => {
const changed = {
...initial,
title: 'Panel 2',
type: 'table',
gridPos: {
...initial.gridPos,
x: 1,
},
targets: [
{
refId: 'A',
query: 'query2',
},
],
} as Panel;
const expectedChanges = {
initialSaveModel: {
...initial,
},
changedSaveModel: {
...changed,
},
diffs: {
title: [
{
endLineNumber: 3,
op: 'replace',
originalValue: 'Panel 1',
path: ['title'],
startLineNumber: 3,
value: 'Panel 2',
},
],
type: [
{
endLineNumber: 2,
op: 'replace',
originalValue: 'graph',
path: ['type'],
startLineNumber: 2,
value: 'table',
},
],
gridPos: [
{
endLineNumber: 5,
op: 'replace',
originalValue: 0,
path: ['gridPos', 'x'],
startLineNumber: 5,
value: 1,
},
],
targets: [
{
endLineNumber: 13,
op: 'replace',
originalValue: 'query1',
path: ['targets', '0', 'query'],
startLineNumber: 13,
value: 'query2',
},
],
},
diffCount: 4,
hasChanges: true,
};
expect(getPanelChanges(changed, initial)).toEqual(expectedChanges);
});
});

View File

@ -2,7 +2,7 @@
import jsonMap from 'json-source-map';
import type { AdHocVariableModel, TypedVariableModel } from '@grafana/data';
import { Dashboard, VariableOption } from '@grafana/schema';
import { Dashboard, Panel, VariableOption } from '@grafana/schema';
import { jsonDiff } from '../settings/version-history/utils';
@ -105,3 +105,16 @@ export function applyVariableChanges(saveModel: Dashboard, originalSaveModel: Da
return hasVariableValueChanges;
}
export function getPanelChanges(saveModel: Panel, originalSaveModel: Panel) {
const diff = jsonDiff(originalSaveModel, saveModel);
const diffCount = Object.values(diff).reduce((acc, cur) => acc + cur.length, 0);
return {
changedSaveModel: saveModel,
initialSaveModel: originalSaveModel,
diffs: diff,
diffCount,
hasChanges: diffCount > 0,
};
}

View File

@ -11,7 +11,7 @@ import {
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { SceneDataTransformer, SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
@ -104,12 +104,12 @@ describe('DashboardDatasourceBehaviour', () => {
it('Should re-run query of dashboardDS panel when source query re-runs', async () => {
// spy on runQueries that will be called by the behaviour
const spy = jest.spyOn(dashboardDSPanel.state.$data as SceneQueryRunner, 'runQueries');
const spy = jest.spyOn(dashboardDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries');
// deactivate scene to mimic going into panel edit
sceneDeactivate();
// run source panel queries and update request ID
(sourcePanel.state.$data as SceneQueryRunner).runQueries();
(sourcePanel.state.$data!.state.$data as SceneQueryRunner).runQueries();
await new Promise((r) => setTimeout(r, 1));
@ -121,7 +121,7 @@ describe('DashboardDatasourceBehaviour', () => {
it('Should not run query of dashboardDS panel when source panel queries do not change', async () => {
// spy on runQueries
const spy = jest.spyOn(dashboardDSPanel.state.$data as SceneQueryRunner, 'runQueries');
const spy = jest.spyOn(dashboardDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries');
// deactivate scene to mimic going into panel edit
sceneDeactivate();
@ -273,10 +273,10 @@ describe('DashboardDatasourceBehaviour', () => {
it('Should exit behaviour early if not in a dashboard scene', async () => {
// spy on runQueries
const spy = jest.spyOn(dashboardDSPanel.state.$data as SceneQueryRunner, 'runQueries');
const spy = jest.spyOn(dashboardDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries');
const vizPanelManager = new VizPanelManager({
panel: dashboardDSPanel.clone({ $data: undefined }),
panel: dashboardDSPanel.clone(),
$data: dashboardDSPanel.state.$data?.clone(),
sourcePanel: dashboardDSPanel.getRef(),
});
@ -576,9 +576,12 @@ async function buildTestScene() {
title: 'Panel A',
pluginId: 'table',
key: 'panel-1',
$data: new SceneQueryRunner({
datasource: { uid: 'grafana' },
queries: [{ refId: 'A', queryType: 'randomWalk' }],
$data: new SceneDataTransformer({
transformations: [],
$data: new SceneQueryRunner({
datasource: { uid: 'grafana' },
queries: [{ refId: 'A', queryType: 'randomWalk' }],
}),
}),
});
@ -586,10 +589,13 @@ async function buildTestScene() {
title: 'Panel B',
pluginId: 'table',
key: 'panel-2',
$data: new SceneQueryRunner({
datasource: { uid: SHARED_DASHBOARD_QUERY },
queries: [{ refId: 'A', panelId: 1 }],
$behaviors: [new DashboardDatasourceBehaviour({})],
$data: new SceneDataTransformer({
transformations: [],
$data: new SceneQueryRunner({
datasource: { uid: SHARED_DASHBOARD_QUERY },
queries: [{ refId: 'A', panelId: 1 }],
$behaviors: [new DashboardDatasourceBehaviour({})],
}),
}),
});

View File

@ -66,6 +66,7 @@ export function ToolbarActions({ dashboard }: Props) {
const styles = useStyles2(getStyles);
const isEditingPanel = Boolean(editPanel);
const isViewingPanel = Boolean(viewPanelScene);
const isEditedPanelDirty = useVizManagerDirty(editPanel);
const isEditingLibraryPanel = useEditingLibraryPanel(editPanel);
const hasCopiedPanel = Boolean(copiedPanel);
// Means we are not in settings view, fullscreen panel or edit panel
@ -420,6 +421,7 @@ export function ToolbarActions({ dashboard }: Props) {
onClick={editPanel?.onDiscard}
tooltip="Discard panel changes"
size="sm"
disabled={!isEditedPanelDirty || !isDirty}
key="discard"
fill="outline"
variant="destructive"
@ -629,6 +631,26 @@ function useEditingLibraryPanel(panelEditor?: PanelEditor) {
return isEditingLibraryPanel;
}
// This hook handles when panelEditor is not defined to avoid conditionally hook usage
function useVizManagerDirty(panelEditor?: PanelEditor) {
const [isDirty, setIsDirty] = useState<Boolean>(false);
useEffect(() => {
if (panelEditor) {
const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) =>
setIsDirty(vizManagerState.isDirty || false)
);
return () => {
unsub.unsubscribe();
};
}
setIsDirty(false);
return;
}, [panelEditor]);
return isDirty;
}
interface ToolbarAction {
group: string;
condition?: boolean | string;