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. 112
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx
  9. 542
      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. 374
      public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts
  16. 291
      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. 161
      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. 63
      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. 45
      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 (shouldShowAlertingTab(panel.state.pluginId)) {
tabs.push(new PanelDataAlertingTab({ panelRef }));
}
if (typeof values.tab === 'string') {
this.setState({ tab: values.tab as TabId });
}
}
constructor(panelMgr: VizPanelManager) {
super({
return new PanelDataPane({
panelRef,
tabs,
tab: TabId.Queries,
tabs: [],
});
this.panelManager = panelMgr;
this.addActivationHandler(() => this.onActivate());
}
private onActivate() {
this.buildTabs();
this._subs.add(
// Setup subscription for the case when panel type changed
this.panelManager.subscribeToState((n, p) => {
if (n.pluginId !== p.pluginId) {
this.buildTabs();
}
})
);
return () => {
if (this.panelSubscription) {
this.panelSubscription.unsubscribe();
this.panelSubscription = undefined;
}
};
public onChangeTab = (tab: PanelDataPaneTab) => {
this.setState({ tab: tab.tabId });
};
public getUrlState() {
return { tab: this.state.tab };
}
private buildTabs() {
const panelManager = this.panelManager;
const panel = panelManager.state.panel;
const pluginId = panelManager.state.pluginId;
const runner = this.panelManager.queryRunner;
const tabs: PanelDataPaneTab[] = [];
if (panel) {
if (config.panels[pluginId]?.skipDataQuery) {
this.setState({ tabs });
return;
} else {
if (runner) {
tabs.push(new PanelDataQueriesTab(this.panelManager));
}
tabs.push(new PanelDataTransformationsTab(this.panelManager));
if (shouldShowAlertingTab(panelManager.state.pluginId)) {
tabs.push(new PanelDataAlertingTab(this.panelManager));
}
}
public updateFromUrl(values: SceneObjectUrlValues) {
if (!values.tab) {
return;
}
if (typeof values.tab === 'string') {
this.setState({ tab: values.tab as TabId });
}
this.setState({ tabs });
}
onChangeTab = (tab: PanelDataPaneTab) => {
this.setState({ tab: tab.tabId });
};
}
function PanelDataPaneRendered({ model }: SceneComponentProps<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,112 +246,454 @@ jest.mock('@grafana/runtime', () => ({
return instance1SettingsMock;
},
}),
locationService: {
partial: jest.fn(),
getSearchObject: jest.fn().mockReturnValue({
firstPanel: false,
}),
},
config: {
...jest.requireActual('@grafana/runtime').config,
defaultDatasource: 'gdev-testdata',
},
}));
describe('PanelDataQueriesModel', () => {
it('can add a new query', async () => {
const vizPanelManager = setupVizPanelManger('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const model = new PanelDataQueriesTab(vizPanelManager);
model.addQueryClick();
expect(model.queryRunner.state.queries).toHaveLength(2);
expect(model.queryRunner.state.queries[1].refId).toBe('B');
expect(model.queryRunner.state.queries[1].hide).toBe(false);
expect(model.queryRunner.state.queries[1].datasource).toEqual({
type: 'grafana-testdata-datasource',
uid: 'gdev-testdata',
});
});
it('can add a new query when datasource is mixed', async () => {
const vizPanelManager = setupVizPanelManger('panel-7');
vizPanelManager.activate();
await Promise.resolve();
jest.mock('app/core/store', () => ({
exists: jest.fn(),
get: jest.fn(),
getObject: jest.fn((_a, b) => b),
setObject: jest.fn(),
}));
const model = new PanelDataQueriesTab(vizPanelManager);
expect(vizPanelManager.state.datasource?.uid).toBe('-- Mixed --');
expect(model.queryRunner.state.datasource?.uid).toBe('-- Mixed --');
model.addQueryClick();
expect(model.queryRunner.state.queries).toHaveLength(2);
expect(model.queryRunner.state.queries[1].refId).toBe('B');
expect(model.queryRunner.state.queries[1].hide).toBe(false);
expect(model.queryRunner.state.queries[1].datasource?.uid).toBe('gdev-testdata');
});
});
const store = jest.requireMock('app/core/store');
let deactivators = [] as Array<() => void>;
describe('PanelDataQueriesTab', () => {
it('renders query group top section', async () => {
const modelMock = await createModelMock();
beforeEach(() => {
store.setObject.mockClear();
});
render(<PanelDataQueriesTabRendered model={modelMock}></PanelDataQueriesTabRendered>);
await screen.findByTestId(selectors.components.QueryTab.queryGroupTopSection);
afterEach(() => {
deactivators.forEach((deactivate) => deactivate());
deactivators = [];
});
it('renders queries rows when queries are set', async () => {
const modelMock = await createModelMock();
render(<PanelDataQueriesTabRendered model={modelMock}></PanelDataQueriesTabRendered>);
describe('Adding queries', () => {
it('can add a new query', async () => {
const { queriesTab } = await setupScene('panel-1');
await screen.findByTestId('query-editor-rows');
expect(screen.getAllByTestId('query-editor-row')).toHaveLength(1);
queriesTab.addQueryClick();
expect(queriesTab.queryRunner.state.queries).toHaveLength(2);
expect(queriesTab.queryRunner.state.queries[1].refId).toBe('B');
expect(queriesTab.queryRunner.state.queries[1].hide).toBe(false);
expect(queriesTab.queryRunner.state.queries[1].datasource).toEqual({
type: 'grafana-testdata-datasource',
uid: 'gdev-testdata',
});
});
it('Can add a new query when datasource is mixed', async () => {
const { queriesTab } = await setupScene('panel-7');
expect(queriesTab.state.datasource?.uid).toBe('-- Mixed --');
expect(queriesTab.queryRunner.state.datasource?.uid).toBe('-- Mixed --');
queriesTab.addQueryClick();
expect(queriesTab.queryRunner.state.queries).toHaveLength(2);
expect(queriesTab.queryRunner.state.queries[1].refId).toBe('B');
expect(queriesTab.queryRunner.state.queries[1].hide).toBe(false);
expect(queriesTab.queryRunner.state.queries[1].datasource?.uid).toBe('gdev-testdata');
});
});
it('allow to add a new query when user clicks on add new', async () => {
const modelMock = await createModelMock();
jest.spyOn(modelMock, 'addQueryClick');
jest.spyOn(modelMock, 'onQueriesChange');
render(<PanelDataQueriesTabRendered model={modelMock}></PanelDataQueriesTabRendered>);
await screen.findByTestId(selectors.components.QueryTab.addQuery);
await userEvent.click(screen.getByTestId(selectors.components.QueryTab.addQuery));
const expectedQueries = [
{
datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' },
refId: 'A',
scenarioId: 'random_walk',
seriesCount: 1,
},
{ datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, hide: false, refId: 'B' },
];
describe('PanelDataQueriesTab', () => {
it('renders query group top section', async () => {
const modelMock = await createModelMock();
render(<PanelDataQueriesTabRendered model={modelMock}></PanelDataQueriesTabRendered>);
await screen.findByTestId(selectors.components.QueryTab.queryGroupTopSection);
});
it('renders queries rows when queries are set', async () => {
const modelMock = await createModelMock();
render(<PanelDataQueriesTabRendered model={modelMock}></PanelDataQueriesTabRendered>);
expect(modelMock.addQueryClick).toHaveBeenCalled();
expect(modelMock.onQueriesChange).toHaveBeenCalledWith(expectedQueries);
await screen.findByTestId('query-editor-rows');
expect(screen.getAllByTestId('query-editor-row')).toHaveLength(1);
});
it('allow to add a new query when user clicks on add new', async () => {
const modelMock = await createModelMock();
jest.spyOn(modelMock, 'addQueryClick');
jest.spyOn(modelMock, 'onQueriesChange');
render(<PanelDataQueriesTabRendered model={modelMock}></PanelDataQueriesTabRendered>);
await screen.findByTestId(selectors.components.QueryTab.addQuery);
await userEvent.click(screen.getByTestId(selectors.components.QueryTab.addQuery));
const expectedQueries = [
{
datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' },
refId: 'A',
scenarioId: 'random_walk',
seriesCount: 1,
},
{ datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, hide: false, refId: 'B' },
];
expect(modelMock.addQueryClick).toHaveBeenCalled();
expect(modelMock.onQueriesChange).toHaveBeenCalledWith(expectedQueries);
});
it('allow to remove a query when user clicks on remove', async () => {
const modelMock = await createModelMock();
jest.spyOn(modelMock, 'addQueryClick');
jest.spyOn(modelMock, 'onQueriesChange');
render(<PanelDataQueriesTabRendered model={modelMock}></PanelDataQueriesTabRendered>);
await screen.findByTestId('data-testid Remove query');
await userEvent.click(screen.getByTestId('data-testid Remove query'));
expect(modelMock.onQueriesChange).toHaveBeenCalledWith([]);
});
});
it('allow to remove a query when user clicks on remove', async () => {
const modelMock = await createModelMock();
jest.spyOn(modelMock, 'addQueryClick');
jest.spyOn(modelMock, 'onQueriesChange');
render(<PanelDataQueriesTabRendered model={modelMock}></PanelDataQueriesTabRendered>);
describe('query options', () => {
describe('activation', () => {
it('should load data source', async () => {
const { queriesTab } = await setupScene('panel-1');
expect(queriesTab.state.datasource).toEqual(ds1Mock);
expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock);
});
await screen.findByTestId('data-testid Remove query');
await userEvent.click(screen.getByTestId('data-testid Remove query'));
it('should store loaded data source in local storage', async () => {
await setupScene('panel-1');
expect(store.setObject).toHaveBeenCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', {
dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
datasourceUid: 'gdev-testdata',
});
});
it('should load default datasource if the datasource passed is not found', async () => {
const { queriesTab } = await setupScene('panel-6');
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'abc',
type: 'datasource',
});
expect(config.defaultDatasource).toBe('gdev-testdata');
expect(queriesTab.state.datasource).toEqual(defaultDsMock);
expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock);
});
});
describe('data source change', () => {
it('should load new data source', async () => {
const { queriesTab, panel } = await setupScene('panel-1');
panel.state.$data?.activate();
await queriesTab.onChangeDataSource(
{ type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' } as DataSourceInstanceSettings,
[]
);
expect(store.setObject).toHaveBeenCalledTimes(2);
expect(store.setObject).toHaveBeenLastCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', {
dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
datasourceUid: 'gdev-prometheus',
});
expect(queriesTab.state.datasource).toEqual(ds2Mock);
expect(queriesTab.state.dsSettings).toEqual(instance2SettingsMock);
});
});
expect(modelMock.onQueriesChange).toHaveBeenCalledWith([]);
describe('query options change', () => {
describe('time overrides', () => {
it('should create PanelTimeRange object', async () => {
const { queriesTab, panel } = await setupScene('panel-1');
panel.state.$data?.activate();
expect(panel.state.$timeRange).toBeUndefined();
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
timeRange: { from: '1h' },
});
expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange);
});
it('should update PanelTimeRange object on time options update', async () => {
const { queriesTab, panel } = await setupScene('panel-1');
expect(panel.state.$timeRange).toBeUndefined();
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
timeRange: { from: '1h' },
});
expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange);
expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('1h');
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
timeRange: { from: '2h' },
});
expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('2h');
});
it('should remove PanelTimeRange object on time options cleared', async () => {
const { queriesTab, panel } = await setupScene('panel-1');
expect(panel.state.$timeRange).toBeUndefined();
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
timeRange: { from: '1h' },
});
queriesTab.onQueryOptionsChange({
dataSource: {
name: 'grafana-testdata',
type: 'grafana-testdata-datasource',
default: true,
},
queries: [],
timeRange: { from: null },
});
expect(panel.state.$timeRange).toBeUndefined();
});
});
describe('max data points and interval', () => {
it('should update max data points', async () => {
const { queriesTab } = await setupScene('panel-1');
const dataObj = queriesTab.queryRunner;
expect(dataObj.state.maxDataPoints).toBeUndefined();
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
maxDataPoints: 100,
});
expect(dataObj.state.maxDataPoints).toBe(100);
});
it('should update min interval', async () => {
const { queriesTab } = await setupScene('panel-1');
const dataObj = queriesTab.queryRunner;
expect(dataObj.state.maxDataPoints).toBeUndefined();
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
minInterval: '1s',
});
expect(dataObj.state.minInterval).toBe('1s');
});
});
describe('query caching', () => {
it('updates cacheTimeout and queryCachingTTL', async () => {
const { queriesTab } = await setupScene('panel-1');
const dataObj = queriesTab.queryRunner;
queriesTab.onQueryOptionsChange({
cacheTimeout: '60',
queryCachingTTL: 200000,
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
});
expect(dataObj.state.cacheTimeout).toBe('60');
expect(dataObj.state.queryCachingTTL).toBe(200000);
});
});
});
describe('query inspection', () => {
it('allows query inspection from the tab', async () => {
const { queriesTab } = await setupScene('panel-1');
queriesTab.onOpenInspector();
const params = locationService.getSearchObject();
expect(params.inspect).toBe('1');
expect(params.inspectTab).toBe(InspectTab.Query);
});
});
describe('data source change', () => {
it('changing from one plugin to another', async () => {
const { queriesTab } = await setupScene('panel-1');
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'gdev-testdata',
type: 'grafana-testdata-datasource',
});
await queriesTab.onChangeDataSource({
name: 'grafana-prometheus',
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
meta: {
name: 'Prometheus',
module: 'prometheus',
id: 'grafana-prometheus-datasource',
},
} as DataSourceInstanceSettings);
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'gdev-prometheus',
type: 'grafana-prometheus-datasource',
});
});
it('changing from a plugin to a dashboard data source', async () => {
const { queriesTab } = await setupScene('panel-1');
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'gdev-testdata',
type: 'grafana-testdata-datasource',
});
await queriesTab.onChangeDataSource({
name: SHARED_DASHBOARD_QUERY,
type: 'datasource',
uid: SHARED_DASHBOARD_QUERY,
meta: {
name: 'Prometheus',
module: 'prometheus',
id: DASHBOARD_DATASOURCE_PLUGIN_ID,
},
} as DataSourceInstanceSettings);
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: SHARED_DASHBOARD_QUERY,
type: 'datasource',
});
});
it('changing from dashboard data source to a plugin', async () => {
const { queriesTab } = await setupScene('panel-3');
expect(queriesTab.queryRunner.state.datasource).toEqual({ uid: SHARED_DASHBOARD_QUERY, type: 'datasource' });
await queriesTab.onChangeDataSource({
name: 'grafana-prometheus',
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
meta: {
name: 'Prometheus',
module: 'prometheus',
id: 'grafana-prometheus-datasource',
},
} as DataSourceInstanceSettings);
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'gdev-prometheus',
type: 'grafana-prometheus-datasource',
});
});
});
describe('change queries', () => {
describe('plugin queries', () => {
it('should update queries', async () => {
const { queriesTab, panel } = await setupScene('panel-1');
panel.state.$data?.activate();
queriesTab.onQueriesChange([
{
datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' },
refId: 'A',
scenarioId: 'random_walk',
seriesCount: 5,
},
]);
expect(queriesTab.queryRunner.state.queries).toEqual([
{
datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' },
refId: 'A',
scenarioId: 'random_walk',
seriesCount: 5,
},
]);
});
});
describe('dashboard queries', () => {
it('should update queries', async () => {
const { queriesTab, panel } = await setupScene('panel-3');
panel.state.$data?.activate();
// Changing dashboard query to a panel with transformations
queriesTab.onQueriesChange([
{
refId: 'A',
datasource: { type: DASHBOARD_DATASOURCE_PLUGIN_ID },
panelId: panelWithTransformations.id,
},
]);
expect(queriesTab.queryRunner.state.queries[0].panelId).toEqual(panelWithTransformations.id);
// Changing dashboard query to a panel with queries only
queriesTab.onQueriesChange([
{
refId: 'A',
datasource: { type: DASHBOARD_DATASOURCE_PLUGIN_ID },
panelId: panelWithQueriesOnly.id,
},
]);
expect(queriesTab.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id);
});
it('should load last used data source if no data source specified for a panel', async () => {
store.exists.mockReturnValue(true);
store.getObject.mockReturnValue({
dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
datasourceUid: 'gdev-testdata',
});
const { queriesTab } = await setupScene('panel-5');
expect(queriesTab.state.datasource).toBe(ds1Mock);
expect(queriesTab.state.dsSettings).toBe(instance1SettingsMock);
});
});
});
});
});
const setupVizPanelManger = (panelId: string) => {
const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} });
const panel = findVizPanelByKey(scene, panelId)!;
async function setupScene(panelId: string) {
const dashboard = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} });
const panel = findVizPanelByKey(dashboard, panelId)!;
const vizPanelManager = VizPanelManager.createFor(panel);
const panelEditor = buildPanelEditScene(panel);
dashboard.setState({ editPanel: panelEditor });
// The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it
// @ts-expect-error
getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene));
deactivators.push(dashboard.activate());
deactivators.push(panelEditor.activate());
return vizPanelManager;
};
const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab;
deactivators.push(queriesTab.activate());
await Promise.resolve();
return { panel, scene: dashboard, queriesTab };
}

