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>
This commit is contained in:
co-authored by
Alexandra Vargas
Dominik Prokop
parent
b02ae375ba
commit
29d6cd8fa0
@@ -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);
|
||||
};
|
||||
+9
-8
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user