PanelEdit: Edit the source panel, refactor out VizPanelManager, simplify (#93045)

* Options pane, data pane queries tab and transformations tab working

* Update

* Discard works

* Panel inspect working

* Table viw works

* Repeat options

* Began fixing tests

* More tests fixed

* Progress on tests

* no errors

* init full width when enabling repeat

* Began moving VizPanelManager tests to where the code was moved

* Unlink libray panel code and unit test

* Fixes and unit tests for change tracking and resetting original state and dirty flag when saving

* migrating and improving unit tests

* Done with VizPanelManager tests refactoring

* Update

* Update

* remove console.log

* Removed unnesssary behavior and fixed test

* Update

* Fix unrelated test

* conditional options fix

* remove

* Fixing issue with editing repeated panels and scoping variable to first value

* Minor fix

* Fix discard query runner changes

* Review comment changes

* fix discard issue with new panels

* Add loading state to panel edit

* Fix test

* Update

* fix test

* fix lint

* lint

* Fix

* Fix overrides editing  mutating fieldConfig

---------

Co-authored-by: alexandra vargas <alexa1866@gmail.com>
pull/93691/head
Torkel Ödegaard 9 months ago committed by GitHub
parent 9adb7b03a7
commit 038d9cabde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      .betterer.results
  2. 14
      public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts
  3. 6
      public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx
  4. 4
      public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx
  5. 48
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx
  6. 3
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx
  7. 52
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx
  8. 106
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx
  9. 450
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx
  10. 266
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx
  11. 17
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.test.tsx
  12. 51
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx
  13. 7
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/types.ts
  14. 9
      public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx
  15. 360
      public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts
  16. 285
      public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx
  17. 36
      public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx
  18. 38
      public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx
  19. 13
      public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx
  20. 101
      public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx
  21. 79
      public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx
  22. 39
      public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx
  23. 864
      public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx
  24. 504
      public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx
  25. 81
      public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx
  26. 17
      public/app/features/dashboard-scene/saving/DashboardPrompt.tsx
  27. 12
      public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts
  28. 3
      public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts
  29. 11
      public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx
  30. 61
      public/app/features/dashboard-scene/scene/DashboardGridItem.tsx
  31. 24
      public/app/features/dashboard-scene/scene/DashboardScene.test.tsx
  32. 4
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  33. 49
      public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts
  34. 10
      public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx
  35. 43
      public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
  36. 4
      public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx
  37. 6
      public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx
  38. 2
      public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx
  39. 107
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
  40. 48
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts
  41. 29
      public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx
  42. 6
      public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx
  43. 5
      public/app/features/library-panels/state/api.ts
  44. 4
      public/app/features/trails/DataTrailsHistory.test.tsx
  45. 4
      public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx

@ -2665,8 +2665,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans />", "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"],

@ -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<typeof gridItemToPanel> = { 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);
}

@ -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);
}

@ -28,7 +28,7 @@ import { SceneInspectTab } from './types';
interface PanelInspectDrawerState extends SceneObjectState {
tabs?: SceneInspectTab[];
panelRef?: SceneObjectRef<VizPanel>;
panelRef: SceneObjectRef<VizPanel>;
pluginNotLoaded?: boolean;
canEdit?: boolean;
}
@ -51,7 +51,7 @@ export class PanelInspectDrawer extends SceneObjectBase<PanelInspectDrawerState>
*/
async buildTabs(retry: number) {
const panelRef = this.state.panelRef;
const plugin = panelRef?.resolve()?.getPlugin();
const plugin = panelRef.resolve()?.getPlugin();
const tabs: SceneInspectTab[] = [];
if (!plugin) {

@ -0,0 +1,48 @@
import { Observable } from 'rxjs';
import {
SceneDataProvider,
SceneDataProviderResult,
SceneDataState,
SceneObjectBase,
SceneObjectRef,
} from '@grafana/scenes';
export interface DataProviderSharerState extends SceneDataState {
source: SceneObjectRef<SceneDataProvider>;
}
export class DataProviderSharer extends SceneObjectBase<DataProviderSharerState> 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<SceneDataProviderResult> {
return this.state.source.resolve().getResultsStream();
}
}

@ -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;
}

