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": [
[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": [
[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"]
],
"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": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[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 { SaveDashboardForm } from './SaveDashboardForm';
import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
import { getSaveDashboardChange } from './getSaveDashboardChange';
import { getDashboardChangesFromScene } from './getDashboardChangesFromScene';
interface SaveDashboardDrawerState extends SceneObjectState {
dashboardRef: SceneObjectRef<DashboardScene>;
@ -34,7 +34,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => {
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 dashboard = model.state.dashboardRef.resolve();
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 { findVizPanelByKey } from '../utils/utils';
import { getSaveDashboardChange } from './getSaveDashboardChange';
import { getDashboardChangesFromScene } from './getDashboardChangesFromScene';
describe('getSaveDashboardChange', () => {
describe('getDashboardChangesFromScene', () => {
it('Can detect no changes', () => {
const dashboard = setup();
const result = getSaveDashboardChange(dashboard, false);
const result = getDashboardChangesFromScene(dashboard, false);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
});
@ -20,7 +20,7 @@ describe('getSaveDashboardChange', () => {
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.diffCount).toBe(0);
expect(result.hasTimeChanges).toBe(true);
@ -31,7 +31,7 @@ describe('getSaveDashboardChange', () => {
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.diffCount).toBe(1);
});
@ -42,7 +42,7 @@ describe('getSaveDashboardChange', () => {
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
appVar.changeValueTo('app2');
const result = getSaveDashboardChange(dashboard, false, false);
const result = getDashboardChangesFromScene(dashboard, false, false);
expect(result.hasVariableValueChanges).toBe(true);
expect(result.hasChanges).toBe(false);
@ -55,7 +55,7 @@ describe('getSaveDashboardChange', () => {
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
appVar.changeValueTo('app2');
const result = getSaveDashboardChange(dashboard, false, true);
const result = getDashboardChangesFromScene(dashboard, false, true);
expect(result.hasVariableValueChanges).toBe(true);
expect(result.hasChanges).toBe(true);
@ -72,8 +72,9 @@ describe('getSaveDashboardChange', () => {
dashboard.setState({ editPanel: editScene });
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];
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,
VizPanel,
SceneGridRow,
behaviors,
} from '@grafana/scenes';
import { Dashboard } from '@grafana/schema';
import { Dashboard, DashboardCursorSync } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { VariablesChanged } from 'app/features/variables/types';
import { createWorker } from '../saving/createDetectChangesWorker';
import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
import { historySrv } from '../settings/version-history/HistorySrv';
@ -26,6 +28,20 @@ import { DashboardScene, DashboardSceneState } from './DashboardScene';
jest.mock('../settings/version-history/HistorySrv');
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('@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('DashboardSrv.getCurrent compatibility', () => {
it('Should set to compatibility wrapper', () => {
@ -49,16 +68,29 @@ describe('DashboardScene', () => {
describe('Editing and discarding', () => {
describe('Given scene in edit mode', () => {
let scene: DashboardScene;
let deactivateScene: () => void;
beforeEach(() => {
scene = buildTestScene();
deactivateScene = scene.activate();
scene.onEnterEditMode();
jest.clearAllMocks();
});
it('Should set isEditing to 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', () => {
const gridItem = sceneGraph.findObject(scene, (p) => p.state.key === 'griditem-1') as SceneGridItem;
gridItem.setState({ x: 10, y: 0, width: 10, height: 10 });
@ -70,6 +102,22 @@ describe('DashboardScene', () => {
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`
prop | value
${'title'} | ${'new title'}
@ -77,6 +125,7 @@ describe('DashboardScene', () => {
${'tags'} | ${['tag3', 'tag4']}
${'editable'} | ${false}
${'links'} | ${[]}
${'meta'} | ${{ folderUid: 'new-folder-uid', folderTitle: 'new-folder-title', hasUnsavedFolderChange: true }}
`(
'A change to $prop should set isDirty true',
({ prop, value }: { prop: keyof DashboardSceneState; value: unknown }) => {
@ -123,6 +172,40 @@ describe('DashboardScene', () => {
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', () => {
const scene = buildTestScene({ body: undefined });
@ -304,7 +387,11 @@ describe('DashboardScene', () => {
});
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 scene = buildTestScene({
$variables: new SceneVariableSet({ variables: [varA] }),
@ -319,6 +406,57 @@ describe('DashboardScene', () => {
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', () => {
@ -379,6 +517,7 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
timeZone: 'browser',
}),
controls: new DashboardControls({}),
$behaviors: [new behaviors.CursorSync({})],
body: new SceneGridLayout({
children: [
new SceneGridItem({
@ -427,6 +566,26 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
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 {
const dash: Dashboard = {
title: 'new name',

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

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

@ -1,7 +1,9 @@
import { map, of } from 'rxjs';
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 { 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('Dashboard annotations state', () => {
let annotationsView: AnnotationsEditView;
@ -188,7 +195,11 @@ async function buildTestScene() {
y: 0,
width: 10,
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 { 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 { 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) {
return RTLRender(<TestProvider>{component}</TestProvider>);
}
@ -231,7 +238,11 @@ async function buildTestScene() {
y: 0,
width: 10,
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>) {
const { editIndex } = model.useState();
const dashboard = getDashboardSceneFor(model);
const { links, overlay } = dashboard.useState();
const { links } = dashboard.useState();
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
const linkToEdit = editIndex !== undefined ? links[editIndex] : undefined;
@ -107,7 +107,6 @@ function DashboardLinksEditViewRenderer({ model }: SceneComponentProps<Dashboard
onDuplicate={model.onDuplicate}
onOrderChange={model.onOrderChange}
/>
{overlay && <overlay.Component model={overlay} />}
</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 { DashboardControls } from '../scene/DashboardControls';
@ -7,6 +9,11 @@ import { activateFullSceneTree } from '../utils/test-utils';
import { GeneralSettingsEditView } from './GeneralSettingsEditView';
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
describe('GeneralSettingsEditView', () => {
describe('Dashboard state', () => {
let dashboard: DashboardScene;
@ -129,7 +136,11 @@ async function buildTestScene() {
y: 0,
width: 10,
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 { 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 {
Box,
@ -22,6 +22,7 @@ import { DeleteDashboardButton } from 'app/features/dashboard/components/DeleteD
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
@ -64,13 +65,7 @@ export class GeneralSettingsEditView
}
public getCursorSync() {
const cursorSync = this._dashboard.state.$behaviors?.find((b) => b instanceof behaviors.CursorSync);
if (cursorSync instanceof behaviors.CursorSync) {
return cursorSync;
}
return;
return dashboardSceneGraph.getCursorSync(this._dashboard);
}
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 { activateFullSceneTree } from '../utils/test-utils';
import { PermissionsEditView } from './PermissionsEditView';
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
describe('PermissionsEditView', () => {
describe('Dashboard permissions state', () => {
let dashboard: DashboardScene;
@ -44,7 +51,11 @@ async function buildTestScene() {
y: 0,
width: 10,
height: 12,
body: undefined,
body: new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
}),
}),
],
}),

@ -18,6 +18,7 @@ import {
VizPanel,
AdHocFiltersVariable,
SceneVariableState,
SceneTimeRange,
} from '@grafana/scenes';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor';
@ -308,6 +309,7 @@ async function buildTestScene() {
meta: {
canEdit: true,
},
$timeRange: new SceneTimeRange({}),
$variables: new SceneVariableSet({
variables: [
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 { activateFullSceneTree } from '../utils/test-utils';
@ -8,6 +10,11 @@ import { historySrv } from './version-history';
jest.mock('./version-history/HistorySrv');
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
describe('VersionsEditView', () => {
describe('Dashboard versions state', () => {
let dashboard: DashboardScene;
@ -170,7 +177,11 @@ async function buildTestScene() {
y: 0,
width: 10,
height: 12,
body: undefined,
body: new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
}),
}),
],
}),

@ -36,6 +36,7 @@ import {
getOptionDataSourceTypes,
getNextAvailableId,
getVariableDefault,
isSceneVariableInstance,
} from './utils';
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('when groupByVariable is enabled', () => {
beforeAll(() => {

@ -12,6 +12,8 @@ import {
GroupByVariable,
SceneVariable,
MultiValueVariable,
sceneUtils,
SceneObject,
AdHocFiltersVariable,
SceneVariableState,
} from '@grafana/scenes';
@ -196,5 +198,26 @@ export function getOptionDataSourceTypes() {
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 WORD_CHARACTERS_REGEX = /^\w+$/;

@ -6,7 +6,9 @@ import {
SceneQueryRunner,
SceneTimeRange,
VizPanel,
behaviors,
} from '@grafana/scenes';
import { DashboardCursorSync } from '@grafana/schema';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
@ -20,10 +22,10 @@ import { findVizPanelByKey } from './utils';
describe('dashboardSceneGraph', () => {
describe('getPanelLinks', () => {
it('should throw if no links object defined', () => {
it('should return null if no links object defined', () => {
const scene = buildTestScene();
const panelWithNoLinks = findVizPanelByKey(scene, 'panel-1')!;
expect(() => dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toThrow();
expect(dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toBeNull();
});
it('should resolve VizPanelLinks object', () => {
@ -199,6 +201,22 @@ describe('dashboardSceneGraph', () => {
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>) {
@ -207,6 +225,11 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
uid: 'dash-1',
$timeRange: new SceneTimeRange({}),
controls: new DashboardControls({}),
$behaviors: [
new behaviors.CursorSync({
sync: DashboardCursorSync.Crosshair,
}),
],
$data: new SceneDataLayers({
layers: [
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 { LibraryVizPanel } from '../scene/LibraryVizPanel';
@ -23,7 +31,7 @@ function getPanelLinks(panel: VizPanel) {
return panel.state.titleItems[0];
}
throw new Error('VizPanelLinks links not found');
return null;
}
function getVizPanels(scene: DashboardScene): VizPanel[] {
@ -58,6 +66,16 @@ function getDataLayers(scene: DashboardScene): SceneDataLayers {
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 {
let max = 0;
const body = dashboard.state.body;
@ -119,4 +137,5 @@ export const dashboardSceneGraph = {
getVizPanels,
getDataLayers,
getNextPanelId,
getCursorSync,
};

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

@ -164,7 +164,7 @@ export const getScenePanelLinksSupplier = (
panel: VizPanel,
replaceVariables: InterpolateFunction
): LinkModelSupplier<VizPanel> | undefined => {
const links = dashboardSceneGraph.getPanelLinks(panel).state.rawLinks;
const links = dashboardSceneGraph.getPanelLinks(panel)?.state.rawLinks;
if (!links || links.length === 0) {
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
});
// 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
// so we increase the default timeout to 2secs to avoid flakiness
configure({ asyncUtilTimeout: 2000 });

Loading…
Cancel
Save