@ -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} />;
}
private onActivate() {
this.loadDataSource();
}
this.TabComponent = (props: PanelDataTabHeaderProps) => {
return QueriesTab({ ...props, model: this });
};
private async loadDataSource() {
const panel = this.state.panelRef.resolve();
const dataObj = panel.state.$data;
if (!dataObj) {
return;
}
this._panelManager = panelManager;
let datasourceToLoad = this.queryRunner.state.datasource;
try {
let datasource: DataSourceApi | undefined;
let dsSettings: DataSourceInstanceSettings | undefined;
if (!datasourceToLoad) {
const dashboardScene = getDashboardSceneFor(this);
const dashboardUid = dashboardScene.state.uid ?? '';
const lastUsedDatasource = getLastUsedDatasourceFromStorage(dashboardUid!);
// do we have a last used datasource for this dashboard
if (lastUsedDatasource?.datasourceUid !== null) {
// get datasource from dashbopard uid
dsSettings = getDataSourceSrv().getInstanceSettings({ uid: lastUsedDatasource?.datasourceUid });
if (dsSettings) {
datasource = await getDataSourceSrv().get({
uid: lastUsedDatasource?.datasourceUid,
type: dsSettings.type,
});
this.queryRunner.setState({
datasource: {
...getDataSourceRef(dsSettings),
uid: lastUsedDatasource?.datasourceUid,
},
});
}
}
} else {
datasource = await getDataSourceSrv().get(datasourceToLoad);
dsSettings = getDataSourceSrv().getInstanceSettings(datasourceToLoad);
}
if (datasource && dsSettings) {
this.setState({ datasource, dsSettings });
storeLastUsedDataSourceInLocalStorage(getDataSourceRef(dsSettings) || { default: true });
}
} catch (err) {
//set default datasource if we fail to load the datasource
const datasource = await getDataSourceSrv().get(config.defaultDatasource);
const dsSettings = getDataSourceSrv().getInstanceSettings(config.defaultDatasource);
if (datasource && dsSettings) {
this.setState({
datasource,
dsSettings,
});
this.queryRunner.setState({
datasource: getDataSourceRef(dsSettings),
});
}
console.error(err);
}
}
buildQueryOptions(): QueryGroupOptions {
const panelManager = this._panelManager;
const panelObj = this._panelManager.state.panel;
const queryRunner = this._panelManager.queryRunner;
const timeRangeObj = sceneGraph.getTimeRange(panelObj);
public buildQueryOptions(): QueryGroupOptions {
const panel = this.state.panelRef.resolve();
const queryRunner = getQueryRunnerFor(panel)!;
const timeRangeObj = sceneGraph.getTimeRange(panel);
let timeRangeOpts: QueryGroupOptions['timeRange'] = {
from: undefined,
@ -71,19 +145,14 @@ export class PanelDataQueriesTab extends SceneObjectBase<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', () => ({
},
}));
const dataSources = {
ds1: mockDataSource({
uid: 'ds1',
type: DataSourceType.Prometheus,
}),
};
setDataSourceSrv(new MockDataSourceSrv(dataSources));
let deactivate: CancelActivationHandler | undefined;
describe('PanelEditor', () => {
describe('When closing editor', () => {
it('should apply changes automatically', () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true });
afterEach(() => {
if (deactivate) {
deactivate();
deactivate = undefined;
}
});
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
});
describe('When initializing', () => {
it('should wait for panel plugin to load', async () => {
const { panelEditor, panel, pluginResolve, dashboard } = await setup({ skipWait: true });
const gridItem = new DashboardGridItem({ body: panel });
expect(panel.state.options).toEqual({});
expect(panelEditor.state.isInitializing).toBe(true);
const editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({
editPanel: editScene,
isEditing: true,
body: new SceneGridLayout({
children: [gridItem],
}),
const pluginToLoad = getPanelPlugin({ id: 'text' }).setPanelOptions((build) => {
build.addBooleanSwitch({
path: 'showHeader',
name: 'Show header',
defaultValue: true,
});
});
const deactivate = activateFullSceneTree(scene);
pluginResolve(pluginToLoad);
editScene.state.vizManager.state.panel.setState({ title: 'changed title' });
await new Promise((r) => setTimeout(r, 1));
deactivate();
expect(panelEditor.state.isInitializing).toBe(false);
expect(panel.state.options).toEqual({ showHeader: true });
const updatedPanel = gridItem.state.body as VizPanel;
expect(updatedPanel?.state.title).toBe('changed title');
panel.onOptionsChange({ showHeader: false });
panelEditor.onDiscard();
const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!;
expect(discardedPanel.state.options).toEqual({ showHeader: true });
});
});
it('should discard changes when unmounted and discard changes is marked as true', () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true });
describe('When discarding', () => {
it('should discard changes revert all changes', async () => {
const { panelEditor, panel, dashboard } = await setup();
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
});
panel.setState({ title: 'changed title' });
panelEditor.onDiscard();
const gridItem = new DashboardGridItem({ body: panel });
const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!;
const editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({
editPanel: editScene,
isEditing: true,
body: new SceneGridLayout({
children: [gridItem],
}),
});
expect(discardedPanel.state.title).toBe('original title');
});
it('should discard a newly added panel', async () => {
const { panelEditor, dashboard } = await setup({ isNewPanel: true });
panelEditor.onDiscard();
const deactivate = activateFullSceneTree(scene);
expect((dashboard.state.body as SceneGridLayout).state.children.length).toBe(0);
});
editScene.state.vizManager.state.panel.setState({ title: 'changed title' });
it('should discard query runner changes', async () => {
const { panelEditor, panel, dashboard } = await setup({});
editScene.onDiscard();
deactivate();
const queryRunner = getQueryRunnerFor(panel);
queryRunner?.setState({ maxDataPoints: 123, queries: [{ refId: 'A' }, { refId: 'B' }] });
const updatedPanel = gridItem.state.body as VizPanel;
expect(updatedPanel?.state.title).toBe(panel.state.title);
panelEditor.onDiscard();
const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!;
const restoredQueryRunner = getQueryRunnerFor(discardedPanel);
expect(restoredQueryRunner?.state.maxDataPoints).toBe(500);
expect(restoredQueryRunner?.state.queries.length).toBe(1);
});
});
it('should discard a newly added panel', () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true });
describe('When changes are made', () => {
it('Should set state to dirty', async () => {
const { panelEditor, panel } = await setup({});
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
});
expect(panelEditor.state.isDirty).toBe(undefined);
const gridItem = new DashboardGridItem({ body: panel });
panel.setState({ title: 'changed title' });
const editScene = buildPanelEditScene(panel, true);
const scene = new DashboardScene({
editPanel: editScene,
isEditing: true,
body: new SceneGridLayout({
children: [gridItem],
}),
});
expect(panelEditor.state.isDirty).toBe(true);
});
editScene.onDiscard();
const deactivate = activateFullSceneTree(scene);
it('Should reset dirty and orginal state when dashboard is saved', async () => {
const { panelEditor, panel } = await setup({});
deactivate();
expect(panelEditor.state.isDirty).toBe(undefined);
panel.setState({ title: 'changed title' });
panelEditor.dashboardSaved();
expect(panelEditor.state.isDirty).toBe(false);
expect((scene.state.body as SceneGridLayout).state.children.length).toBe(0);
panel.setState({ title: 'changed title 2' });
expect(panelEditor.state.isDirty).toBe(true);
// Change back to already saved state
panel.setState({ title: 'changed title' });
expect(panelEditor.state.isDirty).toBe(false);
});
});
describe('When opening a repeated panel', () => {
it('Should default to the first variable value if panel is repeated', async () => {
const { panel } = await setup({ repeatByVariable: 'server' });
const variable = sceneGraph.lookupVariable('server', panel);
expect(variable?.getValue()).toBe('A');
});
});
describe('Handling library panels', () => {
it('should call the api with the updated panel', async () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true });
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
});
pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true }));
const panel = new VizPanel({ key: 'panel-1', pluginId: 'text' });
const libraryPanelModel = {
title: 'title',
uid: 'uid',
@ -143,15 +204,13 @@ describe('PanelEditor', () => {
_loadedPanel: libraryPanelModel,
});
panel.setState({
$behaviors: [libPanelBehavior],
});
panel.setState({ $behaviors: [libPanelBehavior] });
const gridItem = new DashboardGridItem({ body: panel });
const editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({
editPanel: editScene,
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
isEditing: true,
body: new SceneGridLayout({
children: [gridItem],
@ -160,96 +219,133 @@ describe('PanelEditor', () => {
activateFullSceneTree(scene);
editScene.state.vizManager.state.panel.setState({ title: 'changed title' });
(editScene.state.vizManager.state.panel.state.$behaviors![0] as LibraryPanelBehavior).setState({
name: 'changed name',
});
await new Promise((r) => setTimeout(r, 1));
panel.setState({ title: 'changed title' });
libPanelBehavior.setState({ name: 'changed name' });
jest.spyOn(libAPI, 'saveLibPanel').mockImplementation(async (panel) => {
const updatedPanel = { ...libAPI.libraryVizPanelToSaveModel(panel), version: 2 };
libPanelBehavior.setPanelFromLibPanel(updatedPanel);
});
editScene.state.vizManager.commitChanges();
editScene.onConfirmSaveLibraryPanel();
await new Promise(process.nextTick);
await new Promise(process.nextTick); // Wait for mock api to return and update the library panel
// Wait for mock api to return and update the library panel
expect(libPanelBehavior.state._loadedPanel?.version).toBe(2);
expect(libPanelBehavior.state.name).toBe('changed name');
expect(libPanelBehavior.state.title).toBe('changed title');
expect((gridItem.state.body as VizPanel).state.title).toBe('changed title');
});
});
describe('PanelDataPane', () => {
it('should not exist if panel is skipDataQuery', () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true });
it('unlinks library panel', () => {
const libraryPanelModel = {
title: 'title',
uid: 'uid',
name: 'libraryPanelName',
model: {
title: 'title',
type: 'text',
},
type: 'panel',
version: 1,
};
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
});
new DashboardGridItem({
body: panel,
const libPanelBehavior = new LibraryPanelBehavior({
isLoaded: true,
title: libraryPanelModel.title,
uid: libraryPanelModel.uid,
name: libraryPanelModel.name,
_loadedPanel: libraryPanelModel,
});
// Just adding an extra stateless behavior to verify unlinking does not remvoe it
const otherBehavior = jest.fn();
const panel = new VizPanel({ key: 'panel-1', pluginId: 'text', $behaviors: [libPanelBehavior, otherBehavior] });
const editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({
editPanel: editScene,
});
editScene.onConfirmUnlinkLibraryPanel();
activateFullSceneTree(scene);
expect(panel.state.$behaviors?.length).toBe(1);
expect(panel.state.$behaviors![0]).toBe(otherBehavior);
});
});
expect(editScene.state.dataPane).toBeUndefined();
describe('PanelDataPane', () => {
it('should not exist if panel is skipDataQuery', async () => {
const { panelEditor } = await setup({ pluginSkipDataQuery: true });
expect(panelEditor.state.dataPane).toBeUndefined();
});
it('should exist if panel is supporting querying', () => {
pluginToLoad = getTestPanelPlugin({ id: 'timeseries' });
it('should exist if panel is supporting querying', async () => {
const { panelEditor } = await setup({ pluginSkipDataQuery: false });
expect(panelEditor.state.dataPane).toBeDefined();
});
});
});
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'timeseries',
});
interface SetupOptions {
isNewPanel?: boolean;
pluginSkipDataQuery?: boolean;
repeatByVariable?: string;
skipWait?: boolean;
pluginLoadTime?: number;
}
new DashboardGridItem({
body: panel,
});
const editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({
editPanel: editScene,
});
async function setup(options: SetupOptions = {}) {
const pluginToLoad = getPanelPlugin({ id: 'text', skipDataQuery: options.pluginSkipDataQuery });
let pluginResolve = (plugin: PanelPlugin) => {};
activateFullSceneTree(scene);
expect(editScene.state.dataPane).toBeDefined();
});
pluginPromise = new Promise<PanelPlugin>((resolve) => {
pluginResolve = resolve;
});
});
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;
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
title: 'original title',
$data: new SceneDataTransformer({
transformations: [],
$data: new SceneQueryRunner({
queries: [{ refId: 'A' }],
maxDataPoints: 500,
datasource: { uid: 'ds1' },
}),
}),
});
const gridItem = new DashboardGridItem({ body: panel, variableName: options.repeatByVariable });
const panelEditor = buildPanelEditScene(panel, options.isNewPanel);
const dashboard = new DashboardScene({
editPanel: panelEditor,
isEditing: true,
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
$variables: new SceneVariableSet({
variables: [
new CustomVariable({
name: 'server',
query: 'A,B,C',
isMulti: true,
value: ['A', 'B', 'C'],
text: ['A', 'B', 'C'],
}),
],
}),
body: new SceneGridLayout({
children: [gridItem],
}),
});
panelEditor.debounceSaveModelDiff = false;
deactivate = activateFullSceneTree(dashboard);
if (!options.skipWait) {
//console.log('pluginResolve(pluginToLoad)');
pluginResolve(pluginToLoad);
await new Promise((r) => setTimeout(r, 1));
}
return { dashboard, panel, gridItem, panelEditor, pluginResolve };
}