@ -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<PanelDataPaneTabState> implements PanelDataPaneTab {
static Component = PanelDataAlertingTabRendered;
TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element;
export interface PanelDataAlertingTabState extends SceneObjectState {
panelRef: SceneObjectRef<VizPanel>;
}
tabId = TabId.Alert;
private _panelManager: VizPanelManager;
export class PanelDataAlertingTab extends SceneObjectBase<PanelDataAlertingTabState> 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 <AlertingTab key={this.getTabLabel()} model={this} {...props} />;
}
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<PanelDataAlertingTab>) {
const { model } = props;
export function PanelDataAlertingTabRendered({ model }: SceneComponentProps<PanelDataAlertingTab>) {
const styles = useStyles2(getStyles);
const { errors, loading, rules } = usePanelCombinedRules({
@ -87,7 +74,7 @@ export function PanelDataAlertingTabRendered(props: SceneComponentProps<PanelDat
);
}
const { panel } = model;
const panel = model.state.panelRef.resolve();
const canCreateRules = model.getCanCreateRules();
if (rules.length) {
@ -132,7 +119,6 @@ function AlertingTab(props: PanelDataAlertingTabHeaderProps) {
return (
<Tab
key={props.key}
label={model.getTabLabel()}
icon="bell"
counter={rules.length}

@ -1,115 +1,71 @@
import { css } from '@emotion/css';
import { Unsubscribable } from 'rxjs';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import {
SceneComponentProps,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
VizPanel,
} from '@grafana/scenes';
import { Container, CustomScrollbar, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { config, getConfig } from 'app/core/config';
import { getConfig } from 'app/core/config';
import { contextSrv } from 'app/core/core';
import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { VizPanelManager } from '../VizPanelManager';
import { PanelDataAlertingTab } from './PanelDataAlertingTab';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { PanelDataTransformationsTab } from './PanelDataTransformationsTab';
import { PanelDataPaneTab, TabId } from './types';
export interface PanelDataPaneState extends SceneObjectState {
tabs?: PanelDataPaneTab[];
tab?: TabId;
tabs: PanelDataPaneTab[];
tab: TabId;
panelRef: SceneObjectRef<VizPanel>;
}
export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
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 (typeof values.tab === 'string') {
this.setState({ tab: values.tab as TabId });
}
if (shouldShowAlertingTab(panel.state.pluginId)) {
tabs.push(new PanelDataAlertingTab({ panelRef }));
}
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 });
};
}
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));
public getUrlState() {
return { tab: this.state.tab };
}
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<PanelDataPane>) {
@ -125,15 +81,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
return (
<div className={styles.dataPane} data-testid={selectors.components.PanelEditor.DataPane.content}>
<TabsBar hideBorder={true} className={styles.tabsBar}>
{tabs.map((t, index) => {
return (
<t.TabComponent
key={`${t.getTabLabel()}-${index}`}
active={t.tabId === tab}
onChangeTab={() => model.onChangeTab(t)}
></t.TabComponent>
);
})}
{tabs.map((t) => t.renderTab({ active: t.tabId === tab, onChangeTab: () => model.onChangeTab(t) }))}
</TabsBar>
<CustomScrollbar className={styles.scroll}>
<TabContent className={styles.tabContent}>

@ -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,48 +246,59 @@ 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', () => {
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');
let deactivators = [] as Array<() => void>;
describe('PanelDataQueriesTab', () => {
beforeEach(() => {
store.setObject.mockClear();
});
afterEach(() => {
deactivators.forEach((deactivate) => deactivate());
deactivators = [];
});
describe('Adding queries', () => {
it('can add a new query', async () => {
const vizPanelManager = setupVizPanelManger('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const { queriesTab } = await setupScene('panel-1');
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({
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 vizPanelManager = setupVizPanelManger('panel-7');
vizPanelManager.activate();
await Promise.resolve();
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 --');
const model = new PanelDataQueriesTab(vizPanelManager);
expect(vizPanelManager.state.datasource?.uid).toBe('-- Mixed --');
expect(model.queryRunner.state.datasource?.uid).toBe('-- Mixed --');
model.addQueryClick();
queriesTab.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');
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');
});
});
@ -331,15 +354,346 @@ describe('PanelDataQueriesTab', () => {
});
});
const setupVizPanelManger = (panelId: string) => {
const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} });
const panel = findVizPanelByKey(scene, panelId)!;
describe('query options', () => {
describe('activation', () => {
it('should load data source', async () => {
const { queriesTab } = await setupScene('panel-1');
const vizPanelManager = VizPanelManager.createFor(panel);
expect(queriesTab.state.datasource).toEqual(ds1Mock);
expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock);
});
// 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));
it('should store loaded data source in local storage', async () => {
await setupScene('panel-1');
return vizPanelManager;
};
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);
});
});
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);
});
});
});
});
});
async function setupScene(panelId: string) {
const dashboard = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} });
const panel = findVizPanelByKey(dashboard, panelId)!;
const panelEditor = buildPanelEditScene(panel);
dashboard.setState({ editPanel: panelEditor });
deactivators.push(dashboard.activate());
deactivators.push(panelEditor.activate());
const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab;
deactivators.push(queriesTab.activate());
await Promise.resolve();
return { panel, scene: dashboard, queriesTab };
}

