diff --git a/.betterer.results b/.betterer.results index 45abf50aa40..3ec225ceb44 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2665,8 +2665,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "10"] ], "public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/dashboard-scene/inspect/InspectDataTab.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] @@ -2793,9 +2792,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts b/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts index d3cab702aba..0808233eaad 100644 --- a/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts +++ b/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts @@ -15,7 +15,6 @@ import { VizPanel } from '@grafana/scenes'; import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { DashboardGridItem } from '../../scene/DashboardGridItem'; -import { DashboardScene } from '../../scene/DashboardScene'; import { gridItemToPanel, vizPanelToPanel } from '../../serialization/transformSceneToSaveModel'; import { getQueryRunnerFor, isLibraryPanel } from '../../utils/utils'; @@ -64,25 +63,12 @@ export function getGithubMarkdown(panel: VizPanel, snapshot: string): string { export async function getDebugDashboard(panel: VizPanel, rand: Randomize, timeRange: TimeRange) { let saveModel: ReturnType = { type: '' }; const gridItem = panel.parent as DashboardGridItem; - const scene = panel.getRoot() as DashboardScene; if (isLibraryPanel(panel)) { saveModel = { ...gridItemToPanel(gridItem), ...vizPanelToPanel(panel), }; - } else if (scene.state.editPanel) { - // If panel edit mode is open when the user chooses the "get help" panel menu option - // we want the debug dashboard to include the panel with any changes that were made while - // in panel edit mode. - const sourcePanel = scene.state.editPanel.state.vizManager.state.sourcePanel.resolve(); - const dashGridItem = sourcePanel.parent; - if (dashGridItem instanceof DashboardGridItem) { - saveModel = { - ...gridItemToPanel(dashGridItem), - ...vizPanelToPanel(scene.state.editPanel.state.vizManager.state.panel.clone()), - }; - } } else { saveModel = gridItemToPanel(gridItem); } diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx index c1424bfeeb3..e0de482f519 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx @@ -25,7 +25,6 @@ import { InspectTab } from 'app/features/inspector/types'; import { getPrettyJSON } from 'app/features/inspector/utils/utils'; import { reportPanelInspectInteraction } from 'app/features/search/page/reporting'; -import { VizPanelManager } from '../panel-edit/VizPanelManager'; import { DashboardGridItem } from '../scene/DashboardGridItem'; import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene'; import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; @@ -219,11 +218,6 @@ function getJsonText(show: ShowContent, panel: VizPanel): string { break; } - if (panel.parent instanceof VizPanelManager) { - objToStringify = panel.parent.getPanelSaveModel(); - break; - } - if (gridItem instanceof DashboardGridItem) { objToStringify = gridItemToPanel(gridItem); } diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx index 919c8e32cc3..59b38232d74 100644 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx +++ b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx @@ -28,7 +28,7 @@ import { SceneInspectTab } from './types'; interface PanelInspectDrawerState extends SceneObjectState { tabs?: SceneInspectTab[]; - panelRef?: SceneObjectRef; + panelRef: SceneObjectRef; pluginNotLoaded?: boolean; canEdit?: boolean; } @@ -51,7 +51,7 @@ export class PanelInspectDrawer extends SceneObjectBase */ async buildTabs(retry: number) { const panelRef = this.state.panelRef; - const plugin = panelRef?.resolve()?.getPlugin(); + const plugin = panelRef.resolve()?.getPlugin(); const tabs: SceneInspectTab[] = []; if (!plugin) { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx new file mode 100644 index 00000000000..20bf2c7d267 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx @@ -0,0 +1,48 @@ +import { Observable } from 'rxjs'; + +import { + SceneDataProvider, + SceneDataProviderResult, + SceneDataState, + SceneObjectBase, + SceneObjectRef, +} from '@grafana/scenes'; + +export interface DataProviderSharerState extends SceneDataState { + source: SceneObjectRef; +} + +export class DataProviderSharer extends SceneObjectBase implements SceneDataProvider { + public constructor(state: DataProviderSharerState) { + super({ + source: state.source, + data: state.source.resolve().state.data, + }); + + this.addActivationHandler(() => { + this._subs.add( + this.state.source.resolve().subscribeToState((newState, oldState) => { + if (newState.data !== oldState.data) { + this.setState({ data: newState.data }); + } + }) + ); + }); + } + + public setContainerWidth(width: number) { + this.state.source.resolve().setContainerWidth?.(width); + } + + public isDataReadyToDisplay() { + return this.state.source.resolve().isDataReadyToDisplay?.() ?? true; + } + + public cancelQuery() { + this.state.source.resolve().cancelQuery?.(); + } + + public getResultsStream(): Observable { + return this.state.source.resolve().getResultsStream(); + } +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx index c315a73bcc9..c1dab28f8ed 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -34,7 +34,6 @@ import { AlertQuery, PromRulesResponse } from 'app/types/unified-alerting-dto'; import { createDashboardSceneFromDashboardModel } from '../../serialization/transformSaveModelToScene'; import * as utils from '../../utils/utils'; import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../../utils/utils'; -import { VizPanelManager } from '../VizPanelManager'; import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataAlertingTab'; @@ -361,7 +360,7 @@ async function clickNewButton() { function createModel(dashboard: DashboardModel) { const scene = createDashboardSceneFromDashboardModel(dashboard, {} as DashboardDataDTO); const vizPanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(34))!; - const model = new PanelDataAlertingTab(VizPanelManager.createFor(vizPanel)); + const model = new PanelDataAlertingTab({ panelRef: vizPanel.getRef() }); jest.spyOn(utils, 'getDashboardSceneFor').mockReturnValue(scene); return model; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx index 3ebc9bb51a7..a4fbbc4471d 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx @@ -1,8 +1,7 @@ import { css } from '@emotion/css'; -import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; +import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes'; import { Alert, LoadingPlaceholder, Tab, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { RulesTable } from 'app/features/alerting/unified/components/rules/RulesTable'; @@ -11,58 +10,46 @@ import { getRulesPermissions } from 'app/features/alerting/unified/utils/access- import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; import { getDashboardSceneFor, getPanelIdForVizPanel } from '../../utils/utils'; -import { VizPanelManager } from '../VizPanelManager'; import { ScenesNewRuleFromPanelButton } from './NewAlertRuleButton'; -import { PanelDataPaneTab, PanelDataPaneTabState, PanelDataTabHeaderProps, TabId } from './types'; +import { PanelDataPaneTab, PanelDataTabHeaderProps, TabId } from './types'; -export class PanelDataAlertingTab extends SceneObjectBase implements PanelDataPaneTab { - static Component = PanelDataAlertingTabRendered; - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; +export interface PanelDataAlertingTabState extends SceneObjectState { + panelRef: SceneObjectRef; +} - tabId = TabId.Alert; - private _panelManager: VizPanelManager; +export class PanelDataAlertingTab extends SceneObjectBase implements PanelDataPaneTab { + static Component = PanelDataAlertingTabRendered; + public tabId = TabId.Alert; - constructor(panelManager: VizPanelManager) { - super({}); - this.TabComponent = (props: PanelDataTabHeaderProps) => AlertingTab({ ...props, model: this }); - this._panelManager = panelManager; + public renderTab(props: PanelDataTabHeaderProps) { + return ; } - getTabLabel() { + public getTabLabel() { return 'Alert'; } - getDashboardUID() { + public getDashboardUID() { const dashboard = this.getDashboard(); return dashboard.state.uid!; } - getDashboard() { - return getDashboardSceneFor(this._panelManager); + public getDashboard() { + return getDashboardSceneFor(this); } - getLegacyPanelId() { - return getPanelIdForVizPanel(this._panelManager.state.panel); + public getLegacyPanelId() { + return getPanelIdForVizPanel(this.state.panelRef.resolve()); } - getCanCreateRules() { + public getCanCreateRules() { const rulesPermissions = getRulesPermissions('grafana'); return this.getDashboard().state.meta.canSave && contextSrv.hasPermission(rulesPermissions.create); } - - get panelManager() { - return this._panelManager; - } - - get panel() { - return this._panelManager.state.panel; - } } -export function PanelDataAlertingTabRendered(props: SceneComponentProps) { - const { model } = props; - +export function PanelDataAlertingTabRendered({ model }: SceneComponentProps) { const styles = useStyles2(getStyles); const { errors, loading, rules } = usePanelCombinedRules({ @@ -87,7 +74,7 @@ export function PanelDataAlertingTabRendered(props: SceneComponentProps; } export class PanelDataPane extends SceneObjectBase { static Component = PanelDataPaneRendered; protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['tab'] }); - private panelSubscription: Unsubscribable | undefined; - public panelManager: VizPanelManager; - getUrlState() { - return { - tab: this.state.tab, - }; - } + public static createFor(panel: VizPanel) { + const panelRef = panel.getRef(); + const tabs: PanelDataPaneTab[] = [ + new PanelDataQueriesTab({ panelRef }), + new PanelDataTransformationsTab({ panelRef }), + ]; - updateFromUrl(values: SceneObjectUrlValues) { - if (!values.tab) { - return; + if (shouldShowAlertingTab(panel.state.pluginId)) { + tabs.push(new PanelDataAlertingTab({ panelRef })); } - if (typeof values.tab === 'string') { - this.setState({ tab: values.tab as TabId }); - } - } - constructor(panelMgr: VizPanelManager) { - super({ + return new PanelDataPane({ + panelRef, + tabs, tab: TabId.Queries, - tabs: [], }); - - this.panelManager = panelMgr; - this.addActivationHandler(() => this.onActivate()); } - private onActivate() { - this.buildTabs(); - - this._subs.add( - // Setup subscription for the case when panel type changed - this.panelManager.subscribeToState((n, p) => { - if (n.pluginId !== p.pluginId) { - this.buildTabs(); - } - }) - ); - - return () => { - if (this.panelSubscription) { - this.panelSubscription.unsubscribe(); - this.panelSubscription = undefined; - } - }; + public onChangeTab = (tab: PanelDataPaneTab) => { + this.setState({ tab: tab.tabId }); + }; + + public getUrlState() { + return { tab: this.state.tab }; } - private buildTabs() { - const panelManager = this.panelManager; - const panel = panelManager.state.panel; - const pluginId = panelManager.state.pluginId; - - const runner = this.panelManager.queryRunner; - const tabs: PanelDataPaneTab[] = []; - - if (panel) { - if (config.panels[pluginId]?.skipDataQuery) { - this.setState({ tabs }); - return; - } else { - if (runner) { - tabs.push(new PanelDataQueriesTab(this.panelManager)); - } - - tabs.push(new PanelDataTransformationsTab(this.panelManager)); - - if (shouldShowAlertingTab(panelManager.state.pluginId)) { - tabs.push(new PanelDataAlertingTab(this.panelManager)); - } - } + public updateFromUrl(values: SceneObjectUrlValues) { + if (!values.tab) { + return; + } + if (typeof values.tab === 'string') { + this.setState({ tab: values.tab as TabId }); } - - this.setState({ tabs }); } - - onChangeTab = (tab: PanelDataPaneTab) => { - this.setState({ tab: tab.tabId }); - }; } function PanelDataPaneRendered({ model }: SceneComponentProps) { @@ -125,15 +81,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { return (
- {tabs.map((t, index) => { - return ( - model.onChangeTab(t)} - > - ); - })} + {tabs.map((t) => t.renderTab({ active: t.tabId === tab, onChangeTab: () => model.onChangeTab(t) }))} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx index 9264e85d079..5a406406282 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx @@ -6,6 +6,7 @@ import { DataQuery, DataQueryRequest, DataSourceApi, + DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef, FieldType, @@ -14,29 +15,28 @@ import { TimeRange, toDataFrame, } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { selectors } from '@grafana/e2e-selectors'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { config, locationService, setPluginExtensionsHook } from '@grafana/runtime'; +import { InspectTab } from 'app/features/inspector/types'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; import { DashboardDataDTO } from 'app/types'; +import { PanelTimeRange, PanelTimeRangeState } from '../../scene/PanelTimeRange'; import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene'; -import { DashboardModelCompatibilityWrapper } from '../../utils/DashboardModelCompatibilityWrapper'; import { findVizPanelByKey } from '../../utils/utils'; -import { VizPanelManager } from '../VizPanelManager'; -import { testDashboard } from '../testfiles/testDashboard'; +import { buildPanelEditScene } from '../PanelEditor'; +import { testDashboard, panelWithTransformations, panelWithQueriesOnly } from '../testfiles/testDashboard'; import { PanelDataQueriesTab, PanelDataQueriesTabRendered } from './PanelDataQueriesTab'; async function createModelMock() { - const panelManager = setupVizPanelManger('panel-1'); - panelManager.activate(); - await Promise.resolve(); - const queryTabModel = new PanelDataQueriesTab(panelManager); + const { queriesTab } = await setupScene('panel-1'); // mock queryRunner data state - jest.spyOn(queryTabModel.queryRunner, 'state', 'get').mockReturnValue({ - ...queryTabModel.queryRunner.state, + jest.spyOn(queriesTab.queryRunner, 'state', 'get').mockReturnValue({ + ...queriesTab.queryRunner.state, data: { state: LoadingState.Done, series: [ @@ -52,8 +52,14 @@ async function createModelMock() { }, }); - return queryTabModel; + return queriesTab; } + +setPluginExtensionsHook(() => ({ + extensions: [], + isLoading: false, +})); + const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { const result: PanelData = { state: LoadingState.Loading, @@ -186,11 +192,17 @@ const MixedDsSettingsMock = { }, }; +const panelPlugin = getPanelPlugin({ id: 'timeseries', skipDataQuery: false }); + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { return runRequestMock(ds, request); }, + getPluginImportUtils: () => ({ + getPanelPluginFromCache: jest.fn(() => panelPlugin), + }), + getPluginLinkExtensions: jest.fn(), getDataSourceSrv: () => ({ get: async (ref: DataSourceRef) => { // Mocking the build in Grafana data source to avoid annotations data layer errors. @@ -234,112 +246,454 @@ jest.mock('@grafana/runtime', () => ({ return instance1SettingsMock; }, }), - locationService: { - partial: jest.fn(), - getSearchObject: jest.fn().mockReturnValue({ - firstPanel: false, - }), - }, config: { ...jest.requireActual('@grafana/runtime').config, defaultDatasource: 'gdev-testdata', }, })); -describe('PanelDataQueriesModel', () => { - it('can add a new query', async () => { - const vizPanelManager = setupVizPanelManger('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const model = new PanelDataQueriesTab(vizPanelManager); - model.addQueryClick(); - expect(model.queryRunner.state.queries).toHaveLength(2); - expect(model.queryRunner.state.queries[1].refId).toBe('B'); - expect(model.queryRunner.state.queries[1].hide).toBe(false); - expect(model.queryRunner.state.queries[1].datasource).toEqual({ - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - }); - }); - it('can add a new query when datasource is mixed', async () => { - const vizPanelManager = setupVizPanelManger('panel-7'); - vizPanelManager.activate(); - await Promise.resolve(); +jest.mock('app/core/store', () => ({ + exists: jest.fn(), + get: jest.fn(), + getObject: jest.fn((_a, b) => b), + setObject: jest.fn(), +})); - const model = new PanelDataQueriesTab(vizPanelManager); - expect(vizPanelManager.state.datasource?.uid).toBe('-- Mixed --'); - expect(model.queryRunner.state.datasource?.uid).toBe('-- Mixed --'); - model.addQueryClick(); - - expect(model.queryRunner.state.queries).toHaveLength(2); - expect(model.queryRunner.state.queries[1].refId).toBe('B'); - expect(model.queryRunner.state.queries[1].hide).toBe(false); - expect(model.queryRunner.state.queries[1].datasource?.uid).toBe('gdev-testdata'); - }); -}); +const store = jest.requireMock('app/core/store'); +let deactivators = [] as Array<() => void>; describe('PanelDataQueriesTab', () => { - it('renders query group top section', async () => { - const modelMock = await createModelMock(); + beforeEach(() => { + store.setObject.mockClear(); + }); - render(); - await screen.findByTestId(selectors.components.QueryTab.queryGroupTopSection); + afterEach(() => { + deactivators.forEach((deactivate) => deactivate()); + deactivators = []; }); - it('renders queries rows when queries are set', async () => { - const modelMock = await createModelMock(); - render(); + describe('Adding queries', () => { + it('can add a new query', async () => { + const { queriesTab } = await setupScene('panel-1'); - await screen.findByTestId('query-editor-rows'); - expect(screen.getAllByTestId('query-editor-row')).toHaveLength(1); + queriesTab.addQueryClick(); + + expect(queriesTab.queryRunner.state.queries).toHaveLength(2); + expect(queriesTab.queryRunner.state.queries[1].refId).toBe('B'); + expect(queriesTab.queryRunner.state.queries[1].hide).toBe(false); + expect(queriesTab.queryRunner.state.queries[1].datasource).toEqual({ + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }); + }); + + it('Can add a new query when datasource is mixed', async () => { + const { queriesTab } = await setupScene('panel-7'); + + expect(queriesTab.state.datasource?.uid).toBe('-- Mixed --'); + expect(queriesTab.queryRunner.state.datasource?.uid).toBe('-- Mixed --'); + + queriesTab.addQueryClick(); + + expect(queriesTab.queryRunner.state.queries).toHaveLength(2); + expect(queriesTab.queryRunner.state.queries[1].refId).toBe('B'); + expect(queriesTab.queryRunner.state.queries[1].hide).toBe(false); + expect(queriesTab.queryRunner.state.queries[1].datasource?.uid).toBe('gdev-testdata'); + }); }); - it('allow to add a new query when user clicks on add new', async () => { - const modelMock = await createModelMock(); - jest.spyOn(modelMock, 'addQueryClick'); - jest.spyOn(modelMock, 'onQueriesChange'); - render(); - - await screen.findByTestId(selectors.components.QueryTab.addQuery); - await userEvent.click(screen.getByTestId(selectors.components.QueryTab.addQuery)); - - const expectedQueries = [ - { - datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, - refId: 'A', - scenarioId: 'random_walk', - seriesCount: 1, - }, - { datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, hide: false, refId: 'B' }, - ]; + describe('PanelDataQueriesTab', () => { + it('renders query group top section', async () => { + const modelMock = await createModelMock(); + + render(); + await screen.findByTestId(selectors.components.QueryTab.queryGroupTopSection); + }); + + it('renders queries rows when queries are set', async () => { + const modelMock = await createModelMock(); + render(); - expect(modelMock.addQueryClick).toHaveBeenCalled(); - expect(modelMock.onQueriesChange).toHaveBeenCalledWith(expectedQueries); + await screen.findByTestId('query-editor-rows'); + expect(screen.getAllByTestId('query-editor-row')).toHaveLength(1); + }); + + it('allow to add a new query when user clicks on add new', async () => { + const modelMock = await createModelMock(); + jest.spyOn(modelMock, 'addQueryClick'); + jest.spyOn(modelMock, 'onQueriesChange'); + render(); + + await screen.findByTestId(selectors.components.QueryTab.addQuery); + await userEvent.click(screen.getByTestId(selectors.components.QueryTab.addQuery)); + + const expectedQueries = [ + { + datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 1, + }, + { datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, hide: false, refId: 'B' }, + ]; + + expect(modelMock.addQueryClick).toHaveBeenCalled(); + expect(modelMock.onQueriesChange).toHaveBeenCalledWith(expectedQueries); + }); + + it('allow to remove a query when user clicks on remove', async () => { + const modelMock = await createModelMock(); + jest.spyOn(modelMock, 'addQueryClick'); + jest.spyOn(modelMock, 'onQueriesChange'); + render(); + + await screen.findByTestId('data-testid Remove query'); + await userEvent.click(screen.getByTestId('data-testid Remove query')); + + expect(modelMock.onQueriesChange).toHaveBeenCalledWith([]); + }); }); - it('allow to remove a query when user clicks on remove', async () => { - const modelMock = await createModelMock(); - jest.spyOn(modelMock, 'addQueryClick'); - jest.spyOn(modelMock, 'onQueriesChange'); - render(); + describe('query options', () => { + describe('activation', () => { + it('should load data source', async () => { + const { queriesTab } = await setupScene('panel-1'); + + expect(queriesTab.state.datasource).toEqual(ds1Mock); + expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock); + }); - await screen.findByTestId('data-testid Remove query'); - await userEvent.click(screen.getByTestId('data-testid Remove query')); + it('should store loaded data source in local storage', async () => { + await setupScene('panel-1'); + + expect(store.setObject).toHaveBeenCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { + dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', + datasourceUid: 'gdev-testdata', + }); + }); + + it('should load default datasource if the datasource passed is not found', async () => { + const { queriesTab } = await setupScene('panel-6'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'abc', + type: 'datasource', + }); + + expect(config.defaultDatasource).toBe('gdev-testdata'); + expect(queriesTab.state.datasource).toEqual(defaultDsMock); + expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock); + }); + }); + + describe('data source change', () => { + it('should load new data source', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + panel.state.$data?.activate(); + + await queriesTab.onChangeDataSource( + { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' } as DataSourceInstanceSettings, + [] + ); + + expect(store.setObject).toHaveBeenCalledTimes(2); + expect(store.setObject).toHaveBeenLastCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { + dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', + datasourceUid: 'gdev-prometheus', + }); + + expect(queriesTab.state.datasource).toEqual(ds2Mock); + expect(queriesTab.state.dsSettings).toEqual(instance2SettingsMock); + }); + }); - expect(modelMock.onQueriesChange).toHaveBeenCalledWith([]); + describe('query options change', () => { + describe('time overrides', () => { + it('should create PanelTimeRange object', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + panel.state.$data?.activate(); + + expect(panel.state.$timeRange).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '1h' }, + }); + + expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); + }); + + it('should update PanelTimeRange object on time options update', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + expect(panel.state.$timeRange).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '1h' }, + }); + + expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); + expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('1h'); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '2h' }, + }); + + expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('2h'); + }); + + it('should remove PanelTimeRange object on time options cleared', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + expect(panel.state.$timeRange).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '1h' }, + }); + + queriesTab.onQueryOptionsChange({ + dataSource: { + name: 'grafana-testdata', + type: 'grafana-testdata-datasource', + default: true, + }, + queries: [], + timeRange: { from: null }, + }); + + expect(panel.state.$timeRange).toBeUndefined(); + }); + }); + + describe('max data points and interval', () => { + it('should update max data points', async () => { + const { queriesTab } = await setupScene('panel-1'); + const dataObj = queriesTab.queryRunner; + + expect(dataObj.state.maxDataPoints).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + maxDataPoints: 100, + }); + + expect(dataObj.state.maxDataPoints).toBe(100); + }); + + it('should update min interval', async () => { + const { queriesTab } = await setupScene('panel-1'); + const dataObj = queriesTab.queryRunner; + + expect(dataObj.state.maxDataPoints).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + minInterval: '1s', + }); + expect(dataObj.state.minInterval).toBe('1s'); + }); + }); + + describe('query caching', () => { + it('updates cacheTimeout and queryCachingTTL', async () => { + const { queriesTab } = await setupScene('panel-1'); + const dataObj = queriesTab.queryRunner; + + queriesTab.onQueryOptionsChange({ + cacheTimeout: '60', + queryCachingTTL: 200000, + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + }); + + expect(dataObj.state.cacheTimeout).toBe('60'); + expect(dataObj.state.queryCachingTTL).toBe(200000); + }); + }); + }); + + describe('query inspection', () => { + it('allows query inspection from the tab', async () => { + const { queriesTab } = await setupScene('panel-1'); + queriesTab.onOpenInspector(); + + const params = locationService.getSearchObject(); + expect(params.inspect).toBe('1'); + expect(params.inspectTab).toBe(InspectTab.Query); + }); + }); + + describe('data source change', () => { + it('changing from one plugin to another', async () => { + const { queriesTab } = await setupScene('panel-1'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-testdata', + type: 'grafana-testdata-datasource', + }); + + await queriesTab.onChangeDataSource({ + name: 'grafana-prometheus', + type: 'grafana-prometheus-datasource', + uid: 'gdev-prometheus', + meta: { + name: 'Prometheus', + module: 'prometheus', + id: 'grafana-prometheus-datasource', + }, + } as DataSourceInstanceSettings); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-prometheus', + type: 'grafana-prometheus-datasource', + }); + }); + + it('changing from a plugin to a dashboard data source', async () => { + const { queriesTab } = await setupScene('panel-1'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-testdata', + type: 'grafana-testdata-datasource', + }); + + await queriesTab.onChangeDataSource({ + name: SHARED_DASHBOARD_QUERY, + type: 'datasource', + uid: SHARED_DASHBOARD_QUERY, + meta: { + name: 'Prometheus', + module: 'prometheus', + id: DASHBOARD_DATASOURCE_PLUGIN_ID, + }, + } as DataSourceInstanceSettings); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: SHARED_DASHBOARD_QUERY, + type: 'datasource', + }); + }); + + it('changing from dashboard data source to a plugin', async () => { + const { queriesTab } = await setupScene('panel-3'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ uid: SHARED_DASHBOARD_QUERY, type: 'datasource' }); + + await queriesTab.onChangeDataSource({ + name: 'grafana-prometheus', + type: 'grafana-prometheus-datasource', + uid: 'gdev-prometheus', + meta: { + name: 'Prometheus', + module: 'prometheus', + id: 'grafana-prometheus-datasource', + }, + } as DataSourceInstanceSettings); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-prometheus', + type: 'grafana-prometheus-datasource', + }); + }); + }); + + describe('change queries', () => { + describe('plugin queries', () => { + it('should update queries', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + panel.state.$data?.activate(); + + queriesTab.onQueriesChange([ + { + datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 5, + }, + ]); + + expect(queriesTab.queryRunner.state.queries).toEqual([ + { + datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 5, + }, + ]); + }); + }); + + describe('dashboard queries', () => { + it('should update queries', async () => { + const { queriesTab, panel } = await setupScene('panel-3'); + + panel.state.$data?.activate(); + + // Changing dashboard query to a panel with transformations + queriesTab.onQueriesChange([ + { + refId: 'A', + datasource: { type: DASHBOARD_DATASOURCE_PLUGIN_ID }, + panelId: panelWithTransformations.id, + }, + ]); + + expect(queriesTab.queryRunner.state.queries[0].panelId).toEqual(panelWithTransformations.id); + + // Changing dashboard query to a panel with queries only + queriesTab.onQueriesChange([ + { + refId: 'A', + datasource: { type: DASHBOARD_DATASOURCE_PLUGIN_ID }, + panelId: panelWithQueriesOnly.id, + }, + ]); + + expect(queriesTab.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id); + }); + + it('should load last used data source if no data source specified for a panel', async () => { + store.exists.mockReturnValue(true); + store.getObject.mockReturnValue({ + dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', + datasourceUid: 'gdev-testdata', + }); + + const { queriesTab } = await setupScene('panel-5'); + + expect(queriesTab.state.datasource).toBe(ds1Mock); + expect(queriesTab.state.dsSettings).toBe(instance1SettingsMock); + }); + }); + }); }); }); -const setupVizPanelManger = (panelId: string) => { - const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); - const panel = findVizPanelByKey(scene, panelId)!; +async function setupScene(panelId: string) { + const dashboard = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); + const panel = findVizPanelByKey(dashboard, panelId)!; - const vizPanelManager = VizPanelManager.createFor(panel); + const panelEditor = buildPanelEditScene(panel); + dashboard.setState({ editPanel: panelEditor }); - // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it - // @ts-expect-error - getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); + deactivators.push(dashboard.activate()); + deactivators.push(panelEditor.activate()); - return vizPanelManager; -}; + const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab; + deactivators.push(queriesTab.activate()); + + await Promise.resolve(); + + return { panel, scene: dashboard, queriesTab }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index 94d59e73eb4..23f663ca9f5 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -1,60 +1,134 @@ -import * as React from 'react'; - -import { CoreApp, DataSourceApi, DataSourceInstanceSettings, IconName, getDataSourceRef } from '@grafana/data'; +import { CoreApp, DataSourceApi, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { config, getDataSourceSrv } from '@grafana/runtime'; -import { SceneObjectBase, SceneComponentProps, sceneGraph, SceneQueryRunner } from '@grafana/scenes'; +import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; +import { + SceneObjectBase, + SceneComponentProps, + sceneGraph, + SceneQueryRunner, + SceneObjectRef, + VizPanel, + SceneObjectState, + SceneDataQuery, +} from '@grafana/scenes'; import { DataQuery } from '@grafana/schema'; import { Button, Stack, Tab } from '@grafana/ui'; import { addQuery } from 'app/core/utils/query'; +import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; +import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { GroupActionComponents } from 'app/features/query/components/QueryActionComponent'; import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows'; import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup'; +import { updateQueries } from 'app/features/query/state/updateQueries'; import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard'; -import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; import { QueryGroupOptions } from 'app/types'; -import { PanelTimeRange } from '../../scene/PanelTimeRange'; -import { VizPanelManager } from '../VizPanelManager'; +import { PanelTimeRange, PanelTimeRangeState } from '../../scene/PanelTimeRange'; +import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../../utils/utils'; -import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; +import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; -interface PanelDataQueriesTabState extends PanelDataPaneTabState { +interface PanelDataQueriesTabState extends SceneObjectState { datasource?: DataSourceApi; dsSettings?: DataSourceInstanceSettings; + panelRef: SceneObjectRef; } export class PanelDataQueriesTab extends SceneObjectBase implements PanelDataPaneTab { static Component = PanelDataQueriesTabRendered; - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; - tabId = TabId.Queries; - icon: IconName = 'database'; - private _panelManager: VizPanelManager; - getTabLabel() { + public constructor(state: PanelDataQueriesTabState) { + super(state); + this.addActivationHandler(() => this.onActivate()); + } + + public getTabLabel() { return 'Queries'; } - getItemsCount() { + public getItemsCount() { return this.getQueries().length; } - constructor(panelManager: VizPanelManager) { - super({}); + public renderTab(props: PanelDataTabHeaderProps) { + return ; + } + + private onActivate() { + this.loadDataSource(); + } - this.TabComponent = (props: PanelDataTabHeaderProps) => { - return QueriesTab({ ...props, model: this }); - }; + private async loadDataSource() { + const panel = this.state.panelRef.resolve(); + const dataObj = panel.state.$data; + + if (!dataObj) { + return; + } - this._panelManager = panelManager; + let datasourceToLoad = this.queryRunner.state.datasource; + + try { + let datasource: DataSourceApi | undefined; + let dsSettings: DataSourceInstanceSettings | undefined; + + if (!datasourceToLoad) { + const dashboardScene = getDashboardSceneFor(this); + const dashboardUid = dashboardScene.state.uid ?? ''; + const lastUsedDatasource = getLastUsedDatasourceFromStorage(dashboardUid!); + + // do we have a last used datasource for this dashboard + if (lastUsedDatasource?.datasourceUid !== null) { + // get datasource from dashbopard uid + dsSettings = getDataSourceSrv().getInstanceSettings({ uid: lastUsedDatasource?.datasourceUid }); + if (dsSettings) { + datasource = await getDataSourceSrv().get({ + uid: lastUsedDatasource?.datasourceUid, + type: dsSettings.type, + }); + + this.queryRunner.setState({ + datasource: { + ...getDataSourceRef(dsSettings), + uid: lastUsedDatasource?.datasourceUid, + }, + }); + } + } + } else { + datasource = await getDataSourceSrv().get(datasourceToLoad); + dsSettings = getDataSourceSrv().getInstanceSettings(datasourceToLoad); + } + + if (datasource && dsSettings) { + this.setState({ datasource, dsSettings }); + storeLastUsedDataSourceInLocalStorage(getDataSourceRef(dsSettings) || { default: true }); + } + } catch (err) { + //set default datasource if we fail to load the datasource + const datasource = await getDataSourceSrv().get(config.defaultDatasource); + const dsSettings = getDataSourceSrv().getInstanceSettings(config.defaultDatasource); + + if (datasource && dsSettings) { + this.setState({ + datasource, + dsSettings, + }); + + this.queryRunner.setState({ + datasource: getDataSourceRef(dsSettings), + }); + } + + console.error(err); + } } - buildQueryOptions(): QueryGroupOptions { - const panelManager = this._panelManager; - const panelObj = this._panelManager.state.panel; - const queryRunner = this._panelManager.queryRunner; - const timeRangeObj = sceneGraph.getTimeRange(panelObj); + public buildQueryOptions(): QueryGroupOptions { + const panel = this.state.panelRef.resolve(); + const queryRunner = getQueryRunnerFor(panel)!; + const timeRangeObj = sceneGraph.getTimeRange(panel); let timeRangeOpts: QueryGroupOptions['timeRange'] = { from: undefined, @@ -71,19 +145,14 @@ export class PanelDataQueriesTab extends SceneObjectBase { - this._panelManager.inspectPanel(); + public onOpenInspector = () => { + const panel = this.state.panelRef.resolve(); + const panelId = getPanelIdForVizPanel(panel); + + locationService.partial({ inspect: panelId, inspectTab: 'query' }); }; - onChangeDataSource = async ( - newSettings: DataSourceInstanceSettings, - defaultQueries?: DataQuery[] | GrafanaQuery[] - ) => { - this._panelManager.changePanelDataSource(newSettings, defaultQueries); + public onChangeDataSource = async (newSettings: DataSourceInstanceSettings, defaultQueries?: SceneDataQuery[]) => { + const { dsSettings } = this.state; + const queryRunner = this.queryRunner; + + const currentDS = dsSettings ? await getDataSourceSrv().get({ uid: dsSettings.uid }) : undefined; + const nextDS = await getDataSourceSrv().get({ uid: newSettings.uid }); + + const currentQueries = queryRunner.state.queries; + + // We need to pass in newSettings.uid as well here as that can be a variable expression and we want to store that in the query model not the current ds variable value + const queries = defaultQueries || (await updateQueries(nextDS, newSettings.uid, currentQueries, currentDS)); + + queryRunner.setState({ datasource: getDataSourceRef(newSettings), queries }); + + if (defaultQueries) { + queryRunner.runQueries(); + } + + this.loadDataSource(); }; - onQueryOptionsChange = (options: QueryGroupOptions) => { - this._panelManager.changeQueryOptions(options); + public onQueryOptionsChange = (options: QueryGroupOptions) => { + const panelObj = this.state.panelRef.resolve(); + const dataObj = this.queryRunner; + const timeRangeObj = panelObj.state.$timeRange; + + const dataObjStateUpdate: Partial = {}; + const timeRangeObjStateUpdate: Partial = {}; + + if (options.maxDataPoints !== dataObj.state.maxDataPoints) { + dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined; + } + + if (options.minInterval !== dataObj.state.minInterval && options.minInterval !== null) { + dataObjStateUpdate.minInterval = options.minInterval; + } + + if (options.timeRange) { + timeRangeObjStateUpdate.timeFrom = options.timeRange.from ?? undefined; + timeRangeObjStateUpdate.timeShift = options.timeRange.shift ?? undefined; + timeRangeObjStateUpdate.hideTimeOverride = options.timeRange.hide; + } + + if (timeRangeObj instanceof PanelTimeRange) { + if (timeRangeObjStateUpdate.timeFrom !== undefined || timeRangeObjStateUpdate.timeShift !== undefined) { + // update time override + timeRangeObj.setState(timeRangeObjStateUpdate); + } else { + // remove time override + panelObj.setState({ $timeRange: undefined }); + } + } else { + // no time override present on the panel, let's create one first + panelObj.setState({ $timeRange: new PanelTimeRange(timeRangeObjStateUpdate) }); + } + + if (options.cacheTimeout !== dataObj?.state.cacheTimeout) { + dataObjStateUpdate.cacheTimeout = options.cacheTimeout; + } + + if (options.queryCachingTTL !== dataObj?.state.queryCachingTTL) { + dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL; + } + + dataObj.setState(dataObjStateUpdate); + dataObj.runQueries(); }; - onQueriesChange = (queries: DataQuery[]) => { - this._panelManager.changeQueries(queries); + public onQueriesChange = (queries: SceneDataQuery[]) => { + const runner = this.queryRunner; + runner.setState({ queries }); }; - onRunQueries = () => { - this._panelManager.queryRunner.runQueries(); + public onRunQueries = () => { + this.queryRunner.runQueries(); }; - getQueries() { - return this._panelManager.queryRunner.state.queries; + public getQueries() { + return this.queryRunner.state.queries; } - newQuery(): Partial { - const { dsSettings, datasource } = this._panelManager.state; - + public newQuery(): Partial { + const { dsSettings, datasource } = this.state; let ds; + if (!dsSettings?.meta.mixed) { ds = dsSettings; // Use dsSettings if it is not mixed } else if (!datasource?.meta.mixed) { @@ -138,29 +268,30 @@ export class PanelDataQueriesTab extends SceneObjectBase { + public addQueryClick = () => { const queries = this.getQueries(); this.onQueriesChange(addQuery(queries, this.newQuery())); }; - onAddQuery = (query: Partial) => { + public onAddQuery = (query: Partial) => { const queries = this.getQueries(); - const dsSettings = this._panelManager.state.dsSettings; + const dsSettings = this.state.dsSettings; + this.onQueriesChange( addQuery(queries, query, dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }) ); }; - isExpressionsSupported(dsSettings: DataSourceInstanceSettings): boolean { + public isExpressionsSupported(dsSettings: DataSourceInstanceSettings): boolean { return (dsSettings.meta.alerting || dsSettings.meta.mixed) === true; } - onAddExpressionClick = () => { + public onAddExpressionClick = () => { const queries = this.getQueries(); this.onQueriesChange(addQuery(queries, expressionDatasource.newQuery())); }; - renderExtraActions() { + public renderExtraActions() { return GroupActionComponents.getAllExtraRenderAction() .map((action, index) => action({ @@ -172,18 +303,14 @@ export class PanelDataQueriesTab extends SceneObjectBase) { - const { datasource, dsSettings } = model.panelManager.useState(); - const { data, queries } = model.panelManager.queryRunner.useState(); + const { datasource, dsSettings } = model.useState(); + const { data, queries } = model.queryRunner.useState(); if (!datasource || !dsSettings || !data) { return null; @@ -250,7 +377,6 @@ function QueriesTab(props: QueriesTabProps) { return ( { it('can change transformations', () => { - const vizPanelManager = setupVizPanelManger('panel-1'); - const model = new PanelDataTransformationsTab(vizPanelManager); - model.onChangeTransformations([{ id: 'calculateField', options: {} }]); - expect(model.getDataTransformer().state.transformations).toEqual([{ id: 'calculateField', options: {} }]); + const { transformsTab } = setupTabScene('panel-1'); + transformsTab.onChangeTransformations([{ id: 'calculateField', options: {} }]); + expect(transformsTab.getDataTransformer().state.transformations).toEqual([{ id: 'calculateField', options: {} }]); }); }); @@ -169,15 +167,16 @@ describe('PanelDataTransformationsTab', () => { }); }); -const setupVizPanelManger = (panelId: string) => { +function setupTabScene(panelId: string) { const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); const panel = findVizPanelByKey(scene, panelId)!; - const vizPanelManager = VizPanelManager.createFor(panel); + const transformsTab = new PanelDataTransformationsTab({ panelRef: panel.getRef() }); + transformsTab.activate(); // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it // @ts-expect-error getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); - return vizPanelManager; -}; + return { transformsTab }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx index a90022f66d2..5f0d7d4ad05 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx @@ -2,56 +2,62 @@ import { css } from '@emotion/css'; import { DragDropContext, DropResult, Droppable } from '@hello-pangea/dnd'; import { useState } from 'react'; -import { DataTransformerConfig, GrafanaTheme2, IconName, PanelData } from '@grafana/data'; +import { DataTransformerConfig, GrafanaTheme2, PanelData } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneObjectBase, SceneComponentProps, SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes'; +import { + SceneObjectBase, + SceneComponentProps, + SceneDataTransformer, + SceneQueryRunner, + SceneObjectRef, + VizPanel, + SceneObjectState, +} from '@grafana/scenes'; import { Button, ButtonGroup, ConfirmModal, Tab, useStyles2 } from '@grafana/ui'; import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows'; -import { VizPanelManager } from '../VizPanelManager'; +import { getQueryRunnerFor } from '../../utils/utils'; import { EmptyTransformationsMessage } from './EmptyTransformationsMessage'; import { TransformationsDrawer } from './TransformationsDrawer'; -import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; +import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; -interface PanelDataTransformationsTabState extends PanelDataPaneTabState {} +interface PanelDataTransformationsTabState extends SceneObjectState { + panelRef: SceneObjectRef; +} export class PanelDataTransformationsTab extends SceneObjectBase implements PanelDataPaneTab { static Component = PanelDataTransformationsTabRendered; - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; - tabId = TabId.Transformations; - icon: IconName = 'process'; - private _panelManager: VizPanelManager; getTabLabel() { return 'Transformations'; } - constructor(panelManager: VizPanelManager) { - super({}); - this.TabComponent = (props: PanelDataTabHeaderProps) => TransformationsTab({ ...props, model: this }); - - this._panelManager = panelManager; + public renderTab(props: PanelDataTabHeaderProps) { + return ; } public getQueryRunner(): SceneQueryRunner { - return this._panelManager.queryRunner; + return getQueryRunnerFor(this.state.panelRef.resolve())!; } public getDataTransformer(): SceneDataTransformer { - return this._panelManager.dataTransformer; - } + const provider = this.state.panelRef.resolve().state.$data; - public onChangeTransformations(transformations: DataTransformerConfig[]) { - this._panelManager.changeTransformations(transformations); + if (!provider || !(provider instanceof SceneDataTransformer)) { + throw new Error('Could not find SceneDataTransformer for panel'); + } + return provider; } - get panelManager() { - return this._panelManager; + public onChangeTransformations(transformations: DataTransformerConfig[]) { + const transformer = this.getDataTransformer(); + transformer.setState({ transformations }); + transformer.reprocessTransformations(); } } @@ -200,11 +206,10 @@ interface TransformationsTabProps extends PanelDataTabHeaderProps { function TransformationsTab(props: TransformationsTabProps) { const { model } = props; - const transformerState = model.getDataTransformer().useState(); + return ( ) => void; } export interface PanelDataPaneTab extends SceneObject { - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; + renderTab: (props: PanelDataTabHeaderProps) => React.JSX.Element; getTabLabel(): string; tabId: TabId; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx index cf37a7888ea..66c1263e302 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx @@ -1,5 +1,4 @@ import { selectors } from '@grafana/e2e-selectors'; -import { config } from '@grafana/runtime'; import { InlineSwitch } from '@grafana/ui'; import { PanelEditor } from './PanelEditor'; @@ -9,19 +8,17 @@ export interface Props { } export function PanelEditControls({ panelEditor }: Props) { - const vizManager = panelEditor.state.vizManager; - const { panel, tableView } = vizManager.useState(); - const skipDataQuery = config.panels[panel.state.pluginId]?.skipDataQuery; + const { tableView, dataPane } = panelEditor.useState(); return ( <> - {!skipDataQuery && ( + {dataPane && ( vizManager.toggleTableView()} + onClick={panelEditor.onToggleTableView} aria-label="toggle-table-view" data-testid={selectors.components.PanelEditor.toggleTableView} /> diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index 1c18e13fa0d..8117146a4fc 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -1,5 +1,21 @@ -import { PanelPlugin, PanelPluginMeta, PluginType } from '@grafana/data'; -import { SceneGridLayout, VizPanel } from '@grafana/scenes'; +import { of } from 'rxjs'; + +import { DataQueryRequest, DataSourceApi, LoadingState, PanelPlugin } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { setDataSourceSrv } from '@grafana/runtime'; +import { + CancelActivationHandler, + CustomVariable, + SceneDataTransformer, + sceneGraph, + SceneGridLayout, + SceneQueryRunner, + SceneTimeRange, + SceneVariableSet, + VizPanel, +} from '@grafana/scenes'; +import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import * as libAPI from 'app/features/library-panels/state/api'; import { DashboardGridItem } from '../scene/DashboardGridItem'; @@ -7,14 +23,28 @@ import { DashboardScene } from '../scene/DashboardScene'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { activateFullSceneTree } from '../utils/test-utils'; +import { findVizPanelByKey, getQueryRunnerFor } from '../utils/utils'; import { buildPanelEditScene } from './PanelEditor'; -let pluginToLoad: PanelPlugin | undefined; +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(() => pluginToLoad), + getPanelPluginFromCache: jest.fn(() => undefined), + importPanelPlugin: () => pluginPromise, }), config: { ...jest.requireActual('@grafana/runtime').config, @@ -29,103 +59,134 @@ jest.mock('@grafana/runtime', () => ({ }, })); +const dataSources = { + ds1: mockDataSource({ + uid: 'ds1', + type: DataSourceType.Prometheus, + }), +}; + +setDataSourceSrv(new MockDataSourceSrv(dataSources)); + +let deactivate: CancelActivationHandler | undefined; + describe('PanelEditor', () => { - describe('When closing editor', () => { - it('should apply changes automatically', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + afterEach(() => { + if (deactivate) { + deactivate(); + deactivate = undefined; + } + }); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); + describe('When initializing', () => { + it('should wait for panel plugin to load', async () => { + const { panelEditor, panel, pluginResolve, dashboard } = await setup({ skipWait: true }); - const gridItem = new DashboardGridItem({ body: panel }); + expect(panel.state.options).toEqual({}); + expect(panelEditor.state.isInitializing).toBe(true); - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), + const pluginToLoad = getPanelPlugin({ id: 'text' }).setPanelOptions((build) => { + build.addBooleanSwitch({ + path: 'showHeader', + name: 'Show header', + defaultValue: true, + }); }); - const deactivate = activateFullSceneTree(scene); + pluginResolve(pluginToLoad); - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); + await new Promise((r) => setTimeout(r, 1)); - deactivate(); + expect(panelEditor.state.isInitializing).toBe(false); + expect(panel.state.options).toEqual({ showHeader: true }); - const updatedPanel = gridItem.state.body as VizPanel; - expect(updatedPanel?.state.title).toBe('changed title'); + panel.onOptionsChange({ showHeader: false }); + panelEditor.onDiscard(); + + const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; + expect(discardedPanel.state.options).toEqual({ showHeader: true }); }); + }); - it('should discard changes when unmounted and discard changes is marked as true', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + describe('When discarding', () => { + it('should discard changes revert all changes', async () => { + const { panelEditor, panel, dashboard } = await setup(); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); + panel.setState({ title: 'changed title' }); + panelEditor.onDiscard(); - const gridItem = new DashboardGridItem({ body: panel }); + const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - }); + 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 deactivate = activateFullSceneTree(scene); + expect((dashboard.state.body as SceneGridLayout).state.children.length).toBe(0); + }); - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); + it('should discard query runner changes', async () => { + const { panelEditor, panel, dashboard } = await setup({}); - editScene.onDiscard(); - deactivate(); + const queryRunner = getQueryRunnerFor(panel); + queryRunner?.setState({ maxDataPoints: 123, queries: [{ refId: 'A' }, { refId: 'B' }] }); - const updatedPanel = gridItem.state.body as VizPanel; - expect(updatedPanel?.state.title).toBe(panel.state.title); + 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); }); + }); - it('should discard a newly added panel', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + describe('When changes are made', () => { + it('Should set state to dirty', async () => { + const { panelEditor, panel } = await setup({}); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); + expect(panelEditor.state.isDirty).toBe(undefined); - const gridItem = new DashboardGridItem({ body: panel }); + panel.setState({ title: 'changed title' }); - const editScene = buildPanelEditScene(panel, true); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - }); + expect(panelEditor.state.isDirty).toBe(true); + }); - editScene.onDiscard(); - const deactivate = activateFullSceneTree(scene); + it('Should reset dirty and orginal state when dashboard is saved', async () => { + const { panelEditor, panel } = await setup({}); - deactivate(); + expect(panelEditor.state.isDirty).toBe(undefined); + + panel.setState({ title: 'changed title' }); + + panelEditor.dashboardSaved(); + + expect(panelEditor.state.isDirty).toBe(false); - expect((scene.state.body as SceneGridLayout).state.children.length).toBe(0); + 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 () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); + pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true })); + const panel = new VizPanel({ key: 'panel-1', pluginId: 'text' }); const libraryPanelModel = { title: 'title', uid: 'uid', @@ -143,15 +204,13 @@ describe('PanelEditor', () => { _loadedPanel: libraryPanelModel, }); - panel.setState({ - $behaviors: [libPanelBehavior], - }); + 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 SceneGridLayout({ children: [gridItem], @@ -160,96 +219,133 @@ describe('PanelEditor', () => { activateFullSceneTree(scene); - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); - (editScene.state.vizManager.state.panel.state.$behaviors![0] as LibraryPanelBehavior).setState({ - name: 'changed name', - }); + 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.state.vizManager.commitChanges(); + editScene.onConfirmSaveLibraryPanel(); + await new Promise(process.nextTick); - await new Promise(process.nextTick); // Wait for mock api to return and update the library panel + // 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(libPanelBehavior.state.title).toBe('changed title'); expect((gridItem.state.body as VizPanel).state.title).toBe('changed title'); }); - }); - describe('PanelDataPane', () => { - it('should not exist if panel is skipDataQuery', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + it('unlinks library panel', () => { + const libraryPanelModel = { + title: 'title', + uid: 'uid', + name: 'libraryPanelName', + model: { + title: 'title', + type: 'text', + }, + type: 'panel', + version: 1, + }; - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - new DashboardGridItem({ - body: panel, + const libPanelBehavior = new LibraryPanelBehavior({ + isLoaded: true, + title: libraryPanelModel.title, + 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] }); const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - }); + editScene.onConfirmUnlinkLibraryPanel(); - activateFullSceneTree(scene); + expect(panel.state.$behaviors?.length).toBe(1); + expect(panel.state.$behaviors![0]).toBe(otherBehavior); + }); + }); - expect(editScene.state.dataPane).toBeUndefined(); + describe('PanelDataPane', () => { + it('should not exist if panel is skipDataQuery', async () => { + const { panelEditor } = await setup({ pluginSkipDataQuery: true }); + expect(panelEditor.state.dataPane).toBeUndefined(); }); - it('should exist if panel is supporting querying', () => { - pluginToLoad = getTestPanelPlugin({ id: 'timeseries' }); + it('should exist if panel is supporting querying', async () => { + const { panelEditor } = await setup({ pluginSkipDataQuery: false }); + expect(panelEditor.state.dataPane).toBeDefined(); + }); + }); +}); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'timeseries', - }); +interface SetupOptions { + isNewPanel?: boolean; + pluginSkipDataQuery?: boolean; + repeatByVariable?: string; + skipWait?: boolean; + pluginLoadTime?: number; +} - new DashboardGridItem({ - body: panel, - }); - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - }); +async function setup(options: SetupOptions = {}) { + const pluginToLoad = getPanelPlugin({ id: 'text', skipDataQuery: options.pluginSkipDataQuery }); + let pluginResolve = (plugin: PanelPlugin) => {}; - activateFullSceneTree(scene); - expect(editScene.state.dataPane).toBeDefined(); - }); + pluginPromise = new Promise((resolve) => { + pluginResolve = resolve; }); -}); -export function getTestPanelPlugin(options: Partial): PanelPlugin { - const plugin = new PanelPlugin(() => null); - plugin.meta = { - id: options.id!, - type: PluginType.panel, - name: options.id!, - sort: options.sort || 1, - info: { - author: { - name: options.id + 'name', - }, - description: '', - links: [], - logos: { - large: '', - small: '', - }, - screenshots: [], - updated: '', - version: '1.0.', - }, - hideFromList: options.hideFromList === true, - module: options.module ?? '', - baseUrl: '', - skipDataQuery: options.skipDataQuery ?? false, - }; - return plugin; + 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 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 }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 40546061ae5..3a696e97749 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -1,72 +1,186 @@ import * as H from 'history'; - -import { NavIndex } from '@grafana/data'; -import { config, locationService } from '@grafana/runtime'; -import { SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; - -import { DashboardGridItem } from '../scene/DashboardGridItem'; -import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils'; - +import { debounce } from 'lodash'; + +import { NavIndex, PanelPlugin } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { + PanelBuilders, + SceneObjectBase, + SceneObjectRef, + SceneObjectState, + SceneObjectStateChangedEvent, + sceneUtils, + VizPanel, +} from '@grafana/scenes'; +import { Panel } from '@grafana/schema/dist/esm/index.gen'; +import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; +import { saveLibPanel } from 'app/features/library-panels/state/api'; + +import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; +import { getPanelChanges } from '../saving/getDashboardChanges'; +import { DashboardGridItem, DashboardGridItemState } from '../scene/DashboardGridItem'; +import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; +import { + activateInActiveParents, + getDashboardSceneFor, + getLibraryPanelBehavior, + getPanelIdForVizPanel, +} from '../utils/utils'; + +import { DataProviderSharer } from './PanelDataPane/DataProviderSharer'; import { PanelDataPane } from './PanelDataPane/PanelDataPane'; import { PanelEditorRenderer } from './PanelEditorRenderer'; import { PanelOptionsPane } from './PanelOptionsPane'; -import { VizPanelManager, VizPanelManagerState } from './VizPanelManager'; export interface PanelEditorState extends SceneObjectState { isNewPanel: boolean; isDirty?: boolean; - panelId: number; - optionsPane: PanelOptionsPane; + optionsPane?: PanelOptionsPane; dataPane?: PanelDataPane; - vizManager: VizPanelManager; + panelRef: SceneObjectRef; showLibraryPanelSaveModal?: boolean; showLibraryPanelUnlinkModal?: boolean; + tableView?: VizPanel; + pluginLoadErrror?: string; + /** + * Waiting for library panel or panel plugin to load + */ + isInitializing?: boolean; } export class PanelEditor extends SceneObjectBase { - private _initialRepeatOptions: Pick = {}; static Component = PanelEditorRenderer; - private _discardChanges = false; + private _originalLayoutElementState!: DashboardGridItemState; + private _layoutElement!: DashboardGridItem; + private _originalSaveModel!: Panel; public constructor(state: PanelEditorState) { super(state); - const { repeat, repeatDirection, maxPerRow } = state.vizManager.state; - this._initialRepeatOptions = { - repeat, - repeatDirection, - maxPerRow, - }; - + this.setOriginalState(this.state.panelRef); this.addActivationHandler(this._activationHandler.bind(this)); } private _activationHandler() { - const panelManager = this.state.vizManager; - const panel = panelManager.state.panel; - - this._subs.add( - panelManager.subscribeToState((n, p) => { - if (n.pluginId !== p.pluginId) { - this._initDataPane(n.pluginId); - } - }) - ); + const panel = this.state.panelRef.resolve(); + const deactivateParents = activateInActiveParents(panel); + const layoutElement = panel.parent; - this._initDataPane(panel.state.pluginId); + this.waitForPlugin(); return () => { - if (!this._discardChanges) { - this.commitChanges(); - } else if (this.state.isNewPanel) { - getDashboardSceneFor(this).removePanel(panelManager.state.sourcePanel.resolve()!); + if (layoutElement instanceof DashboardGridItem) { + layoutElement.editingCompleted(); + } + if (deactivateParents) { + deactivateParents(); } }; } + private waitForPlugin(retry = 0) { + const panel = this.getPanel(); + const plugin = panel.getPlugin(); + + if (!plugin || plugin.meta.id !== panel.state.pluginId) { + if (retry < 100) { + setTimeout(() => this.waitForPlugin(retry + 1), retry * 10); + } else { + this.setState({ pluginLoadErrror: 'Failed to load panel plugin' }); + } + return; + } + + this.gotPanelPlugin(plugin); + } + + private setOriginalState(panelRef: SceneObjectRef) { + const panel = panelRef.resolve(); + + this._originalSaveModel = vizPanelToPanel(panel); + + if (panel.parent instanceof DashboardGridItem) { + this._originalLayoutElementState = sceneUtils.cloneSceneObjectState(panel.parent.state); + this._layoutElement = panel.parent; + } + } + + /** + * Useful for testing to turn on debounce + */ + public debounceSaveModelDiff = true; + + /** + * Subscribe to state changes and check if the save model has changed + */ + private _setupChangeDetection() { + const panel = this.state.panelRef.resolve(); + const performSaveModelDiff = () => { + const { hasChanges } = getPanelChanges(this._originalSaveModel, vizPanelToPanel(panel)); + this.setState({ isDirty: hasChanges }); + }; + + const performSaveModelDiffDebounced = this.debounceSaveModelDiff + ? debounce(performSaveModelDiff, 250) + : performSaveModelDiff; + + const handleStateChange = (event: SceneObjectStateChangedEvent) => { + if (DashboardSceneChangeTracker.isUpdatingPersistedState(event)) { + performSaveModelDiffDebounced(); + } + }; + + this._subs.add(panel.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); + // Repeat options live on the layout element (DashboardGridItem) + this._subs.add(this._layoutElement.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); + } + + public getPanel(): VizPanel { + return this.state.panelRef?.resolve(); + } + + private gotPanelPlugin(plugin: PanelPlugin) { + const panel = this.getPanel(); + const layoutElement = panel.parent; + + // First time initialization + if (this.state.isInitializing) { + this.setOriginalState(this.state.panelRef); + + if (layoutElement instanceof DashboardGridItem) { + layoutElement.editingStarted(); + } + + this._setupChangeDetection(); + this._updateDataPane(plugin); + + // Listen for panel plugin changes + this._subs.add( + panel.subscribeToState((n, p) => { + if (n.pluginId !== p.pluginId) { + this.waitForPlugin(); + } + }) + ); + + // Setup options pane + this.setState({ + optionsPane: new PanelOptionsPane({ + panelRef: this.state.panelRef, + searchQuery: '', + listMode: OptionFilter.All, + }), + isInitializing: false, + }); + } else { + // plugin changed after first time initialization + // Just update data pane + this._updateDataPane(plugin); + } + } - private _initDataPane(pluginId: string) { - const skipDataQuery = config.panels[pluginId]?.skipDataQuery; + private _updateDataPane(plugin: PanelPlugin) { + const skipDataQuery = plugin.meta.skipDataQuery; if (skipDataQuery && this.state.dataPane) { locationService.partial({ tab: null }, true); @@ -74,12 +188,16 @@ export class PanelEditor extends SceneObjectBase { } if (!skipDataQuery && !this.state.dataPane) { - this.setState({ dataPane: new PanelDataPane(this.state.vizManager) }); + this.setState({ dataPane: PanelDataPane.createFor(this.getPanel()) }); } } public getUrlKey() { - return this.state.panelId.toString(); + return this.getPanelId().toString(); + } + + public getPanelId() { + return getPanelIdForVizPanel(this.state.panelRef.resolve()); } public getPageNav(location: H.Location, navIndex: NavIndex) { @@ -92,53 +210,23 @@ export class PanelEditor extends SceneObjectBase { } public onDiscard = () => { - this.state.vizManager.setState({ isDirty: false }); - this._discardChanges = true; - locationService.partial({ editPanel: null }); - }; - - public commitChanges() { - const dashboard = getDashboardSceneFor(this); - - if (!dashboard.state.isEditing) { - dashboard.onEnterEditMode(); - } + this.setState({ isDirty: false }); - const panelManager = this.state.vizManager; - const sourcePanel = panelManager.state.sourcePanel.resolve(); - const gridItem = sourcePanel!.parent; + const panel = this.state.panelRef.resolve(); - if (!(gridItem instanceof DashboardGridItem)) { - console.error('Unsupported scene object type'); - return; + if (this.state.isNewPanel) { + getDashboardSceneFor(this).removePanel(panel); + } else { + // Revert any layout element changes + this._layoutElement.setState(this._originalLayoutElementState!); } - this.commitChangesToSource(gridItem); - } - - private commitChangesToSource(gridItem: DashboardGridItem) { - let width = gridItem.state.width ?? 1; - let height = gridItem.state.height; - - const panelManager = this.state.vizManager; - const horizontalToVertical = - this._initialRepeatOptions.repeatDirection === 'h' && panelManager.state.repeatDirection === 'v'; - const verticalToHorizontal = - this._initialRepeatOptions.repeatDirection === 'v' && panelManager.state.repeatDirection === 'h'; - if (horizontalToVertical) { - width = Math.floor(width / (gridItem.state.maxPerRow ?? 1)); - } else if (verticalToHorizontal) { - width = 24; - } + locationService.partial({ editPanel: null }); + }; - gridItem.setState({ - body: panelManager.state.panel.clone(), - repeatDirection: panelManager.state.repeatDirection, - variableName: panelManager.state.repeat, - maxPerRow: panelManager.state.maxPerRow, - width, - height, - }); + public dashboardSaved() { + this.setOriginalState(this.state.panelRef); + this.setState({ isDirty: false }); } public onSaveLibraryPanel = () => { @@ -146,8 +234,8 @@ export class PanelEditor extends SceneObjectBase { }; public onConfirmSaveLibraryPanel = () => { - this.state.vizManager.commitChanges(); - this.state.vizManager.setState({ isDirty: false }); + saveLibPanel(this.state.panelRef.resolve()); + this.setState({ isDirty: false }); locationService.partial({ editPanel: null }); }; @@ -164,16 +252,43 @@ export class PanelEditor extends SceneObjectBase { }; public onConfirmUnlinkLibraryPanel = () => { - this.state.vizManager.unlinkLibraryPanel(); + const libPanelBehavior = getLibraryPanelBehavior(this.getPanel()); + if (!libPanelBehavior) { + return; + } + + libPanelBehavior.unlink(); + this.setState({ showLibraryPanelUnlinkModal: false }); }; + + public onToggleTableView = () => { + if (this.state.tableView) { + this.setState({ tableView: undefined }); + return; + } + + const panel = this.state.panelRef.resolve(); + const dataProvider = panel.state.$data; + if (!dataProvider) { + return; + } + + this.setState({ + tableView: PanelBuilders.table() + .setTitle('') + .setOption('showTypeIcons', true) + .setOption('showHeader', true) + .setData(new DataProviderSharer({ source: dataProvider.getRef() })) + .build(), + }); + }; } export function buildPanelEditScene(panel: VizPanel, isNewPanel = false): PanelEditor { return new PanelEditor({ - panelId: getPanelIdForVizPanel(panel), - optionsPane: new PanelOptionsPane({}), - vizManager: VizPanelManager.createFor(panel), + isInitializing: true, + panelRef: panel.getRef(), isNewPanel, }); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index 50f259d12ff..ae136dd08b7 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -2,8 +2,8 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneComponentProps } from '@grafana/scenes'; -import { Button, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { SceneComponentProps, VizPanel } from '@grafana/scenes'; +import { Button, Spinner, ToolbarButton, useStyles2 } from '@grafana/ui'; import { NavToolbarActions } from '../scene/NavToolbarActions'; import { UnlinkModal } from '../scene/UnlinkModal'; @@ -54,7 +54,8 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) />
)} - {!splitterState.collapsed && } + {!splitterState.collapsed && optionsPane && } + {!splitterState.collapsed && !optionsPane && } @@ -63,9 +64,9 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) function VizAndDataPane({ model }: SceneComponentProps) { const dashboard = getDashboardSceneFor(model); - const { vizManager, dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal } = model.useState(); - const { sourcePanel } = vizManager.useState(); - const libraryPanel = getLibraryPanelBehavior(sourcePanel.resolve()); + const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView } = model.useState(); + const panel = model.getPanel(); + const libraryPanel = getLibraryPanelBehavior(panel); const { controls } = dashboard.useState(); const styles = useStyles2(getStyles); @@ -94,7 +95,7 @@ function VizAndDataPane({ model }: SceneComponentProps) { )}
- +
{showLibraryPanelSaveModal && libraryPanel && ( ) { ); } +interface VizWrapperProps { + panel: VizPanel; + tableView?: VizPanel; +} + +function VizWrapper({ panel, tableView }: VizWrapperProps) { + const styles = useStyles2(getStyles); + const panelToShow = tableView ?? panel; + + return ( +
+ +
+ ); +} + function getStyles(theme: GrafanaTheme2) { return { pageContainer: css({ @@ -215,5 +232,10 @@ function getStyles(theme: GrafanaTheme2) { rotate: '-90deg', }, }), + vizWrapper: css({ + height: '100%', + width: '100%', + paddingLeft: theme.spacing(2), + }), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx index 619183a9dd2..cfba5a9c57f 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx @@ -18,7 +18,7 @@ import { activateFullSceneTree } from '../utils/test-utils'; import * as utils from '../utils/utils'; import { PanelOptions } from './PanelOptions'; -import { VizPanelManager } from './VizPanelManager'; +import { PanelOptionsPane } from './PanelOptionsPane'; const OptionsPaneSelector = selectors.components.PanelEditor.OptionsPane; @@ -92,43 +92,47 @@ function setup(options: SetupOptions = {}) { } // need to wait for plugin to load - const vizManager = VizPanelManager.createFor(panel); - - activateFullSceneTree(vizManager); + const panelOptionsScene = new PanelOptionsPane({ + panelRef: panel.getRef(), + searchQuery: '', + listMode: OptionFilter.All, + }); - const panelOptions = ; + activateFullSceneTree(panelOptionsScene); + panel.activate(); + const panelOptions = ; const renderResult = render(panelOptions); - return { renderResult, vizManager }; + return { renderResult, panelOptionsScene, panel }; } describe('PanelOptions', () => { describe('Can render and edit panel frame options', () => { it('Can edit title', async () => { - const { vizManager } = setup(); + const { panel } = setup(); expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument(); const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title')); fireEvent.change(input, { target: { value: 'New title' } }); - expect(vizManager.state.panel.state.title).toBe('New title'); + expect(panel.state.title).toBe('New title'); }); it('Clearing title should set hoverHeader to true', async () => { - const { vizManager } = setup(); + const { panel } = setup(); expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument(); const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title')); fireEvent.change(input, { target: { value: '' } }); - expect(vizManager.state.panel.state.title).toBe(''); - expect(vizManager.state.panel.state.hoverHeader).toBe(true); + expect(panel.state.title).toBe(''); + expect(panel.state.hoverHeader).toBe(true); fireEvent.change(input, { target: { value: 'Muu' } }); - expect(vizManager.state.panel.state.hoverHeader).toBe(false); + expect(panel.state.hoverHeader).toBe(false); }); }); @@ -179,13 +183,11 @@ describe('PanelOptions', () => { _loadedPanel: libraryPanelModel, }); - panel.setState({ - $behaviors: [libraryPanel], - }); + panel.setState({ $behaviors: [libraryPanel] }); new DashboardGridItem({ body: panel }); - const { renderResult, vizManager } = setup({ panel: panel }); + const { renderResult } = setup({ panel: panel }); const input = await renderResult.findByTestId('library panel name input'); @@ -193,8 +195,6 @@ describe('PanelOptions', () => { fireEvent.blur(input, { target: { value: 'new library panel name' } }); }); - expect((vizManager.state.panel.state.$behaviors![0] as LibraryPanelBehavior).state.name).toBe( - 'new library panel name' - ); + expect((panel.state.$behaviors![0] as LibraryPanelBehavior).state.name).toBe('new library panel name'); }); }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx index 01e4655d832..3428dbe707b 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx @@ -13,24 +13,23 @@ import { import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; -import { VizPanelManager } from './VizPanelManager'; import { getPanelFrameCategory2 } from './getPanelFrameOptions'; interface Props { - vizManager: VizPanelManager; + panel: VizPanel; searchQuery: string; listMode: OptionFilter; data?: PanelData; } -export const PanelOptions = React.memo(({ vizManager, searchQuery, listMode, data }) => { - const { panel, repeat } = vizManager.useState(); +export const PanelOptions = React.memo(({ panel, searchQuery, listMode, data }) => { const { options, fieldConfig, _pluginInstanceState } = panel.useState(); + const layoutElement = panel.parent!; + const layoutElementState = layoutElement.useState(); - // eslint-disable-next-line react-hooks/exhaustive-deps const panelFrameOptions = useMemo( - () => getPanelFrameCategory2(vizManager, panel, repeat), - [vizManager, panel, repeat] + () => getPanelFrameCategory2(panel, layoutElementState), + [panel, layoutElementState] ); const visualizationOptions = useMemo(() => { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx new file mode 100644 index 00000000000..a63717aa9f7 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx @@ -0,0 +1,101 @@ +import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; + +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; +import { findVizPanelByKey } from '../utils/utils'; + +import { PanelOptionsPane } from './PanelOptionsPane'; +import { testDashboard } from './testfiles/testDashboard'; + +describe('PanelOptionsPane', () => { + describe('When changing plugin', () => { + it('Should set the cache', () => { + const { optionsPane, panel } = setupTest('panel-1'); + panel.changePluginType = jest.fn(); + + expect(panel.state.pluginId).toBe('timeseries'); + + optionsPane.onChangePanelPlugin({ pluginId: 'table' }); + + expect(optionsPane['_cachedPluginOptions']['timeseries']?.options).toBe(panel.state.options); + expect(optionsPane['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe(panel.state.fieldConfig); + }); + + it('Should preserve correct field config', () => { + const { optionsPane, panel } = setupTest('panel-1'); + + const mockFn = jest.fn(); + panel.changePluginType = mockFn; + + const fieldConfig = panel.state.fieldConfig; + + fieldConfig.defaults = { + ...fieldConfig.defaults, + unit: 'flop', + decimals: 2, + }; + + fieldConfig.overrides = [ + { + matcher: { + id: 'byName', + options: 'A-series', + }, + properties: [ + { + id: 'displayName', + value: 'test', + }, + ], + }, + { + matcher: { id: 'byName', options: 'D-series' }, + //should be removed because it's custom + properties: [ + { + id: 'custom.customPropNoExist', + value: 'google', + }, + ], + }, + ]; + + panel.setState({ fieldConfig: fieldConfig }); + + expect(panel.state.fieldConfig.defaults.color?.mode).toBe('palette-classic'); + expect(panel.state.fieldConfig.defaults.thresholds?.mode).toBe('absolute'); + expect(panel.state.fieldConfig.defaults.unit).toBe('flop'); + expect(panel.state.fieldConfig.defaults.decimals).toBe(2); + expect(panel.state.fieldConfig.overrides).toHaveLength(2); + expect(panel.state.fieldConfig.overrides[1].properties).toHaveLength(1); + expect(panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow'); + + optionsPane.onChangePanelPlugin({ pluginId: 'table' }); + + expect(mockFn).toHaveBeenCalled(); + expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic'); + expect(mockFn.mock.calls[0][2].defaults.thresholds?.mode).toBe('absolute'); + expect(mockFn.mock.calls[0][2].defaults.unit).toBe('flop'); + expect(mockFn.mock.calls[0][2].defaults.decimals).toBe(2); + expect(mockFn.mock.calls[0][2].overrides).toHaveLength(2); + //removed custom property + expect(mockFn.mock.calls[0][2].overrides[1].properties).toHaveLength(0); + //removed fieldConfig custom values as well + expect(mockFn.mock.calls[0][2].defaults.custom).toStrictEqual({}); + }); + }); +}); + +function setupTest(panelId: string) { + const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} }); + const panel = findVizPanelByKey(scene, panelId)!; + + const optionsPane = new PanelOptionsPane({ panelRef: panel.getRef(), listMode: OptionFilter.All, searchQuery: '' }); + + // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it + // @ts-expect-error + getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); + + return { optionsPane, scene, panel }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index 5effa56b8f8..3b100b15b83 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -1,15 +1,30 @@ import { css } from '@emotion/css'; import { useMemo } from 'react'; -import { GrafanaTheme2, PanelPluginMeta } from '@grafana/data'; +import { + FieldConfigSource, + filterFieldConfigOverrides, + GrafanaTheme2, + isStandardFieldProp, + PanelPluginMeta, + restoreCustomOverrideRules, +} from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState, sceneGraph } from '@grafana/scenes'; +import { + DeepPartial, + SceneComponentProps, + SceneObjectBase, + SceneObjectRef, + SceneObjectState, + VizPanel, + sceneGraph, +} from '@grafana/scenes'; import { FilterInput, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPluginError'; +import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types'; import { getAllPanelPluginMeta } from 'app/features/panel/state/util'; -import { PanelEditor } from './PanelEditor'; import { PanelOptions } from './PanelOptions'; import { PanelVizTypePicker } from './PanelVizTypePicker'; @@ -17,21 +32,48 @@ export interface PanelOptionsPaneState extends SceneObjectState { isVizPickerOpen?: boolean; searchQuery: string; listMode: OptionFilter; + panelRef: SceneObjectRef; +} + +interface PluginOptionsCache { + options: DeepPartial<{}>; + fieldConfig: FieldConfigSource>; } export class PanelOptionsPane extends SceneObjectBase { - public constructor(state: Partial) { - super({ - searchQuery: '', - listMode: OptionFilter.All, - ...state, - }); - } + private _cachedPluginOptions: Record = {}; onToggleVizPicker = () => { this.setState({ isVizPickerOpen: !this.state.isVizPickerOpen }); }; + onChangePanelPlugin = (options: VizTypeChangeDetails) => { + const panel = this.state.panelRef.resolve(); + const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = panel.state; + const pluginId = options.pluginId; + + // clear custom options + let newFieldConfig: FieldConfigSource = { + defaults: { + ...prevFieldConfig.defaults, + custom: {}, + }, + overrides: filterFieldConfigOverrides(prevFieldConfig.overrides, isStandardFieldProp), + }; + + this._cachedPluginOptions[prevPluginId] = { options: prevOptions, fieldConfig: prevFieldConfig }; + + const cachedOptions = this._cachedPluginOptions[pluginId]?.options; + const cachedFieldConfig = this._cachedPluginOptions[pluginId]?.fieldConfig; + + if (cachedFieldConfig) { + newFieldConfig = restoreCustomOverrideRules(newFieldConfig, cachedFieldConfig); + } + + panel.changePluginType(pluginId, cachedOptions, newFieldConfig); + this.onToggleVizPicker(); + }; + onSetSearchQuery = (searchQuery: string) => { this.setState({ searchQuery }); }; @@ -41,10 +83,10 @@ export class PanelOptionsPane extends SceneObjectBase { }; static Component = ({ model }: SceneComponentProps) => { - const { isVizPickerOpen, searchQuery, listMode } = model.useState(); - const vizManager = sceneGraph.getAncestor(model, PanelEditor).state.vizManager; - const { pluginId } = vizManager.useState(); - const { data } = sceneGraph.getData(vizManager.state.panel).useState(); + const { isVizPickerOpen, searchQuery, listMode, panelRef } = model.useState(); + const panel = panelRef.resolve(); + const { pluginId } = panel.useState(); + const { data } = sceneGraph.getData(panel).useState(); const styles = useStyles2(getStyles); return ( @@ -61,12 +103,17 @@ export class PanelOptionsPane extends SceneObjectBase { />
- +
)} {isVizPickerOpen && ( - + )} ); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx index 0da983abab6..2e180642bc9 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -4,6 +4,7 @@ import { useLocalStorage } from 'react-use'; import { GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { VizPanel } from '@grafana/scenes'; import { Button, CustomScrollbar, Field, FilterInput, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { LS_VISUALIZATION_SELECT_TAB_KEY, LS_WIDGET_SELECT_TAB_KEY } from 'app/core/constants'; import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types'; @@ -13,16 +14,14 @@ import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicke import { PanelModelCompatibilityWrapper } from '../utils/PanelModelCompatibilityWrapper'; -import { VizPanelManager } from './VizPanelManager'; - export interface Props { data?: PanelData; - vizManager: VizPanelManager; - onChange: () => void; + panel: VizPanel; + onChange: (options: VizTypeChangeDetails) => void; + onClose: () => void; } -export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { - const { panel } = vizManager.useState(); +export function PanelVizTypePicker({ panel, data, onChange, onClose }: Props) { const styles = useStyles2(getStyles); const [searchQuery, setSearchQuery] = useState(''); @@ -50,22 +49,8 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { const radioOptions: Array> = [ { label: 'Visualizations', value: VisualizationSelectPaneTab.Visualizations }, { label: 'Suggestions', value: VisualizationSelectPaneTab.Suggestions }, - // { - // label: 'Library panels', - // value: VisualizationSelectPaneTab.LibraryPanels, - // description: 'Reusable panels you can share between multiple dashboards.', - // }, ]; - const onVizTypeChange = (options: VizTypeChangeDetails) => { - vizManager.changePluginType(options.pluginId); - onChange(); - }; - - const onCloseVizPicker = () => { - onChange(); - }; - return (
@@ -82,7 +67,7 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { icon="angle-up" className={styles.closeButton} data-testid={selectors.components.PanelEditor.toggleVizPicker} - onClick={onCloseVizPicker} + onClick={onClose} />
@@ -90,18 +75,10 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { {listMode === VisualizationSelectPaneTab.Visualizations && ( - + )} - {/* {listMode === VisualizationSelectPaneTab.Widgets && ( - - )} */} {listMode === VisualizationSelectPaneTab.Suggestions && ( - + )}
diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx deleted file mode 100644 index 887c876bf70..00000000000 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx +++ /dev/null @@ -1,864 +0,0 @@ -import { map, of } from 'rxjs'; - -import { DataQueryRequest, DataSourceApi, DataSourceInstanceSettings, LoadingState, PanelData } from '@grafana/data'; -import { calculateFieldTransformer } from '@grafana/data/src/transformations/transformers/calculateField'; -import { mockTransformationsRegistry } from '@grafana/data/src/utils/tests/mockTransformationsRegistry'; -import { config, locationService } from '@grafana/runtime'; -import { - CustomVariable, - LocalValueVariable, - SceneGridRow, - SceneVariableSet, - VizPanel, - sceneGraph, -} from '@grafana/scenes'; -import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { InspectTab } from 'app/features/inspector/types'; -import * as libAPI from 'app/features/library-panels/state/api'; -import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; -import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; - -import { DashboardGridItem } from '../scene/DashboardGridItem'; -import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; -import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange'; -import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; -import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; -import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; -import { findVizPanelByKey } from '../utils/utils'; - -import { buildPanelEditScene } from './PanelEditor'; -import { VizPanelManager } from './VizPanelManager'; -import { panelWithQueriesOnly, panelWithTransformations, testDashboard } from './testfiles/testDashboard'; - -const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { - const result: PanelData = { - state: LoadingState.Loading, - series: [], - timeRange: request.range, - }; - - return of([]).pipe( - map(() => { - result.state = LoadingState.Done; - result.series = []; - - return result; - }) - ); -}); - -const ds1Mock: DataSourceApi = { - meta: { - id: 'grafana-testdata-datasource', - }, - name: 'grafana-testdata-datasource', - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - getRef: () => { - return { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }; - }, -} as DataSourceApi; - -const ds2Mock: DataSourceApi = { - meta: { - id: 'grafana-prometheus-datasource', - }, - name: 'grafana-prometheus-datasource', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - getRef: () => { - return { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' }; - }, -} as DataSourceApi; - -const ds3Mock: DataSourceApi = { - meta: { - id: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - name: SHARED_DASHBOARD_QUERY, - type: SHARED_DASHBOARD_QUERY, - uid: SHARED_DASHBOARD_QUERY, - getRef: () => { - return { type: SHARED_DASHBOARD_QUERY, uid: SHARED_DASHBOARD_QUERY }; - }, -} as DataSourceApi; - -const defaultDsMock: DataSourceApi = { - meta: { - id: 'grafana-testdata-datasource', - }, - name: 'grafana-testdata-datasource', - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - getRef: () => { - return { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }; - }, -} as DataSourceApi; - -const instance1SettingsMock = { - id: 1, - uid: 'gdev-testdata', - name: 'testDs1', - type: 'grafana-testdata-datasource', - meta: { - id: 'grafana-testdata-datasource', - }, -}; - -const instance2SettingsMock = { - id: 1, - uid: 'gdev-prometheus', - name: 'testDs2', - type: 'grafana-prometheus-datasource', - meta: { - id: 'grafana-prometheus-datasource', - }, -}; - -// Mocking the build in Grafana data source to avoid annotations data layer errors. -const grafanaDs = { - id: 1, - uid: '-- Grafana --', - name: 'grafana', - type: 'grafana', - meta: { - id: 'grafana', - }, -}; - -// Mock the store module -jest.mock('app/core/store', () => ({ - exists: jest.fn(), - get: jest.fn(), - getObject: jest.fn((_a, b) => b), - setObject: jest.fn(), -})); - -const store = jest.requireMock('app/core/store'); - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { - return runRequestMock(ds, request); - }, - getDataSourceSrv: () => ({ - get: async (ref: DataSourceRef) => { - // Mocking the build in Grafana data source to avoid annotations data layer errors. - - if (ref.uid === '-- Grafana --') { - return grafanaDs; - } - - if (ref.uid === 'gdev-testdata') { - return ds1Mock; - } - - if (ref.uid === 'gdev-prometheus') { - return ds2Mock; - } - - if (ref.uid === SHARED_DASHBOARD_QUERY) { - return ds3Mock; - } - - // if datasource is not found, return default datasource - return defaultDsMock; - }, - getInstanceSettings: (ref: DataSourceRef) => { - if (ref.uid === 'gdev-testdata') { - return instance1SettingsMock; - } - - if (ref.uid === 'gdev-prometheus') { - return instance2SettingsMock; - } - - // if datasource is not found, return default instance settings - return instance1SettingsMock; - }, - }), - locationService: { - partial: jest.fn(), - }, - config: { - ...jest.requireActual('@grafana/runtime').config, - defaultDatasource: 'gdev-testdata', - }, -})); - -mockTransformationsRegistry([calculateFieldTransformer]); - -jest.useFakeTimers(); - -describe('VizPanelManager', () => { - describe('When changing plugin', () => { - it('Should set the cache', () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.state.panel.changePluginType = jest.fn(); - - expect(vizPanelManager.state.panel.state.pluginId).toBe('timeseries'); - - vizPanelManager.changePluginType('table'); - - expect(vizPanelManager['_cachedPluginOptions']['timeseries']?.options).toBe( - vizPanelManager.state.panel.state.options - ); - expect(vizPanelManager['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe( - vizPanelManager.state.panel.state.fieldConfig - ); - }); - - it('Should preserve correct field config', () => { - const { vizPanelManager } = setupTest('panel-1'); - const mockFn = jest.fn(); - vizPanelManager.state.panel.changePluginType = mockFn; - const fieldConfig = vizPanelManager.state.panel.state.fieldConfig; - fieldConfig.defaults = { - ...fieldConfig.defaults, - unit: 'flop', - decimals: 2, - }; - fieldConfig.overrides = [ - { - matcher: { - id: 'byName', - options: 'A-series', - }, - properties: [ - { - id: 'displayName', - value: 'test', - }, - ], - }, - { - matcher: { id: 'byName', options: 'D-series' }, - //should be removed because it's custom - properties: [ - { - id: 'custom.customPropNoExist', - value: 'google', - }, - ], - }, - ]; - vizPanelManager.state.panel.setState({ - fieldConfig: fieldConfig, - }); - - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.color?.mode).toBe('palette-classic'); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.thresholds?.mode).toBe('absolute'); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.unit).toBe('flop'); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.decimals).toBe(2); - expect(vizPanelManager.state.panel.state.fieldConfig.overrides).toHaveLength(2); - expect(vizPanelManager.state.panel.state.fieldConfig.overrides[1].properties).toHaveLength(1); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow'); - - vizPanelManager.changePluginType('table'); - - expect(mockFn).toHaveBeenCalled(); - expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic'); - expect(mockFn.mock.calls[0][2].defaults.thresholds?.mode).toBe('absolute'); - expect(mockFn.mock.calls[0][2].defaults.unit).toBe('flop'); - expect(mockFn.mock.calls[0][2].defaults.decimals).toBe(2); - expect(mockFn.mock.calls[0][2].overrides).toHaveLength(2); - //removed custom property - expect(mockFn.mock.calls[0][2].overrides[1].properties).toHaveLength(0); - //removed fieldConfig custom values as well - expect(mockFn.mock.calls[0][2].defaults.custom).toStrictEqual({}); - }); - }); - - describe('library panels', () => { - it('saves library panels on commit', () => { - 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, - title: libraryPanelModel.title, - uid: libraryPanelModel.uid, - name: libraryPanelModel.name, - _loadedPanel: libraryPanelModel, - }); - - panel.setState({ - $behaviors: [libPanelBehavior], - }); - - new DashboardGridItem({ body: panel }); - - const panelManager = VizPanelManager.createFor(panel); - - const apiCall = jest.spyOn(libAPI, 'saveLibPanel'); - - panelManager.state.panel.setState({ title: 'new title' }); - panelManager.commitChanges(); - - expect(apiCall.mock.calls[0][0].state.title).toBe('new title'); - }); - - it('unlinks library panel', () => { - 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, - title: libraryPanelModel.title, - uid: libraryPanelModel.uid, - name: libraryPanelModel.name, - _loadedPanel: libraryPanelModel, - }); - - panel.setState({ - $behaviors: [libPanelBehavior], - }); - - new DashboardGridItem({ body: panel }); - - const panelManager = VizPanelManager.createFor(panel); - panelManager.unlinkLibraryPanel(); - - const sourcePanel = panelManager.state.sourcePanel.resolve(); - expect(sourcePanel.state.$behaviors).toBe(undefined); - }); - }); - - describe('query options', () => { - beforeEach(() => { - store.setObject.mockClear(); - }); - - describe('activation', () => { - it('should load data source', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.state.datasource).toEqual(ds1Mock); - expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); - }); - - it('should store loaded data source in local storage', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(store.setObject).toHaveBeenCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { - dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', - datasourceUid: 'gdev-testdata', - }); - }); - - it('should load default datasource if the datasource passed is not found', async () => { - const { vizPanelManager } = setupTest('panel-6'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'abc', - type: 'datasource', - }); - - expect(config.defaultDatasource).toBe('gdev-testdata'); - expect(vizPanelManager.state.datasource).toEqual(defaultDsMock); - expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); - }); - }); - - describe('data source change', () => { - it('should load new data source', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - await Promise.resolve(); - - await vizPanelManager.changePanelDataSource( - { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' } as DataSourceInstanceSettings, - [] - ); - - expect(store.setObject).toHaveBeenCalledTimes(2); - expect(store.setObject).toHaveBeenLastCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { - dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', - datasourceUid: 'gdev-prometheus', - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.state.datasource).toEqual(ds2Mock); - expect(vizPanelManager.state.dsSettings).toEqual(instance2SettingsMock); - }); - }); - - describe('query options change', () => { - describe('time overrides', () => { - it('should create PanelTimeRange object', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - await Promise.resolve(); - - const panel = vizPanelManager.state.panel; - - expect(panel.state.$timeRange).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '1h', - }, - }); - - expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); - }); - it('should update PanelTimeRange object on time options update', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const panel = vizPanelManager.state.panel; - - expect(panel.state.$timeRange).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '1h', - }, - }); - - expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); - expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('1h'); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '2h', - }, - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('2h'); - }); - - it('should remove PanelTimeRange object on time options cleared', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const panel = vizPanelManager.state.panel; - - expect(panel.state.$timeRange).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '1h', - }, - }); - - expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: null, - }, - }); - - expect(panel.state.$timeRange).toBeUndefined(); - }); - }); - - describe('max data points and interval', () => { - it('should update max data points', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const dataObj = vizPanelManager.queryRunner; - - expect(dataObj.state.maxDataPoints).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - maxDataPoints: 100, - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(dataObj.state.maxDataPoints).toBe(100); - }); - - it('should update min interval', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const dataObj = vizPanelManager.queryRunner; - - expect(dataObj.state.maxDataPoints).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - minInterval: '1s', - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(dataObj.state.minInterval).toBe('1s'); - }); - }); - - describe('query caching', () => { - it('updates cacheTimeout and queryCachingTTL', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const dataObj = vizPanelManager.queryRunner; - - vizPanelManager.changeQueryOptions({ - cacheTimeout: '60', - queryCachingTTL: 200000, - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(dataObj.state.cacheTimeout).toBe('60'); - expect(dataObj.state.queryCachingTTL).toBe(200000); - }); - }); - }); - - describe('query inspection', () => { - it('allows query inspection from the tab', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.inspectPanel(); - - expect(locationService.partial).toHaveBeenCalledWith({ inspect: 1, inspectTab: InspectTab.Query }); - }); - }); - - describe('data source change', () => { - it('changing from one plugin to another', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-testdata', - type: 'grafana-testdata-datasource', - }); - - await vizPanelManager.changePanelDataSource({ - name: 'grafana-prometheus', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - meta: { - name: 'Prometheus', - module: 'prometheus', - id: 'grafana-prometheus-datasource', - }, - } as DataSourceInstanceSettings); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-prometheus', - type: 'grafana-prometheus-datasource', - }); - }); - - it('changing from a plugin to a dashboard data source', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-testdata', - type: 'grafana-testdata-datasource', - }); - - await vizPanelManager.changePanelDataSource({ - name: SHARED_DASHBOARD_QUERY, - type: 'datasource', - uid: SHARED_DASHBOARD_QUERY, - meta: { - name: 'Prometheus', - module: 'prometheus', - id: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - } as DataSourceInstanceSettings); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: SHARED_DASHBOARD_QUERY, - type: 'datasource', - }); - }); - - it('changing from dashboard data source to a plugin', async () => { - const { vizPanelManager } = setupTest('panel-3'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: SHARED_DASHBOARD_QUERY, - type: 'datasource', - }); - - await vizPanelManager.changePanelDataSource({ - name: 'grafana-prometheus', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - meta: { - name: 'Prometheus', - module: 'prometheus', - id: 'grafana-prometheus-datasource', - }, - } as DataSourceInstanceSettings); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-prometheus', - type: 'grafana-prometheus-datasource', - }); - }); - }); - }); - - describe('change transformations', () => { - it('should update and reprocess transformations', () => { - const { scene, panel } = setupTest('panel-3'); - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - const reprocessMock = jest.fn(); - vizPanelManager.dataTransformer.reprocessTransformations = reprocessMock; - vizPanelManager.changeTransformations([{ id: 'calculateField', options: {} }]); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(reprocessMock).toHaveBeenCalledTimes(1); - expect(vizPanelManager.dataTransformer.state.transformations).toEqual([{ id: 'calculateField', options: {} }]); - }); - }); - - describe('change queries', () => { - describe('plugin queries', () => { - it('should update queries', () => { - const { vizPanelManager } = setupTest('panel-1'); - - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - vizPanelManager.changeQueries([ - { - datasource: { - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - }, - refId: 'A', - scenarioId: 'random_walk', - seriesCount: 5, - }, - ]); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.queries).toEqual([ - { - datasource: { - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - }, - refId: 'A', - scenarioId: 'random_walk', - seriesCount: 5, - }, - ]); - }); - }); - - describe('dashboard queries', () => { - it('should update queries', () => { - const { scene, panel } = setupTest('panel-3'); - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - // Changing dashboard query to a panel with transformations - vizPanelManager.changeQueries([ - { - refId: 'A', - datasource: { - type: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - panelId: panelWithTransformations.id, - }, - ]); - - expect(vizPanelManager.queryRunner.state.queries[0].panelId).toEqual(panelWithTransformations.id); - - // Changing dashboard query to a panel with queries only - vizPanelManager.changeQueries([ - { - refId: 'A', - datasource: { - type: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - panelId: panelWithQueriesOnly.id, - }, - ]); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id); - }); - }); - }); - - it('should load last used data source if no data source specified for a panel', async () => { - store.exists.mockReturnValue(true); - store.getObject.mockReturnValue({ - dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', - datasourceUid: 'gdev-testdata', - }); - const { scene, panel } = setupTest('panel-5'); - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.state.datasource).toEqual(ds1Mock); - expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); - }); - - it('Should default to the first variable value if panel is repeated', async () => { - const { scene, panel } = setupTest('panel-10'); - - scene.setState({ - $variables: new SceneVariableSet({ - variables: [ - new CustomVariable({ name: 'custom', query: 'A,B,C', value: ['A', 'B', 'C'], text: ['A', 'B', 'C'] }), - ], - }), - }); - - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - - const variable = sceneGraph.lookupVariable('custom', vizPanelManager); - expect(variable?.getValue()).toBe('A'); - }); - - describe('Given a panel inside repeated row', () => { - it('Should include row variable scope', () => { - const { panel } = setupTest('panel-9'); - - const row = panel.parent?.parent; - if (!(row instanceof SceneGridRow)) { - throw new Error('Did not find parent row'); - } - - row.setState({ - $variables: new SceneVariableSet({ variables: [new LocalValueVariable({ name: 'hello', value: 'A' })] }), - }); - - const editor = buildPanelEditScene(panel); - const variable = sceneGraph.lookupVariable('hello', editor.state.vizManager); - expect(variable?.getValue()).toBe('A'); - }); - }); -}); - -const setupTest = (panelId: string) => { - const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} }); - - const panel = findVizPanelByKey(scene, panelId)!; - - const vizPanelManager = VizPanelManager.createFor(panel); - // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it - // @ts-expect-error - getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); - - return { vizPanelManager, scene, panel }; -}; diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx deleted file mode 100644 index c27ed98146c..00000000000 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx +++ /dev/null @@ -1,504 +0,0 @@ -import { css } from '@emotion/css'; -import { debounce } from 'lodash'; -import { useEffect } from 'react'; - -import { - DataSourceApi, - DataSourceInstanceSettings, - FieldConfigSource, - GrafanaTheme2, - filterFieldConfigOverrides, - getDataSourceRef, - isStandardFieldProp, - restoreCustomOverrideRules, -} from '@grafana/data'; -import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; -import { - DeepPartial, - LocalValueVariable, - MultiValueVariable, - PanelBuilders, - SceneComponentProps, - SceneDataTransformer, - SceneObjectBase, - SceneObjectRef, - SceneObjectState, - SceneObjectStateChangedEvent, - SceneQueryRunner, - SceneVariableSet, - SceneVariables, - VizPanel, - sceneGraph, -} from '@grafana/scenes'; -import { DataQuery, DataTransformerConfig, Panel } from '@grafana/schema'; -import { useStyles2 } from '@grafana/ui'; -import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; -import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils'; -import { saveLibPanel } from 'app/features/library-panels/state/api'; -import { updateQueries } from 'app/features/query/state/updateQueries'; -import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; -import { QueryGroupOptions } from 'app/types'; - -import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; -import { getPanelChanges } from '../saving/getDashboardChanges'; -import { DashboardGridItem, RepeatDirection } from '../scene/DashboardGridItem'; -import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange'; -import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; -import { - getDashboardSceneFor, - getMultiVariableValues, - getPanelIdForVizPanel, - getQueryRunnerFor, - isLibraryPanel, -} from '../utils/utils'; - -export interface VizPanelManagerState extends SceneObjectState { - panel: VizPanel; - sourcePanel: SceneObjectRef; - pluginId: string; - datasource?: DataSourceApi; - dsSettings?: DataSourceInstanceSettings; - tableView?: VizPanel; - repeat?: string; - repeatDirection?: RepeatDirection; - maxPerRow?: number; - isDirty?: boolean; -} - -export enum DisplayMode { - Fill = 0, - Fit = 1, - Exact = 2, -} - -// VizPanelManager serves as an API to manipulate VizPanel state from the outside. It allows panel type, options and data manipulation. -export class VizPanelManager extends SceneObjectBase { - private _cachedPluginOptions: Record< - string, - { options: DeepPartial<{}>; fieldConfig: FieldConfigSource> } | undefined - > = {}; - - public constructor(state: VizPanelManagerState) { - super(state); - this.addActivationHandler(() => this._onActivate()); - } - - /** - * Will clone the source panel and move the data provider to - * live on the VizPanelManager level instead of the VizPanel level - */ - public static createFor(sourcePanel: VizPanel) { - let repeatOptions: Pick = {}; - - const gridItem = sourcePanel.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - console.error('VizPanel is not a child of a dashboard grid item'); - throw new Error('VizPanel is not a child of a dashboard grid item'); - } - - const { variableName: repeat, repeatDirection, maxPerRow } = gridItem.state; - repeatOptions = { repeat, repeatDirection, maxPerRow }; - - let variables: SceneVariables | undefined; - - if (gridItem.parent?.state.$variables) { - variables = gridItem.parent.state.$variables.clone(); - } - - if (repeatOptions.repeat) { - const variable = sceneGraph.lookupVariable(repeatOptions.repeat, gridItem); - - if (variable instanceof MultiValueVariable && variable.state.value.length) { - const { values, texts } = getMultiVariableValues(variable); - - const varWithDefaultValue = new LocalValueVariable({ - name: variable.state.name, - value: values[0], - text: String(texts[0]), - }); - - if (!variables) { - variables = new SceneVariableSet({ - variables: [varWithDefaultValue], - }); - } else { - variables.setState({ variables: [varWithDefaultValue] }); - } - } - } - - return new VizPanelManager({ - $variables: variables, - panel: sourcePanel.clone(), - sourcePanel: sourcePanel.getRef(), - pluginId: sourcePanel.state.pluginId, - ...repeatOptions, - }); - } - - private _onActivate() { - this.loadDataSource(); - const changesSub = this.subscribeToEvent(SceneObjectStateChangedEvent, this._handleStateChange); - - return () => { - changesSub.unsubscribe(); - }; - } - - private _detectPanelModelChanges = debounce(() => { - const { hasChanges } = getPanelChanges( - vizPanelToPanel(this.state.sourcePanel.resolve().clone({ $behaviors: undefined })), - vizPanelToPanel(this.state.panel.clone({ $behaviors: undefined })) - ); - this.setState({ isDirty: hasChanges }); - }, 250); - - private _handleStateChange = (event: SceneObjectStateChangedEvent) => { - if (DashboardSceneChangeTracker.isUpdatingPersistedState(event)) { - this._detectPanelModelChanges(); - } - }; - - private async loadDataSource() { - const dataObj = this.state.panel.state.$data; - - if (!dataObj) { - return; - } - - let datasourceToLoad = this.queryRunner.state.datasource; - - try { - let datasource: DataSourceApi | undefined; - let dsSettings: DataSourceInstanceSettings | undefined; - - if (!datasourceToLoad) { - const dashboardScene = getDashboardSceneFor(this); - const dashboardUid = dashboardScene.state.uid ?? ''; - const lastUsedDatasource = getLastUsedDatasourceFromStorage(dashboardUid!); - - // do we have a last used datasource for this dashboard - if (lastUsedDatasource?.datasourceUid !== null) { - // get datasource from dashbopard uid - dsSettings = getDataSourceSrv().getInstanceSettings({ uid: lastUsedDatasource?.datasourceUid }); - if (dsSettings) { - datasource = await getDataSourceSrv().get({ - uid: lastUsedDatasource?.datasourceUid, - type: dsSettings.type, - }); - - this.queryRunner.setState({ - datasource: { - ...getDataSourceRef(dsSettings), - uid: lastUsedDatasource?.datasourceUid, - }, - }); - } - } - } else { - datasource = await getDataSourceSrv().get(datasourceToLoad); - dsSettings = getDataSourceSrv().getInstanceSettings(datasourceToLoad); - } - - if (datasource && dsSettings) { - this.setState({ datasource, dsSettings }); - - storeLastUsedDataSourceInLocalStorage(getDataSourceRef(dsSettings) || { default: true }); - } - } catch (err) { - //set default datasource if we fail to load the datasource - const datasource = await getDataSourceSrv().get(config.defaultDatasource); - const dsSettings = getDataSourceSrv().getInstanceSettings(config.defaultDatasource); - - if (datasource && dsSettings) { - this.setState({ - datasource, - dsSettings, - }); - - this.queryRunner.setState({ - datasource: getDataSourceRef(dsSettings), - }); - } - - console.error(err); - } - } - - public changePluginType(pluginId: string) { - const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = this.state.panel.state; - - // clear custom options - let newFieldConfig: FieldConfigSource = { - defaults: { - ...prevFieldConfig.defaults, - custom: {}, - }, - overrides: filterFieldConfigOverrides(prevFieldConfig.overrides, isStandardFieldProp), - }; - - this._cachedPluginOptions[prevPluginId] = { options: prevOptions, fieldConfig: prevFieldConfig }; - - const cachedOptions = this._cachedPluginOptions[pluginId]?.options; - const cachedFieldConfig = this._cachedPluginOptions[pluginId]?.fieldConfig; - - if (cachedFieldConfig) { - newFieldConfig = restoreCustomOverrideRules(newFieldConfig, cachedFieldConfig); - } - - // When changing from non-data to data panel, we need to add a new data provider - if (!this.state.panel.state.$data && !config.panels[pluginId].skipDataQuery) { - let ds = getLastUsedDatasourceFromStorage(getDashboardSceneFor(this).state.uid!)?.datasourceUid; - - if (!ds) { - ds = config.defaultDatasource; - } - - this.state.panel.setState({ - $data: new SceneDataTransformer({ - $data: new SceneQueryRunner({ - datasource: { - uid: ds, - }, - queries: [{ refId: 'A' }], - }), - transformations: [], - }), - }); - } - - this.setState({ pluginId }); - this.state.panel.changePluginType(pluginId, cachedOptions, newFieldConfig); - - this.loadDataSource(); - } - - public async changePanelDataSource( - newSettings: DataSourceInstanceSettings, - defaultQueries?: DataQuery[] | GrafanaQuery[] - ) { - const { dsSettings } = this.state; - const queryRunner = this.queryRunner; - - const currentDS = dsSettings ? await getDataSourceSrv().get({ uid: dsSettings.uid }) : undefined; - const nextDS = await getDataSourceSrv().get({ uid: newSettings.uid }); - - const currentQueries = queryRunner.state.queries; - - // We need to pass in newSettings.uid as well here as that can be a variable expression and we want to store that in the query model not the current ds variable value - const queries = defaultQueries || (await updateQueries(nextDS, newSettings.uid, currentQueries, currentDS)); - - queryRunner.setState({ - datasource: getDataSourceRef(newSettings), - queries, - }); - if (defaultQueries) { - queryRunner.runQueries(); - } - - this.loadDataSource(); - } - - public changeQueryOptions(options: QueryGroupOptions) { - const panelObj = this.state.panel; - const dataObj = this.queryRunner; - const timeRangeObj = panelObj.state.$timeRange; - - const dataObjStateUpdate: Partial = {}; - const timeRangeObjStateUpdate: Partial = {}; - - if (options.maxDataPoints !== dataObj.state.maxDataPoints) { - dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined; - } - - if (options.minInterval !== dataObj.state.minInterval && options.minInterval !== null) { - dataObjStateUpdate.minInterval = options.minInterval; - } - - if (options.timeRange) { - timeRangeObjStateUpdate.timeFrom = options.timeRange.from ?? undefined; - timeRangeObjStateUpdate.timeShift = options.timeRange.shift ?? undefined; - timeRangeObjStateUpdate.hideTimeOverride = options.timeRange.hide; - } - - if (timeRangeObj instanceof PanelTimeRange) { - if (timeRangeObjStateUpdate.timeFrom !== undefined || timeRangeObjStateUpdate.timeShift !== undefined) { - // update time override - timeRangeObj.setState(timeRangeObjStateUpdate); - } else { - // remove time override - panelObj.setState({ $timeRange: undefined }); - } - } else { - // no time override present on the panel, let's create one first - panelObj.setState({ $timeRange: new PanelTimeRange(timeRangeObjStateUpdate) }); - } - - if (options.cacheTimeout !== dataObj?.state.cacheTimeout) { - dataObjStateUpdate.cacheTimeout = options.cacheTimeout; - } - - if (options.queryCachingTTL !== dataObj?.state.queryCachingTTL) { - dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL; - } - - dataObj.setState(dataObjStateUpdate); - dataObj.runQueries(); - } - - public changeQueries(queries: T[]) { - const runner = this.queryRunner; - runner.setState({ queries }); - } - - public changeTransformations(transformations: DataTransformerConfig[]) { - const dataprovider = this.dataTransformer; - dataprovider.setState({ transformations }); - dataprovider.reprocessTransformations(); - } - - public inspectPanel() { - const panel = this.state.panel; - const panelId = getPanelIdForVizPanel(panel); - - locationService.partial({ - inspect: panelId, - inspectTab: 'query', - }); - } - - get queryRunner(): SceneQueryRunner { - // Panel data object is always SceneQueryRunner wrapped in a SceneDataTransformer - const runner = getQueryRunnerFor(this.state.panel); - - if (!runner) { - throw new Error('Query runner not found'); - } - - return runner; - } - - get dataTransformer(): SceneDataTransformer { - const provider = this.state.panel.state.$data; - if (!provider || !(provider instanceof SceneDataTransformer)) { - throw new Error('Could not find SceneDataTransformer for panel'); - } - return provider; - } - - public toggleTableView() { - if (this.state.tableView) { - this.setState({ tableView: undefined }); - return; - } - - this.setState({ - tableView: PanelBuilders.table() - .setTitle('') - .setOption('showTypeIcons', true) - .setOption('showHeader', true) - // Here we are breaking a scene rule and changing the parent of the main panel data provider - // But we need to share this same instance as the queries tab is subscribing to it - .setData(this.dataTransformer) - .build(), - }); - } - - public unlinkLibraryPanel() { - const sourcePanel = this.state.sourcePanel.resolve(); - if (!isLibraryPanel(sourcePanel)) { - throw new Error('VizPanel is not a library panel'); - } - - const gridItem = sourcePanel.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - throw new Error('Library panel not a child of a grid item'); - } - - const newSourcePanel = this.state.panel.clone({ $data: sourcePanel.state.$data?.clone(), $behaviors: undefined }); - gridItem.setState({ - body: newSourcePanel, - }); - - this.state.panel.setState({ $behaviors: undefined }); - this.setState({ sourcePanel: newSourcePanel.getRef() }); - } - - public commitChanges() { - const sourcePanel = this.state.sourcePanel.resolve(); - this.commitChangesTo(sourcePanel); - } - - public commitChangesTo(sourcePanel: VizPanel) { - const repeatUpdate = { - variableName: this.state.repeat, - repeatDirection: this.state.repeatDirection, - maxPerRow: this.state.maxPerRow, - }; - - const vizPanel = this.state.panel.clone(); - - if (sourcePanel.parent instanceof DashboardGridItem) { - sourcePanel.parent.setState({ - ...repeatUpdate, - body: vizPanel, - }); - } - - if (isLibraryPanel(vizPanel)) { - saveLibPanel(vizPanel); - } - } - - /** - * Used from inspect json tab to view the current persisted model - */ - public getPanelSaveModel(): Panel | object { - const sourcePanel = this.state.sourcePanel.resolve(); - const gridItem = sourcePanel.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - return { error: 'Unsupported panel parent' }; - } - - const parentClone = gridItem.clone({ - body: this.state.panel.clone(), - }); - - return gridItemToPanel(parentClone); - } - - public setPanelTitle(newTitle: string) { - this.state.panel.setState({ title: newTitle, hoverHeader: newTitle === '' }); - } - - public static Component = ({ model }: SceneComponentProps) => { - const { panel, tableView } = model.useState(); - const styles = useStyles2(getStyles); - const panelToShow = tableView ?? panel; - const dataProvider = panelToShow.state.$data; - - // This is to preserve SceneQueryRunner stays alive when switching between visualizations and table view - useEffect(() => { - return dataProvider?.activate(); - }, [dataProvider]); - - return ( - <> -
{}
- - ); - }; -} - -function getStyles(theme: GrafanaTheme2) { - return { - wrapper: css({ - height: '100%', - width: '100%', - paddingLeft: theme.spacing(2), - }), - }; -} diff --git a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx index 7762bdc2965..0f2a8961278 100644 --- a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx @@ -1,8 +1,8 @@ import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; -import { VizPanel } from '@grafana/scenes'; -import { RadioButtonGroup, Select, DataLinksInlineEditor, Input, TextArea, Switch } from '@grafana/ui'; +import { SceneObjectState, VizPanel } from '@grafana/scenes'; +import { DataLinksInlineEditor, Input, TextArea, Switch, RadioButtonGroup, Select } from '@grafana/ui'; import { GenAIPanelDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton'; import { GenAIPanelTitleButton } from 'app/features/dashboard/components/GenAI/GenAIPanelTitleButton'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; @@ -10,17 +10,15 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; +import { DashboardGridItem } from '../scene/DashboardGridItem'; import { VizPanelLinks } from '../scene/PanelLinks'; import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getDashboardSceneFor } from '../utils/utils'; -import { VizPanelManager, VizPanelManagerState } from './VizPanelManager'; - export function getPanelFrameCategory2( - vizManager: VizPanelManager, panel: VizPanel, - repeat?: string + layoutElementState: SceneObjectState ): OptionsPaneCategoryDescriptor { const descriptor = new OptionsPaneCategoryDescriptor({ title: 'Panel options', @@ -31,19 +29,20 @@ export function getPanelFrameCategory2( const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel); const links = panelLinksObject?.state.rawLinks ?? []; const dashboard = getDashboardSceneFor(panel); + const layoutElement = panel.parent; - return descriptor + descriptor .addItem( new OptionsPaneItemDescriptor({ title: 'Title', value: panel.state.title, popularRank: 1, render: function renderTitle() { - return ; + return ; }, addon: config.featureToggles.dashgpt && ( vizManager.setPanelTitle(title)} + onGenerate={(title) => setPanelTitle(panel, title)} panel={vizPanelToPanel(panel)} dashboard={transformSceneToSaveModel(dashboard)} /> @@ -95,73 +94,77 @@ export function getPanelFrameCategory2( render: () => , }) ) - ) - .addCategory( - new OptionsPaneCategoryDescriptor({ - title: 'Repeat options', - id: 'Repeat options', - isOpenDefault: false, + ); + + if (layoutElement instanceof DashboardGridItem) { + const gridItem = layoutElement; + + const category = new OptionsPaneCategoryDescriptor({ + title: 'Repeat options', + id: 'Repeat options', + isOpenDefault: false, + }); + + category.addItem( + new OptionsPaneItemDescriptor({ + title: 'Repeat by variable', + description: + 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.', + render: function renderRepeatOptions() { + return ( + gridItem.setRepeatByVariable(value)} + /> + ); + }, }) - .addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat by variable', - description: - 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.', - render: function renderRepeatOptions() { - return ( - { - const stateUpdate: Partial = { repeat: value }; - if (value && !vizManager.state.repeatDirection) { - stateUpdate.repeatDirection = 'h'; - } - vizManager.setState(stateUpdate); - }} - /> - ); - }, - }) - ) - .addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat direction', - showIf: () => !!vizManager.state.repeat, - render: function renderRepeatOptions() { - const directionOptions: Array> = [ - { label: 'Horizontal', value: 'h' }, - { label: 'Vertical', value: 'v' }, - ]; - - return ( - vizManager.setState({ repeatDirection: value })} - /> - ); - }, - }) - ) - .addItem( - new OptionsPaneItemDescriptor({ - title: 'Max per row', - showIf: () => Boolean(vizManager.state.repeat && vizManager.state.repeatDirection === 'h'), - render: function renderOption() { - const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value })); - return ( - gridItem.setState({ maxPerRow: value.value })} + /> + ); + }, + }) + ); + + descriptor.addCategory(category); + } + + return descriptor; } interface ScenePanelLinksEditorProps { @@ -181,14 +184,14 @@ function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) { ); } -function PanelFrameTitle({ vizManager }: { vizManager: VizPanelManager }) { - const { title } = vizManager.state.panel.useState(); +function PanelFrameTitle({ panel }: { panel: VizPanel }) { + const { title } = panel.useState(); return ( vizManager.setPanelTitle(e.currentTarget.value)} + onChange={(e) => setPanelTitle(panel, e.currentTarget.value)} /> ); } @@ -204,3 +207,7 @@ function DescriptionTextArea({ panel }: { panel: VizPanel }) { /> ); } + +function setPanelTitle(panel: VizPanel, title: string) { + panel.setState({ title: title, hoverHeader: title === '' }); +} diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx index e841f7a47ba..94d1655686c 100644 --- a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx @@ -40,19 +40,12 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { }, [dashboard]); const onHistoryBlock = (location: H.Location) => { - const panelInEdit = dashboard.state.editPanel; - const vizPanelManager = panelInEdit?.state.vizManager; - const vizPanel = vizPanelManager?.state.panel; + const panelEditor = dashboard.state.editPanel; + const vizPanel = panelEditor?.getPanel(); const search = new URLSearchParams(location.search); // Are we leaving panel edit & library panel? - if ( - panelInEdit && - vizPanel && - isLibraryPanel(vizPanel) && - vizPanelManager.state.isDirty && - !search.has('editPanel') - ) { + if (panelEditor && vizPanel && isLibraryPanel(vizPanel) && panelEditor.state.isDirty && !search.has('editPanel')) { const libPanelBehavior = getLibraryPanelBehavior(vizPanel); showModal(SaveLibraryVizPanelModal, { @@ -60,12 +53,12 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { isUnsavedPrompt: true, libraryPanel: libPanelBehavior!, onConfirm: () => { - panelInEdit.onConfirmSaveLibraryPanel(); + panelEditor.onConfirmSaveLibraryPanel(); hideModal(); moveToBlockedLocationAfterReactStateUpdate(location); }, onDiscard: () => { - panelInEdit.onDiscard(); + panelEditor.onDiscard(); hideModal(); moveToBlockedLocationAfterReactStateUpdate(location); }, diff --git a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts index d99c4132687..d7b5cf74ff8 100644 --- a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts +++ b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts @@ -14,7 +14,6 @@ import { } from '@grafana/scenes'; import { createWorker } from 'app/features/dashboard-scene/saving/createDetectChangesWorker'; -import { VizPanelManager } from '../panel-edit/VizPanelManager'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; import { DashboardGridItem } from '../scene/DashboardGridItem'; @@ -43,7 +42,6 @@ export class DashboardSceneChangeTracker { } // Any change in the panel should trigger a change detection - // The VizPanelManager includes configuration for the panel like repeat // The PanelTimeRange includes the overrides configuration if ( payload.changedObject instanceof VizPanel || @@ -52,16 +50,6 @@ export class DashboardSceneChangeTracker { ) { return true; } - // VizPanelManager includes the repeat configuration - if (payload.changedObject instanceof VizPanelManager) { - if ( - Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'repeat') || - Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'repeatDirection') || - Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'maxPerRow') - ) { - return true; - } - } // SceneQueryRunner includes the DS configuration if (payload.changedObject instanceof SceneQueryRunner) { if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) { diff --git a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts index d14ae73ed3a..3a62712b70d 100644 --- a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts +++ b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts @@ -237,8 +237,7 @@ describe('getDashboardChangesFromScene', () => { dashboard.onEnterEditMode(); dashboard.setState({ editPanel: editScene }); - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); - editScene.commitChanges(); + editScene.state.panelRef.resolve().setState({ title: 'changed title' }); const result = getDashboardChangesFromScene(dashboard, false, true); const panelSaveModel = result.changedSaveModel.panels![0]; diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx index ce5d1f04606..0be50bd2ecc 100644 --- a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx @@ -11,11 +11,10 @@ import { } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { setPluginImportUtils } from '@grafana/runtime'; -import { SceneDataTransformer, SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; +import { SceneDataTransformer, SceneFlexLayout, SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; -import { VizPanelManager } from '../panel-edit/VizPanelManager'; import { activateFullSceneTree } from '../utils/test-utils'; import { DashboardDatasourceBehaviour } from './DashboardDatasourceBehaviour'; @@ -275,14 +274,12 @@ describe('DashboardDatasourceBehaviour', () => { // spy on runQueries const spy = jest.spyOn(dashboardDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries'); - const vizPanelManager = new VizPanelManager({ - panel: dashboardDSPanel.clone(), + const scene = new SceneFlexLayout({ $data: dashboardDSPanel.state.$data?.clone(), - sourcePanel: dashboardDSPanel.getRef(), - pluginId: dashboardDSPanel.state.pluginId, + children: [], }); - vizPanelManager.activate(); + scene.activate(); expect(spy).not.toHaveBeenCalled(); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx index 97e44cef66b..2dad7ee9618 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx @@ -21,7 +21,7 @@ import { SceneVariable, SceneVariableDependencyConfigLike, } from '@grafana/scenes'; -import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; +import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { getMultiVariableValues, getQueryRunnerFor } from '../utils/utils'; @@ -41,7 +41,8 @@ export type RepeatDirection = 'v' | 'h'; export class DashboardGridItem extends SceneObjectBase implements SceneGridItemLike { private _prevRepeatValues?: VariableValueSingle[]; - private _oldBody?: VizPanel; + private _prevPanelState: VizPanelState | undefined; + private _prevGridItemState: DashboardGridItemState | undefined; protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this); @@ -54,14 +55,17 @@ export class DashboardGridItem extends SceneObjectBase i private _activationHandler() { if (this.state.variableName) { this._subs.add(this.subscribeToState((newState, prevState) => this._handleGridResize(newState, prevState))); - if (this._oldBody !== this.state.body) { - this._prevRepeatValues = undefined; - } - + this.clearCachedStateIfBodyOrOptionsChanged(); this.performRepeat(); } } + private clearCachedStateIfBodyOrOptionsChanged() { + if (this._prevGridItemState !== this.state || this._prevPanelState !== this.state.body.state) { + this._prevRepeatValues = undefined; + } + } + /** * Uses the current repeat item count to calculate the user intended desired itemHeight */ @@ -116,9 +120,6 @@ export class DashboardGridItem extends SceneObjectBase i return; } - this._oldBody = this.state.body; - this._prevRepeatValues = values; - const panelToRepeat = this.state.body; const repeatedPanels: VizPanel[] = []; @@ -178,10 +179,54 @@ export class DashboardGridItem extends SceneObjectBase i } } + this._prevGridItemState = this.state; + this._prevPanelState = this.state.body.state; + this._prevRepeatValues = values; + // Used from dashboard url sync this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); } + public setRepeatByVariable(variableName: string | undefined) { + const stateUpdate: Partial = { variableName }; + + if (variableName && !this.state.repeatDirection) { + stateUpdate.repeatDirection = 'h'; + } + + if (this.state.body.state.$variables) { + this.state.body.setState({ $variables: undefined }); + } + + this.setState(stateUpdate); + } + + /** + * Logic to prep panel for panel edit + */ + public editingStarted() { + if (!this.state.variableName) { + return; + } + + if (this.state.repeatedPanels?.length ?? 0 > 1) { + this.state.body.setState({ + $variables: this.state.repeatedPanels![0].state.$variables?.clone(), + $data: this.state.repeatedPanels![0].state.$data?.clone(), + }); + this._prevPanelState = this.state.body.state; + } + } + + /** + * Going back to dashboards logic + */ + public editingCompleted() { + if (this.state.variableName && this.state.repeatDirection === 'h' && this.state.width !== GRID_COLUMN_COUNT) { + this.setState({ width: GRID_COLUMN_COUNT }); + } + } + public notifyRepeatedPanelsWaitingForVariables(variable: SceneVariable) { for (const panel of this.state.repeatedPanels ?? []) { const queryRunner = getQueryRunnerFor(panel); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index c2a2ba61966..3df3a8f73e6 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -18,7 +18,7 @@ import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { VariablesChanged } from 'app/features/variables/types'; -import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor'; +import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createWorker } from '../saving/createDetectChangesWorker'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; @@ -197,18 +197,16 @@ describe('DashboardScene', () => { expect(resoredLayout.state.children.map((c) => c.state.key)).toEqual(originalPanelOrder); }); - it('Should exit edit mode and discard panel changes if leaving the dashboard while in panel edit', () => { - const panel = findVizPanelByKey(scene, 'panel-1'); + it('Should exit edit mode and discard panel changes if leaving the dashboard while in panel edit', async () => { + const panel = findVizPanelByKey(scene, 'panel-1')!; const editPanel = buildPanelEditScene(panel!); - scene.setState({ - editPanel, - }); - - expect(scene.state.editPanel!['_discardChanges']).toBe(false); + scene.setState({ editPanel }); + panel.setState({ title: 'new title' }); scene.exitEditMode({ skipConfirm: true }); - expect(scene.state.editPanel!['_discardChanges']).toBe(true); + const discardPanel = findVizPanelByKey(scene, panel.state.key)!; + expect(discardPanel.state.title).toBe('Panel A'); }); it.each` @@ -1023,14 +1021,14 @@ describe('DashboardScene', () => { panelPluginId: 'table', }); }); + test('when editing', () => { const panel = findVizPanelByKey(scene, 'panel-1'); const editPanel = buildPanelEditScene(panel!); - scene.setState({ - editPanel, - }); + scene.setState({ editPanel }); + + const queryRunner = editPanel.getPanel().state.$data!; - const queryRunner = (scene.state.editPanel as PanelEditor).state.vizManager.queryRunner; expect(scene.enrichDataRequest(queryRunner)).toEqual({ app: CoreApp.Dashboard, dashboardUID: 'dash-1', diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 48424ea2b1a..6c1bffe482a 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -252,6 +252,7 @@ export class DashboardScene extends SceneObjectBase { }; this._changeTracker.stopTrackingChanges(); + this.setState({ version: result.version, isDirty: false, @@ -267,6 +268,7 @@ export class DashboardScene extends SceneObjectBase { }, }); + this.state.editPanel?.dashboardSaved(); this._changeTracker.startTrackingChanges(); } @@ -801,7 +803,7 @@ export class DashboardScene extends SceneObjectBase { let panel = getClosestVizPanel(sceneObject); if (dashboard.state.isEditing && dashboard.state.editPanel) { - panel = dashboard.state.editPanel.state.vizManager.state.panel; + panel = dashboard.state.editPanel.state.panelRef.resolve(); } let panelId = 0; diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 53067b92a34..21335a1904d 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -2,14 +2,7 @@ import { Unsubscribable } from 'rxjs'; import { AppEvents } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; -import { - SceneGridLayout, - SceneObjectBase, - SceneObjectState, - SceneObjectUrlSyncHandler, - SceneObjectUrlValues, - VizPanel, -} from '@grafana/scenes'; +import { SceneGridLayout, SceneObjectUrlSyncHandler, SceneObjectUrlValues, VizPanel } from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { KioskMode } from 'app/types'; @@ -18,7 +11,7 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; -import { findVizPanelByKey, getDashboardSceneFor, getLibraryPanelBehavior, isPanelClone } from '../utils/utils'; +import { findVizPanelByKey, getLibraryPanelBehavior, isPanelClone } from '../utils/utils'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; @@ -78,9 +71,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } update.inspectPanelKey = values.inspect; - update.overlay = new PanelInspectDrawer({ - $behaviors: [new ResolveInspectPanelByKey({ panelKey: values.inspect })], - }); + update.overlay = new PanelInspectDrawer({ panelRef: panel.getRef() }); } else if (inspectPanelKey) { update.inspectPanelKey = undefined; update.overlay = undefined; @@ -196,37 +187,3 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { }); } } - -interface ResolveInspectPanelByKeyState extends SceneObjectState { - panelKey: string; -} - -class ResolveInspectPanelByKey extends SceneObjectBase { - constructor(state: ResolveInspectPanelByKeyState) { - super(state); - this.addActivationHandler(this._onActivate); - } - - private _onActivate = () => { - const parent = this.parent; - - if (!parent || !(parent instanceof PanelInspectDrawer)) { - throw new Error('ResolveInspectPanelByKey must be attached to a PanelInspectDrawer'); - } - - const dashboard = getDashboardSceneFor(parent); - if (!dashboard) { - return; - } - const panelId = this.state.panelKey; - let panel = findVizPanelByKey(dashboard, panelId); - - if (dashboard.state.editPanel) { - panel = dashboard.state.editPanel.state.vizManager.state.panel; - } - - if (panel) { - parent.setState({ panelRef: panel.getRef() }); - } - }; -} diff --git a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx index d3848d1fccf..ba2551e57ec 100644 --- a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx @@ -77,6 +77,16 @@ export class LibraryPanelBehavior extends SceneObjectBase b !== this) }); + } + } + private async loadLibraryPanelFromPanelModel() { let vizPanel = this.parent; diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index d062d96a7f0..4b00fa431ba 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -61,8 +61,8 @@ export function ToolbarActions({ dashboard }: Props) { const styles = useStyles2(getStyles); const isEditingPanel = Boolean(editPanel); const isViewingPanel = Boolean(viewPanelScene); - const isEditedPanelDirty = useVizManagerDirty(editPanel); - const isEditingLibraryPanel = useEditingLibraryPanel(editPanel); + const isEditedPanelDirty = usePanelEditDirty(editPanel); + const isEditingLibraryPanel = editPanel && isLibraryPanel(editPanel.state.panelRef.resolve()); const hasCopiedPanel = store.exists(LS_PANEL_COPY_KEY); // Means we are not in settings view, fullscreen panel or edit panel const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel; @@ -422,7 +422,7 @@ export function ToolbarActions({ dashboard }: Props) { onClick={editPanel?.onDiscard} tooltip={editPanel?.state.isNewPanel ? 'Discard panel' : 'Discard panel changes'} size="sm" - disabled={!isEditedPanelDirty || !isDirty} + disabled={!isEditedPanelDirty} key="discard" fill="outline" variant="destructive" @@ -613,41 +613,22 @@ function addDynamicActions( } } -function useEditingLibraryPanel(panelEditor?: PanelEditor) { - const [isEditingLibraryPanel, setEditingLibraryPanel] = useState(false); - - useEffect(() => { - if (panelEditor) { - const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) => - setEditingLibraryPanel(isLibraryPanel(vizManagerState.sourcePanel.resolve())) - ); - return () => { - unsub.unsubscribe(); - }; - } - setEditingLibraryPanel(false); - return; - }, [panelEditor]); - - return isEditingLibraryPanel; -} - // This hook handles when panelEditor is not defined to avoid conditionally hook usage -function useVizManagerDirty(panelEditor?: PanelEditor) { - const [isDirty, setIsDirty] = useState(false); +function usePanelEditDirty(panelEditor?: PanelEditor) { + const [isDirty, setIsDirty] = useState(); useEffect(() => { if (panelEditor) { - const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) => - setIsDirty(vizManagerState.isDirty || false) - ); - return () => { - unsub.unsubscribe(); - }; + const unsub = panelEditor.subscribeToState((state) => { + if (state.isDirty !== isDirty) { + setIsDirty(state.isDirty); + } + }); + + return () => unsub.unsubscribe(); } - setIsDirty(false); return; - }, [panelEditor]); + }, [panelEditor, isDirty]); return isDirty; } diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx index f112fb3d465..7b019aa5405 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx +++ b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx @@ -24,7 +24,7 @@ describe('DashboardRow', () => { { it('Should not show warning component when does not have warningMessage prop', () => { render( - + ); expect( diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx index 270f7a7f5ea..9977d96b93e 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx +++ b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx @@ -12,13 +12,13 @@ export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void export interface Props { title: string; repeat?: string; - parent: SceneObject; + sceneContext: SceneObject; onUpdate: OnRowOptionsUpdate; onCancel: () => void; warning?: React.ReactNode; } -export const RowOptionsForm = ({ repeat, title, parent, warning, onUpdate, onCancel }: Props) => { +export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate, onCancel }: Props) => { const [newRepeat, setNewRepeat] = useState(repeat); const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]); @@ -38,7 +38,7 @@ export const RowOptionsForm = ({ repeat, title, parent, warning, onUpdate, onCan - + {warning && ( { activateFullSceneTree(scene); expect(repeater.state.repeatedPanels?.length).toBe(2); - const result = panelRepeaterToPanels(repeater, undefined, true); + const result = panelRepeaterToPanels(repeater, true); expect(result).toHaveLength(2); @@ -861,7 +852,7 @@ describe('transformSceneToSaveModel', () => { ); activateFullSceneTree(scene); - const result = panelRepeaterToPanels(repeater, undefined, true); + const result = panelRepeaterToPanels(repeater, true); expect(result).toHaveLength(1); @@ -886,7 +877,7 @@ describe('transformSceneToSaveModel', () => { activateFullSceneTree(scene); let panels: Panel[] = []; - gridRowToSaveModel(row, panels, undefined, true); + gridRowToSaveModel(row, panels, true); expect(panels).toHaveLength(2); expect(panels[0].repeat).toBe('handler'); @@ -914,7 +905,7 @@ describe('transformSceneToSaveModel', () => { activateFullSceneTree(scene); let panels: Panel[] = []; - gridRowToSaveModel(row, panels, undefined, true); + gridRowToSaveModel(row, panels, true); expect(panels[0].repeat).toBe('handler'); @@ -1024,94 +1015,6 @@ describe('transformSceneToSaveModel', () => { }); }); - describe('Given a scene with an open panel editor', () => { - it('should persist changes to panel model', async () => { - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const gridItem = new DashboardGridItem({ body: panel }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - $timeRange: new SceneTimeRange({ - from: 'now-6h', - to: 'now', - timeZone: '', - }), - }); - - editScene!.state.vizManager.state.panel.setState({ - options: { - mode: 'markdown', - code: { - language: 'plaintext', - showLineNumbers: false, - showMiniMap: false, - }, - content: 'new content', - }, - }); - activateFullSceneTree(scene); - const saveModel = transformSceneToSaveModel(scene); - expect((saveModel.panels![0] as any).options.content).toBe('new content'); - }); - - it('should persist changes to panel model in row', async () => { - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - options: { - content: 'old content', - }, - }); - - const gridItem = new DashboardGridItem({ body: panel }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [ - new SceneGridRow({ - key: '23', - isCollapsed: false, - children: [gridItem], - }), - ], - }), - $timeRange: new SceneTimeRange({ - from: 'now-6h', - to: 'now', - timeZone: '', - }), - }); - activateFullSceneTree(scene); - - editScene!.state.vizManager.state.panel.setState({ - options: { - mode: 'markdown', - code: { - language: 'plaintext', - showLineNumbers: false, - showMiniMap: false, - }, - content: 'new content', - }, - }); - - const saveModel = transformSceneToSaveModel(scene); - expect((saveModel.panels![1] as any).options.content).toBe('new content'); - }); - }); - describe('Given a scene with repeated panels and non-repeated panels', () => { it('should save repeated panels itemHeight as height', () => { const scene = transformSaveModelToScene({ diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 45db9cf1fdb..01605ed28ab 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -33,7 +33,7 @@ import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardGridItem } from '../scene/DashboardGridItem'; -import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { DashboardScene } from '../scene/DashboardScene'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; @@ -58,9 +58,9 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa if (child instanceof DashboardGridItem) { // handle panel repeater scenario if (child.state.variableName) { - panels = panels.concat(panelRepeaterToPanels(child, state, isSnapshot)); + panels = panels.concat(panelRepeaterToPanels(child, isSnapshot)); } else { - panels.push(gridItemToPanel(child, state, isSnapshot)); + panels.push(gridItemToPanel(child, isSnapshot)); } } @@ -69,7 +69,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) { continue; } - gridRowToSaveModel(child, panels, state, isSnapshot); + gridRowToSaveModel(child, panels, isSnapshot); } } } @@ -139,11 +139,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa return sortedDeepCloneWithoutNulls(dashboard); } -export function gridItemToPanel( - gridItem: DashboardGridItem, - sceneState?: DashboardSceneState, - isSnapshot = false -): Panel { +export function gridItemToPanel(gridItem: DashboardGridItem, isSnapshot = false): Panel { let vizPanel: VizPanel | undefined; let x = 0, y = 0, @@ -152,19 +148,6 @@ export function gridItemToPanel( let gridItem_ = gridItem; - // If we're saving while the panel editor is open, we need to persist those changes in the panel model - if ( - sceneState && - sceneState.editPanel?.state.vizManager && - sceneState.editPanel.state.vizManager.state.sourcePanel.resolve() === gridItem.state.body - ) { - const gridItemClone = gridItem.clone(); - if (gridItemClone.state.body instanceof VizPanel && !isLibraryPanel(gridItemClone.state.body)) { - sceneState.editPanel.state.vizManager.commitChangesTo(gridItemClone.state.body); - gridItem_ = gridItemClone; - } - } - if (!(gridItem_.state.body instanceof VizPanel)) { throw new Error('DashboardGridItem body expected to be VizPanel'); } @@ -325,13 +308,9 @@ function vizPanelDataToPanel( return panel; } -export function panelRepeaterToPanels( - repeater: DashboardGridItem, - sceneState?: DashboardSceneState, - isSnapshot = false -): Panel[] { +export function panelRepeaterToPanels(repeater: DashboardGridItem, isSnapshot = false): Panel[] { if (!isSnapshot) { - return [gridItemToPanel(repeater, sceneState)]; + return [gridItemToPanel(repeater)]; } else { // return early if the repeated panel is a library panel if (repeater.state.body instanceof VizPanel && isLibraryPanel(repeater.state.body)) { @@ -388,12 +367,7 @@ export function panelRepeaterToPanels( } } -export function gridRowToSaveModel( - gridRow: SceneGridRow, - panelsArray: Array, - sceneState?: DashboardSceneState, - isSnapshot = false -) { +export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array, isSnapshot = false) { const collapsed = Boolean(gridRow.state.isCollapsed); const rowPanel: RowPanel = { type: 'row', @@ -443,10 +417,10 @@ export function gridRowToSaveModel( if (c instanceof DashboardGridItem) { if (c.state.variableName) { // Perform snapshot only for uncollapsed rows - panelsInsideRow = panelsInsideRow.concat(panelRepeaterToPanels(c, sceneState, !collapsed)); + panelsInsideRow = panelsInsideRow.concat(panelRepeaterToPanels(c, !collapsed)); } else { // Perform snapshot only for uncollapsed panels - panelsInsideRow.push(gridItemToPanel(c, sceneState, !collapsed)); + panelsInsideRow.push(gridItemToPanel(c, !collapsed)); } } }); @@ -455,7 +429,7 @@ export function gridRowToSaveModel( if (!(c instanceof DashboardGridItem)) { throw new Error('Row child expected to be DashboardGridItem'); } - return gridItemToPanel(c, sceneState); + return gridItemToPanel(c); }); } diff --git a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx index 5ed6672b923..918d9fc6906 100644 --- a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx @@ -110,20 +110,17 @@ export function getFieldOverrideCategories( onOverrideChange(idx, override); }; - const onDynamicConfigValueAdd = (o: ConfigOverrideRule, value: SelectableValue) => { + const onDynamicConfigValueAdd = (override: ConfigOverrideRule, value: SelectableValue) => { const registryItem = registry.get(value.value!); const propertyConfig: DynamicConfigValue = { id: registryItem.id, value: registryItem.defaultValue, }; - if (override.properties) { - o.properties.push(propertyConfig); - } else { - o.properties = [propertyConfig]; - } + const properties = override.properties ?? []; + properties.push(propertyConfig); - onOverrideChange(idx, o); + onOverrideChange(idx, { ...override, properties }); }; /** @@ -158,13 +155,23 @@ export function getFieldOverrideCategories( } const onPropertyChange = (value: DynamicConfigValue) => { - override.properties[propIdx].value = value; - onOverrideChange(idx, override); + onOverrideChange(idx, { + ...override, + properties: override.properties.map((prop, i) => { + if (i === propIdx) { + return { ...prop, value: value }; + } + + return prop; + }), + }); }; const onPropertyRemove = () => { - override.properties.splice(propIdx, 1); - onOverrideChange(idx, override); + onOverrideChange(idx, { + ...override, + properties: override.properties.filter((_, i) => i !== propIdx), + }); }; /** diff --git a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx index c35a286a4ea..67e9fcd45b4 100644 --- a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx +++ b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx @@ -44,14 +44,14 @@ export const RepeatRowSelect = ({ repeat, onChange, id }: Props) => { }; interface Props2 { - parent: SceneObject; + sceneContext: SceneObject; repeat: string | undefined; id?: string; onChange: (name?: string) => void; } -export const RepeatRowSelect2 = ({ parent, repeat, id, onChange }: Props2) => { - const sceneVars = useMemo(() => sceneGraph.getVariables(parent), [parent]); +export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2) => { + const sceneVars = useMemo(() => sceneGraph.getVariables(sceneContext.getRoot()), [sceneContext]); const variables = sceneVars.useState().variables; const variableOptions = useMemo(() => { diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts index c92d295b3d6..8356e8e3ec9 100644 --- a/public/app/features/library-panels/state/api.ts +++ b/public/app/features/library-panels/state/api.ts @@ -3,7 +3,6 @@ import { lastValueFrom } from 'rxjs'; import { VizPanel } from '@grafana/scenes'; import { LibraryPanel, defaultDashboard } from '@grafana/schema'; import { DashboardModel } from 'app/features/dashboard/state'; -import { VizPanelManager } from 'app/features/dashboard-scene/panel-edit/VizPanelManager'; import { DashboardGridItem } from 'app/features/dashboard-scene/scene/DashboardGridItem'; import { vizPanelToPanel } from 'app/features/dashboard-scene/serialization/transformSceneToSaveModel'; import { getLibraryPanelBehavior } from 'app/features/dashboard-scene/utils/utils'; @@ -146,10 +145,6 @@ export function libraryVizPanelToSaveModel(vizPanel: VizPanel) { let gridItem = vizPanel.parent; - if (gridItem instanceof VizPanelManager) { - gridItem = gridItem.state.sourcePanel.resolve().parent; - } - if (!gridItem || !(gridItem instanceof DashboardGridItem)) { throw new Error('Trying to save a library panel that does not have a DashboardGridItem parent'); } diff --git a/public/app/features/trails/DataTrailsHistory.test.tsx b/public/app/features/trails/DataTrailsHistory.test.tsx index 13e65c6adce..24e27dadc23 100644 --- a/public/app/features/trails/DataTrailsHistory.test.tsx +++ b/public/app/features/trails/DataTrailsHistory.test.tsx @@ -24,7 +24,7 @@ describe('DataTrailsHistory', () => { { name: 'from history', input: { from: '2024-07-22T18:30:00.000Z', to: '2024-07-22T19:30:00.000Z' }, - expected: '2024-07-22 12:30:00 - 2024-07-22 13:30:00', + expected: '2024-07-22 13:30:00 - 2024-07-22 14:30:00', }, { name: 'time change event with timezone', @@ -33,7 +33,7 @@ describe('DataTrailsHistory', () => { }, ])('$name', ({ input, expected }) => { const result = parseTimeTooltip(input); - expect(result).toBe(expected); + expect(result).toEqual(expected); }); }); diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx index 073cb173dd8..47f70640b07 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx @@ -23,9 +23,11 @@ function getQueryDisplayText(query: DataQuery): string { function isPanelInEdit(panelId: number, panelInEditId?: number) { let idToCompareWith = panelInEditId; + if (window.__grafanaSceneContext && window.__grafanaSceneContext instanceof DashboardScene) { - idToCompareWith = window.__grafanaSceneContext.state.editPanel?.state.panelId; + idToCompareWith = window.__grafanaSceneContext.state.editPanel?.getPanelId(); } + return panelId === idToCompareWith; }