@ -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;
this._subs.add(
panelManager.subscribeToState((n, p) => {
if (n.pluginId !== p.pluginId) {
this._initDataPane(n.pluginId);
}
})
);
const panel = this.state.panelRef.resolve();
const deactivateParents = activateInActiveParents(panel);
const layoutElement = panel.parent;
this._initDataPane(panel.state.pluginId);
this.waitForPlugin();
return () => {
if (!this._discardChanges) {
this.commitChanges();
} else if (this.state.isNewPanel) {
getDashboardSceneFor(this).removePanel(panelManager.state.sourcePanel.resolve()!);
if (layoutElement instanceof DashboardGridItem) {
layoutElement.editingCompleted();
}
if (deactivateParents) {
deactivateParents();
}
};
}
private waitForPlugin(retry = 0) {
const panel = this.getPanel();
const plugin = panel.getPlugin();
if (!plugin || plugin.meta.id !== panel.state.pluginId) {
if (retry < 100) {
setTimeout(() => this.waitForPlugin(retry + 1), retry * 10);
} else {
this.setState({ pluginLoadErrror: 'Failed to load panel plugin' });
}
return;
}
this.gotPanelPlugin(plugin);
}
private setOriginalState(panelRef: SceneObjectRef<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(
panel.subscribeToState((n, p) => {
if (n.pluginId !== p.pluginId) {
this.waitForPlugin();
}
})
);
// Setup options pane
this.setState({
optionsPane: new PanelOptionsPane({
panelRef: this.state.panelRef,
searchQuery: '',
listMode: OptionFilter.All,
}),
isInitializing: false,
});
} else {
// plugin changed after first time initialization
// Just update data pane
this._updateDataPane(plugin);
}
}
private _initDataPane(pluginId: string) {
const skipDataQuery = config.panels[pluginId]?.skipDataQuery;
private _updateDataPane(plugin: PanelPlugin) {
const skipDataQuery = plugin.meta.skipDataQuery;
if (skipDataQuery && this.state.dataPane) {
locationService.partial({ tab: null }, true);
@ -74,12 +188,16 @@ export class PanelEditor extends SceneObjectBase<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 });
};
public commitChanges() {
const dashboard = getDashboardSceneFor(this);
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
}
this.setState({ isDirty: false });
const panelManager = this.state.vizManager;
const sourcePanel = panelManager.state.sourcePanel.resolve();
const gridItem = sourcePanel!.parent;
const panel = this.state.panelRef.resolve();
if (!(gridItem instanceof DashboardGridItem)) {
console.error('Unsupported scene object type');
return;
if (this.state.isNewPanel) {
getDashboardSceneFor(this).removePanel(panel);
} else {
// Revert any layout element changes
this._layoutElement.setState(this._originalLayoutElementState!);
}
this.commitChangesToSource(gridItem);
}
private commitChangesToSource(gridItem: DashboardGridItem) {
let width = gridItem.state.width ?? 1;
let height = gridItem.state.height;
const panelManager = this.state.vizManager;
const horizontalToVertical =
this._initialRepeatOptions.repeatDirection === 'h' && panelManager.state.repeatDirection === 'v';
const verticalToHorizontal =
this._initialRepeatOptions.repeatDirection === 'v' && panelManager.state.repeatDirection === 'h';
if (horizontalToVertical) {
width = Math.floor(width / (gridItem.state.maxPerRow ?? 1));
} else if (verticalToHorizontal) {
width = 24;
}
locationService.partial({ editPanel: null });
};
gridItem.setState({
body: panelManager.state.panel.clone(),
repeatDirection: panelManager.state.repeatDirection,
variableName: panelManager.state.repeat,
maxPerRow: panelManager.state.maxPerRow,
width,
height,
});
public dashboardSaved() {
this.setOriginalState(this.state.panelRef);
this.setState({ isDirty: false });
}
public onSaveLibraryPanel = () => {
@ -146,8 +234,8 @@ export class PanelEditor extends SceneObjectBase<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>;
}
interface PluginOptionsCache {
options: DeepPartial<{}>;
fieldConfig: FieldConfigSource<DeepPartial<{}>>;
}
export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
public constructor(state: Partial<PanelOptionsPaneState>) {
super({
searchQuery: '',
listMode: OptionFilter.All,
...state,
});
}
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,73 +94,77 @@ export function getPanelFrameCategory2(
render: () => <ScenePanelLinksEditor panelLinks={panelLinksObject ?? undefined} />,
})
)
)
.addCategory(
new OptionsPaneCategoryDescriptor({
title: 'Repeat options',
id: 'Repeat options',
isOpenDefault: false,
);
if (layoutElement instanceof DashboardGridItem) {
const gridItem = layoutElement;
const category = new OptionsPaneCategoryDescriptor({
title: 'Repeat options',
id: 'Repeat options',
isOpenDefault: false,
});
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat by variable',
description:
'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.',
render: function renderRepeatOptions() {
return (
<RepeatRowSelect2
id="repeat-by-variable-select"
sceneContext={panel}
repeat={gridItem.state.variableName}
onChange={(value?: string) => gridItem.setRepeatByVariable(value)}
/>
);
},
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat by variable',
description:
'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.',
render: function renderRepeatOptions() {
return (
<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);
}}
/>
);
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat direction',
showIf: () => !!vizManager.state.repeat,
render: function renderRepeatOptions() {
const directionOptions: Array<SelectableValue<'h' | 'v'>> = [
{ label: 'Horizontal', value: 'h' },
{ label: 'Vertical', value: 'v' },
];
return (
<RadioButtonGroup
options={directionOptions}
value={vizManager.state.repeatDirection ?? 'h'}
onChange={(value) => vizManager.setState({ repeatDirection: value })}
/>
);
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Max per row',
showIf: () => Boolean(vizManager.state.repeat && vizManager.state.repeatDirection === 'h'),
render: function renderOption() {
const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value }));
return (
<Select
options={maxPerRowOptions}
value={vizManager.state.maxPerRow}
onChange={(value) => vizManager.setState({ maxPerRow: value.value })}
/>
);
},
})
)
);
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat direction',
showIf: () => Boolean(gridItem.state.variableName),
render: function renderRepeatOptions() {
const directionOptions: Array<SelectableValue<'h' | 'v'>> = [
{ label: 'Horizontal', value: 'h' },
{ label: 'Vertical', value: 'v' },
];
return (
<RadioButtonGroup
options={directionOptions}
value={gridItem.state.repeatDirection ?? 'h'}
onChange={(value) => gridItem.setState({ repeatDirection: value })}
/>
);
},
})
);
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Max per row',
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={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,14 +55,17 @@ 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();
}
}
private clearCachedStateIfBodyOrOptionsChanged() {
if (this._prevGridItemState !== this.state || this._prevPanelState !== this.state.body.state) {
this._prevRepeatValues = undefined;
}
}
/**
* Uses the current repeat item count to calculate the user intended desired itemHeight
*/
@ -116,9 +120,6 @@ export class DashboardGridItem extends SceneObjectBase<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);
useEffect(() => {
if (panelEditor) {
const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) =>
setEditingLibraryPanel(isLibraryPanel(vizManagerState.sourcePanel.resolve()))
);
return () => {
unsub.unsubscribe();
};
}
setEditingLibraryPanel(false);
return;
}, [panelEditor]);
return isEditingLibraryPanel;
}
// This hook handles when panelEditor is not defined to avoid conditionally hook usage
function useVizManagerDirty(panelEditor?: PanelEditor) {
const [isDirty, setIsDirty] = useState<Boolean>(false);
function usePanelEditDirty(panelEditor?: PanelEditor) {
const [isDirty, setIsDirty] = useState<Boolean | undefined>();
useEffect(() => {
if (panelEditor) {
const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) =>
setIsDirty(vizManagerState.isDirty || false)
);
return () => {
unsub.unsubscribe();
};
const unsub = panelEditor.subscribeToState((state) => {
if (state.isDirty !== isDirty) {
setIsDirty(state.isDirty);
}
});
return () => unsub.unsubscribe();
}
setIsDirty(false);
return;
}, [panelEditor]);
}, [panelEditor, isDirty]);
return isDirty;
}

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