@ -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<VizPanel>;
}
export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabState> 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 <QueriesTab key={this.getTabLabel()} model={this} {...props} />;
}
this.TabComponent = (props: PanelDataTabHeaderProps) => {
return QueriesTab({ ...props, model: this });
};
private onActivate() {
this.loadDataSource();
}
private async loadDataSource() {
const panel = this.state.panelRef.resolve();
const dataObj = 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);
}
this._panelManager = panelManager;
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<PanelDataQueriesTabStat
}
let queries: QueryGroupOptions['queries'] = queryRunner.state.queries;
const dsSettings = this.state.dsSettings;
return {
cacheTimeout: panelManager.state.dsSettings?.meta.queryOptions?.cacheTimeout
? queryRunner.state.cacheTimeout
: undefined,
queryCachingTTL: panelManager.state.dsSettings?.cachingConfig?.enabled
? queryRunner.state.queryCachingTTL
: undefined,
cacheTimeout: dsSettings?.meta.queryOptions?.cacheTimeout ? queryRunner.state.cacheTimeout : undefined,
queryCachingTTL: dsSettings?.cachingConfig?.enabled ? queryRunner.state.queryCachingTTL : undefined,
dataSource: {
default: panelManager.state.dsSettings?.isDefault,
...(panelManager.state.dsSettings
? getDataSourceRef(panelManager.state.dsSettings)
: { type: undefined, uid: undefined }),
default: dsSettings?.isDefault,
...(dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }),
},
queries,
maxDataPoints: queryRunner.state.maxDataPoints,
@ -92,37 +161,98 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
};
}
onOpenInspector = () => {
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<SceneQueryRunner['state']> = {};
const timeRangeObjStateUpdate: Partial<PanelTimeRangeState> = {};
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<DataQuery> {
const { dsSettings, datasource } = this._panelManager.state;
public newQuery(): Partial<DataQuery> {
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<PanelDataQueriesTabStat
};
}
addQueryClick = () => {
public addQueryClick = () => {
const queries = this.getQueries();
this.onQueriesChange(addQuery(queries, this.newQuery()));
};
onAddQuery = (query: Partial<DataQuery>) => {
public onAddQuery = (query: Partial<DataQuery>) => {
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<PanelDataQueriesTabStat
.filter(Boolean);
}
get queryRunner(): SceneQueryRunner {
return this._panelManager.queryRunner;
}
get panelManager() {
return this._panelManager;
public get queryRunner(): SceneQueryRunner {
return getQueryRunnerFor(this.state.panelRef.resolve())!;
}
}
export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<PanelDataQueriesTab>) {
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 (
<Tab
key={props.key}
label={model.getTabLabel()}
icon="database"
counter={queryRunnerState.queries.length}

@ -19,7 +19,6 @@ import { DashboardDataDTO } from 'app/types';
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 { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
@ -52,10 +51,9 @@ const mockData = {
describe('PanelDataTransformationsModel', () => {
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 };
}

@ -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<VizPanel>;
}
export class PanelDataTransformationsTab
extends SceneObjectBase<PanelDataTransformationsTabState>
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 <TransformationsTab key={this.getTabLabel()} model={this} {...props} />;
}
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 (
<Tab
key={props.key}
label={model.getTabLabel()}
icon="process"
counter={transformerState.transformations.length}

@ -1,6 +1,4 @@
import { SceneObject, SceneObjectState } from '@grafana/scenes';
export interface PanelDataPaneTabState extends SceneObjectState {}
import { SceneObject } from '@grafana/scenes';
export enum TabId {
Queries = 'queries',
@ -9,13 +7,12 @@ export enum TabId {
}
export interface PanelDataTabHeaderProps {
key: string;
active: boolean;
onChangeTab?: (event: React.MouseEvent<HTMLElement>) => void;
}
export interface PanelDataPaneTab extends SceneObject {
TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element;
renderTab: (props: PanelDataTabHeaderProps) => React.JSX.Element;
getTabLabel(): string;
tabId: TabId;
}

@ -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 && (
<InlineSwitch
label="Table view"
showLabel={true}
id="table-view"
value={tableView ? true : false}
onClick={() => vizManager.toggleTableView()}
onClick={panelEditor.onToggleTableView}
aria-label="toggle-table-view"
data-testid={selectors.components.PanelEditor.toggleTableView}
/>

@ -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<PanelPlugin> | 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', () => ({
},
}));
describe('PanelEditor', () => {
describe('When closing editor', () => {
it('should apply changes automatically', () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true });
const dataSources = {
ds1: mockDataSource({
uid: 'ds1',
type: DataSourceType.Prometheus,
}),
};
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
setDataSourceSrv(new MockDataSourceSrv(dataSources));
let deactivate: CancelActivationHandler | undefined;
describe('PanelEditor', () => {
afterEach(() => {
if (deactivate) {
deactivate();
deactivate = undefined;
}
});
const gridItem = new DashboardGridItem({ body: panel });
describe('When initializing', () => {
it('should wait for panel plugin to load', async () => {
const { panelEditor, panel, pluginResolve, dashboard } = await setup({ skipWait: true });
const editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({
editPanel: editScene,
isEditing: true,
body: new SceneGridLayout({
children: [gridItem],
}),
expect(panel.state.options).toEqual({});
expect(panelEditor.state.isInitializing).toBe(true);
const pluginToLoad = getPanelPlugin({ id: 'text' }).setPanelOptions((build) => {
build.addBooleanSwitch({
path: 'showHeader',
name: 'Show header',
defaultValue: true,
});
});
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 discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!;
expect(discardedPanel.state.title).toBe('original title');
});
const gridItem = new DashboardGridItem({ body: panel });
it('should discard a newly added panel', async () => {
const { panelEditor, dashboard } = await setup({ isNewPanel: true });
panelEditor.onDiscard();
const editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({
editPanel: editScene,
isEditing: true,
body: new SceneGridLayout({
children: [gridItem],
}),
expect((dashboard.state.body as SceneGridLayout).state.children.length).toBe(0);
});
const deactivate = activateFullSceneTree(scene);
it('should discard query runner changes', async () => {
const { panelEditor, panel, dashboard } = await setup({});
editScene.state.vizManager.state.panel.setState({ title: 'changed title' });
const queryRunner = getQueryRunnerFor(panel);
queryRunner?.setState({ maxDataPoints: 123, queries: [{ refId: 'A' }, { refId: 'B' }] });
editScene.onDiscard();
deactivate();
panelEditor.onDiscard();
const updatedPanel = gridItem.state.body as VizPanel;
expect(updatedPanel?.state.title).toBe(panel.state.title);
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((scene.state.body as SceneGridLayout).state.children.length).toBe(0);
expect(panelEditor.state.isDirty).toBe(false);
panel.setState({ title: 'changed title 2' });
expect(panelEditor.state.isDirty).toBe(true);
// Change back to already saved state
panel.setState({ title: 'changed title' });
expect(panelEditor.state.isDirty).toBe(false);
});
});
describe('When opening a repeated panel', () => {
it('Should default to the first variable value if panel is repeated', async () => {
const { panel } = await setup({ repeatByVariable: 'server' });
const variable = sceneGraph.lookupVariable('server', panel);
expect(variable?.getValue()).toBe('A');
});
});
describe('Handling library panels', () => {
it('should call the api with the updated panel', async () => {
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();
expect(panel.state.$behaviors?.length).toBe(1);
expect(panel.state.$behaviors![0]).toBe(otherBehavior);
});
});
activateFullSceneTree(scene);
describe('PanelDataPane', () => {
it('should not exist if panel is skipDataQuery', async () => {
const { panelEditor } = await setup({ pluginSkipDataQuery: true });
expect(panelEditor.state.dataPane).toBeUndefined();
});
expect(editScene.state.dataPane).toBeUndefined();
it('should exist if panel is supporting querying', async () => {
const { panelEditor } = await setup({ pluginSkipDataQuery: false });
expect(panelEditor.state.dataPane).toBeDefined();
});
});
});
it('should exist if panel is supporting querying', () => {
pluginToLoad = getTestPanelPlugin({ id: 'timeseries' });
interface SetupOptions {
isNewPanel?: boolean;
pluginSkipDataQuery?: boolean;
repeatByVariable?: string;
skipWait?: boolean;
pluginLoadTime?: number;
}
async function setup(options: SetupOptions = {}) {
const pluginToLoad = getPanelPlugin({ id: 'text', skipDataQuery: options.pluginSkipDataQuery });
let pluginResolve = (plugin: PanelPlugin) => {};
pluginPromise = new Promise<PanelPlugin>((resolve) => {
pluginResolve = resolve;
});
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'timeseries',
pluginId: 'text',
title: 'original title',
$data: new SceneDataTransformer({
transformations: [],
$data: new SceneQueryRunner({
queries: [{ refId: 'A' }],
maxDataPoints: 500,
datasource: { uid: 'ds1' },
}),
}),
});
new DashboardGridItem({
body: panel,
});
const editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({
editPanel: editScene,
});
const gridItem = new DashboardGridItem({ body: panel, variableName: options.repeatByVariable });
activateFullSceneTree(scene);
expect(editScene.state.dataPane).toBeDefined();
});
});
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],
}),
});
export function getTestPanelPlugin(options: Partial<PanelPluginMeta>): 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;
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 };
}

