diff --git a/.betterer.results b/.betterer.results index 2e2644ad9aa..bd9901094fd 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] diff --git a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts new file mode 100644 index 00000000000..a107817007a --- /dev/null +++ b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts @@ -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) => { + 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(); + } +} diff --git a/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts b/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts new file mode 100644 index 00000000000..4c29daef47f --- /dev/null +++ b/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts @@ -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); diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx index b9dd8acaddf..8c4a83d5ea4 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx @@ -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; @@ -34,7 +34,7 @@ export class SaveDashboardDrawer extends SceneObjectBase) => { 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; diff --git a/public/app/features/dashboard-scene/saving/__mocks__/createDetectChangesWorker.ts b/public/app/features/dashboard-scene/saving/__mocks__/createDetectChangesWorker.ts new file mode 100644 index 00000000000..746a759c558 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/__mocks__/createDetectChangesWorker.ts @@ -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 }; diff --git a/public/app/features/dashboard-scene/saving/createDetectChangesWorker.ts b/public/app/features/dashboard-scene/saving/createDetectChangesWorker.ts new file mode 100644 index 00000000000..4f34b2a15d4 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/createDetectChangesWorker.ts @@ -0,0 +1 @@ +export const createWorker = () => new Worker(new URL('./DetectChangesWorker.ts', import.meta.url)); diff --git a/public/app/features/dashboard-scene/saving/getDashboardChanges.ts b/public/app/features/dashboard-scene/saving/getDashboardChanges.ts new file mode 100644 index 00000000000..c9d2dc7e4b7 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/getDashboardChanges.ts @@ -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>((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); +}; diff --git a/public/app/features/dashboard-scene/saving/getSaveDashboardChange.test.ts b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts similarity index 84% rename from public/app/features/dashboard-scene/saving/getSaveDashboardChange.test.ts rename to public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts index 553d2bc0683..b1d1b62d6b1 100644 --- a/public/app/features/dashboard-scene/saving/getSaveDashboardChange.test.ts +++ b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts @@ -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'); }); diff --git a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts new file mode 100644 index 00000000000..4859fe77f4b --- /dev/null +++ b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts @@ -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; +} diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 0b135657229..55e7062d1ab 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -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) { timeZone: 'browser', }), controls: new DashboardControls({}), + $behaviors: [new behaviors.CursorSync({})], body: new SceneGridLayout({ children: [ new SceneGridItem({ @@ -427,6 +566,26 @@ function buildTestScene(overrides?: Partial) { 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', diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index a1a4059e093..51c6eec0c03 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -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 { */ private _initialUrlState?: H.Location; /** - * change tracking subscription + * Dashboard changes tracker */ - private _changeTrackerSub?: Unsubscribable; + private _changeTracker: DashboardSceneChangeTracker; public constructor(state: Partial) { super({ @@ -153,6 +148,8 @@ export class DashboardScene extends SceneObjectBase { ...state, }); + this._changeTracker = new DashboardSceneChangeTracker(this); + this.addActivationHandler(() => this._activationHandler()); } @@ -162,7 +159,7 @@ export class DashboardScene extends SceneObjectBase { 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 { 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 { // 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 { version: result.version, }; - this.stopTrackingChanges(); + this._changeTracker.stopTrackingChanges(); this.setState({ version: result.version, isDirty: false, @@ -231,7 +229,7 @@ export class DashboardScene extends SceneObjectBase { folderUid: folderUid, }, }); - this.startTrackingChanges(); + this._changeTracker.startTrackingChanges(); } private propagateEditModeChange() { @@ -265,7 +263,7 @@ export class DashboardScene extends SceneObjectBase { 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 { 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; } diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 12028b43cdc..7fd73757aaa 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -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; diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx index 0ed532e2f70..d16f598b590 100644 --- a/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx @@ -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', + }), }), ], }), diff --git a/public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx index e9e04c6a702..508630a2561 100644 --- a/public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx @@ -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({component}); } @@ -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', + }), }), ], }), diff --git a/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx index eb3c8ecd018..91d8fef4c8e 100644 --- a/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx +++ b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx @@ -79,7 +79,7 @@ export class DashboardLinksEditView extends SceneObjectBase) { 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 - {overlay && } ); } diff --git a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx index 4becbbb2c77..d438483300a 100644 --- a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx @@ -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', + }), }), ], }), diff --git a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx index 3c1315b0a79..1599ae3dd4b 100644 --- a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx @@ -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() { diff --git a/public/app/features/dashboard-scene/settings/PermissionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/PermissionsEditView.test.tsx index 3e0e84ac76b..45bee212ab5 100644 --- a/public/app/features/dashboard-scene/settings/PermissionsEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/PermissionsEditView.test.tsx @@ -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', + }), }), ], }), diff --git a/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx b/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx index 02d0232e89d..7ecc3ee306c 100644 --- a/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx @@ -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({ diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx index afafdc5089b..ee702bd8d6a 100644 --- a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx @@ -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', + }), }), ], }), diff --git a/public/app/features/dashboard-scene/settings/variables/utils.test.ts b/public/app/features/dashboard-scene/settings/variables/utils.test.ts index 64a14b84fc3..74c19faacd9 100644 --- a/public/app/features/dashboard-scene/settings/variables/utils.test.ts +++ b/public/app/features/dashboard-scene/settings/variables/utils.test.ts @@ -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(() => { diff --git a/public/app/features/dashboard-scene/settings/variables/utils.ts b/public/app/features/dashboard-scene/settings/variables/utils.ts index cb484b34166..221387b87d1 100644 --- a/public/app/features/dashboard-scene/settings/variables/utils.ts +++ b/public/app/features/dashboard-scene/settings/variables/utils.ts @@ -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+$/; diff --git a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts index a799efe2c38..ea529d79c17 100644 --- a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts +++ b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts @@ -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) { @@ -207,6 +225,11 @@ function buildTestScene(overrides?: Partial) { uid: 'dash-1', $timeRange: new SceneTimeRange({}), controls: new DashboardControls({}), + $behaviors: [ + new behaviors.CursorSync({ + sync: DashboardCursorSync.Crosshair, + }), + ], $data: new SceneDataLayers({ layers: [ new DashboardAnnotationsDataLayer({ diff --git a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts index 898885a7304..2a289ff47ad 100644 --- a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts +++ b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts @@ -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, }; diff --git a/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx index eb2fe924106..6b3adccb4e4 100644 --- a/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx @@ -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: () => , + render: () => , }) ) ) @@ -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 ( panelLinks.setState({ rawLinks: links })} + onChange={(links) => panelLinks?.setState({ rawLinks: links })} getSuggestions={getPanelLinksVariableSuggestions} data={[]} /> diff --git a/public/app/features/panel/panellinks/linkSuppliers.ts b/public/app/features/panel/panellinks/linkSuppliers.ts index 4924278d01f..c2810f7dabe 100644 --- a/public/app/features/panel/panellinks/linkSuppliers.ts +++ b/public/app/features/panel/panellinks/linkSuppliers.ts @@ -164,7 +164,7 @@ export const getScenePanelLinksSupplier = ( panel: VizPanel, replaceVariables: InterpolateFunction ): LinkModelSupplier | undefined => { - const links = dashboardSceneGraph.getPanelLinks(panel).state.rawLinks; + const links = dashboardSceneGraph.getPanelLinks(panel)?.state.rawLinks; if (!links || links.length === 0) { return undefined; diff --git a/public/test/setupTests.ts b/public/test/setupTests.ts index 650614f5123..4b59a6b5b5b 100644 --- a/public/test/setupTests.ts +++ b/public/test/setupTests.ts @@ -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 });