DashboardScene: Share change detection logic between saving and runtime (#81958)

Co-authored-by: Alexandra Vargas <alexa1866@gmail.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
pull/83548/head
Ivan Ortega Alba 1 year ago committed by GitHub
parent b02ae375ba
commit 29d6cd8fa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      .betterer.results
  2. 117
      public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts
  3. 10
      public/app/features/dashboard-scene/saving/DetectChangesWorker.ts
  4. 4
      public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx
  5. 19
      public/app/features/dashboard-scene/saving/__mocks__/createDetectChangesWorker.ts
  6. 1
      public/app/features/dashboard-scene/saving/createDetectChangesWorker.ts
  7. 145
      public/app/features/dashboard-scene/saving/getDashboardChanges.ts
  8. 17
      public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts
  9. 14
      public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts
  10. 163
      public/app/features/dashboard-scene/scene/DashboardScene.test.tsx
  11. 75
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  12. 2
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts
  13. 15
      public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx
  14. 15
      public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx
  15. 3
      public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx
  16. 15
      public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx
  17. 11
      public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx
  18. 15
      public/app/features/dashboard-scene/settings/PermissionsEditView.test.tsx
  19. 2
      public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx
  20. 15
      public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx
  21. 25
      public/app/features/dashboard-scene/settings/variables/utils.test.ts
  22. 23
      public/app/features/dashboard-scene/settings/variables/utils.ts
  23. 27
      public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts
  24. 23
      public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts
  25. 10
      public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx
  26. 2
      public/app/features/panel/panellinks/linkSuppliers.ts
  27. 5
      public/test/setupTests.ts

@ -2529,10 +2529,18 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx:5381": [ "public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
"public/app/features/dashboard-scene/saving/DetectChangesWorker.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx:5381": [ "public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"] [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"]
], ],
"public/app/features/dashboard-scene/saving/getDashboardChanges.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/dashboard-scene/saving/getSaveDashboardChange.ts:5381": [ "public/app/features/dashboard-scene/saving/getSaveDashboardChange.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"] [0, 0, 0, "Do not use any type assertions.", "1"]

@ -0,0 +1,117 @@
import { Unsubscribable } from 'rxjs';
import {
SceneDataLayers,
SceneGridItem,
SceneGridLayout,
SceneObjectStateChangedEvent,
SceneRefreshPicker,
SceneTimeRange,
SceneVariableSet,
behaviors,
} from '@grafana/scenes';
import { createWorker } from 'app/features/dashboard-scene/saving/createDetectChangesWorker';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardScene, PERSISTED_PROPS } from '../scene/DashboardScene';
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { isSceneVariableInstance } from '../settings/variables/utils';
import { DashboardChangeInfo } from './shared';
export class DashboardSceneChangeTracker {
private _changeTrackerSub: Unsubscribable | undefined;
private _changesWorker: Worker;
private _dashboard: DashboardScene;
constructor(dashboard: DashboardScene) {
this._dashboard = dashboard;
this._changesWorker = createWorker();
}
private onStateChanged({ payload }: SceneObjectStateChangedEvent) {
if (payload.changedObject instanceof SceneRefreshPicker) {
if (Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'intervals')) {
this.detectChanges();
}
}
if (payload.changedObject instanceof behaviors.CursorSync) {
this.detectChanges();
}
if (payload.changedObject instanceof SceneDataLayers) {
this.detectChanges();
}
if (payload.changedObject instanceof SceneGridItem) {
this.detectChanges();
}
if (payload.changedObject instanceof SceneGridLayout) {
this.detectChanges();
}
if (payload.changedObject instanceof DashboardScene) {
if (Object.keys(payload.partialUpdate).some((key) => PERSISTED_PROPS.includes(key))) {
this.detectChanges();
}
}
if (payload.changedObject instanceof SceneTimeRange) {
this.detectChanges();
}
if (payload.changedObject instanceof DashboardControls) {
if (Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'hideTimeControls')) {
this.detectChanges();
}
}
if (payload.changedObject instanceof SceneVariableSet) {
this.detectChanges();
}
if (payload.changedObject instanceof DashboardAnnotationsDataLayer) {
if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) {
this.detectChanges();
}
}
if (isSceneVariableInstance(payload.changedObject)) {
this.detectChanges();
}
}
private detectChanges() {
this._changesWorker?.postMessage({
changed: transformSceneToSaveModel(this._dashboard),
initial: this._dashboard.getInitialSaveModel(),
});
}
private updateIsDirty(result: DashboardChangeInfo) {
const { hasChanges } = result;
if (hasChanges) {
if (!this._dashboard.state.isDirty) {
this._dashboard.setState({ isDirty: true });
}
} else {
if (this._dashboard.state.isDirty) {
this._dashboard.setState({ isDirty: false });
}
}
}
public startTrackingChanges() {
this._changesWorker.onmessage = (e: MessageEvent<DashboardChangeInfo>) => {
this.updateIsDirty(e.data);
};
this._changeTrackerSub = this._dashboard.subscribeToEvent(
SceneObjectStateChangedEvent,
this.onStateChanged.bind(this)
);
}
public stopTrackingChanges() {
this._changeTrackerSub?.unsubscribe();
}
public terminate() {
this.stopTrackingChanges();
this._changesWorker.terminate();
}
}

@ -0,0 +1,10 @@
// Worker is not three shakable, so we should not import the whole loadash library
// eslint-disable-next-line lodash/import-scope
import debounce from 'lodash/debounce';
import { getDashboardChanges } from './getDashboardChanges';
self.onmessage = debounce((e: MessageEvent<{ initial: any; changed: any }>) => {
const result = getDashboardChanges(e.data.initial, e.data.changed, false, false);
self.postMessage(result);
}, 500);