@ -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<VizPanel>;
showLibraryPanelSaveModal?: boolean;
showLibraryPanelUnlinkModal?: boolean;
tableView?: VizPanel;
pluginLoadErrror?: string;
/**
* Waiting for library panel or panel plugin to load
*/
isInitializing?: boolean;
}
export class PanelEditor extends SceneObjectBase<PanelEditorState> {
private _initialRepeatOptions: Pick<VizPanelManagerState, 'repeat' | 'repeatDirection' | 'maxPerRow'> = {};
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;
const panel = this.state.panelRef.resolve();
const deactivateParents = activateInActiveParents(panel);
const layoutElement = panel.parent;
this.waitForPlugin();
return () => {
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<VizPanel>) {
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(
panelManager.subscribeToState((n, p) => {
panel.subscribeToState((n, p) => {
if (n.pluginId !== p.pluginId) {
this._initDataPane(n.pluginId);
this.waitForPlugin();
}
})
);
this._initDataPane(panel.state.pluginId);
return () => {
if (!this._discardChanges) {
this.commitChanges();
} else if (this.state.isNewPanel) {
getDashboardSceneFor(this).removePanel(panelManager.state.sourcePanel.resolve()!);
// 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<PanelEditorState> {
}
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<PanelEditorState> {
}
public onDiscard = () => {
this.state.vizManager.setState({ isDirty: false });
this._discardChanges = true;
locationService.partial({ editPanel: null });
};
this.setState({ isDirty: false });
public commitChanges() {
const dashboard = getDashboardSceneFor(this);
const panel = this.state.panelRef.resolve();
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
if (this.state.isNewPanel) {
getDashboardSceneFor(this).removePanel(panel);
} else {
// Revert any layout element changes
this._layoutElement.setState(this._originalLayoutElementState!);
}
const panelManager = this.state.vizManager;
const sourcePanel = panelManager.state.sourcePanel.resolve();
const gridItem = sourcePanel!.parent;
if (!(gridItem instanceof DashboardGridItem)) {
console.error('Unsupported scene object type');
return;
}
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<PanelEditorState> {
};
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<PanelEditorState> {
};
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,
});
}

@ -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<PanelEditor>)
/>
</div>
)}
{!splitterState.collapsed && <optionsPane.Component model={optionsPane} />}
{!splitterState.collapsed && optionsPane && <optionsPane.Component model={optionsPane} />}
{!splitterState.collapsed && !optionsPane && <Spinner />}
</div>
</div>
</>
@ -63,9 +64,9 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
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<PanelEditor>) {
)}
<div {...containerProps}>
<div {...primaryProps}>
<vizManager.Component model={vizManager} />
<VizWrapper panel={panel} tableView={tableView} />
</div>
{showLibraryPanelSaveModal && libraryPanel && (
<SaveLibraryVizPanelModal
@ -137,6 +138,22 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
);
}
interface VizWrapperProps {
panel: VizPanel;
tableView?: VizPanel;
}
function VizWrapper({ panel, tableView }: VizWrapperProps) {
const styles = useStyles2(getStyles);
const panelToShow = tableView ?? panel;
return (
<div className={styles.vizWrapper}>
<panelToShow.Component model={panelToShow} />
</div>
);
}
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),
}),
};
}

