import { of } from 'rxjs'; import { DataQueryRequest, DataSourceApi, LoadingState, PanelPlugin } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { CancelActivationHandler, CustomVariable, SceneDataTransformer, sceneGraph, SceneGridLayout, SceneQueryRunner, SceneTimeRange, SceneVariableSet, VizPanel, } from '@grafana/scenes'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import * as libAPI from 'app/features/library-panels/state/api'; import { DashboardScene } from '../scene/DashboardScene'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { activateFullSceneTree } from '../utils/test-utils'; import { findVizPanelByKey, getQueryRunnerFor } from '../utils/utils'; import { buildPanelEditScene } from './PanelEditor'; const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { return of({ state: LoadingState.Loading, series: [], timeRange: request.range, }); }); let pluginPromise: Promise | undefined; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { return runRequestMock(ds, request); }, getPluginImportUtils: () => ({ getPanelPluginFromCache: jest.fn(() => undefined), importPanelPlugin: () => pluginPromise, }), config: { ...jest.requireActual('@grafana/runtime').config, panels: { text: { skipDataQuery: true, }, timeseries: { skipDataQuery: false, }, }, }, })); const dataSources = { ds1: mockDataSource( { uid: 'ds1', type: DataSourceType.Prometheus, }, { module: 'core:plugin/prometheus' } ), }; setupDataSources(...Object.values(dataSources)); let deactivate: CancelActivationHandler | undefined; describe('PanelEditor', () => { afterEach(() => { if (deactivate) { deactivate(); deactivate = undefined; } }); describe('When initializing', () => { it('should wait for panel plugin to load', async () => { const { panelEditor, panel, pluginResolve, dashboard } = await setup({ skipWait: true }); expect(panel.state.options).toEqual({}); expect(panelEditor.state.isInitializing).toBe(true); const pluginToLoad = getPanelPlugin({ id: 'text' }).setPanelOptions((build) => { build.addBooleanSwitch({ path: 'showHeader', name: 'Show header', defaultValue: true, }); }); pluginResolve(pluginToLoad); await new Promise((r) => setTimeout(r, 1)); expect(panelEditor.state.isInitializing).toBe(false); expect(panel.state.options).toEqual({ showHeader: true }); panel.onOptionsChange({ showHeader: false }); panelEditor.onDiscard(); const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; expect(discardedPanel.state.options).toEqual({ showHeader: true }); }); }); describe('When discarding', () => { it('should discard changes revert all changes', async () => { const { panelEditor, panel, dashboard } = await setup(); panel.setState({ title: 'changed title' }); panelEditor.onDiscard(); const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; expect(discardedPanel.state.title).toBe('original title'); }); it('should discard a newly added panel', async () => { const { panelEditor, dashboard } = await setup({ isNewPanel: true }); panelEditor.onDiscard(); const panels = dashboard.state.body.getVizPanels(); expect(panels.length).toBe(0); }); it('should discard query runner changes', async () => { const { panelEditor, panel, dashboard } = await setup({}); const queryRunner = getQueryRunnerFor(panel); queryRunner?.setState({ maxDataPoints: 123, queries: [{ refId: 'A' }, { refId: 'B' }] }); panelEditor.onDiscard(); const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; const restoredQueryRunner = getQueryRunnerFor(discardedPanel); expect(restoredQueryRunner?.state.maxDataPoints).toBe(500); expect(restoredQueryRunner?.state.queries.length).toBe(1); }); }); describe('When changes are made', () => { it('Should set state to dirty', async () => { const { panelEditor, panel } = await setup({}); expect(panelEditor.state.isDirty).toBe(undefined); panel.setState({ title: 'changed title' }); expect(panelEditor.state.isDirty).toBe(true); }); it('Should reset dirty and orginal state when dashboard is saved', async () => { const { panelEditor, panel } = await setup({}); expect(panelEditor.state.isDirty).toBe(undefined); panel.setState({ title: 'changed title' }); panelEditor.dashboardSaved(); expect(panelEditor.state.isDirty).toBe(false); panel.setState({ title: 'changed title 2' }); expect(panelEditor.state.isDirty).toBe(true); // Change back to already saved state panel.setState({ title: 'changed title' }); expect(panelEditor.state.isDirty).toBe(false); }); }); describe('When opening a repeated panel', () => { it('Should default to the first variable value if panel is repeated', async () => { const { panel } = await setup({ repeatByVariable: 'server' }); const variable = sceneGraph.lookupVariable('server', panel); expect(variable?.getValue()).toBe('A'); }); }); describe('Handling library panels', () => { it('should call the api with the updated panel', async () => { pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true })); const panel = new VizPanel({ key: 'panel-1', pluginId: 'text' }); const libraryPanelModel = { title: 'title', uid: 'uid', name: 'libraryPanelName', model: vizPanelToPanel(panel), type: 'panel', version: 1, }; const libPanelBehavior = new LibraryPanelBehavior({ isLoaded: true, uid: libraryPanelModel.uid, name: libraryPanelModel.name, _loadedPanel: libraryPanelModel, }); panel.setState({ $behaviors: [libPanelBehavior] }); const gridItem = new DashboardGridItem({ body: panel }); const editScene = buildPanelEditScene(panel); const scene = new DashboardScene({ editPanel: editScene, $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), isEditing: true, body: new DefaultGridLayoutManager({ grid: new SceneGridLayout({ children: [gridItem], }), }), }); activateFullSceneTree(scene); await new Promise((r) => setTimeout(r, 1)); panel.setState({ title: 'changed title' }); libPanelBehavior.setState({ name: 'changed name' }); jest.spyOn(libAPI, 'saveLibPanel').mockImplementation(async (panel) => { const updatedPanel = { ...libAPI.libraryVizPanelToSaveModel(panel), version: 2 }; libPanelBehavior.setPanelFromLibPanel(updatedPanel); }); editScene.onConfirmSaveLibraryPanel(); await new Promise(process.nextTick); // Wait for mock api to return and update the library panel expect(libPanelBehavior.state._loadedPanel?.version).toBe(2); expect(libPanelBehavior.state.name).toBe('changed name'); expect(panel.state.title).toBe('changed title'); expect((gridItem.state.body as VizPanel).state.title).toBe('changed title'); }); it('unlinks library panel', () => { const libraryPanelModel = { title: 'title', uid: 'uid', name: 'libraryPanelName', model: { title: 'title', type: 'text', }, type: 'panel', version: 1, }; const libPanelBehavior = new LibraryPanelBehavior({ isLoaded: true, uid: libraryPanelModel.uid, name: libraryPanelModel.name, _loadedPanel: libraryPanelModel, }); // Just adding an extra stateless behavior to verify unlinking does not remvoe it const otherBehavior = jest.fn(); const panel = new VizPanel({ key: 'panel-1', pluginId: 'text', $behaviors: [libPanelBehavior, otherBehavior] }); new DashboardGridItem({ body: panel }); const editScene = buildPanelEditScene(panel); editScene.onConfirmUnlinkLibraryPanel(); expect(panel.state.$behaviors?.length).toBe(1); expect(panel.state.$behaviors![0]).toBe(otherBehavior); }); }); describe('PanelDataPane', () => { it('should not exist if panel is skipDataQuery', async () => { const { panelEditor, panel } = await setup({ pluginSkipDataQuery: true }); expect(panelEditor.state.dataPane).toBeUndefined(); expect(panel.state.$data).toBeUndefined(); }); it('should exist if panel is supporting querying', async () => { const { panelEditor, panel } = await setup({ pluginSkipDataQuery: false }); expect(panelEditor.state.dataPane).toBeDefined(); expect(panel.state.$data).toBeDefined(); }); }); }); interface SetupOptions { isNewPanel?: boolean; pluginSkipDataQuery?: boolean; repeatByVariable?: string; skipWait?: boolean; pluginLoadTime?: number; } async function setup(options: SetupOptions = {}) { const pluginToLoad = getPanelPlugin({ id: 'text', skipDataQuery: options.pluginSkipDataQuery }); let pluginResolve = (plugin: PanelPlugin) => {}; pluginPromise = new Promise((resolve) => { pluginResolve = resolve; }); const panel = new VizPanel({ key: 'panel-1', pluginId: 'text', title: 'original title', $data: new SceneDataTransformer({ transformations: [], $data: new SceneQueryRunner({ queries: [{ refId: 'A' }], maxDataPoints: 500, datasource: { uid: 'ds1' }, }), }), }); const gridItem = new DashboardGridItem({ body: panel, variableName: options.repeatByVariable }); const panelEditor = buildPanelEditScene(panel, options.isNewPanel); const dashboard = new DashboardScene({ editPanel: panelEditor, isEditing: true, $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), $variables: new SceneVariableSet({ variables: [ new CustomVariable({ name: 'server', query: 'A,B,C', isMulti: true, value: ['A', 'B', 'C'], text: ['A', 'B', 'C'], }), ], }), body: new DefaultGridLayoutManager({ grid: new SceneGridLayout({ children: [gridItem], }), }), }); panelEditor.debounceSaveModelDiff = false; deactivate = activateFullSceneTree(dashboard); if (!options.skipWait) { //console.log('pluginResolve(pluginToLoad)'); pluginResolve(pluginToLoad); await new Promise((r) => setTimeout(r, 1)); } return { dashboard, panel, gridItem, panelEditor, pluginResolve }; }