@ -9,7 +9,7 @@ import { DashboardScene } from '../scene/DashboardScene';
import { SaveDashboardAsForm } from './SaveDashboardAsForm'; import { SaveDashboardAsForm } from './SaveDashboardAsForm';
import { SaveDashboardForm } from './SaveDashboardForm'; import { SaveDashboardForm } from './SaveDashboardForm';
import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm'; import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
import { getSaveDashboardChange } from './getSaveDashboardChange'; import { getDashboardChangesFromScene } from './getDashboardChangesFromScene';
interface SaveDashboardDrawerState extends SceneObjectState { interface SaveDashboardDrawerState extends SceneObjectState {
dashboardRef: SceneObjectRef<DashboardScene>; dashboardRef: SceneObjectRef<DashboardScene>;
@ -34,7 +34,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => { static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => {
const { showDiff, saveAsCopy, saveTimeRange, saveVariables } = model.useState(); const { showDiff, saveAsCopy, saveTimeRange, saveVariables } = model.useState();
const changeInfo = getSaveDashboardChange(model.state.dashboardRef.resolve(), saveTimeRange, saveVariables); const changeInfo = getDashboardChangesFromScene(model.state.dashboardRef.resolve(), saveTimeRange, saveVariables);
const { changedSaveModel, initialSaveModel, diffs, diffCount } = changeInfo; const { changedSaveModel, initialSaveModel, diffs, diffCount } = changeInfo;
const dashboard = model.state.dashboardRef.resolve(); const dashboard = model.state.dashboardRef.resolve();
const isProvisioned = dashboard.state.meta.provisioned; const isProvisioned = dashboard.state.meta.provisioned;

@ -0,0 +1,19 @@
const worker = {
postMessage: jest.fn(),
onmessage: jest.fn(),
terminate: jest.fn(),
};
jest.mocked(worker.postMessage).mockImplementation(() => {
worker.onmessage?.({
data: {
hasChanges: true,
hasTimeChanges: true,
hasVariableValueChanges: true,
},
} as unknown as MessageEvent);
});
const createWorker = () => worker;
export { createWorker };

@ -0,0 +1 @@
export const createWorker = () => new Worker(new URL('./DetectChangesWorker.ts', import.meta.url));

@ -0,0 +1,145 @@
import { compare, Operation } from 'fast-json-patch';
// @ts-ignore
import jsonMap from 'json-source-map';
import { flow, get, isEqual, sortBy, tail } from 'lodash';
import { AdHocVariableModel, TypedVariableModel } from '@grafana/data';
import { Dashboard } from '@grafana/schema';
export function getDashboardChanges(
initial: Dashboard,
changed: Dashboard,
saveTimeRange?: boolean,
saveVariables?: boolean
) {
const initialSaveModel = initial;
const changedSaveModel = changed;
const hasTimeChanged = getHasTimeChanged(changedSaveModel, initialSaveModel);
const hasVariableValueChanges = applyVariableChanges(changedSaveModel, initialSaveModel, saveVariables);
if (!saveTimeRange) {
changedSaveModel.time = initialSaveModel.time;
}
const diff = jsonDiff(initialSaveModel, changedSaveModel);
let diffCount = 0;
for (const d of Object.values(diff)) {
diffCount += d.length;
}
return {
changedSaveModel,
initialSaveModel,
diffs: diff,
diffCount,
hasChanges: diffCount > 0,
hasTimeChanges: hasTimeChanged,
isNew: changedSaveModel.version === 0,
hasVariableValueChanges,
};
}
export function getHasTimeChanged(saveModel: Dashboard, originalSaveModel: Dashboard) {
return saveModel.time?.from !== originalSaveModel.time?.from || saveModel.time?.to !== originalSaveModel.time?.to;
}
export function applyVariableChanges(saveModel: Dashboard, originalSaveModel: Dashboard, saveVariables?: boolean) {
const originalVariables = originalSaveModel.templating?.list ?? [];
const variablesToSave = saveModel.templating?.list ?? [];
let hasVariableValueChanges = false;
for (const variable of variablesToSave) {
const original = originalVariables.find(({ name, type }) => name === variable.name && type === variable.type);
if (!original) {
continue;
}
// Old schema property that never should be in persisted model
if (original.current && Object.hasOwn(original.current, 'selected')) {
delete original.current.selected;
}
if (!isEqual(variable.current, original.current)) {
hasVariableValueChanges = true;
}
if (!saveVariables) {
const typed = variable as TypedVariableModel;
if (typed.type === 'adhoc') {
typed.filters = (original as AdHocVariableModel).filters;
} else {
variable.current = original.current;
variable.options = original.options;
}
}
}
return hasVariableValueChanges;
}
export type Diff = {
op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move';
value: unknown;
originalValue: unknown;
path: string[];
startLineNumber: number;
};
export type Diffs = {
[key: string]: Diff[];
};
export type JSONValue = string | Dashboard;
export const jsonDiff = (lhs: JSONValue, rhs: JSONValue): Diffs => {
const diffs = compare(lhs, rhs);
const lhsMap = jsonMap.stringify(lhs, null, 2);
const rhsMap = jsonMap.stringify(rhs, null, 2);
const getDiffInformation = (diffs: Operation[]): Diff[] => {
return diffs.map((diff) => {
let originalValue = undefined;
let value = undefined;
let startLineNumber = 0;
const path = tail(diff.path.split('/'));
if (diff.op === 'replace' && rhsMap.pointers[diff.path]) {
originalValue = get(lhs, path);
value = diff.value;
startLineNumber = rhsMap.pointers[diff.path].value.line;
}
if (diff.op === 'add' && rhsMap.pointers[diff.path]) {
value = diff.value;
startLineNumber = rhsMap.pointers[diff.path].value.line;
}
if (diff.op === 'remove' && lhsMap.pointers[diff.path]) {
originalValue = get(lhs, path);
startLineNumber = lhsMap.pointers[diff.path].value.line;
}
return {
op: diff.op,
value,
path,
originalValue,
startLineNumber,
};
});
};
const sortByLineNumber = (diffs: Diff[]) => sortBy(diffs, 'startLineNumber');
const groupByPath = (diffs: Diff[]) =>
diffs.reduce<Record<string, Diff[]>>((acc, value) => {
const groupKey: string = value.path[0];
if (!acc[groupKey]) {
acc[groupKey] = [];
}
acc[groupKey].push(value);
return acc;
}, {});
// return 1;
return flow([getDiffInformation, sortByLineNumber, groupByPath])(diffs);
};

@ -5,12 +5,12 @@ import { transformSaveModelToScene } from '../serialization/transformSaveModelTo
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { findVizPanelByKey } from '../utils/utils'; import { findVizPanelByKey } from '../utils/utils';
import { getSaveDashboardChange } from './getSaveDashboardChange'; import { getDashboardChangesFromScene } from './getDashboardChangesFromScene';
describe('getSaveDashboardChange', () => { describe('getDashboardChangesFromScene', () => {
it('Can detect no changes', () => { it('Can detect no changes', () => {
const dashboard = setup(); const dashboard = setup();
const result = getSaveDashboardChange(dashboard, false); const result = getDashboardChangesFromScene(dashboard, false);
expect(result.hasChanges).toBe(false); expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0); expect(result.diffCount).toBe(0);
}); });
@ -20,7 +20,7 @@ describe('getSaveDashboardChange', () => {
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' }); sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' });
const result = getSaveDashboardChange(dashboard, false); const result = getDashboardChangesFromScene(dashboard, false);
expect(result.hasChanges).toBe(false); expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0); expect(result.diffCount).toBe(0);
expect(result.hasTimeChanges).toBe(true); expect(result.hasTimeChanges).toBe(true);
@ -31,7 +31,7 @@ describe('getSaveDashboardChange', () => {
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' }); sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' });
const result = getSaveDashboardChange(dashboard, true); const result = getDashboardChangesFromScene(dashboard, true);
expect(result.hasChanges).toBe(true); expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1); expect(result.diffCount).toBe(1);
}); });
@ -42,7 +42,7 @@ describe('getSaveDashboardChange', () => {
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable; const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
appVar.changeValueTo('app2'); appVar.changeValueTo('app2');
const result = getSaveDashboardChange(dashboard, false, false); const result = getDashboardChangesFromScene(dashboard, false, false);
expect(result.hasVariableValueChanges).toBe(true); expect(result.hasVariableValueChanges).toBe(true);
expect(result.hasChanges).toBe(false); expect(result.hasChanges).toBe(false);
@ -55,7 +55,7 @@ describe('getSaveDashboardChange', () => {
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable; const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
appVar.changeValueTo('app2'); appVar.changeValueTo('app2');
const result = getSaveDashboardChange(dashboard, false, true); const result = getDashboardChangesFromScene(dashboard, false, true);
expect(result.hasVariableValueChanges).toBe(true); expect(result.hasVariableValueChanges).toBe(true);
expect(result.hasChanges).toBe(true); expect(result.hasChanges).toBe(true);
@ -72,8 +72,9 @@ describe('getSaveDashboardChange', () => {
dashboard.setState({ editPanel: editScene }); dashboard.setState({ editPanel: editScene });
editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); editScene.state.vizManager.state.panel.setState({ title: 'changed title' });
editScene.commitChanges();
const result = getSaveDashboardChange(dashboard, false, true); const result = getDashboardChangesFromScene(dashboard, false, true);
const panelSaveModel = result.changedSaveModel.panels![0]; const panelSaveModel = result.changedSaveModel.panels![0];
expect(panelSaveModel.title).toBe('changed title'); expect(panelSaveModel.title).toBe('changed title');
}); });

@ -0,0 +1,14 @@
import { DashboardScene } from '../scene/DashboardScene';
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { getDashboardChanges } from './getDashboardChanges';
export function getDashboardChangesFromScene(scene: DashboardScene, saveTimeRange?: boolean, saveVariables?: boolean) {
const changeInfo = getDashboardChanges(
scene.getInitialSaveModel()!,
transformSceneToSaveModel(scene),
saveTimeRange,
saveVariables
);
return changeInfo;
}

@ -9,12 +9,14 @@ import {
TestVariable, TestVariable,
VizPanel, VizPanel,
SceneGridRow, SceneGridRow,
behaviors,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { Dashboard } from '@grafana/schema'; import { Dashboard, DashboardCursorSync } from '@grafana/schema';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { VariablesChanged } from 'app/features/variables/types'; import { VariablesChanged } from 'app/features/variables/types';
import { createWorker } from '../saving/createDetectChangesWorker';
import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DecoratedRevisionModel } from '../settings/VersionsEditView';
import { historySrv } from '../settings/version-history/HistorySrv'; import { historySrv } from '../settings/version-history/HistorySrv';
@ -26,6 +28,20 @@ import { DashboardScene, DashboardSceneState } from './DashboardScene';
jest.mock('../settings/version-history/HistorySrv'); jest.mock('../settings/version-history/HistorySrv');
jest.mock('../serialization/transformSaveModelToScene'); jest.mock('../serialization/transformSaveModelToScene');
jest.mock('../saving/getDashboardChangesFromScene', () => ({
// It compares the initial and changed save models and returns the differences
// By default we assume there are differences to have the dirty state test logic tested
getDashboardChangesFromScene: jest.fn(() => ({
changedSaveModel: {},
initialSaveModel: {},
diffs: [],
diffCount: 0,
hasChanges: true,
hasTimeChanges: false,
isNew: false,
hasVariableValueChanges: false,
})),
}));
jest.mock('../serialization/transformSceneToSaveModel'); jest.mock('../serialization/transformSceneToSaveModel');
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
@ -36,6 +52,9 @@ jest.mock('@grafana/runtime', () => ({
}, },
})); }));
const worker = createWorker();
mockResultsOfDetectChangesWorker({ hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: false });
describe('DashboardScene', () => { describe('DashboardScene', () => {
describe('DashboardSrv.getCurrent compatibility', () => { describe('DashboardSrv.getCurrent compatibility', () => {
it('Should set to compatibility wrapper', () => { it('Should set to compatibility wrapper', () => {
@ -49,16 +68,29 @@ describe('DashboardScene', () => {
describe('Editing and discarding', () => { describe('Editing and discarding', () => {
describe('Given scene in edit mode', () => { describe('Given scene in edit mode', () => {
let scene: DashboardScene; let scene: DashboardScene;
let deactivateScene: () => void;
beforeEach(() => { beforeEach(() => {
scene = buildTestScene(); scene = buildTestScene();
deactivateScene = scene.activate();
scene.onEnterEditMode(); scene.onEnterEditMode();
jest.clearAllMocks();
}); });
it('Should set isEditing to true', () => { it('Should set isEditing to true', () => {
expect(scene.state.isEditing).toBe(true); expect(scene.state.isEditing).toBe(true);
}); });
it('Should start the detect changes worker', () => {
expect(worker.onmessage).toBeDefined();
});
it('Should terminate the detect changes worker when deactivate', () => {
expect(worker.terminate).toHaveBeenCalledTimes(0);
deactivateScene();
expect(worker.terminate).toHaveBeenCalledTimes(1);
});
it('A change to griditem pos should set isDirty true', () => { it('A change to griditem pos should set isDirty true', () => {
const gridItem = sceneGraph.findObject(scene, (p) => p.state.key === 'griditem-1') as SceneGridItem; const gridItem = sceneGraph.findObject(scene, (p) => p.state.key === 'griditem-1') as SceneGridItem;
gridItem.setState({ x: 10, y: 0, width: 10, height: 10 }); gridItem.setState({ x: 10, y: 0, width: 10, height: 10 });
@ -70,6 +102,22 @@ describe('DashboardScene', () => {
expect(gridItem2.state.x).toBe(0); expect(gridItem2.state.x).toBe(0);
}); });
it('A change to gridlayout children order should set isDirty true', () => {
const layout = sceneGraph.findObject(scene, (p) => p instanceof SceneGridLayout) as SceneGridLayout;
const originalPanelOrder = layout.state.children.map((c) => c.state.key);
// Change the order of the children. This happen when panels move around, then the children are re-ordered
layout.setState({
children: [layout.state.children[1], layout.state.children[0], layout.state.children[2]],
});
expect(scene.state.isDirty).toBe(true);
scene.exitEditMode({ skipConfirm: true });
const resoredLayout = sceneGraph.findObject(scene, (p) => p instanceof SceneGridLayout) as SceneGridLayout;
expect(resoredLayout.state.children.map((c) => c.state.key)).toEqual(originalPanelOrder);
});
it.each` it.each`
prop | value prop | value
${'title'} | ${'new title'} ${'title'} | ${'new title'}
@ -77,6 +125,7 @@ describe('DashboardScene', () => {
${'tags'} | ${['tag3', 'tag4']} ${'tags'} | ${['tag3', 'tag4']}
${'editable'} | ${false} ${'editable'} | ${false}
${'links'} | ${[]} ${'links'} | ${[]}
${'meta'} | ${{ folderUid: 'new-folder-uid', folderTitle: 'new-folder-title', hasUnsavedFolderChange: true }}
`( `(
'A change to $prop should set isDirty true', 'A change to $prop should set isDirty true',
({ prop, value }: { prop: keyof DashboardSceneState; value: unknown }) => { ({ prop, value }: { prop: keyof DashboardSceneState; value: unknown }) => {
@ -123,6 +172,40 @@ describe('DashboardScene', () => {
expect(sceneGraph.getTimeRange(scene)!.state.timeZone).toBe(prevState); expect(sceneGraph.getTimeRange(scene)!.state.timeZone).toBe(prevState);
}); });
it('A change to a cursor sync config should set isDirty true', () => {
const cursorSync = dashboardSceneGraph.getCursorSync(scene)!;
const initialState = cursorSync.state;
cursorSync.setState({
sync: DashboardCursorSync.Tooltip,
});
expect(scene.state.isDirty).toBe(true);
scene.exitEditMode({ skipConfirm: true });
expect(dashboardSceneGraph.getCursorSync(scene)!.state).toEqual(initialState);
});
it.each([
{ hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: false },
{ hasChanges: true, hasTimeChanges: true, hasVariableValueChanges: false },
{ hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: true },
])('should set the state to true if there are changes detected in the saving model', (diffResults) => {
mockResultsOfDetectChangesWorker(diffResults);
scene.setState({ title: 'hello' });
expect(scene.state.isDirty).toBeTruthy();
});
it.each([
{ hasChanges: false, hasTimeChanges: false, hasVariableValueChanges: false },
{ hasChanges: false, hasTimeChanges: true, hasVariableValueChanges: false },
{ hasChanges: false, hasTimeChanges: false, hasVariableValueChanges: true },
])('should not set the state to true if there are no change detected in the dashboard', (diffResults) => {
mockResultsOfDetectChangesWorker(diffResults);
scene.setState({ title: 'hello' });
expect(scene.state.isDirty).toBeFalsy();
});
it('Should throw an error when adding a panel to a layout that is not SceneGridLayout', () => { it('Should throw an error when adding a panel to a layout that is not SceneGridLayout', () => {
const scene = buildTestScene({ body: undefined }); const scene = buildTestScene({ body: undefined });
@ -304,7 +387,11 @@ describe('DashboardScene', () => {
}); });
describe('When variables change', () => { describe('When variables change', () => {
it('A change to griditem pos should set isDirty true', () => { beforeEach(() => {
jest.clearAllMocks();
});
it('A change to variable values should trigger VariablesChanged event', () => {
const varA = new TestVariable({ name: 'A', query: 'A.*', value: 'A.AA', text: '', options: [], delayMs: 0 }); const varA = new TestVariable({ name: 'A', query: 'A.*', value: 'A.AA', text: '', options: [], delayMs: 0 });
const scene = buildTestScene({ const scene = buildTestScene({
$variables: new SceneVariableSet({ variables: [varA] }), $variables: new SceneVariableSet({ variables: [varA] }),
@ -319,6 +406,57 @@ describe('DashboardScene', () => {
expect(eventHandler).toHaveBeenCalledTimes(1); expect(eventHandler).toHaveBeenCalledTimes(1);
}); });
it('A change to the variable set should set isDirty true', () => {
const varA = new TestVariable({ name: 'A', query: 'A.*', value: 'A.AA', text: '', options: [], delayMs: 0 });
const scene = buildTestScene({
$variables: new SceneVariableSet({ variables: [varA] }),
});
scene.activate();
scene.onEnterEditMode();
const variableSet = sceneGraph.getVariables(scene);
variableSet.setState({ variables: [] });
expect(scene.state.isDirty).toBe(true);
});
it('A change to a variable state should set isDirty true', () => {
mockResultsOfDetectChangesWorker({ hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: true });
const variable = new TestVariable({ name: 'A' });
const scene = buildTestScene({
$variables: new SceneVariableSet({ variables: [variable] }),
});
scene.activate();
scene.onEnterEditMode();
variable.setState({ name: 'new-name' });
expect(variable.state.name).toBe('new-name');
expect(scene.state.isDirty).toBe(true);
});
it('A change to variable name is restored to original name should set isDirty back to false', () => {
const variable = new TestVariable({ name: 'A' });
const scene = buildTestScene({
$variables: new SceneVariableSet({ variables: [variable] }),
});
scene.activate();
scene.onEnterEditMode();
mockResultsOfDetectChangesWorker({ hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: false });
variable.setState({ name: 'B' });
expect(scene.state.isDirty).toBe(true);
mockResultsOfDetectChangesWorker(
// No changes, it is the same name than before comparing saving models
{ hasChanges: false, hasTimeChanges: false, hasVariableValueChanges: false }
);
variable.setState({ name: 'A' });
expect(scene.state.isDirty).toBe(false);
});
}); });
describe('When a dashboard is restored', () => { describe('When a dashboard is restored', () => {
@ -379,6 +517,7 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
timeZone: 'browser', timeZone: 'browser',
}), }),
controls: new DashboardControls({}), controls: new DashboardControls({}),
$behaviors: [new behaviors.CursorSync({})],
body: new SceneGridLayout({ body: new SceneGridLayout({
children: [ children: [
new SceneGridItem({ new SceneGridItem({
@ -427,6 +566,26 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
return scene; return scene;
} }
function mockResultsOfDetectChangesWorker({
hasChanges,
hasTimeChanges,
hasVariableValueChanges,
}: {
hasChanges: boolean;
hasTimeChanges: boolean;
hasVariableValueChanges: boolean;
}) {
jest.mocked(worker.postMessage).mockImplementationOnce(() => {
worker.onmessage?.({
data: {
hasChanges: hasChanges ?? true,
hasTimeChanges: hasTimeChanges ?? true,
hasVariableValueChanges: hasVariableValueChanges ?? true,
},
} as unknown as MessageEvent);
});
}
function getVersionMock(): DecoratedRevisionModel { function getVersionMock(): DecoratedRevisionModel {
const dash: Dashboard = { const dash: Dashboard = {
title: 'new name', title: 'new name',

@ -1,12 +1,9 @@
import * as H from 'history'; import * as H from 'history';
import { Unsubscribable } from 'rxjs';
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data'; import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { import {
dataLayers,
getUrlSyncManager, getUrlSyncManager,
SceneDataLayers,
SceneFlexLayout, SceneFlexLayout,
sceneGraph, sceneGraph,
SceneGridItem, SceneGridItem,
@ -15,9 +12,6 @@ import {
SceneObject, SceneObject,
SceneObjectBase, SceneObjectBase,
SceneObjectState, SceneObjectState,
SceneObjectStateChangedEvent,
SceneRefreshPicker,
SceneTimeRange,
sceneUtils, sceneUtils,
SceneVariable, SceneVariable,
SceneVariableDependencyConfigLike, SceneVariableDependencyConfigLike,
@ -36,6 +30,7 @@ import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types
import { ShowConfirmModalEvent } from 'app/types/events'; import { ShowConfirmModalEvent } from 'app/types/events';
import { PanelEditor } from '../panel-edit/PanelEditor'; import { PanelEditor } from '../panel-edit/PanelEditor';
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer'; import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer';
import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
@ -66,7 +61,7 @@ import { PanelRepeaterGridItem } from './PanelRepeaterGridItem';
import { ViewPanelScene } from './ViewPanelScene'; import { ViewPanelScene } from './ViewPanelScene';
import { setupKeyboardShortcuts } from './keyboardShortcuts'; import { setupKeyboardShortcuts } from './keyboardShortcuts';
export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links']; export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links', 'meta'];
export interface DashboardSceneState extends SceneObjectState { export interface DashboardSceneState extends SceneObjectState {
/** The title */ /** The title */
@ -138,9 +133,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
*/ */
private _initialUrlState?: H.Location; private _initialUrlState?: H.Location;
/** /**
* change tracking subscription * Dashboard changes tracker
*/ */
private _changeTrackerSub?: Unsubscribable; private _changeTracker: DashboardSceneChangeTracker;
public constructor(state: Partial<DashboardSceneState>) { public constructor(state: Partial<DashboardSceneState>) {
super({ super({
@ -153,6 +148,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
...state, ...state,
}); });
this._changeTracker = new DashboardSceneChangeTracker(this);
this.addActivationHandler(() => this._activationHandler()); this.addActivationHandler(() => this._activationHandler());
} }
@ -162,7 +159,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
window.__grafanaSceneContext = this; window.__grafanaSceneContext = this;
if (this.state.isEditing) { if (this.state.isEditing) {
this.startTrackingChanges(); this._changeTracker.startTrackingChanges();
} }
if (!this.state.meta.isEmbedded && this.state.uid) { if (!this.state.meta.isEmbedded && this.state.uid) {
@ -179,7 +176,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
return () => { return () => {
window.__grafanaSceneContext = prevSceneContext; window.__grafanaSceneContext = prevSceneContext;
clearKeyBindings(); clearKeyBindings();
this.stopTrackingChanges(); this._changeTracker.terminate();
this.stopUrlSync(); this.stopUrlSync();
oldDashboardWrapper.destroy(); oldDashboardWrapper.destroy();
dashboardWatcher.leave(); dashboardWatcher.leave();
@ -206,7 +203,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
// Propagate change edit mode change to children // Propagate change edit mode change to children
this.propagateEditModeChange(); this.propagateEditModeChange();
this.startTrackingChanges();
this._changeTracker.startTrackingChanges();
}; };
public saveCompleted(saveModel: Dashboard, result: SaveDashboardResponseDTO, folderUid?: string) { public saveCompleted(saveModel: Dashboard, result: SaveDashboardResponseDTO, folderUid?: string) {
@ -217,7 +215,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
version: result.version, version: result.version,
}; };
this.stopTrackingChanges(); this._changeTracker.stopTrackingChanges();
this.setState({ this.setState({
version: result.version, version: result.version,
isDirty: false, isDirty: false,
@ -231,7 +229,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
folderUid: folderUid, folderUid: folderUid,
}, },
}); });
this.startTrackingChanges(); this._changeTracker.startTrackingChanges();
} }
private propagateEditModeChange() { private propagateEditModeChange() {
@ -265,7 +263,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
private exitEditModeConfirmed() { private exitEditModeConfirmed() {
// No need to listen to changes anymore // No need to listen to changes anymore
this.stopTrackingChanges(); this._changeTracker.stopTrackingChanges();
// Stop url sync before updating url // Stop url sync before updating url
this.stopUrlSync(); this.stopUrlSync();
@ -380,53 +378,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
return this.state.viewPanelScene ?? this.state.body; return this.state.viewPanelScene ?? this.state.body;
} }
private startTrackingChanges() {
this._changeTrackerSub = this.subscribeToEvent(
SceneObjectStateChangedEvent,
(event: SceneObjectStateChangedEvent) => {
if (event.payload.changedObject instanceof SceneRefreshPicker) {
if (Object.prototype.hasOwnProperty.call(event.payload.partialUpdate, 'intervals')) {
this.setIsDirty();
}
}
if (event.payload.changedObject instanceof SceneDataLayers) {
this.setIsDirty();
}
if (event.payload.changedObject instanceof dataLayers.AnnotationsDataLayer) {
if (!Object.prototype.hasOwnProperty.call(event.payload.partialUpdate, 'data')) {
this.setIsDirty();
}
}
if (event.payload.changedObject instanceof SceneGridItem) {
this.setIsDirty();
}
if (event.payload.changedObject instanceof DashboardScene) {
if (Object.keys(event.payload.partialUpdate).some((key) => PERSISTED_PROPS.includes(key))) {
this.setIsDirty();
}
}
if (event.payload.changedObject instanceof SceneTimeRange) {
this.setIsDirty();
}
if (event.payload.changedObject instanceof DashboardControls) {
if (Object.prototype.hasOwnProperty.call(event.payload.partialUpdate, 'hideTimeControls')) {
this.setIsDirty();
}
}
}
);
}
private setIsDirty() {
if (!this.state.isDirty) {
this.setState({ isDirty: true });
}
}
private stopTrackingChanges() {
this._changeTrackerSub?.unsubscribe();
}
public getInitialState(): DashboardSceneState | undefined { public getInitialState(): DashboardSceneState | undefined {
return this._initialState; return this._initialState;
} }

@ -244,7 +244,7 @@ export function vizPanelToPanel(
} }
const panelLinks = dashboardSceneGraph.getPanelLinks(vizPanel); const panelLinks = dashboardSceneGraph.getPanelLinks(vizPanel);
panel.links = (panelLinks.state.rawLinks as DashboardLink[]) ?? []; panel.links = (panelLinks?.state.rawLinks as DashboardLink[]) ?? [];
if (panel.links.length === 0) { if (panel.links.length === 0) {
delete panel.links; delete panel.links;

@ -1,7 +1,9 @@
import { map, of } from 'rxjs'; import { map, of } from 'rxjs';
import { AnnotationQuery, DataQueryRequest, DataSourceApi, LoadingState, PanelData } from '@grafana/data'; import { AnnotationQuery, DataQueryRequest, DataSourceApi, LoadingState, PanelData } from '@grafana/data';
import { SceneDataLayers, SceneGridItem, SceneGridLayout, SceneTimeRange, dataLayers } from '@grafana/scenes'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneDataLayers, SceneGridItem, SceneGridLayout, SceneTimeRange, VizPanel, dataLayers } from '@grafana/scenes';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
@ -44,6 +46,11 @@ jest.mock('@grafana/runtime', () => ({
}, },
})); }));
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
describe('AnnotationsEditView', () => { describe('AnnotationsEditView', () => {
describe('Dashboard annotations state', () => { describe('Dashboard annotations state', () => {
let annotationsView: AnnotationsEditView; let annotationsView: AnnotationsEditView;
@ -188,7 +195,11 @@ async function buildTestScene() {
y: 0, y: 0,
width: 10, width: 10,
height: 12, height: 12,
body: undefined, body: new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
}),
}), }),
], ],
}), }),

@ -2,7 +2,9 @@ import { render as RTLRender } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider'; import { TestProvider } from 'test/helpers/TestProvider';
import { behaviors, SceneGridLayout, SceneGridItem, SceneTimeRange } from '@grafana/scenes'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneGridItem, SceneGridLayout, SceneTimeRange, VizPanel, behaviors } from '@grafana/scenes';
import { DashboardCursorSync } from '@grafana/schema'; import { DashboardCursorSync } from '@grafana/schema';
import { DashboardControls } from '../scene/DashboardControls'; import { DashboardControls } from '../scene/DashboardControls';
@ -23,6 +25,11 @@ jest.mock('react-router-dom', () => ({
}), }),
})); }));
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
function render(component: React.ReactNode) { function render(component: React.ReactNode) {
return RTLRender(<TestProvider>{component}</TestProvider>); return RTLRender(<TestProvider>{component}</TestProvider>);
} }
@ -231,7 +238,11 @@ async function buildTestScene() {
y: 0, y: 0,
width: 10, width: 10,
height: 12, height: 12,
body: undefined, body: new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
}),
}), }),
], ],
}), }),

@ -79,7 +79,7 @@ export class DashboardLinksEditView extends SceneObjectBase<DashboardLinksEditVi
function DashboardLinksEditViewRenderer({ model }: SceneComponentProps<DashboardLinksEditView>) { function DashboardLinksEditViewRenderer({ model }: SceneComponentProps<DashboardLinksEditView>) {
const { editIndex } = model.useState(); const { editIndex } = model.useState();
const dashboard = getDashboardSceneFor(model); const dashboard = getDashboardSceneFor(model);
const { links, overlay } = dashboard.useState(); const { links } = dashboard.useState();
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
const linkToEdit = editIndex !== undefined ? links[editIndex] : undefined; const linkToEdit = editIndex !== undefined ? links[editIndex] : undefined;
@ -107,7 +107,6 @@ function DashboardLinksEditViewRenderer({ model }: SceneComponentProps<Dashboard
onDuplicate={model.onDuplicate} onDuplicate={model.onDuplicate}
onOrderChange={model.onOrderChange} onOrderChange={model.onOrderChange}
/> />
{overlay && <overlay.Component model={overlay} />}
</Page> </Page>
); );
} }

@ -1,4 +1,6 @@
import { behaviors, SceneGridLayout, SceneGridItem, SceneTimeRange } from '@grafana/scenes'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import { behaviors, SceneGridLayout, SceneGridItem, SceneTimeRange, VizPanel } from '@grafana/scenes';
import { DashboardCursorSync } from '@grafana/schema'; import { DashboardCursorSync } from '@grafana/schema';
import { DashboardControls } from '../scene/DashboardControls'; import { DashboardControls } from '../scene/DashboardControls';
@ -7,6 +9,11 @@ import { activateFullSceneTree } from '../utils/test-utils';
import { GeneralSettingsEditView } from './GeneralSettingsEditView'; import { GeneralSettingsEditView } from './GeneralSettingsEditView';
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
describe('GeneralSettingsEditView', () => { describe('GeneralSettingsEditView', () => {
describe('Dashboard state', () => { describe('Dashboard state', () => {
let dashboard: DashboardScene; let dashboard: DashboardScene;
@ -129,7 +136,11 @@ async function buildTestScene() {
y: 0, y: 0,
width: 10, width: 10,
height: 12, height: 12,
body: undefined, body: new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
}),
}), }),
], ],
}), }),

@ -1,7 +1,7 @@
import React, { ChangeEvent } from 'react'; import React, { ChangeEvent } from 'react';
import { PageLayoutType } from '@grafana/data'; import { PageLayoutType } from '@grafana/data';
import { behaviors, SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes'; import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes';
import { TimeZone } from '@grafana/schema'; import { TimeZone } from '@grafana/schema';
import { import {
Box, Box,
@ -22,6 +22,7 @@ import { DeleteDashboardButton } from 'app/features/dashboard/components/DeleteD
import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions'; import { NavToolbarActions } from '../scene/NavToolbarActions';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../utils/utils'; import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
@ -64,13 +65,7 @@ export class GeneralSettingsEditView
} }
public getCursorSync() { public getCursorSync() {
const cursorSync = this._dashboard.state.$behaviors?.find((b) => b instanceof behaviors.CursorSync); return dashboardSceneGraph.getCursorSync(this._dashboard);
if (cursorSync instanceof behaviors.CursorSync) {
return cursorSync;
}
return;
} }
public getDashboardControls() { public getDashboardControls() {

@ -1,10 +1,17 @@
import { SceneGridItem, SceneGridLayout, SceneTimeRange } from '@grafana/scenes'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneGridItem, SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
import { activateFullSceneTree } from '../utils/test-utils'; import { activateFullSceneTree } from '../utils/test-utils';
import { PermissionsEditView } from './PermissionsEditView'; import { PermissionsEditView } from './PermissionsEditView';
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
describe('PermissionsEditView', () => { describe('PermissionsEditView', () => {
describe('Dashboard permissions state', () => { describe('Dashboard permissions state', () => {
let dashboard: DashboardScene; let dashboard: DashboardScene;
@ -44,7 +51,11 @@ async function buildTestScene() {
y: 0, y: 0,
width: 10, width: 10,
height: 12, height: 12,
body: undefined, body: new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
}),
}), }),
], ],
}), }),

@ -18,6 +18,7 @@ import {
VizPanel, VizPanel,
AdHocFiltersVariable, AdHocFiltersVariable,
SceneVariableState, SceneVariableState,
SceneTimeRange,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor';
@ -308,6 +309,7 @@ async function buildTestScene() {
meta: { meta: {
canEdit: true, canEdit: true,
}, },
$timeRange: new SceneTimeRange({}),
$variables: new SceneVariableSet({ $variables: new SceneVariableSet({
variables: [ variables: [
new CustomVariable({ new CustomVariable({

@ -1,4 +1,6 @@
import { SceneGridItem, SceneGridLayout, SceneTimeRange } from '@grafana/scenes'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneGridItem, SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
import { activateFullSceneTree } from '../utils/test-utils'; import { activateFullSceneTree } from '../utils/test-utils';
@ -8,6 +10,11 @@ import { historySrv } from './version-history';
jest.mock('./version-history/HistorySrv'); jest.mock('./version-history/HistorySrv');
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
describe('VersionsEditView', () => { describe('VersionsEditView', () => {
describe('Dashboard versions state', () => { describe('Dashboard versions state', () => {
let dashboard: DashboardScene; let dashboard: DashboardScene;
@ -170,7 +177,11 @@ async function buildTestScene() {
y: 0, y: 0,
width: 10, width: 10,
height: 12, height: 12,
body: undefined, body: new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
}),
}), }),
], ],
}), }),

@ -36,6 +36,7 @@ import {
getOptionDataSourceTypes, getOptionDataSourceTypes,
getNextAvailableId, getNextAvailableId,
getVariableDefault, getVariableDefault,
isSceneVariableInstance,
} from './utils'; } from './utils';
const templateSrv = { const templateSrv = {
@ -98,6 +99,30 @@ describe('isEditableVariableType', () => {
}); });
}); });
describe('isSceneVariableInstance', () => {
it.each([
CustomVariable,
QueryVariable,
ConstantVariable,
IntervalVariable,
DataSourceVariable,
AdHocFiltersVariable,
GroupByVariable,
TextBoxVariable,
])('should return true for scene variable instances %s', (instanceType) => {
const variable = new instanceType({ name: 'MyVariable' });
expect(isSceneVariableInstance(variable)).toBe(true);
});
it('should return false for non-scene variable instances', () => {
const variable = {
name: 'MyVariable',
type: 'query',
};
expect(variable).not.toBeInstanceOf(QueryVariable);
});
});
describe('getVariableTypeSelectOptions', () => { describe('getVariableTypeSelectOptions', () => {
describe('when groupByVariable is enabled', () => { describe('when groupByVariable is enabled', () => {
beforeAll(() => { beforeAll(() => {

@ -12,6 +12,8 @@ import {
GroupByVariable, GroupByVariable,
SceneVariable, SceneVariable,
MultiValueVariable, MultiValueVariable,
sceneUtils,
SceneObject,
AdHocFiltersVariable, AdHocFiltersVariable,
SceneVariableState, SceneVariableState,
} from '@grafana/scenes'; } from '@grafana/scenes';
@ -196,5 +198,26 @@ export function getOptionDataSourceTypes() {
return optionTypes; return optionTypes;
} }
function isSceneVariable(sceneObject: SceneObject): sceneObject is SceneVariable {
return 'type' in sceneObject.state && 'getValue' in sceneObject;
}
export function isSceneVariableInstance(sceneObject: SceneObject): sceneObject is SceneVariable {
if (!isSceneVariable(sceneObject)) {
return false;
}
return (
sceneUtils.isAdHocVariable(sceneObject) ||
sceneUtils.isConstantVariable(sceneObject) ||
sceneUtils.isCustomVariable(sceneObject) ||
sceneUtils.isDataSourceVariable(sceneObject) ||
sceneUtils.isIntervalVariable(sceneObject) ||
sceneUtils.isQueryVariable(sceneObject) ||
sceneUtils.isTextBoxVariable(sceneObject) ||
sceneUtils.isGroupByVariable(sceneObject)
);
}
export const RESERVED_GLOBAL_VARIABLE_NAME_REGEX = /^(?!__).*$/; export const RESERVED_GLOBAL_VARIABLE_NAME_REGEX = /^(?!__).*$/;
export const WORD_CHARACTERS_REGEX = /^\w+$/; export const WORD_CHARACTERS_REGEX = /^\w+$/;

@ -6,7 +6,9 @@ import {
SceneQueryRunner, SceneQueryRunner,
SceneTimeRange, SceneTimeRange,
VizPanel, VizPanel,
behaviors,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { DashboardCursorSync } from '@grafana/schema';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
@ -20,10 +22,10 @@ import { findVizPanelByKey } from './utils';
describe('dashboardSceneGraph', () => { describe('dashboardSceneGraph', () => {
describe('getPanelLinks', () => { describe('getPanelLinks', () => {
it('should throw if no links object defined', () => { it('should return null if no links object defined', () => {
const scene = buildTestScene(); const scene = buildTestScene();
const panelWithNoLinks = findVizPanelByKey(scene, 'panel-1')!; const panelWithNoLinks = findVizPanelByKey(scene, 'panel-1')!;
expect(() => dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toThrow(); expect(dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toBeNull();
}); });
it('should resolve VizPanelLinks object', () => { it('should resolve VizPanelLinks object', () => {
@ -199,6 +201,22 @@ describe('dashboardSceneGraph', () => {
expect(() => getNextPanelId(scene)).toThrow('Dashboard body is not a SceneGridLayout'); expect(() => getNextPanelId(scene)).toThrow('Dashboard body is not a SceneGridLayout');
}); });
}); });
describe('getCursorSync', () => {
it('should return cursor sync behavior', () => {
const scene = buildTestScene();
const cursorSync = dashboardSceneGraph.getCursorSync(scene);
expect(cursorSync).toBeInstanceOf(behaviors.CursorSync);
});
it('should return undefined if no cursor sync behavior', () => {
const scene = buildTestScene({ $behaviors: [] });
const cursorSync = dashboardSceneGraph.getCursorSync(scene);
expect(cursorSync).toBeUndefined();
});
});
}); });
function buildTestScene(overrides?: Partial<DashboardSceneState>) { function buildTestScene(overrides?: Partial<DashboardSceneState>) {
@ -207,6 +225,11 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
uid: 'dash-1', uid: 'dash-1',
$timeRange: new SceneTimeRange({}), $timeRange: new SceneTimeRange({}),
controls: new DashboardControls({}), controls: new DashboardControls({}),
$behaviors: [
new behaviors.CursorSync({
sync: DashboardCursorSync.Crosshair,
}),
],
$data: new SceneDataLayers({ $data: new SceneDataLayers({
layers: [ layers: [
new DashboardAnnotationsDataLayer({ new DashboardAnnotationsDataLayer({

@ -1,4 +1,12 @@
import { VizPanel, SceneGridItem, SceneGridRow, SceneDataLayers, sceneGraph, SceneGridLayout } from '@grafana/scenes'; import {
VizPanel,
SceneGridItem,
SceneGridRow,
SceneDataLayers,
sceneGraph,
SceneGridLayout,
behaviors,
} from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { LibraryVizPanel } from '../scene/LibraryVizPanel';
@ -23,7 +31,7 @@ function getPanelLinks(panel: VizPanel) {
return panel.state.titleItems[0]; return panel.state.titleItems[0];
} }
throw new Error('VizPanelLinks links not found'); return null;
} }
function getVizPanels(scene: DashboardScene): VizPanel[] { function getVizPanels(scene: DashboardScene): VizPanel[] {
@ -58,6 +66,16 @@ function getDataLayers(scene: DashboardScene): SceneDataLayers {
return data; return data;
} }
export function getCursorSync(scene: DashboardScene) {
const cursorSync = scene.state.$behaviors?.find((b) => b instanceof behaviors.CursorSync);
if (cursorSync instanceof behaviors.CursorSync) {
return cursorSync;
}
return;
}
export function getNextPanelId(dashboard: DashboardScene): number { export function getNextPanelId(dashboard: DashboardScene): number {
let max = 0; let max = 0;
const body = dashboard.state.body; const body = dashboard.state.body;
@ -119,4 +137,5 @@ export const dashboardSceneGraph = {
getVizPanels, getVizPanels,
getDataLayers, getDataLayers,
getNextPanelId, getNextPanelId,
getCursorSync,
}; };

@ -185,7 +185,7 @@ export function getPanelFrameCategory2(panelManager: VizPanelManager): OptionsPa
}); });
const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel); const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel);
const links = panelLinksObject.state.rawLinks; const links = panelLinksObject?.state.rawLinks ?? [];
return descriptor return descriptor
.addItem( .addItem(
@ -251,7 +251,7 @@ export function getPanelFrameCategory2(panelManager: VizPanelManager): OptionsPa
}).addItem( }).addItem(
new OptionsPaneItemDescriptor({ new OptionsPaneItemDescriptor({
title: 'Panel links', title: 'Panel links',
render: () => <ScenePanelLinksEditor panelLinks={panelLinksObject} />, render: () => <ScenePanelLinksEditor panelLinks={panelLinksObject ?? undefined} />,
}) })
) )
) )
@ -323,16 +323,16 @@ export function getPanelFrameCategory2(panelManager: VizPanelManager): OptionsPa
} }
interface ScenePanelLinksEditorProps { interface ScenePanelLinksEditorProps {
panelLinks: VizPanelLinks; panelLinks?: VizPanelLinks;
} }
function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) { function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) {
const { rawLinks: links } = panelLinks.useState(); const { rawLinks: links } = panelLinks ? panelLinks.useState() : { rawLinks: [] };
return ( return (
<DataLinksInlineEditor <DataLinksInlineEditor
links={links} links={links}
onChange={(links) => panelLinks.setState({ rawLinks: links })} onChange={(links) => panelLinks?.setState({ rawLinks: links })}
getSuggestions={getPanelLinksVariableSuggestions} getSuggestions={getPanelLinksVariableSuggestions}
data={[]} data={[]}
/> />

@ -164,7 +164,7 @@ export const getScenePanelLinksSupplier = (
panel: VizPanel, panel: VizPanel,
replaceVariables: InterpolateFunction replaceVariables: InterpolateFunction
): LinkModelSupplier<VizPanel> | undefined => { ): LinkModelSupplier<VizPanel> | undefined => {
const links = dashboardSceneGraph.getPanelLinks(panel).state.rawLinks; const links = dashboardSceneGraph.getPanelLinks(panel)?.state.rawLinks;
if (!links || links.length === 0) { if (!links || links.length === 0) {
return undefined; return undefined;

@ -22,6 +22,11 @@ i18next.use(initReactI18next).init({
lng: 'en-US', // this should be the locale of the phrases in our source JSX lng: 'en-US', // this should be the locale of the phrases in our source JSX
}); });
// mock out the worker that detects changes in the dashboard
// The mock is needed because JSDOM does not support workers and
// the factory uses import.meta.url so we can't use it in CommonJS modules.
jest.mock('app/features/dashboard-scene/saving/createDetectChangesWorker.ts');
// our tests are heavy in CI due to parallelisation and monaco and kusto // our tests are heavy in CI due to parallelisation and monaco and kusto
// so we increase the default timeout to 2secs to avoid flakiness // so we increase the default timeout to 2secs to avoid flakiness
configure({ asyncUtilTimeout: 2000 }); configure({ asyncUtilTimeout: 2000 });

Loading…
Cancel
Save