@ -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 = <PanelOptions vizManager={vizManager} searchQuery="" listMode={OptionFilter.All}></PanelOptions>;
activateFullSceneTree(panelOptionsScene);
panel.activate();
const panelOptions = <PanelOptions panel={panel} searchQuery="" listMode={OptionFilter.All}></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');
});
});

@ -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<Props>(({ vizManager, searchQuery, listMode, data }) => {
const { panel, repeat } = vizManager.useState();
export const PanelOptions = React.memo<Props>(({ 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(() => {

@ -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 };
}

@ -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<VizPanel>;
}
export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
public constructor(state: Partial<PanelOptionsPaneState>) {
super({
searchQuery: '',
listMode: OptionFilter.All,
...state,
});
interface PluginOptionsCache {
options: DeepPartial<{}>;
fieldConfig: FieldConfigSource<DeepPartial<{}>>;
}
export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
private _cachedPluginOptions: Record<string, PluginOptionsCache | undefined> = {};
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<PanelOptionsPaneState> {
};
static Component = ({ model }: SceneComponentProps<PanelOptionsPane>) => {
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<PanelOptionsPaneState> {
/>
</div>
<div className={styles.listOfOptions}>
<PanelOptions vizManager={vizManager} searchQuery={searchQuery} listMode={listMode} data={data} />
<PanelOptions panel={panel} searchQuery={searchQuery} listMode={listMode} data={data} />
</div>
</>
)}
{isVizPickerOpen && (
<PanelVizTypePicker vizManager={vizManager} onChange={model.onToggleVizPicker} data={data} />
<PanelVizTypePicker
panel={panel}
onChange={model.onChangePanelPlugin}
onClose={model.onToggleVizPicker}
data={data}
/>
)}
</>
);

@ -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<SelectableValue<VisualizationSelectPaneTab>> = [
{ 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 (
<div className={styles.wrapper}>
<div className={styles.searchRow}>
@ -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}
/>
</div>
<Field className={styles.customFieldMargin}>
@ -90,18 +75,10 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) {
</Field>
<CustomScrollbar>
{listMode === VisualizationSelectPaneTab.Visualizations && (
<VizTypePicker pluginId={panel.state.pluginId} searchQuery={searchQuery} onChange={onVizTypeChange} />
<VizTypePicker pluginId={panel.state.pluginId} searchQuery={searchQuery} onChange={onChange} />
)}
{/* {listMode === VisualizationSelectPaneTab.Widgets && (
<VizTypePicker pluginId={plugin.meta.id} onChange={onVizChange} searchQuery={searchQuery} isWidget />
)} */}
{listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions
onChange={onVizTypeChange}
searchQuery={searchQuery}
panel={panelModel}
data={data}
/>
<VisualizationSuggestions onChange={onChange} searchQuery={searchQuery} panel={panelModel} data={data} />
)}
</CustomScrollbar>
</div>

@ -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<DataQuery, DataSourceJsonData, {}>;
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<DataQuery, DataSourceJsonData, {}>;
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<DataQuery, DataSourceJsonData, {}>;
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<DataQuery, DataSourceJsonData, {}>;
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 };
};

@ -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<VizPanel>;
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<VizPanelManagerState> {
private _cachedPluginOptions: Record<
string,
{ options: DeepPartial<{}>; fieldConfig: FieldConfigSource<DeepPartial<{}>> } | 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<VizPanelManagerState, 'repeat' | 'repeatDirection' | 'maxPerRow'> = {};
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<SceneQueryRunner['state']> = {};
const timeRangeObjStateUpdate: Partial<PanelTimeRangeState> = {};
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<T extends DataQuery>(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<VizPanelManager>) => {
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 (
<>
<div className={styles.wrapper}>{<panelToShow.Component model={panelToShow} />}</div>
</>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
height: '100%',
width: '100%',
paddingLeft: theme.spacing(2),
}),
};
}

@ -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 <PanelFrameTitle vizManager={vizManager} />;
return <PanelFrameTitle panel={panel} />;
},
addon: config.featureToggles.dashgpt && (
<GenAIPanelTitleButton
onGenerate={(title) => vizManager.setPanelTitle(title)}
onGenerate={(title) => setPanelTitle(panel, title)}
panel={vizPanelToPanel(panel)}
dashboard={transformSceneToSaveModel(dashboard)}
/>
@ -95,14 +94,18 @@ export function getPanelFrameCategory2(
render: () => <ScenePanelLinksEditor panelLinks={panelLinksObject ?? undefined} />,
})
)
)
.addCategory(
new OptionsPaneCategoryDescriptor({
);
if (layoutElement instanceof DashboardGridItem) {
const gridItem = layoutElement;
const category = new OptionsPaneCategoryDescriptor({
title: 'Repeat options',
id: 'Repeat options',
isOpenDefault: false,
})
.addItem(
});
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat by variable',
description:
@ -111,24 +114,19 @@ export function getPanelFrameCategory2(
return (
<RepeatRowSelect2
id="repeat-by-variable-select"
parent={panel}
repeat={repeat}
onChange={(value?: string) => {
const stateUpdate: Partial<VizPanelManagerState> = { repeat: value };
if (value && !vizManager.state.repeatDirection) {
stateUpdate.repeatDirection = 'h';
}
vizManager.setState(stateUpdate);
}}
sceneContext={panel}
repeat={gridItem.state.variableName}
onChange={(value?: string) => gridItem.setRepeatByVariable(value)}
/>
);
},
})
)
.addItem(
);
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat direction',
showIf: () => !!vizManager.state.repeat,
showIf: () => Boolean(gridItem.state.variableName),
render: function renderRepeatOptions() {
const directionOptions: Array<SelectableValue<'h' | 'v'>> = [
{ label: 'Horizontal', value: 'h' },
@ -138,30 +136,35 @@ export function getPanelFrameCategory2(
return (
<RadioButtonGroup
options={directionOptions}
value={vizManager.state.repeatDirection ?? 'h'}
onChange={(value) => vizManager.setState({ repeatDirection: value })}
value={gridItem.state.repeatDirection ?? 'h'}
onChange={(value) => gridItem.setState({ repeatDirection: value })}
/>
);
},
})
)
.addItem(
);
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Max per row',
showIf: () => Boolean(vizManager.state.repeat && vizManager.state.repeatDirection === 'h'),
showIf: () => Boolean(gridItem.state.variableName && gridItem.state.repeatDirection === 'h'),
render: function renderOption() {
const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value }));
return (
<Select
options={maxPerRowOptions}
value={vizManager.state.maxPerRow}
onChange={(value) => vizManager.setState({ maxPerRow: value.value })}
value={gridItem.state.maxPerRow ?? 4}
onChange={(value) => 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 (
<Input
data-testid={selectors.components.PanelEditor.OptionsPane.fieldInput('Title')}
value={title}
onChange={(e) => 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 === '' });
}

@ -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);
},

@ -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')) {

@ -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];

@ -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();
});

@ -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<DashboardGridItemState> implements SceneGridItemLike {
private _prevRepeatValues?: VariableValueSingle[];
private _oldBody?: VizPanel;
private _prevPanelState: VizPanelState | undefined;
private _prevGridItemState: DashboardGridItemState | undefined;
protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this);
@ -54,11 +55,14 @@ export class DashboardGridItem extends SceneObjectBase<DashboardGridItemState> 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();
}
}
this.performRepeat();
private clearCachedStateIfBodyOrOptionsChanged() {
if (this._prevGridItemState !== this.state || this._prevPanelState !== this.state.body.state) {
this._prevRepeatValues = undefined;
}
}
@ -116,9 +120,6 @@ export class DashboardGridItem extends SceneObjectBase<DashboardGridItemState> 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<DashboardGridItemState> 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<DashboardGridItemState> = { 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);

@ -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',

@ -252,6 +252,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
};
this._changeTracker.stopTrackingChanges();
this.setState({
version: result.version,
isDirty: false,
@ -267,6 +268,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
},
});
this.state.editPanel?.dashboardSaved();
this._changeTracker.startTrackingChanges();
}
@ -801,7 +803,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
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;

@ -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<ResolveInspectPanelByKeyState> {
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() });
}
};
}

@ -77,6 +77,16 @@ export class LibraryPanelBehavior extends SceneObjectBase<LibraryPanelBehaviorSt
}
}
/**
* Removes itself from the parent panel's behaviors array
*/
public unlink() {
const panel = this.parent;
if (panel instanceof VizPanel) {
panel.setState({ $behaviors: panel.state.$behaviors?.filter((b) => b !== this) });
}
}
private async loadLibraryPanelFromPanelModel() {
let vizPanel = this.parent;

@ -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<Boolean>(false);
// This hook handles when panelEditor is not defined to avoid conditionally hook usage
function usePanelEditDirty(panelEditor?: PanelEditor) {
const [isDirty, setIsDirty] = useState<Boolean | undefined>();
useEffect(() => {
if (panelEditor) {
const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) =>
setEditingLibraryPanel(isLibraryPanel(vizManagerState.sourcePanel.resolve()))
);
return () => {
unsub.unsubscribe();
};
const unsub = panelEditor.subscribeToState((state) => {
if (state.isDirty !== isDirty) {
setIsDirty(state.isDirty);
}
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<Boolean>(false);
});
useEffect(() => {
if (panelEditor) {
const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) =>
setIsDirty(vizManagerState.isDirty || false)
);
return () => {
unsub.unsubscribe();
};
return () => unsub.unsubscribe();
}
setIsDirty(false);
return;
}, [panelEditor]);
}, [panelEditor, isDirty]);
return isDirty;
}

@ -24,7 +24,7 @@ describe('DashboardRow', () => {
<TestProvider>
<RowOptionsForm
repeat={'3'}
parent={scene}
sceneContext={scene}
title=""
onCancel={jest.fn()}
onUpdate={jest.fn()}
@ -40,7 +40,7 @@ describe('DashboardRow', () => {
it('Should not show warning component when does not have warningMessage prop', () => {
render(
<TestProvider>
<RowOptionsForm repeat={'3'} parent={scene} title="" onCancel={jest.fn()} onUpdate={jest.fn()} />
<RowOptionsForm repeat={'3'} sceneContext={scene} title="" onCancel={jest.fn()} onUpdate={jest.fn()} />
</TestProvider>
);
expect(

@ -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<string | undefined>(repeat);
const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]);
@ -38,7 +38,7 @@ export const RowOptionsForm = ({ repeat, title, parent, warning, onUpdate, onCan
<Input {...register('title')} type="text" />
</Field>
<Field label="Repeat for">
<RepeatRowSelect2 parent={parent} repeat={newRepeat} onChange={onChangeRepeat} />
<RepeatRowSelect2 sceneContext={sceneContext} repeat={newRepeat} onChange={onChangeRepeat} />
</Field>
{warning && (
<Alert

@ -21,7 +21,7 @@ export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, wa
return (
<Modal isOpen={true} title="Row options" onDismiss={onDismiss} className={styles.modal}>
<RowOptionsForm
parent={parent}
sceneContext={parent}
repeat={repeat}
title={title}
onCancel={onDismiss}

@ -15,14 +15,7 @@ import {
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { getPluginLinkExtensions, setPluginImportUtils } from '@grafana/runtime';
import {
MultiValueVariable,
sceneGraph,
SceneGridLayout,
SceneGridRow,
SceneTimeRange,
VizPanel,
} from '@grafana/scenes';
import { MultiValueVariable, sceneGraph, SceneGridLayout, SceneGridRow, VizPanel } from '@grafana/scenes';
import { Dashboard, LoadingState, Panel, RowPanel, VariableRefresh } from '@grafana/schema';
import { PanelModel } from 'app/features/dashboard/state';
import { getTimeRange } from 'app/features/dashboard/utils/timeRange';
@ -30,10 +23,8 @@ import { reduceTransformRegistryItem } from 'app/features/transformers/editors/R
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DashboardDataDTO } from 'app/types';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardGridItem } from '../scene/DashboardGridItem';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { NEW_LINK } from '../settings/links/utils';
@ -804,7 +795,7 @@ describe('transformSceneToSaveModel', () => {
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({

@ -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<Panel | RowPanel>,
sceneState?: DashboardSceneState,
isSnapshot = false
) {
export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array<Panel | RowPanel>, 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);
});
}

@ -110,20 +110,17 @@ export function getFieldOverrideCategories(
onOverrideChange(idx, override);
};
const onDynamicConfigValueAdd = (o: ConfigOverrideRule, value: SelectableValue<string>) => {
const onDynamicConfigValueAdd = (override: ConfigOverrideRule, value: SelectableValue<string>) => {
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),
});
};
/**

@ -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(() => {

@ -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');
}

@ -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);
});
});

@ -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;
}

Loading…
Cancel
Save