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

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

* Update

* Discard works

* Panel inspect working

* Table viw works

* Repeat options

* Began fixing tests

* More tests fixed

* Progress on tests

* no errors

* init full width when enabling repeat

* Began moving VizPanelManager tests to where the code was moved

* Unlink libray panel code and unit test

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

* migrating and improving unit tests

* Done with VizPanelManager tests refactoring

* Update

* Update

* remove console.log

* Removed unnesssary behavior and fixed test

* Update

* Fix unrelated test

* conditional options fix

* remove

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

* Minor fix

* Fix discard query runner changes

* Review comment changes

* fix discard issue with new panels

* Add loading state to panel edit

* Fix test

* Update

* fix test

* fix lint

* lint

* Fix

* Fix overrides editing  mutating fieldConfig

---------

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

@ -2665,8 +2665,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"] [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
], ],
"public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts:5381": [ "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.", "0"]
[0, 0, 0, "Do not use any type assertions.", "1"]
], ],
"public/app/features/dashboard-scene/inspect/InspectDataTab.tsx:5381": [ "public/app/features/dashboard-scene/inspect/InspectDataTab.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"] [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"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
"public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts:5381": [ "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.", "0"]
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
], ],
"public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [ "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [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 { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { DashboardGridItem } from '../../scene/DashboardGridItem'; import { DashboardGridItem } from '../../scene/DashboardGridItem';
import { DashboardScene } from '../../scene/DashboardScene';
import { gridItemToPanel, vizPanelToPanel } from '../../serialization/transformSceneToSaveModel'; import { gridItemToPanel, vizPanelToPanel } from '../../serialization/transformSceneToSaveModel';
import { getQueryRunnerFor, isLibraryPanel } from '../../utils/utils'; 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) { export async function getDebugDashboard(panel: VizPanel, rand: Randomize, timeRange: TimeRange) {
let saveModel: ReturnType<typeof gridItemToPanel> = { type: '' }; let saveModel: ReturnType<typeof gridItemToPanel> = { type: '' };
const gridItem = panel.parent as DashboardGridItem; const gridItem = panel.parent as DashboardGridItem;
const scene = panel.getRoot() as DashboardScene;
if (isLibraryPanel(panel)) { if (isLibraryPanel(panel)) {
saveModel = { saveModel = {
...gridItemToPanel(gridItem), ...gridItemToPanel(gridItem),
...vizPanelToPanel(panel), ...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 { } else {
saveModel = gridItemToPanel(gridItem); saveModel = gridItemToPanel(gridItem);
} }

@ -25,7 +25,6 @@ import { InspectTab } from 'app/features/inspector/types';
import { getPrettyJSON } from 'app/features/inspector/utils/utils'; import { getPrettyJSON } from 'app/features/inspector/utils/utils';
import { reportPanelInspectInteraction } from 'app/features/search/page/reporting'; import { reportPanelInspectInteraction } from 'app/features/search/page/reporting';
import { VizPanelManager } from '../panel-edit/VizPanelManager';
import { DashboardGridItem } from '../scene/DashboardGridItem'; import { DashboardGridItem } from '../scene/DashboardGridItem';
import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene'; import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene';
import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
@ -219,11 +218,6 @@ function getJsonText(show: ShowContent, panel: VizPanel): string {
break; break;
} }
if (panel.parent instanceof VizPanelManager) {
objToStringify = panel.parent.getPanelSaveModel();
break;
}
if (gridItem instanceof DashboardGridItem) { if (gridItem instanceof DashboardGridItem) {
objToStringify = gridItemToPanel(gridItem); objToStringify = gridItemToPanel(gridItem);
} }

@ -28,7 +28,7 @@ import { SceneInspectTab } from './types';
interface PanelInspectDrawerState extends SceneObjectState { interface PanelInspectDrawerState extends SceneObjectState {
tabs?: SceneInspectTab[]; tabs?: SceneInspectTab[];
panelRef?: SceneObjectRef<VizPanel>; panelRef: SceneObjectRef<VizPanel>;
pluginNotLoaded?: boolean; pluginNotLoaded?: boolean;
canEdit?: boolean; canEdit?: boolean;
} }
@ -51,7 +51,7 @@ export class PanelInspectDrawer extends SceneObjectBase<PanelInspectDrawerState>
*/ */
async buildTabs(retry: number) { async buildTabs(retry: number) {
const panelRef = this.state.panelRef; const panelRef = this.state.panelRef;
const plugin = panelRef?.resolve()?.getPlugin(); const plugin = panelRef.resolve()?.getPlugin();
const tabs: SceneInspectTab[] = []; const tabs: SceneInspectTab[] = [];
if (!plugin) { 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 { createDashboardSceneFromDashboardModel } from '../../serialization/transformSaveModelToScene';
import * as utils from '../../utils/utils'; import * as utils from '../../utils/utils';
import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../../utils/utils'; import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../../utils/utils';
import { VizPanelManager } from '../VizPanelManager';
import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataAlertingTab'; import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataAlertingTab';
@ -361,7 +360,7 @@ async function clickNewButton() {
function createModel(dashboard: DashboardModel) { function createModel(dashboard: DashboardModel) {
const scene = createDashboardSceneFromDashboardModel(dashboard, {} as DashboardDataDTO); const scene = createDashboardSceneFromDashboardModel(dashboard, {} as DashboardDataDTO);
const vizPanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(34))!; 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); jest.spyOn(utils, 'getDashboardSceneFor').mockReturnValue(scene);
return model; return model;
} }

@ -1,8 +1,7 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data'; 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 { Alert, LoadingPlaceholder, Tab, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { RulesTable } from 'app/features/alerting/unified/components/rules/RulesTable'; 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 { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { getDashboardSceneFor, getPanelIdForVizPanel } from '../../utils/utils'; import { getDashboardSceneFor, getPanelIdForVizPanel } from '../../utils/utils';
import { VizPanelManager } from '../VizPanelManager';
import { ScenesNewRuleFromPanelButton } from './NewAlertRuleButton'; 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 { export interface PanelDataAlertingTabState extends SceneObjectState {
static Component = PanelDataAlertingTabRendered; panelRef: SceneObjectRef<VizPanel>;
TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; }
tabId = TabId.Alert; export class PanelDataAlertingTab extends SceneObjectBase<PanelDataAlertingTabState> implements PanelDataPaneTab {
private _panelManager: VizPanelManager; static Component = PanelDataAlertingTabRendered;
public tabId = TabId.Alert;
constructor(panelManager: VizPanelManager) { public renderTab(props: PanelDataTabHeaderProps) {
super({}); return <AlertingTab key={this.getTabLabel()} model={this} {...props} />;
this.TabComponent = (props: PanelDataTabHeaderProps) => AlertingTab({ ...props, model: this });
this._panelManager = panelManager;
} }
getTabLabel() { public getTabLabel() {
return 'Alert'; return 'Alert';
} }
getDashboardUID() { public getDashboardUID() {
const dashboard = this.getDashboard(); const dashboard = this.getDashboard();
return dashboard.state.uid!; return dashboard.state.uid!;
} }
getDashboard() { public getDashboard() {
return getDashboardSceneFor(this._panelManager); return getDashboardSceneFor(this);
} }
getLegacyPanelId() { public getLegacyPanelId() {
return getPanelIdForVizPanel(this._panelManager.state.panel); return getPanelIdForVizPanel(this.state.panelRef.resolve());
} }
getCanCreateRules() { public getCanCreateRules() {
const rulesPermissions = getRulesPermissions('grafana'); const rulesPermissions = getRulesPermissions('grafana');
return this.getDashboard().state.meta.canSave && contextSrv.hasPermission(rulesPermissions.create); return this.getDashboard().state.meta.canSave && contextSrv.hasPermission(rulesPermissions.create);
} }
get panelManager() {
return this._panelManager;
} }
get panel() { export function PanelDataAlertingTabRendered({ model }: SceneComponentProps<PanelDataAlertingTab>) {
return this._panelManager.state.panel;
}
}
export function PanelDataAlertingTabRendered(props: SceneComponentProps<PanelDataAlertingTab>) {
const { model } = props;
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { errors, loading, rules } = usePanelCombinedRules({ 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(); const canCreateRules = model.getCanCreateRules();
if (rules.length) { if (rules.length) {
@ -132,7 +119,6 @@ function AlertingTab(props: PanelDataAlertingTabHeaderProps) {
return ( return (
<Tab <Tab
key={props.key}
label={model.getTabLabel()} label={model.getTabLabel()}
icon="bell" icon="bell"
counter={rules.length} counter={rules.length}

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

@ -6,6 +6,7 @@ import {
DataQuery, DataQuery,
DataQueryRequest, DataQueryRequest,
DataSourceApi, DataSourceApi,
DataSourceInstanceSettings,
DataSourceJsonData, DataSourceJsonData,
DataSourceRef, DataSourceRef,
FieldType, FieldType,
@ -14,29 +15,28 @@ import {
TimeRange, TimeRange,
toDataFrame, toDataFrame,
} from '@grafana/data'; } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { selectors } from '@grafana/e2e-selectors'; 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 { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
import { DashboardDataDTO } from 'app/types'; import { DashboardDataDTO } from 'app/types';
import { PanelTimeRange, PanelTimeRangeState } from '../../scene/PanelTimeRange';
import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene'; import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene';
import { DashboardModelCompatibilityWrapper } from '../../utils/DashboardModelCompatibilityWrapper';
import { findVizPanelByKey } from '../../utils/utils'; import { findVizPanelByKey } from '../../utils/utils';
import { VizPanelManager } from '../VizPanelManager'; import { buildPanelEditScene } from '../PanelEditor';
import { testDashboard } from '../testfiles/testDashboard'; import { testDashboard, panelWithTransformations, panelWithQueriesOnly } from '../testfiles/testDashboard';
import { PanelDataQueriesTab, PanelDataQueriesTabRendered } from './PanelDataQueriesTab'; import { PanelDataQueriesTab, PanelDataQueriesTabRendered } from './PanelDataQueriesTab';
async function createModelMock() { async function createModelMock() {
const panelManager = setupVizPanelManger('panel-1'); const { queriesTab } = await setupScene('panel-1');
panelManager.activate();
await Promise.resolve();
const queryTabModel = new PanelDataQueriesTab(panelManager);
// mock queryRunner data state // mock queryRunner data state
jest.spyOn(queryTabModel.queryRunner, 'state', 'get').mockReturnValue({ jest.spyOn(queriesTab.queryRunner, 'state', 'get').mockReturnValue({
...queryTabModel.queryRunner.state, ...queriesTab.queryRunner.state,
data: { data: {
state: LoadingState.Done, state: LoadingState.Done,
series: [ 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 runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => {
const result: PanelData = { const result: PanelData = {
state: LoadingState.Loading, state: LoadingState.Loading,
@ -186,11 +192,17 @@ const MixedDsSettingsMock = {
}, },
}; };
const panelPlugin = getPanelPlugin({ id: 'timeseries', skipDataQuery: false });
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => {
return runRequestMock(ds, request); return runRequestMock(ds, request);
}, },
getPluginImportUtils: () => ({
getPanelPluginFromCache: jest.fn(() => panelPlugin),
}),
getPluginLinkExtensions: jest.fn(),
getDataSourceSrv: () => ({ getDataSourceSrv: () => ({
get: async (ref: DataSourceRef) => { get: async (ref: DataSourceRef) => {
// Mocking the build in Grafana data source to avoid annotations data layer errors. // Mocking the build in Grafana data source to avoid annotations data layer errors.
@ -234,48 +246,59 @@ jest.mock('@grafana/runtime', () => ({
return instance1SettingsMock; return instance1SettingsMock;
}, },
}), }),
locationService: {
partial: jest.fn(),
getSearchObject: jest.fn().mockReturnValue({
firstPanel: false,
}),
},
config: { config: {
...jest.requireActual('@grafana/runtime').config, ...jest.requireActual('@grafana/runtime').config,
defaultDatasource: 'gdev-testdata', defaultDatasource: 'gdev-testdata',
}, },
})); }));
describe('PanelDataQueriesModel', () => {
jest.mock('app/core/store', () => ({
exists: jest.fn(),
get: jest.fn(),
getObject: jest.fn((_a, b) => b),
setObject: jest.fn(),
}));
const store = jest.requireMock('app/core/store');
let deactivators = [] as Array<() => void>;
describe('PanelDataQueriesTab', () => {
beforeEach(() => {
store.setObject.mockClear();
});
afterEach(() => {
deactivators.forEach((deactivate) => deactivate());
deactivators = [];
});
describe('Adding queries', () => {
it('can add a new query', async () => { it('can add a new query', async () => {
const vizPanelManager = setupVizPanelManger('panel-1'); const { queriesTab } = await setupScene('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const model = new PanelDataQueriesTab(vizPanelManager); queriesTab.addQueryClick();
model.addQueryClick();
expect(model.queryRunner.state.queries).toHaveLength(2); expect(queriesTab.queryRunner.state.queries).toHaveLength(2);
expect(model.queryRunner.state.queries[1].refId).toBe('B'); expect(queriesTab.queryRunner.state.queries[1].refId).toBe('B');
expect(model.queryRunner.state.queries[1].hide).toBe(false); expect(queriesTab.queryRunner.state.queries[1].hide).toBe(false);
expect(model.queryRunner.state.queries[1].datasource).toEqual({ expect(queriesTab.queryRunner.state.queries[1].datasource).toEqual({
type: 'grafana-testdata-datasource', type: 'grafana-testdata-datasource',
uid: 'gdev-testdata', uid: 'gdev-testdata',
}); });
}); });
it('can add a new query when datasource is mixed', async () => { it('Can add a new query when datasource is mixed', async () => {
const vizPanelManager = setupVizPanelManger('panel-7'); const { queriesTab } = await setupScene('panel-7');
vizPanelManager.activate();
await Promise.resolve(); expect(queriesTab.state.datasource?.uid).toBe('-- Mixed --');
expect(queriesTab.queryRunner.state.datasource?.uid).toBe('-- Mixed --');
const model = new PanelDataQueriesTab(vizPanelManager); queriesTab.addQueryClick();
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(queriesTab.queryRunner.state.queries).toHaveLength(2);
expect(model.queryRunner.state.queries[1].refId).toBe('B'); expect(queriesTab.queryRunner.state.queries[1].refId).toBe('B');
expect(model.queryRunner.state.queries[1].hide).toBe(false); expect(queriesTab.queryRunner.state.queries[1].hide).toBe(false);
expect(model.queryRunner.state.queries[1].datasource?.uid).toBe('gdev-testdata'); expect(queriesTab.queryRunner.state.queries[1].datasource?.uid).toBe('gdev-testdata');
}); });
}); });
@ -331,15 +354,346 @@ describe('PanelDataQueriesTab', () => {
}); });
}); });
const setupVizPanelManger = (panelId: string) => { describe('query options', () => {
const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); describe('activation', () => {
const panel = findVizPanelByKey(scene, panelId)!; it('should load data source', async () => {
const { queriesTab } = await setupScene('panel-1');
const vizPanelManager = VizPanelManager.createFor(panel); expect(queriesTab.state.datasource).toEqual(ds1Mock);
expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock);
});
// The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it it('should store loaded data source in local storage', async () => {
// @ts-expect-error await setupScene('panel-1');
getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene));
return vizPanelManager; expect(store.setObject).toHaveBeenCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', {
}; dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
datasourceUid: 'gdev-testdata',
});
});
it('should load default datasource if the datasource passed is not found', async () => {
const { queriesTab } = await setupScene('panel-6');
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'abc',
type: 'datasource',
});
expect(config.defaultDatasource).toBe('gdev-testdata');
expect(queriesTab.state.datasource).toEqual(defaultDsMock);
expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock);
});
});
describe('data source change', () => {
it('should load new data source', async () => {
const { queriesTab, panel } = await setupScene('panel-1');
panel.state.$data?.activate();
await queriesTab.onChangeDataSource(
{ type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' } as DataSourceInstanceSettings,
[]
);
expect(store.setObject).toHaveBeenCalledTimes(2);
expect(store.setObject).toHaveBeenLastCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', {
dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
datasourceUid: 'gdev-prometheus',
});
expect(queriesTab.state.datasource).toEqual(ds2Mock);
expect(queriesTab.state.dsSettings).toEqual(instance2SettingsMock);
});
});
describe('query options change', () => {
describe('time overrides', () => {
it('should create PanelTimeRange object', async () => {
const { queriesTab, panel } = await setupScene('panel-1');
panel.state.$data?.activate();
expect(panel.state.$timeRange).toBeUndefined();
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
timeRange: { from: '1h' },
});
expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange);
});
it('should update PanelTimeRange object on time options update', async () => {
const { queriesTab, panel } = await setupScene('panel-1');
expect(panel.state.$timeRange).toBeUndefined();
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
timeRange: { from: '1h' },
});
expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange);
expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('1h');
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
timeRange: { from: '2h' },
});
expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('2h');
});
it('should remove PanelTimeRange object on time options cleared', async () => {
const { queriesTab, panel } = await setupScene('panel-1');
expect(panel.state.$timeRange).toBeUndefined();
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
timeRange: { from: '1h' },
});
queriesTab.onQueryOptionsChange({
dataSource: {
name: 'grafana-testdata',
type: 'grafana-testdata-datasource',
default: true,
},
queries: [],
timeRange: { from: null },
});
expect(panel.state.$timeRange).toBeUndefined();
});
});
describe('max data points and interval', () => {
it('should update max data points', async () => {
const { queriesTab } = await setupScene('panel-1');
const dataObj = queriesTab.queryRunner;
expect(dataObj.state.maxDataPoints).toBeUndefined();
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
maxDataPoints: 100,
});
expect(dataObj.state.maxDataPoints).toBe(100);
});
it('should update min interval', async () => {
const { queriesTab } = await setupScene('panel-1');
const dataObj = queriesTab.queryRunner;
expect(dataObj.state.maxDataPoints).toBeUndefined();
queriesTab.onQueryOptionsChange({
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
minInterval: '1s',
});
expect(dataObj.state.minInterval).toBe('1s');
});
});
describe('query caching', () => {
it('updates cacheTimeout and queryCachingTTL', async () => {
const { queriesTab } = await setupScene('panel-1');
const dataObj = queriesTab.queryRunner;
queriesTab.onQueryOptionsChange({
cacheTimeout: '60',
queryCachingTTL: 200000,
dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true },
queries: [],
});
expect(dataObj.state.cacheTimeout).toBe('60');
expect(dataObj.state.queryCachingTTL).toBe(200000);
});
});
});
describe('query inspection', () => {
it('allows query inspection from the tab', async () => {
const { queriesTab } = await setupScene('panel-1');
queriesTab.onOpenInspector();
const params = locationService.getSearchObject();
expect(params.inspect).toBe('1');
expect(params.inspectTab).toBe(InspectTab.Query);
});
});
describe('data source change', () => {
it('changing from one plugin to another', async () => {
const { queriesTab } = await setupScene('panel-1');
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'gdev-testdata',
type: 'grafana-testdata-datasource',
});
await queriesTab.onChangeDataSource({
name: 'grafana-prometheus',
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
meta: {
name: 'Prometheus',
module: 'prometheus',
id: 'grafana-prometheus-datasource',
},
} as DataSourceInstanceSettings);
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'gdev-prometheus',
type: 'grafana-prometheus-datasource',
});
});
it('changing from a plugin to a dashboard data source', async () => {
const { queriesTab } = await setupScene('panel-1');
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'gdev-testdata',
type: 'grafana-testdata-datasource',
});
await queriesTab.onChangeDataSource({
name: SHARED_DASHBOARD_QUERY,
type: 'datasource',
uid: SHARED_DASHBOARD_QUERY,
meta: {
name: 'Prometheus',
module: 'prometheus',
id: DASHBOARD_DATASOURCE_PLUGIN_ID,
},
} as DataSourceInstanceSettings);
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: SHARED_DASHBOARD_QUERY,
type: 'datasource',
});
});
it('changing from dashboard data source to a plugin', async () => {
const { queriesTab } = await setupScene('panel-3');
expect(queriesTab.queryRunner.state.datasource).toEqual({ uid: SHARED_DASHBOARD_QUERY, type: 'datasource' });
await queriesTab.onChangeDataSource({
name: 'grafana-prometheus',
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
meta: {
name: 'Prometheus',
module: 'prometheus',
id: 'grafana-prometheus-datasource',
},
} as DataSourceInstanceSettings);
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'gdev-prometheus',
type: 'grafana-prometheus-datasource',
});
});
});
describe('change queries', () => {
describe('plugin queries', () => {
it('should update queries', async () => {
const { queriesTab, panel } = await setupScene('panel-1');
panel.state.$data?.activate();
queriesTab.onQueriesChange([
{
datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' },
refId: 'A',
scenarioId: 'random_walk',
seriesCount: 5,
},
]);
expect(queriesTab.queryRunner.state.queries).toEqual([
{
datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' },
refId: 'A',
scenarioId: 'random_walk',
seriesCount: 5,
},
]);
});
});
describe('dashboard queries', () => {
it('should update queries', async () => {
const { queriesTab, panel } = await setupScene('panel-3');
panel.state.$data?.activate();
// Changing dashboard query to a panel with transformations
queriesTab.onQueriesChange([
{
refId: 'A',
datasource: { type: DASHBOARD_DATASOURCE_PLUGIN_ID },
panelId: panelWithTransformations.id,
},
]);
expect(queriesTab.queryRunner.state.queries[0].panelId).toEqual(panelWithTransformations.id);
// Changing dashboard query to a panel with queries only
queriesTab.onQueriesChange([
{
refId: 'A',
datasource: { type: DASHBOARD_DATASOURCE_PLUGIN_ID },
panelId: panelWithQueriesOnly.id,
},
]);
expect(queriesTab.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id);
});
it('should load last used data source if no data source specified for a panel', async () => {
store.exists.mockReturnValue(true);
store.getObject.mockReturnValue({
dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
datasourceUid: 'gdev-testdata',
});
const { queriesTab } = await setupScene('panel-5');
expect(queriesTab.state.datasource).toBe(ds1Mock);
expect(queriesTab.state.dsSettings).toBe(instance1SettingsMock);
});
});
});
});
});
async function setupScene(panelId: string) {
const dashboard = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} });
const panel = findVizPanelByKey(dashboard, panelId)!;
const panelEditor = buildPanelEditScene(panel);
dashboard.setState({ editPanel: panelEditor });
deactivators.push(dashboard.activate());
deactivators.push(panelEditor.activate());
const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab;
deactivators.push(queriesTab.activate());
await Promise.resolve();
return { panel, scene: dashboard, queriesTab };
}

@ -1,60 +1,134 @@
import * as React from 'react'; import { CoreApp, DataSourceApi, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data';
import { CoreApp, DataSourceApi, DataSourceInstanceSettings, IconName, getDataSourceRef } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { config, getDataSourceSrv } from '@grafana/runtime'; import { config, getDataSourceSrv, locationService } from '@grafana/runtime';
import { SceneObjectBase, SceneComponentProps, sceneGraph, SceneQueryRunner } from '@grafana/scenes'; import {
SceneObjectBase,
SceneComponentProps,
sceneGraph,
SceneQueryRunner,
SceneObjectRef,
VizPanel,
SceneObjectState,
SceneDataQuery,
} from '@grafana/scenes';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { Button, Stack, Tab } from '@grafana/ui'; import { Button, Stack, Tab } from '@grafana/ui';
import { addQuery } from 'app/core/utils/query'; 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 { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource';
import { GroupActionComponents } from 'app/features/query/components/QueryActionComponent'; import { GroupActionComponents } from 'app/features/query/components/QueryActionComponent';
import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows'; import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows';
import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup'; import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup';
import { updateQueries } from 'app/features/query/state/updateQueries';
import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard'; import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard';
import { GrafanaQuery } from 'app/plugins/datasource/grafana/types';
import { QueryGroupOptions } from 'app/types'; import { QueryGroupOptions } from 'app/types';
import { PanelTimeRange } from '../../scene/PanelTimeRange'; import { PanelTimeRange, PanelTimeRangeState } from '../../scene/PanelTimeRange';
import { VizPanelManager } from '../VizPanelManager'; 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; datasource?: DataSourceApi;
dsSettings?: DataSourceInstanceSettings; dsSettings?: DataSourceInstanceSettings;
panelRef: SceneObjectRef<VizPanel>;
} }
export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabState> implements PanelDataPaneTab { export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabState> implements PanelDataPaneTab {
static Component = PanelDataQueriesTabRendered; static Component = PanelDataQueriesTabRendered;
TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element;
tabId = TabId.Queries; tabId = TabId.Queries;
icon: IconName = 'database';
private _panelManager: VizPanelManager;
getTabLabel() { public constructor(state: PanelDataQueriesTabState) {
super(state);
this.addActivationHandler(() => this.onActivate());
}
public getTabLabel() {
return 'Queries'; return 'Queries';
} }
getItemsCount() { public getItemsCount() {
return this.getQueries().length; return this.getQueries().length;
} }
constructor(panelManager: VizPanelManager) { public renderTab(props: PanelDataTabHeaderProps) {
super({}); return <QueriesTab key={this.getTabLabel()} model={this} {...props} />;
}
this.TabComponent = (props: PanelDataTabHeaderProps) => { private onActivate() {
return QueriesTab({ ...props, model: this }); this.loadDataSource();
}; }
private async loadDataSource() {
const panel = this.state.panelRef.resolve();
const dataObj = panel.state.$data;
if (!dataObj) {
return;
}
let datasourceToLoad = this.queryRunner.state.datasource;
try {
let datasource: DataSourceApi | undefined;
let dsSettings: DataSourceInstanceSettings | undefined;
if (!datasourceToLoad) {
const dashboardScene = getDashboardSceneFor(this);
const dashboardUid = dashboardScene.state.uid ?? '';
const lastUsedDatasource = getLastUsedDatasourceFromStorage(dashboardUid!);
// do we have a last used datasource for this dashboard
if (lastUsedDatasource?.datasourceUid !== null) {
// get datasource from dashbopard uid
dsSettings = getDataSourceSrv().getInstanceSettings({ uid: lastUsedDatasource?.datasourceUid });
if (dsSettings) {
datasource = await getDataSourceSrv().get({
uid: lastUsedDatasource?.datasourceUid,
type: dsSettings.type,
});
this.queryRunner.setState({
datasource: {
...getDataSourceRef(dsSettings),
uid: lastUsedDatasource?.datasourceUid,
},
});
}
}
} else {
datasource = await getDataSourceSrv().get(datasourceToLoad);
dsSettings = getDataSourceSrv().getInstanceSettings(datasourceToLoad);
}
this._panelManager = panelManager; if (datasource && dsSettings) {
this.setState({ datasource, dsSettings });
storeLastUsedDataSourceInLocalStorage(getDataSourceRef(dsSettings) || { default: true });
}
} catch (err) {
//set default datasource if we fail to load the datasource
const datasource = await getDataSourceSrv().get(config.defaultDatasource);
const dsSettings = getDataSourceSrv().getInstanceSettings(config.defaultDatasource);
if (datasource && dsSettings) {
this.setState({
datasource,
dsSettings,
});
this.queryRunner.setState({
datasource: getDataSourceRef(dsSettings),
});
}
console.error(err);
}
} }
buildQueryOptions(): QueryGroupOptions { public buildQueryOptions(): QueryGroupOptions {
const panelManager = this._panelManager; const panel = this.state.panelRef.resolve();
const panelObj = this._panelManager.state.panel; const queryRunner = getQueryRunnerFor(panel)!;
const queryRunner = this._panelManager.queryRunner; const timeRangeObj = sceneGraph.getTimeRange(panel);
const timeRangeObj = sceneGraph.getTimeRange(panelObj);
let timeRangeOpts: QueryGroupOptions['timeRange'] = { let timeRangeOpts: QueryGroupOptions['timeRange'] = {
from: undefined, from: undefined,
@ -71,19 +145,14 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
} }
let queries: QueryGroupOptions['queries'] = queryRunner.state.queries; let queries: QueryGroupOptions['queries'] = queryRunner.state.queries;
const dsSettings = this.state.dsSettings;
return { return {
cacheTimeout: panelManager.state.dsSettings?.meta.queryOptions?.cacheTimeout cacheTimeout: dsSettings?.meta.queryOptions?.cacheTimeout ? queryRunner.state.cacheTimeout : undefined,
? queryRunner.state.cacheTimeout queryCachingTTL: dsSettings?.cachingConfig?.enabled ? queryRunner.state.queryCachingTTL : undefined,
: undefined,
queryCachingTTL: panelManager.state.dsSettings?.cachingConfig?.enabled
? queryRunner.state.queryCachingTTL
: undefined,
dataSource: { dataSource: {
default: panelManager.state.dsSettings?.isDefault, default: dsSettings?.isDefault,
...(panelManager.state.dsSettings ...(dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }),
? getDataSourceRef(panelManager.state.dsSettings)
: { type: undefined, uid: undefined }),
}, },
queries, queries,
maxDataPoints: queryRunner.state.maxDataPoints, maxDataPoints: queryRunner.state.maxDataPoints,
@ -92,37 +161,98 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
}; };
} }
onOpenInspector = () => { public onOpenInspector = () => {
this._panelManager.inspectPanel(); const panel = this.state.panelRef.resolve();
const panelId = getPanelIdForVizPanel(panel);
locationService.partial({ inspect: panelId, inspectTab: 'query' });
}; };
onChangeDataSource = async ( public onChangeDataSource = async (newSettings: DataSourceInstanceSettings, defaultQueries?: SceneDataQuery[]) => {
newSettings: DataSourceInstanceSettings, const { dsSettings } = this.state;
defaultQueries?: DataQuery[] | GrafanaQuery[] const queryRunner = this.queryRunner;
) => {
this._panelManager.changePanelDataSource(newSettings, defaultQueries); 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) => { public onQueryOptionsChange = (options: QueryGroupOptions) => {
this._panelManager.changeQueryOptions(options); 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[]) => { public onQueriesChange = (queries: SceneDataQuery[]) => {
this._panelManager.changeQueries(queries); const runner = this.queryRunner;
runner.setState({ queries });
}; };
onRunQueries = () => { public onRunQueries = () => {
this._panelManager.queryRunner.runQueries(); this.queryRunner.runQueries();
}; };
getQueries() { public getQueries() {
return this._panelManager.queryRunner.state.queries; return this.queryRunner.state.queries;
} }
newQuery(): Partial<DataQuery> { public newQuery(): Partial<DataQuery> {
const { dsSettings, datasource } = this._panelManager.state; const { dsSettings, datasource } = this.state;
let ds; let ds;
if (!dsSettings?.meta.mixed) { if (!dsSettings?.meta.mixed) {
ds = dsSettings; // Use dsSettings if it is not mixed ds = dsSettings; // Use dsSettings if it is not mixed
} else if (!datasource?.meta.mixed) { } else if (!datasource?.meta.mixed) {
@ -138,29 +268,30 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
}; };
} }
addQueryClick = () => { public addQueryClick = () => {
const queries = this.getQueries(); const queries = this.getQueries();
this.onQueriesChange(addQuery(queries, this.newQuery())); this.onQueriesChange(addQuery(queries, this.newQuery()));
}; };
onAddQuery = (query: Partial<DataQuery>) => { public onAddQuery = (query: Partial<DataQuery>) => {
const queries = this.getQueries(); const queries = this.getQueries();
const dsSettings = this._panelManager.state.dsSettings; const dsSettings = this.state.dsSettings;
this.onQueriesChange( this.onQueriesChange(
addQuery(queries, query, dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }) 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; return (dsSettings.meta.alerting || dsSettings.meta.mixed) === true;
} }
onAddExpressionClick = () => { public onAddExpressionClick = () => {
const queries = this.getQueries(); const queries = this.getQueries();
this.onQueriesChange(addQuery(queries, expressionDatasource.newQuery())); this.onQueriesChange(addQuery(queries, expressionDatasource.newQuery()));
}; };
renderExtraActions() { public renderExtraActions() {
return GroupActionComponents.getAllExtraRenderAction() return GroupActionComponents.getAllExtraRenderAction()
.map((action, index) => .map((action, index) =>
action({ action({
@ -172,18 +303,14 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
.filter(Boolean); .filter(Boolean);
} }
get queryRunner(): SceneQueryRunner { public get queryRunner(): SceneQueryRunner {
return this._panelManager.queryRunner; return getQueryRunnerFor(this.state.panelRef.resolve())!;
}
get panelManager() {
return this._panelManager;
} }
} }
export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<PanelDataQueriesTab>) { export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<PanelDataQueriesTab>) {
const { datasource, dsSettings } = model.panelManager.useState(); const { datasource, dsSettings } = model.useState();
const { data, queries } = model.panelManager.queryRunner.useState(); const { data, queries } = model.queryRunner.useState();
if (!datasource || !dsSettings || !data) { if (!datasource || !dsSettings || !data) {
return null; return null;
@ -250,7 +377,6 @@ function QueriesTab(props: QueriesTabProps) {
return ( return (
<Tab <Tab
key={props.key}
label={model.getTabLabel()} label={model.getTabLabel()}
icon="database" icon="database"
counter={queryRunnerState.queries.length} counter={queryRunnerState.queries.length}

@ -19,7 +19,6 @@ import { DashboardDataDTO } from 'app/types';
import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene'; import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene';
import { DashboardModelCompatibilityWrapper } from '../../utils/DashboardModelCompatibilityWrapper'; import { DashboardModelCompatibilityWrapper } from '../../utils/DashboardModelCompatibilityWrapper';
import { findVizPanelByKey } from '../../utils/utils'; import { findVizPanelByKey } from '../../utils/utils';
import { VizPanelManager } from '../VizPanelManager';
import { testDashboard } from '../testfiles/testDashboard'; import { testDashboard } from '../testfiles/testDashboard';
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab'; import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
@ -52,10 +51,9 @@ const mockData = {
describe('PanelDataTransformationsModel', () => { describe('PanelDataTransformationsModel', () => {
it('can change transformations', () => { it('can change transformations', () => {
const vizPanelManager = setupVizPanelManger('panel-1'); const { transformsTab } = setupTabScene('panel-1');
const model = new PanelDataTransformationsTab(vizPanelManager); transformsTab.onChangeTransformations([{ id: 'calculateField', options: {} }]);
model.onChangeTransformations([{ id: 'calculateField', options: {} }]); expect(transformsTab.getDataTransformer().state.transformations).toEqual([{ id: 'calculateField', options: {} }]);
expect(model.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 scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} });
const panel = findVizPanelByKey(scene, panelId)!; 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 // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it
// @ts-expect-error // @ts-expect-error
getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); 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 { DragDropContext, DropResult, Droppable } from '@hello-pangea/dnd';
import { useState } from 'react'; 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 { 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 { Button, ButtonGroup, ConfirmModal, Tab, useStyles2 } from '@grafana/ui';
import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows'; import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows';
import { VizPanelManager } from '../VizPanelManager'; import { getQueryRunnerFor } from '../../utils/utils';
import { EmptyTransformationsMessage } from './EmptyTransformationsMessage'; import { EmptyTransformationsMessage } from './EmptyTransformationsMessage';
import { TransformationsDrawer } from './TransformationsDrawer'; 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 export class PanelDataTransformationsTab
extends SceneObjectBase<PanelDataTransformationsTabState> extends SceneObjectBase<PanelDataTransformationsTabState>
implements PanelDataPaneTab implements PanelDataPaneTab
{ {
static Component = PanelDataTransformationsTabRendered; static Component = PanelDataTransformationsTabRendered;
TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element;
tabId = TabId.Transformations; tabId = TabId.Transformations;
icon: IconName = 'process';
private _panelManager: VizPanelManager;
getTabLabel() { getTabLabel() {
return 'Transformations'; return 'Transformations';
} }
constructor(panelManager: VizPanelManager) { public renderTab(props: PanelDataTabHeaderProps) {
super({}); return <TransformationsTab key={this.getTabLabel()} model={this} {...props} />;
this.TabComponent = (props: PanelDataTabHeaderProps) => TransformationsTab({ ...props, model: this });
this._panelManager = panelManager;
} }
public getQueryRunner(): SceneQueryRunner { public getQueryRunner(): SceneQueryRunner {
return this._panelManager.queryRunner; return getQueryRunnerFor(this.state.panelRef.resolve())!;
} }
public getDataTransformer(): SceneDataTransformer { public getDataTransformer(): SceneDataTransformer {
return this._panelManager.dataTransformer; const provider = this.state.panelRef.resolve().state.$data;
}
public onChangeTransformations(transformations: DataTransformerConfig[]) { if (!provider || !(provider instanceof SceneDataTransformer)) {
this._panelManager.changeTransformations(transformations); throw new Error('Could not find SceneDataTransformer for panel');
}
return provider;
} }
get panelManager() { public onChangeTransformations(transformations: DataTransformerConfig[]) {
return this._panelManager; const transformer = this.getDataTransformer();
transformer.setState({ transformations });
transformer.reprocessTransformations();
} }
} }
@ -200,11 +206,10 @@ interface TransformationsTabProps extends PanelDataTabHeaderProps {
function TransformationsTab(props: TransformationsTabProps) { function TransformationsTab(props: TransformationsTabProps) {
const { model } = props; const { model } = props;
const transformerState = model.getDataTransformer().useState(); const transformerState = model.getDataTransformer().useState();
return ( return (
<Tab <Tab
key={props.key}
label={model.getTabLabel()} label={model.getTabLabel()}
icon="process" icon="process"
counter={transformerState.transformations.length} counter={transformerState.transformations.length}

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

@ -1,5 +1,4 @@
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { InlineSwitch } from '@grafana/ui'; import { InlineSwitch } from '@grafana/ui';
import { PanelEditor } from './PanelEditor'; import { PanelEditor } from './PanelEditor';
@ -9,19 +8,17 @@ export interface Props {
} }
export function PanelEditControls({ panelEditor }: Props) { export function PanelEditControls({ panelEditor }: Props) {
const vizManager = panelEditor.state.vizManager; const { tableView, dataPane } = panelEditor.useState();
const { panel, tableView } = vizManager.useState();
const skipDataQuery = config.panels[panel.state.pluginId]?.skipDataQuery;
return ( return (
<> <>
{!skipDataQuery && ( {dataPane && (
<InlineSwitch <InlineSwitch
label="Table view" label="Table view"
showLabel={true} showLabel={true}
id="table-view" id="table-view"
value={tableView ? true : false} value={tableView ? true : false}
onClick={() => vizManager.toggleTableView()} onClick={panelEditor.onToggleTableView}
aria-label="toggle-table-view" aria-label="toggle-table-view"
data-testid={selectors.components.PanelEditor.toggleTableView} data-testid={selectors.components.PanelEditor.toggleTableView}
/> />

@ -1,5 +1,21 @@
import { PanelPlugin, PanelPluginMeta, PluginType } from '@grafana/data'; import { of } from 'rxjs';
import { SceneGridLayout, VizPanel } from '@grafana/scenes';
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 * as libAPI from 'app/features/library-panels/state/api';
import { DashboardGridItem } from '../scene/DashboardGridItem'; import { DashboardGridItem } from '../scene/DashboardGridItem';
@ -7,14 +23,28 @@ import { DashboardScene } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { activateFullSceneTree } from '../utils/test-utils'; import { activateFullSceneTree } from '../utils/test-utils';
import { findVizPanelByKey, getQueryRunnerFor } from '../utils/utils';
import { buildPanelEditScene } from './PanelEditor'; 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.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => {
return runRequestMock(ds, request);
},
getPluginImportUtils: () => ({ getPluginImportUtils: () => ({
getPanelPluginFromCache: jest.fn(() => pluginToLoad), getPanelPluginFromCache: jest.fn(() => undefined),
importPanelPlugin: () => pluginPromise,
}), }),
config: { config: {
...jest.requireActual('@grafana/runtime').config, ...jest.requireActual('@grafana/runtime').config,
@ -29,103 +59,134 @@ jest.mock('@grafana/runtime', () => ({
}, },
})); }));
describe('PanelEditor', () => { const dataSources = {
describe('When closing editor', () => { ds1: mockDataSource({
it('should apply changes automatically', () => { uid: 'ds1',
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); type: DataSourceType.Prometheus,
}),
};
const panel = new VizPanel({ setDataSourceSrv(new MockDataSourceSrv(dataSources));
key: 'panel-1',
pluginId: 'text', let deactivate: CancelActivationHandler | undefined;
describe('PanelEditor', () => {
afterEach(() => {
if (deactivate) {
deactivate();
deactivate = undefined;
}
}); });
const gridItem = new DashboardGridItem({ body: panel }); describe('When initializing', () => {
it('should wait for panel plugin to load', async () => {
const { panelEditor, panel, pluginResolve, dashboard } = await setup({ skipWait: true });
const editScene = buildPanelEditScene(panel); expect(panel.state.options).toEqual({});
const scene = new DashboardScene({ expect(panelEditor.state.isInitializing).toBe(true);
editPanel: editScene,
isEditing: true, const pluginToLoad = getPanelPlugin({ id: 'text' }).setPanelOptions((build) => {
body: new SceneGridLayout({ build.addBooleanSwitch({
children: [gridItem], 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; panel.onOptionsChange({ showHeader: false });
expect(updatedPanel?.state.title).toBe('changed title'); 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', () => { describe('When discarding', () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); it('should discard changes revert all changes', async () => {
const { panelEditor, panel, dashboard } = await setup();
const panel = new VizPanel({ panel.setState({ title: 'changed title' });
key: 'panel-1', panelEditor.onDiscard();
pluginId: 'text',
const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!;
expect(discardedPanel.state.title).toBe('original title');
}); });
const gridItem = new DashboardGridItem({ body: panel }); it('should discard a newly added panel', async () => {
const { panelEditor, dashboard } = await setup({ isNewPanel: true });
panelEditor.onDiscard();
const editScene = buildPanelEditScene(panel); expect((dashboard.state.body as SceneGridLayout).state.children.length).toBe(0);
const scene = new DashboardScene({
editPanel: editScene,
isEditing: true,
body: new SceneGridLayout({
children: [gridItem],
}),
}); });
const deactivate = activateFullSceneTree(scene); it('should discard query runner changes', async () => {
const { panelEditor, panel, dashboard } = await setup({});
editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); const queryRunner = getQueryRunnerFor(panel);
queryRunner?.setState({ maxDataPoints: 123, queries: [{ refId: 'A' }, { refId: 'B' }] });
editScene.onDiscard(); panelEditor.onDiscard();
deactivate();
const updatedPanel = gridItem.state.body as VizPanel; const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!;
expect(updatedPanel?.state.title).toBe(panel.state.title); const restoredQueryRunner = getQueryRunnerFor(discardedPanel);
expect(restoredQueryRunner?.state.maxDataPoints).toBe(500);
expect(restoredQueryRunner?.state.queries.length).toBe(1);
});
}); });
it('should discard a newly added panel', () => { describe('When changes are made', () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); it('Should set state to dirty', async () => {
const { panelEditor, panel } = await setup({});
const panel = new VizPanel({ expect(panelEditor.state.isDirty).toBe(undefined);
key: 'panel-1',
pluginId: 'text',
});
const gridItem = new DashboardGridItem({ body: panel }); panel.setState({ title: 'changed title' });
const editScene = buildPanelEditScene(panel, true); expect(panelEditor.state.isDirty).toBe(true);
const scene = new DashboardScene({
editPanel: editScene,
isEditing: true,
body: new SceneGridLayout({
children: [gridItem],
}),
}); });
editScene.onDiscard(); it('Should reset dirty and orginal state when dashboard is saved', async () => {
const deactivate = activateFullSceneTree(scene); const { panelEditor, panel } = await setup({});
deactivate(); expect(panelEditor.state.isDirty).toBe(undefined);
panel.setState({ title: 'changed title' });
panelEditor.dashboardSaved();
expect((scene.state.body as SceneGridLayout).state.children.length).toBe(0); expect(panelEditor.state.isDirty).toBe(false);
panel.setState({ title: 'changed title 2' });
expect(panelEditor.state.isDirty).toBe(true);
// Change back to already saved state
panel.setState({ title: 'changed title' });
expect(panelEditor.state.isDirty).toBe(false);
});
});
describe('When opening a repeated panel', () => {
it('Should default to the first variable value if panel is repeated', async () => {
const { panel } = await setup({ repeatByVariable: 'server' });
const variable = sceneGraph.lookupVariable('server', panel);
expect(variable?.getValue()).toBe('A');
}); });
}); });
describe('Handling library panels', () => { describe('Handling library panels', () => {
it('should call the api with the updated panel', async () => { it('should call the api with the updated panel', async () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true }));
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
});
const panel = new VizPanel({ key: 'panel-1', pluginId: 'text' });
const libraryPanelModel = { const libraryPanelModel = {
title: 'title', title: 'title',
uid: 'uid', uid: 'uid',
@ -143,15 +204,13 @@ describe('PanelEditor', () => {
_loadedPanel: libraryPanelModel, _loadedPanel: libraryPanelModel,
}); });
panel.setState({ panel.setState({ $behaviors: [libPanelBehavior] });
$behaviors: [libPanelBehavior],
});
const gridItem = new DashboardGridItem({ body: panel }); const gridItem = new DashboardGridItem({ body: panel });
const editScene = buildPanelEditScene(panel); const editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({ const scene = new DashboardScene({
editPanel: editScene, editPanel: editScene,
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
isEditing: true, isEditing: true,
body: new SceneGridLayout({ body: new SceneGridLayout({
children: [gridItem], children: [gridItem],
@ -160,96 +219,133 @@ describe('PanelEditor', () => {
activateFullSceneTree(scene); activateFullSceneTree(scene);
editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); await new Promise((r) => setTimeout(r, 1));
(editScene.state.vizManager.state.panel.state.$behaviors![0] as LibraryPanelBehavior).setState({
name: 'changed name', panel.setState({ title: 'changed title' });
}); libPanelBehavior.setState({ name: 'changed name' });
jest.spyOn(libAPI, 'saveLibPanel').mockImplementation(async (panel) => { jest.spyOn(libAPI, 'saveLibPanel').mockImplementation(async (panel) => {
const updatedPanel = { ...libAPI.libraryVizPanelToSaveModel(panel), version: 2 }; const updatedPanel = { ...libAPI.libraryVizPanelToSaveModel(panel), version: 2 };
libPanelBehavior.setPanelFromLibPanel(updatedPanel); 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._loadedPanel?.version).toBe(2);
expect(libPanelBehavior.state.name).toBe('changed name'); expect(libPanelBehavior.state.name).toBe('changed name');
expect(libPanelBehavior.state.title).toBe('changed title'); expect(libPanelBehavior.state.title).toBe('changed title');
expect((gridItem.state.body as VizPanel).state.title).toBe('changed title'); expect((gridItem.state.body as VizPanel).state.title).toBe('changed title');
}); });
});
describe('PanelDataPane', () => { it('unlinks library panel', () => {
it('should not exist if panel is skipDataQuery', () => { const libraryPanelModel = {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); title: 'title',
uid: 'uid',
name: 'libraryPanelName',
model: {
title: 'title',
type: 'text',
},
type: 'panel',
version: 1,
};
const panel = new VizPanel({ const libPanelBehavior = new LibraryPanelBehavior({
key: 'panel-1', isLoaded: true,
pluginId: 'text', title: libraryPanelModel.title,
}); uid: libraryPanelModel.uid,
new DashboardGridItem({ name: libraryPanelModel.name,
body: panel, _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 editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({ editScene.onConfirmUnlinkLibraryPanel();
editPanel: editScene,
expect(panel.state.$behaviors?.length).toBe(1);
expect(panel.state.$behaviors![0]).toBe(otherBehavior);
});
}); });
activateFullSceneTree(scene); describe('PanelDataPane', () => {
it('should not exist if panel is skipDataQuery', async () => {
const { panelEditor } = await setup({ pluginSkipDataQuery: true });
expect(panelEditor.state.dataPane).toBeUndefined();
});
expect(editScene.state.dataPane).toBeUndefined(); it('should exist if panel is supporting querying', async () => {
const { panelEditor } = await setup({ pluginSkipDataQuery: false });
expect(panelEditor.state.dataPane).toBeDefined();
});
});
}); });
it('should exist if panel is supporting querying', () => { interface SetupOptions {
pluginToLoad = getTestPanelPlugin({ id: 'timeseries' }); isNewPanel?: boolean;
pluginSkipDataQuery?: boolean;
repeatByVariable?: string;
skipWait?: boolean;
pluginLoadTime?: number;
}
async function setup(options: SetupOptions = {}) {
const pluginToLoad = getPanelPlugin({ id: 'text', skipDataQuery: options.pluginSkipDataQuery });
let pluginResolve = (plugin: PanelPlugin) => {};
pluginPromise = new Promise<PanelPlugin>((resolve) => {
pluginResolve = resolve;
});
const panel = new VizPanel({ const panel = new VizPanel({
key: 'panel-1', key: 'panel-1',
pluginId: 'timeseries', pluginId: 'text',
title: 'original title',
$data: new SceneDataTransformer({
transformations: [],
$data: new SceneQueryRunner({
queries: [{ refId: 'A' }],
maxDataPoints: 500,
datasource: { uid: 'ds1' },
}),
}),
}); });
new DashboardGridItem({ const gridItem = new DashboardGridItem({ body: panel, variableName: options.repeatByVariable });
body: panel,
});
const editScene = buildPanelEditScene(panel);
const scene = new DashboardScene({
editPanel: editScene,
});
activateFullSceneTree(scene); const panelEditor = buildPanelEditScene(panel, options.isNewPanel);
expect(editScene.state.dataPane).toBeDefined(); const dashboard = new DashboardScene({
}); editPanel: panelEditor,
}); isEditing: true,
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
$variables: new SceneVariableSet({
variables: [
new CustomVariable({
name: 'server',
query: 'A,B,C',
isMulti: true,
value: ['A', 'B', 'C'],
text: ['A', 'B', 'C'],
}),
],
}),
body: new SceneGridLayout({
children: [gridItem],
}),
}); });
export function getTestPanelPlugin(options: Partial<PanelPluginMeta>): PanelPlugin { panelEditor.debounceSaveModelDiff = false;
const plugin = new PanelPlugin(() => null);
plugin.meta = { deactivate = activateFullSceneTree(dashboard);
id: options.id!,
type: PluginType.panel, if (!options.skipWait) {
name: options.id!, //console.log('pluginResolve(pluginToLoad)');
sort: options.sort || 1, pluginResolve(pluginToLoad);
info: { await new Promise((r) => setTimeout(r, 1));
author: { }
name: options.id + 'name',
}, return { dashboard, panel, gridItem, panelEditor, pluginResolve };
description: '',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '',
version: '1.0.',
},
hideFromList: options.hideFromList === true,
module: options.module ?? '',
baseUrl: '',
skipDataQuery: options.skipDataQuery ?? false,
};
return plugin;
} }

@ -1,72 +1,186 @@
import * as H from 'history'; import * as H from 'history';
import { debounce } from 'lodash';
import { NavIndex } from '@grafana/data';
import { config, locationService } from '@grafana/runtime'; import { NavIndex, PanelPlugin } from '@grafana/data';
import { SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; import { locationService } from '@grafana/runtime';
import {
import { DashboardGridItem } from '../scene/DashboardGridItem'; PanelBuilders,
import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils'; 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 { PanelDataPane } from './PanelDataPane/PanelDataPane';
import { PanelEditorRenderer } from './PanelEditorRenderer'; import { PanelEditorRenderer } from './PanelEditorRenderer';
import { PanelOptionsPane } from './PanelOptionsPane'; import { PanelOptionsPane } from './PanelOptionsPane';
import { VizPanelManager, VizPanelManagerState } from './VizPanelManager';
export interface PanelEditorState extends SceneObjectState { export interface PanelEditorState extends SceneObjectState {
isNewPanel: boolean; isNewPanel: boolean;
isDirty?: boolean; isDirty?: boolean;
panelId: number; optionsPane?: PanelOptionsPane;
optionsPane: PanelOptionsPane;
dataPane?: PanelDataPane; dataPane?: PanelDataPane;
vizManager: VizPanelManager; panelRef: SceneObjectRef<VizPanel>;
showLibraryPanelSaveModal?: boolean; showLibraryPanelSaveModal?: boolean;
showLibraryPanelUnlinkModal?: boolean; showLibraryPanelUnlinkModal?: boolean;
tableView?: VizPanel;
pluginLoadErrror?: string;
/**
* Waiting for library panel or panel plugin to load
*/
isInitializing?: boolean;
} }
export class PanelEditor extends SceneObjectBase<PanelEditorState> { export class PanelEditor extends SceneObjectBase<PanelEditorState> {
private _initialRepeatOptions: Pick<VizPanelManagerState, 'repeat' | 'repeatDirection' | 'maxPerRow'> = {};
static Component = PanelEditorRenderer; static Component = PanelEditorRenderer;
private _discardChanges = false; private _originalLayoutElementState!: DashboardGridItemState;
private _layoutElement!: DashboardGridItem;
private _originalSaveModel!: Panel;
public constructor(state: PanelEditorState) { public constructor(state: PanelEditorState) {
super(state); super(state);
const { repeat, repeatDirection, maxPerRow } = state.vizManager.state; this.setOriginalState(this.state.panelRef);
this._initialRepeatOptions = {
repeat,
repeatDirection,
maxPerRow,
};
this.addActivationHandler(this._activationHandler.bind(this)); this.addActivationHandler(this._activationHandler.bind(this));
} }
private _activationHandler() { private _activationHandler() {
const panelManager = this.state.vizManager; const panel = this.state.panelRef.resolve();
const panel = panelManager.state.panel; const deactivateParents = activateInActiveParents(panel);
const layoutElement = panel.parent;
this.waitForPlugin();
return () => {
if (layoutElement instanceof DashboardGridItem) {
layoutElement.editingCompleted();
}
if (deactivateParents) {
deactivateParents();
}
};
}
private waitForPlugin(retry = 0) {
const panel = this.getPanel();
const plugin = panel.getPlugin();
if (!plugin || plugin.meta.id !== panel.state.pluginId) {
if (retry < 100) {
setTimeout(() => this.waitForPlugin(retry + 1), retry * 10);
} else {
this.setState({ pluginLoadErrror: 'Failed to load panel plugin' });
}
return;
}
this.gotPanelPlugin(plugin);
}
private setOriginalState(panelRef: SceneObjectRef<VizPanel>) {
const panel = panelRef.resolve();
this._originalSaveModel = vizPanelToPanel(panel);
if (panel.parent instanceof DashboardGridItem) {
this._originalLayoutElementState = sceneUtils.cloneSceneObjectState(panel.parent.state);
this._layoutElement = panel.parent;
}
}
/**
* Useful for testing to turn on debounce
*/
public debounceSaveModelDiff = true;
/**
* Subscribe to state changes and check if the save model has changed
*/
private _setupChangeDetection() {
const panel = this.state.panelRef.resolve();
const performSaveModelDiff = () => {
const { hasChanges } = getPanelChanges(this._originalSaveModel, vizPanelToPanel(panel));
this.setState({ isDirty: hasChanges });
};
const performSaveModelDiffDebounced = this.debounceSaveModelDiff
? debounce(performSaveModelDiff, 250)
: performSaveModelDiff;
const handleStateChange = (event: SceneObjectStateChangedEvent) => {
if (DashboardSceneChangeTracker.isUpdatingPersistedState(event)) {
performSaveModelDiffDebounced();
}
};
this._subs.add(panel.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange));
// Repeat options live on the layout element (DashboardGridItem)
this._subs.add(this._layoutElement.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange));
}
public getPanel(): VizPanel {
return this.state.panelRef?.resolve();
}
private gotPanelPlugin(plugin: PanelPlugin) {
const panel = this.getPanel();
const layoutElement = panel.parent;
// First time initialization
if (this.state.isInitializing) {
this.setOriginalState(this.state.panelRef);
if (layoutElement instanceof DashboardGridItem) {
layoutElement.editingStarted();
}
this._setupChangeDetection();
this._updateDataPane(plugin);
// Listen for panel plugin changes
this._subs.add( this._subs.add(
panelManager.subscribeToState((n, p) => { panel.subscribeToState((n, p) => {
if (n.pluginId !== p.pluginId) { if (n.pluginId !== p.pluginId) {
this._initDataPane(n.pluginId); this.waitForPlugin();
} }
}) })
); );
this._initDataPane(panel.state.pluginId); // Setup options pane
this.setState({
return () => { optionsPane: new PanelOptionsPane({
if (!this._discardChanges) { panelRef: this.state.panelRef,
this.commitChanges(); searchQuery: '',
} else if (this.state.isNewPanel) { listMode: OptionFilter.All,
getDashboardSceneFor(this).removePanel(panelManager.state.sourcePanel.resolve()!); }),
isInitializing: false,
});
} else {
// plugin changed after first time initialization
// Just update data pane
this._updateDataPane(plugin);
} }
};
} }
private _initDataPane(pluginId: string) { private _updateDataPane(plugin: PanelPlugin) {
const skipDataQuery = config.panels[pluginId]?.skipDataQuery; const skipDataQuery = plugin.meta.skipDataQuery;
if (skipDataQuery && this.state.dataPane) { if (skipDataQuery && this.state.dataPane) {
locationService.partial({ tab: null }, true); locationService.partial({ tab: null }, true);
@ -74,12 +188,16 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
} }
if (!skipDataQuery && !this.state.dataPane) { if (!skipDataQuery && !this.state.dataPane) {
this.setState({ dataPane: new PanelDataPane(this.state.vizManager) }); this.setState({ dataPane: PanelDataPane.createFor(this.getPanel()) });
} }
} }
public getUrlKey() { 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) { public getPageNav(location: H.Location, navIndex: NavIndex) {
@ -92,53 +210,23 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
} }
public onDiscard = () => { public onDiscard = () => {
this.state.vizManager.setState({ isDirty: false }); this.setState({ isDirty: false });
this._discardChanges = true;
locationService.partial({ editPanel: null });
};
public commitChanges() { const panel = this.state.panelRef.resolve();
const dashboard = getDashboardSceneFor(this);
if (!dashboard.state.isEditing) { if (this.state.isNewPanel) {
dashboard.onEnterEditMode(); getDashboardSceneFor(this).removePanel(panel);
} else {
// Revert any layout element changes
this._layoutElement.setState(this._originalLayoutElementState!);
} }
const panelManager = this.state.vizManager; locationService.partial({ editPanel: null });
const sourcePanel = panelManager.state.sourcePanel.resolve(); };
const gridItem = sourcePanel!.parent;
if (!(gridItem instanceof DashboardGridItem)) {
console.error('Unsupported scene object type');
return;
}
this.commitChangesToSource(gridItem);
}
private commitChangesToSource(gridItem: DashboardGridItem) {
let width = gridItem.state.width ?? 1;
let height = gridItem.state.height;
const panelManager = this.state.vizManager;
const horizontalToVertical =
this._initialRepeatOptions.repeatDirection === 'h' && panelManager.state.repeatDirection === 'v';
const verticalToHorizontal =
this._initialRepeatOptions.repeatDirection === 'v' && panelManager.state.repeatDirection === 'h';
if (horizontalToVertical) {
width = Math.floor(width / (gridItem.state.maxPerRow ?? 1));
} else if (verticalToHorizontal) {
width = 24;
}
gridItem.setState({ public dashboardSaved() {
body: panelManager.state.panel.clone(), this.setOriginalState(this.state.panelRef);
repeatDirection: panelManager.state.repeatDirection, this.setState({ isDirty: false });
variableName: panelManager.state.repeat,
maxPerRow: panelManager.state.maxPerRow,
width,
height,
});
} }
public onSaveLibraryPanel = () => { public onSaveLibraryPanel = () => {
@ -146,8 +234,8 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
}; };
public onConfirmSaveLibraryPanel = () => { public onConfirmSaveLibraryPanel = () => {
this.state.vizManager.commitChanges(); saveLibPanel(this.state.panelRef.resolve());
this.state.vizManager.setState({ isDirty: false }); this.setState({ isDirty: false });
locationService.partial({ editPanel: null }); locationService.partial({ editPanel: null });
}; };
@ -164,16 +252,43 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
}; };
public onConfirmUnlinkLibraryPanel = () => { public onConfirmUnlinkLibraryPanel = () => {
this.state.vizManager.unlinkLibraryPanel(); const libPanelBehavior = getLibraryPanelBehavior(this.getPanel());
if (!libPanelBehavior) {
return;
}
libPanelBehavior.unlink();
this.setState({ showLibraryPanelUnlinkModal: false }); 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 { export function buildPanelEditScene(panel: VizPanel, isNewPanel = false): PanelEditor {
return new PanelEditor({ return new PanelEditor({
panelId: getPanelIdForVizPanel(panel), isInitializing: true,
optionsPane: new PanelOptionsPane({}), panelRef: panel.getRef(),
vizManager: VizPanelManager.createFor(panel),
isNewPanel, isNewPanel,
}); });
} }

@ -2,8 +2,8 @@ import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { SceneComponentProps } from '@grafana/scenes'; import { SceneComponentProps, VizPanel } from '@grafana/scenes';
import { Button, ToolbarButton, useStyles2 } from '@grafana/ui'; import { Button, Spinner, ToolbarButton, useStyles2 } from '@grafana/ui';
import { NavToolbarActions } from '../scene/NavToolbarActions'; import { NavToolbarActions } from '../scene/NavToolbarActions';
import { UnlinkModal } from '../scene/UnlinkModal'; import { UnlinkModal } from '../scene/UnlinkModal';
@ -54,7 +54,8 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
/> />
</div> </div>
)} )}
{!splitterState.collapsed && <optionsPane.Component model={optionsPane} />} {!splitterState.collapsed && optionsPane && <optionsPane.Component model={optionsPane} />}
{!splitterState.collapsed && !optionsPane && <Spinner />}
</div> </div>
</div> </div>
</> </>
@ -63,9 +64,9 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) { function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
const dashboard = getDashboardSceneFor(model); const dashboard = getDashboardSceneFor(model);
const { vizManager, dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal } = model.useState(); const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView } = model.useState();
const { sourcePanel } = vizManager.useState(); const panel = model.getPanel();
const libraryPanel = getLibraryPanelBehavior(sourcePanel.resolve()); const libraryPanel = getLibraryPanelBehavior(panel);
const { controls } = dashboard.useState(); const { controls } = dashboard.useState();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@ -94,7 +95,7 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
)} )}
<div {...containerProps}> <div {...containerProps}>
<div {...primaryProps}> <div {...primaryProps}>
<vizManager.Component model={vizManager} /> <VizWrapper panel={panel} tableView={tableView} />
</div> </div>
{showLibraryPanelSaveModal && libraryPanel && ( {showLibraryPanelSaveModal && libraryPanel && (
<SaveLibraryVizPanelModal <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) { function getStyles(theme: GrafanaTheme2) {
return { return {
pageContainer: css({ pageContainer: css({
@ -215,5 +232,10 @@ function getStyles(theme: GrafanaTheme2) {
rotate: '-90deg', 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 * as utils from '../utils/utils';
import { PanelOptions } from './PanelOptions'; import { PanelOptions } from './PanelOptions';
import { VizPanelManager } from './VizPanelManager'; import { PanelOptionsPane } from './PanelOptionsPane';
const OptionsPaneSelector = selectors.components.PanelEditor.OptionsPane; const OptionsPaneSelector = selectors.components.PanelEditor.OptionsPane;
@ -92,43 +92,47 @@ function setup(options: SetupOptions = {}) {
} }
// need to wait for plugin to load // need to wait for plugin to load
const vizManager = VizPanelManager.createFor(panel); const panelOptionsScene = new PanelOptionsPane({
panelRef: panel.getRef(),
activateFullSceneTree(vizManager); 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); const renderResult = render(panelOptions);
return { renderResult, vizManager }; return { renderResult, panelOptionsScene, panel };
} }
describe('PanelOptions', () => { describe('PanelOptions', () => {
describe('Can render and edit panel frame options', () => { describe('Can render and edit panel frame options', () => {
it('Can edit title', async () => { it('Can edit title', async () => {
const { vizManager } = setup(); const { panel } = setup();
expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument(); expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument();
const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title')); const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title'));
fireEvent.change(input, { target: { value: 'New 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 () => { it('Clearing title should set hoverHeader to true', async () => {
const { vizManager } = setup(); const { panel } = setup();
expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument(); expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument();
const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title')); const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title'));
fireEvent.change(input, { target: { value: '' } }); fireEvent.change(input, { target: { value: '' } });
expect(vizManager.state.panel.state.title).toBe(''); expect(panel.state.title).toBe('');
expect(vizManager.state.panel.state.hoverHeader).toBe(true); expect(panel.state.hoverHeader).toBe(true);
fireEvent.change(input, { target: { value: 'Muu' } }); 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, _loadedPanel: libraryPanelModel,
}); });
panel.setState({ panel.setState({ $behaviors: [libraryPanel] });
$behaviors: [libraryPanel],
});
new DashboardGridItem({ body: panel }); new DashboardGridItem({ body: panel });
const { renderResult, vizManager } = setup({ panel: panel }); const { renderResult } = setup({ panel: panel });
const input = await renderResult.findByTestId('library panel name input'); const input = await renderResult.findByTestId('library panel name input');
@ -193,8 +195,6 @@ describe('PanelOptions', () => {
fireEvent.blur(input, { target: { value: 'new library panel name' } }); fireEvent.blur(input, { target: { value: 'new library panel name' } });
}); });
expect((vizManager.state.panel.state.$behaviors![0] as LibraryPanelBehavior).state.name).toBe( expect((panel.state.$behaviors![0] as LibraryPanelBehavior).state.name).toBe('new library panel name');
'new library panel name'
);
}); });
}); });

@ -13,24 +13,23 @@ import {
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils';
import { VizPanelManager } from './VizPanelManager';
import { getPanelFrameCategory2 } from './getPanelFrameOptions'; import { getPanelFrameCategory2 } from './getPanelFrameOptions';
interface Props { interface Props {
vizManager: VizPanelManager; panel: VizPanel;
searchQuery: string; searchQuery: string;
listMode: OptionFilter; listMode: OptionFilter;
data?: PanelData; data?: PanelData;
} }
export const PanelOptions = React.memo<Props>(({ vizManager, searchQuery, listMode, data }) => { export const PanelOptions = React.memo<Props>(({ panel, searchQuery, listMode, data }) => {
const { panel, repeat } = vizManager.useState();
const { options, fieldConfig, _pluginInstanceState } = panel.useState(); 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( const panelFrameOptions = useMemo(
() => getPanelFrameCategory2(vizManager, panel, repeat), () => getPanelFrameCategory2(panel, layoutElementState),
[vizManager, panel, repeat] [panel, layoutElementState]
); );
const visualizationOptions = useMemo(() => { 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 { css } from '@emotion/css';
import { useMemo } from 'react'; 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 { 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 { FilterInput, Stack, ToolbarButton, useStyles2 } from '@grafana/ui';
import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions';
import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPluginError'; 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 { getAllPanelPluginMeta } from 'app/features/panel/state/util';
import { PanelEditor } from './PanelEditor';
import { PanelOptions } from './PanelOptions'; import { PanelOptions } from './PanelOptions';
import { PanelVizTypePicker } from './PanelVizTypePicker'; import { PanelVizTypePicker } from './PanelVizTypePicker';
@ -17,21 +32,48 @@ export interface PanelOptionsPaneState extends SceneObjectState {
isVizPickerOpen?: boolean; isVizPickerOpen?: boolean;
searchQuery: string; searchQuery: string;
listMode: OptionFilter; listMode: OptionFilter;
panelRef: SceneObjectRef<VizPanel>;
} }
export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> { interface PluginOptionsCache {
public constructor(state: Partial<PanelOptionsPaneState>) { options: DeepPartial<{}>;
super({ fieldConfig: FieldConfigSource<DeepPartial<{}>>;
searchQuery: '',
listMode: OptionFilter.All,
...state,
});
} }
export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
private _cachedPluginOptions: Record<string, PluginOptionsCache | undefined> = {};
onToggleVizPicker = () => { onToggleVizPicker = () => {
this.setState({ isVizPickerOpen: !this.state.isVizPickerOpen }); 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) => { onSetSearchQuery = (searchQuery: string) => {
this.setState({ searchQuery }); this.setState({ searchQuery });
}; };
@ -41,10 +83,10 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
}; };
static Component = ({ model }: SceneComponentProps<PanelOptionsPane>) => { static Component = ({ model }: SceneComponentProps<PanelOptionsPane>) => {
const { isVizPickerOpen, searchQuery, listMode } = model.useState(); const { isVizPickerOpen, searchQuery, listMode, panelRef } = model.useState();
const vizManager = sceneGraph.getAncestor(model, PanelEditor).state.vizManager; const panel = panelRef.resolve();
const { pluginId } = vizManager.useState(); const { pluginId } = panel.useState();
const { data } = sceneGraph.getData(vizManager.state.panel).useState(); const { data } = sceneGraph.getData(panel).useState();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
@ -61,12 +103,17 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
/> />
</div> </div>
<div className={styles.listOfOptions}> <div className={styles.listOfOptions}>
<PanelOptions vizManager={vizManager} searchQuery={searchQuery} listMode={listMode} data={data} /> <PanelOptions panel={panel} searchQuery={searchQuery} listMode={listMode} data={data} />
</div> </div>
</> </>
)} )}
{isVizPickerOpen && ( {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 { GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { VizPanel } from '@grafana/scenes';
import { Button, CustomScrollbar, Field, FilterInput, RadioButtonGroup, useStyles2 } from '@grafana/ui'; 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 { LS_VISUALIZATION_SELECT_TAB_KEY, LS_WIDGET_SELECT_TAB_KEY } from 'app/core/constants';
import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types'; 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 { PanelModelCompatibilityWrapper } from '../utils/PanelModelCompatibilityWrapper';
import { VizPanelManager } from './VizPanelManager';
export interface Props { export interface Props {
data?: PanelData; data?: PanelData;
vizManager: VizPanelManager; panel: VizPanel;
onChange: () => void; onChange: (options: VizTypeChangeDetails) => void;
onClose: () => void;
} }
export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { export function PanelVizTypePicker({ panel, data, onChange, onClose }: Props) {
const { panel } = vizManager.useState();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@ -50,22 +49,8 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) {
const radioOptions: Array<SelectableValue<VisualizationSelectPaneTab>> = [ const radioOptions: Array<SelectableValue<VisualizationSelectPaneTab>> = [
{ label: 'Visualizations', value: VisualizationSelectPaneTab.Visualizations }, { label: 'Visualizations', value: VisualizationSelectPaneTab.Visualizations },
{ label: 'Suggestions', value: VisualizationSelectPaneTab.Suggestions }, { 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 ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.searchRow}> <div className={styles.searchRow}>
@ -82,7 +67,7 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) {
icon="angle-up" icon="angle-up"
className={styles.closeButton} className={styles.closeButton}
data-testid={selectors.components.PanelEditor.toggleVizPicker} data-testid={selectors.components.PanelEditor.toggleVizPicker}
onClick={onCloseVizPicker} onClick={onClose}
/> />
</div> </div>
<Field className={styles.customFieldMargin}> <Field className={styles.customFieldMargin}>
@ -90,18 +75,10 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) {
</Field> </Field>
<CustomScrollbar> <CustomScrollbar>
{listMode === VisualizationSelectPaneTab.Visualizations && ( {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 && ( {listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions <VisualizationSuggestions onChange={onChange} searchQuery={searchQuery} panel={panelModel} data={data} />
onChange={onVizTypeChange}
searchQuery={searchQuery}
panel={panelModel}
data={data}
/>
)} )}
</CustomScrollbar> </CustomScrollbar>
</div> </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 { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes'; import { SceneObjectState, VizPanel } from '@grafana/scenes';
import { RadioButtonGroup, Select, DataLinksInlineEditor, Input, TextArea, Switch } from '@grafana/ui'; import { DataLinksInlineEditor, Input, TextArea, Switch, RadioButtonGroup, Select } from '@grafana/ui';
import { GenAIPanelDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton'; import { GenAIPanelDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton';
import { GenAIPanelTitleButton } from 'app/features/dashboard/components/GenAI/GenAIPanelTitleButton'; import { GenAIPanelTitleButton } from 'app/features/dashboard/components/GenAI/GenAIPanelTitleButton';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; 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 { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
import { DashboardGridItem } from '../scene/DashboardGridItem';
import { VizPanelLinks } from '../scene/PanelLinks'; import { VizPanelLinks } from '../scene/PanelLinks';
import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../utils/utils'; import { getDashboardSceneFor } from '../utils/utils';
import { VizPanelManager, VizPanelManagerState } from './VizPanelManager';
export function getPanelFrameCategory2( export function getPanelFrameCategory2(
vizManager: VizPanelManager,
panel: VizPanel, panel: VizPanel,
repeat?: string layoutElementState: SceneObjectState
): OptionsPaneCategoryDescriptor { ): OptionsPaneCategoryDescriptor {
const descriptor = new OptionsPaneCategoryDescriptor({ const descriptor = new OptionsPaneCategoryDescriptor({
title: 'Panel options', title: 'Panel options',
@ -31,19 +29,20 @@ export function getPanelFrameCategory2(
const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel); const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel);
const links = panelLinksObject?.state.rawLinks ?? []; const links = panelLinksObject?.state.rawLinks ?? [];
const dashboard = getDashboardSceneFor(panel); const dashboard = getDashboardSceneFor(panel);
const layoutElement = panel.parent;
return descriptor descriptor
.addItem( .addItem(
new OptionsPaneItemDescriptor({ new OptionsPaneItemDescriptor({
title: 'Title', title: 'Title',
value: panel.state.title, value: panel.state.title,
popularRank: 1, popularRank: 1,
render: function renderTitle() { render: function renderTitle() {
return <PanelFrameTitle vizManager={vizManager} />; return <PanelFrameTitle panel={panel} />;
}, },
addon: config.featureToggles.dashgpt && ( addon: config.featureToggles.dashgpt && (
<GenAIPanelTitleButton <GenAIPanelTitleButton
onGenerate={(title) => vizManager.setPanelTitle(title)} onGenerate={(title) => setPanelTitle(panel, title)}
panel={vizPanelToPanel(panel)} panel={vizPanelToPanel(panel)}
dashboard={transformSceneToSaveModel(dashboard)} dashboard={transformSceneToSaveModel(dashboard)}
/> />
@ -95,14 +94,18 @@ export function getPanelFrameCategory2(
render: () => <ScenePanelLinksEditor panelLinks={panelLinksObject ?? undefined} />, render: () => <ScenePanelLinksEditor panelLinks={panelLinksObject ?? undefined} />,
}) })
) )
) );
.addCategory(
new OptionsPaneCategoryDescriptor({ if (layoutElement instanceof DashboardGridItem) {
const gridItem = layoutElement;
const category = new OptionsPaneCategoryDescriptor({
title: 'Repeat options', title: 'Repeat options',
id: 'Repeat options', id: 'Repeat options',
isOpenDefault: false, isOpenDefault: false,
}) });
.addItem(
category.addItem(
new OptionsPaneItemDescriptor({ new OptionsPaneItemDescriptor({
title: 'Repeat by variable', title: 'Repeat by variable',
description: description:
@ -111,24 +114,19 @@ export function getPanelFrameCategory2(
return ( return (
<RepeatRowSelect2 <RepeatRowSelect2
id="repeat-by-variable-select" id="repeat-by-variable-select"
parent={panel} sceneContext={panel}
repeat={repeat} repeat={gridItem.state.variableName}
onChange={(value?: string) => { onChange={(value?: string) => gridItem.setRepeatByVariable(value)}
const stateUpdate: Partial<VizPanelManagerState> = { repeat: value };
if (value && !vizManager.state.repeatDirection) {
stateUpdate.repeatDirection = 'h';
}
vizManager.setState(stateUpdate);
}}
/> />
); );
}, },
}) })
) );
.addItem(
category.addItem(
new OptionsPaneItemDescriptor({ new OptionsPaneItemDescriptor({
title: 'Repeat direction', title: 'Repeat direction',
showIf: () => !!vizManager.state.repeat, showIf: () => Boolean(gridItem.state.variableName),
render: function renderRepeatOptions() { render: function renderRepeatOptions() {
const directionOptions: Array<SelectableValue<'h' | 'v'>> = [ const directionOptions: Array<SelectableValue<'h' | 'v'>> = [
{ label: 'Horizontal', value: 'h' }, { label: 'Horizontal', value: 'h' },
@ -138,30 +136,35 @@ export function getPanelFrameCategory2(
return ( return (
<RadioButtonGroup <RadioButtonGroup
options={directionOptions} options={directionOptions}
value={vizManager.state.repeatDirection ?? 'h'} value={gridItem.state.repeatDirection ?? 'h'}
onChange={(value) => vizManager.setState({ repeatDirection: value })} onChange={(value) => gridItem.setState({ repeatDirection: value })}
/> />
); );
}, },
}) })
) );
.addItem(
category.addItem(
new OptionsPaneItemDescriptor({ new OptionsPaneItemDescriptor({
title: 'Max per row', title: 'Max per row',
showIf: () => Boolean(vizManager.state.repeat && vizManager.state.repeatDirection === 'h'), showIf: () => Boolean(gridItem.state.variableName && gridItem.state.repeatDirection === 'h'),
render: function renderOption() { render: function renderOption() {
const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value })); const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value }));
return ( return (
<Select <Select
options={maxPerRowOptions} options={maxPerRowOptions}
value={vizManager.state.maxPerRow} value={gridItem.state.maxPerRow ?? 4}
onChange={(value) => vizManager.setState({ maxPerRow: value.value })} onChange={(value) => gridItem.setState({ maxPerRow: value.value })}
/> />
); );
}, },
}) })
)
); );
descriptor.addCategory(category);
}
return descriptor;
} }
interface ScenePanelLinksEditorProps { interface ScenePanelLinksEditorProps {
@ -181,14 +184,14 @@ function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) {
); );
} }
function PanelFrameTitle({ vizManager }: { vizManager: VizPanelManager }) { function PanelFrameTitle({ panel }: { panel: VizPanel }) {
const { title } = vizManager.state.panel.useState(); const { title } = panel.useState();
return ( return (
<Input <Input
data-testid={selectors.components.PanelEditor.OptionsPane.fieldInput('Title')} data-testid={selectors.components.PanelEditor.OptionsPane.fieldInput('Title')}
value={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]); }, [dashboard]);
const onHistoryBlock = (location: H.Location) => { const onHistoryBlock = (location: H.Location) => {
const panelInEdit = dashboard.state.editPanel; const panelEditor = dashboard.state.editPanel;
const vizPanelManager = panelInEdit?.state.vizManager; const vizPanel = panelEditor?.getPanel();
const vizPanel = vizPanelManager?.state.panel;
const search = new URLSearchParams(location.search); const search = new URLSearchParams(location.search);
// Are we leaving panel edit & library panel? // Are we leaving panel edit & library panel?
if ( if (panelEditor && vizPanel && isLibraryPanel(vizPanel) && panelEditor.state.isDirty && !search.has('editPanel')) {
panelInEdit &&
vizPanel &&
isLibraryPanel(vizPanel) &&
vizPanelManager.state.isDirty &&
!search.has('editPanel')
) {
const libPanelBehavior = getLibraryPanelBehavior(vizPanel); const libPanelBehavior = getLibraryPanelBehavior(vizPanel);
showModal(SaveLibraryVizPanelModal, { showModal(SaveLibraryVizPanelModal, {
@ -60,12 +53,12 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => {
isUnsavedPrompt: true, isUnsavedPrompt: true,
libraryPanel: libPanelBehavior!, libraryPanel: libPanelBehavior!,
onConfirm: () => { onConfirm: () => {
panelInEdit.onConfirmSaveLibraryPanel(); panelEditor.onConfirmSaveLibraryPanel();
hideModal(); hideModal();
moveToBlockedLocationAfterReactStateUpdate(location); moveToBlockedLocationAfterReactStateUpdate(location);
}, },
onDiscard: () => { onDiscard: () => {
panelInEdit.onDiscard(); panelEditor.onDiscard();
hideModal(); hideModal();
moveToBlockedLocationAfterReactStateUpdate(location); moveToBlockedLocationAfterReactStateUpdate(location);
}, },

@ -14,7 +14,6 @@ import {
} from '@grafana/scenes'; } from '@grafana/scenes';
import { createWorker } from 'app/features/dashboard-scene/saving/createDetectChangesWorker'; import { createWorker } from 'app/features/dashboard-scene/saving/createDetectChangesWorker';
import { VizPanelManager } from '../panel-edit/VizPanelManager';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls'; import { DashboardControls } from '../scene/DashboardControls';
import { DashboardGridItem } from '../scene/DashboardGridItem'; import { DashboardGridItem } from '../scene/DashboardGridItem';
@ -43,7 +42,6 @@ export class DashboardSceneChangeTracker {
} }
// Any change in the panel should trigger a change detection // 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 // The PanelTimeRange includes the overrides configuration
if ( if (
payload.changedObject instanceof VizPanel || payload.changedObject instanceof VizPanel ||
@ -52,16 +50,6 @@ export class DashboardSceneChangeTracker {
) { ) {
return true; 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 // SceneQueryRunner includes the DS configuration
if (payload.changedObject instanceof SceneQueryRunner) { if (payload.changedObject instanceof SceneQueryRunner) {
if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) { if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) {

@ -237,8 +237,7 @@ describe('getDashboardChangesFromScene', () => {
dashboard.onEnterEditMode(); dashboard.onEnterEditMode();
dashboard.setState({ editPanel: editScene }); dashboard.setState({ editPanel: editScene });
editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); editScene.state.panelRef.resolve().setState({ title: 'changed title' });
editScene.commitChanges();
const result = getDashboardChangesFromScene(dashboard, false, true); const result = getDashboardChangesFromScene(dashboard, false, true);
const panelSaveModel = result.changedSaveModel.panels![0]; const panelSaveModel = result.changedSaveModel.panels![0];

@ -11,11 +11,10 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime'; 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 { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
import { VizPanelManager } from '../panel-edit/VizPanelManager';
import { activateFullSceneTree } from '../utils/test-utils'; import { activateFullSceneTree } from '../utils/test-utils';
import { DashboardDatasourceBehaviour } from './DashboardDatasourceBehaviour'; import { DashboardDatasourceBehaviour } from './DashboardDatasourceBehaviour';
@ -275,14 +274,12 @@ describe('DashboardDatasourceBehaviour', () => {
// spy on runQueries // spy on runQueries
const spy = jest.spyOn(dashboardDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries'); const spy = jest.spyOn(dashboardDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries');
const vizPanelManager = new VizPanelManager({ const scene = new SceneFlexLayout({
panel: dashboardDSPanel.clone(),
$data: dashboardDSPanel.state.$data?.clone(), $data: dashboardDSPanel.state.$data?.clone(),
sourcePanel: dashboardDSPanel.getRef(), children: [],
pluginId: dashboardDSPanel.state.pluginId,
}); });
vizPanelManager.activate(); scene.activate();
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });

@ -21,7 +21,7 @@ import {
SceneVariable, SceneVariable,
SceneVariableDependencyConfigLike, SceneVariableDependencyConfigLike,
} from '@grafana/scenes'; } 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'; import { getMultiVariableValues, getQueryRunnerFor } from '../utils/utils';
@ -41,7 +41,8 @@ export type RepeatDirection = 'v' | 'h';
export class DashboardGridItem extends SceneObjectBase<DashboardGridItemState> implements SceneGridItemLike { export class DashboardGridItem extends SceneObjectBase<DashboardGridItemState> implements SceneGridItemLike {
private _prevRepeatValues?: VariableValueSingle[]; private _prevRepeatValues?: VariableValueSingle[];
private _oldBody?: VizPanel; private _prevPanelState: VizPanelState | undefined;
private _prevGridItemState: DashboardGridItemState | undefined;
protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this); protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this);
@ -54,11 +55,14 @@ export class DashboardGridItem extends SceneObjectBase<DashboardGridItemState> i
private _activationHandler() { private _activationHandler() {
if (this.state.variableName) { if (this.state.variableName) {
this._subs.add(this.subscribeToState((newState, prevState) => this._handleGridResize(newState, prevState))); this._subs.add(this.subscribeToState((newState, prevState) => this._handleGridResize(newState, prevState)));
if (this._oldBody !== this.state.body) { this.clearCachedStateIfBodyOrOptionsChanged();
this._prevRepeatValues = undefined; this.performRepeat();
}
} }
this.performRepeat(); private clearCachedStateIfBodyOrOptionsChanged() {
if (this._prevGridItemState !== this.state || this._prevPanelState !== this.state.body.state) {
this._prevRepeatValues = undefined;
} }
} }
@ -116,9 +120,6 @@ export class DashboardGridItem extends SceneObjectBase<DashboardGridItemState> i
return; return;
} }
this._oldBody = this.state.body;
this._prevRepeatValues = values;
const panelToRepeat = this.state.body; const panelToRepeat = this.state.body;
const repeatedPanels: VizPanel[] = []; 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 // Used from dashboard url sync
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); 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) { public notifyRepeatedPanelsWaitingForVariables(variable: SceneVariable) {
for (const panel of this.state.repeatedPanels ?? []) { for (const panel of this.state.repeatedPanels ?? []) {
const queryRunner = getQueryRunnerFor(panel); 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 { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { VariablesChanged } from 'app/features/variables/types'; 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 { createWorker } from '../saving/createDetectChangesWorker';
import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DecoratedRevisionModel } from '../settings/VersionsEditView';
@ -197,18 +197,16 @@ describe('DashboardScene', () => {
expect(resoredLayout.state.children.map((c) => c.state.key)).toEqual(originalPanelOrder); 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', () => { 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 panel = findVizPanelByKey(scene, 'panel-1')!;
const editPanel = buildPanelEditScene(panel!); const editPanel = buildPanelEditScene(panel!);
scene.setState({ scene.setState({ editPanel });
editPanel,
});
expect(scene.state.editPanel!['_discardChanges']).toBe(false);
panel.setState({ title: 'new title' });
scene.exitEditMode({ skipConfirm: true }); 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` it.each`
@ -1023,14 +1021,14 @@ describe('DashboardScene', () => {
panelPluginId: 'table', panelPluginId: 'table',
}); });
}); });
test('when editing', () => { test('when editing', () => {
const panel = findVizPanelByKey(scene, 'panel-1'); const panel = findVizPanelByKey(scene, 'panel-1');
const editPanel = buildPanelEditScene(panel!); const editPanel = buildPanelEditScene(panel!);
scene.setState({ scene.setState({ editPanel });
editPanel,
}); const queryRunner = editPanel.getPanel().state.$data!;
const queryRunner = (scene.state.editPanel as PanelEditor).state.vizManager.queryRunner;
expect(scene.enrichDataRequest(queryRunner)).toEqual({ expect(scene.enrichDataRequest(queryRunner)).toEqual({
app: CoreApp.Dashboard, app: CoreApp.Dashboard,
dashboardUID: 'dash-1', dashboardUID: 'dash-1',

@ -252,6 +252,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
}; };
this._changeTracker.stopTrackingChanges(); this._changeTracker.stopTrackingChanges();
this.setState({ this.setState({
version: result.version, version: result.version,
isDirty: false, isDirty: false,
@ -267,6 +268,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
}, },
}); });
this.state.editPanel?.dashboardSaved();
this._changeTracker.startTrackingChanges(); this._changeTracker.startTrackingChanges();
} }
@ -801,7 +803,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
let panel = getClosestVizPanel(sceneObject); let panel = getClosestVizPanel(sceneObject);
if (dashboard.state.isEditing && dashboard.state.editPanel) { 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; let panelId = 0;

@ -2,14 +2,7 @@ import { Unsubscribable } from 'rxjs';
import { AppEvents } from '@grafana/data'; import { AppEvents } from '@grafana/data';
import { config, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { import { SceneGridLayout, SceneObjectUrlSyncHandler, SceneObjectUrlValues, VizPanel } from '@grafana/scenes';
SceneGridLayout,
SceneObjectBase,
SceneObjectState,
SceneObjectUrlSyncHandler,
SceneObjectUrlValues,
VizPanel,
} from '@grafana/scenes';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { KioskMode } from 'app/types'; import { KioskMode } from 'app/types';
@ -18,7 +11,7 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { createDashboardEditViewFor } from '../settings/utils'; import { createDashboardEditViewFor } from '../settings/utils';
import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
import { ShareModal } from '../sharing/ShareModal'; 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 { DashboardScene, DashboardSceneState } from './DashboardScene';
import { LibraryPanelBehavior } from './LibraryPanelBehavior'; import { LibraryPanelBehavior } from './LibraryPanelBehavior';
@ -78,9 +71,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
} }
update.inspectPanelKey = values.inspect; update.inspectPanelKey = values.inspect;
update.overlay = new PanelInspectDrawer({ update.overlay = new PanelInspectDrawer({ panelRef: panel.getRef() });
$behaviors: [new ResolveInspectPanelByKey({ panelKey: values.inspect })],
});
} else if (inspectPanelKey) { } else if (inspectPanelKey) {
update.inspectPanelKey = undefined; update.inspectPanelKey = undefined;
update.overlay = 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() { private async loadLibraryPanelFromPanelModel() {
let vizPanel = this.parent; let vizPanel = this.parent;

@ -61,8 +61,8 @@ export function ToolbarActions({ dashboard }: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const isEditingPanel = Boolean(editPanel); const isEditingPanel = Boolean(editPanel);
const isViewingPanel = Boolean(viewPanelScene); const isViewingPanel = Boolean(viewPanelScene);
const isEditedPanelDirty = useVizManagerDirty(editPanel); const isEditedPanelDirty = usePanelEditDirty(editPanel);
const isEditingLibraryPanel = useEditingLibraryPanel(editPanel); const isEditingLibraryPanel = editPanel && isLibraryPanel(editPanel.state.panelRef.resolve());
const hasCopiedPanel = store.exists(LS_PANEL_COPY_KEY); const hasCopiedPanel = store.exists(LS_PANEL_COPY_KEY);
// Means we are not in settings view, fullscreen panel or edit panel // Means we are not in settings view, fullscreen panel or edit panel
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel; const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
@ -422,7 +422,7 @@ export function ToolbarActions({ dashboard }: Props) {
onClick={editPanel?.onDiscard} onClick={editPanel?.onDiscard}
tooltip={editPanel?.state.isNewPanel ? 'Discard panel' : 'Discard panel changes'} tooltip={editPanel?.state.isNewPanel ? 'Discard panel' : 'Discard panel changes'}
size="sm" size="sm"
disabled={!isEditedPanelDirty || !isDirty} disabled={!isEditedPanelDirty}
key="discard" key="discard"
fill="outline" fill="outline"
variant="destructive" variant="destructive"
@ -613,41 +613,22 @@ function addDynamicActions(
} }
} }
function useEditingLibraryPanel(panelEditor?: PanelEditor) { // This hook handles when panelEditor is not defined to avoid conditionally hook usage
const [isEditingLibraryPanel, setEditingLibraryPanel] = useState<Boolean>(false); function usePanelEditDirty(panelEditor?: PanelEditor) {
const [isDirty, setIsDirty] = useState<Boolean | undefined>();
useEffect(() => { useEffect(() => {
if (panelEditor) { if (panelEditor) {
const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) => const unsub = panelEditor.subscribeToState((state) => {
setEditingLibraryPanel(isLibraryPanel(vizManagerState.sourcePanel.resolve())) if (state.isDirty !== isDirty) {
); setIsDirty(state.isDirty);
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);
useEffect(() => { return () => unsub.unsubscribe();
if (panelEditor) {
const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) =>
setIsDirty(vizManagerState.isDirty || false)
);
return () => {
unsub.unsubscribe();
};
} }
setIsDirty(false);
return; return;
}, [panelEditor]); }, [panelEditor, isDirty]);
return isDirty; return isDirty;
} }

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

@ -12,13 +12,13 @@ export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void
export interface Props { export interface Props {
title: string; title: string;
repeat?: string; repeat?: string;
parent: SceneObject; sceneContext: SceneObject;
onUpdate: OnRowOptionsUpdate; onUpdate: OnRowOptionsUpdate;
onCancel: () => void; onCancel: () => void;
warning?: React.ReactNode; 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 [newRepeat, setNewRepeat] = useState<string | undefined>(repeat);
const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]); 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" /> <Input {...register('title')} type="text" />
</Field> </Field>
<Field label="Repeat for"> <Field label="Repeat for">
<RepeatRowSelect2 parent={parent} repeat={newRepeat} onChange={onChangeRepeat} /> <RepeatRowSelect2 sceneContext={sceneContext} repeat={newRepeat} onChange={onChangeRepeat} />
</Field> </Field>
{warning && ( {warning && (
<Alert <Alert

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

@ -15,14 +15,7 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { getPluginLinkExtensions, setPluginImportUtils } from '@grafana/runtime'; import { getPluginLinkExtensions, setPluginImportUtils } from '@grafana/runtime';
import { import { MultiValueVariable, sceneGraph, SceneGridLayout, SceneGridRow, VizPanel } from '@grafana/scenes';
MultiValueVariable,
sceneGraph,
SceneGridLayout,
SceneGridRow,
SceneTimeRange,
VizPanel,
} from '@grafana/scenes';
import { Dashboard, LoadingState, Panel, RowPanel, VariableRefresh } from '@grafana/schema'; import { Dashboard, LoadingState, Panel, RowPanel, VariableRefresh } from '@grafana/schema';
import { PanelModel } from 'app/features/dashboard/state'; import { PanelModel } from 'app/features/dashboard/state';
import { getTimeRange } from 'app/features/dashboard/utils/timeRange'; 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 { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DashboardDataDTO } from 'app/types'; import { DashboardDataDTO } from 'app/types';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardGridItem } from '../scene/DashboardGridItem'; import { DashboardGridItem } from '../scene/DashboardGridItem';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { NEW_LINK } from '../settings/links/utils'; import { NEW_LINK } from '../settings/links/utils';
@ -804,7 +795,7 @@ describe('transformSceneToSaveModel', () => {
activateFullSceneTree(scene); activateFullSceneTree(scene);
expect(repeater.state.repeatedPanels?.length).toBe(2); expect(repeater.state.repeatedPanels?.length).toBe(2);
const result = panelRepeaterToPanels(repeater, undefined, true); const result = panelRepeaterToPanels(repeater, true);
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
@ -861,7 +852,7 @@ describe('transformSceneToSaveModel', () => {
); );
activateFullSceneTree(scene); activateFullSceneTree(scene);
const result = panelRepeaterToPanels(repeater, undefined, true); const result = panelRepeaterToPanels(repeater, true);
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
@ -886,7 +877,7 @@ describe('transformSceneToSaveModel', () => {
activateFullSceneTree(scene); activateFullSceneTree(scene);
let panels: Panel[] = []; let panels: Panel[] = [];
gridRowToSaveModel(row, panels, undefined, true); gridRowToSaveModel(row, panels, true);
expect(panels).toHaveLength(2); expect(panels).toHaveLength(2);
expect(panels[0].repeat).toBe('handler'); expect(panels[0].repeat).toBe('handler');
@ -914,7 +905,7 @@ describe('transformSceneToSaveModel', () => {
activateFullSceneTree(scene); activateFullSceneTree(scene);
let panels: Panel[] = []; let panels: Panel[] = [];
gridRowToSaveModel(row, panels, undefined, true); gridRowToSaveModel(row, panels, true);
expect(panels[0].repeat).toBe('handler'); 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', () => { describe('Given a scene with repeated panels and non-repeated panels', () => {
it('should save repeated panels itemHeight as height', () => { it('should save repeated panels itemHeight as height', () => {
const scene = transformSaveModelToScene({ const scene = transformSaveModelToScene({

@ -33,7 +33,7 @@ import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardGridItem } from '../scene/DashboardGridItem'; import { DashboardGridItem } from '../scene/DashboardGridItem';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
import { PanelTimeRange } from '../scene/PanelTimeRange'; import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
@ -58,9 +58,9 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
if (child instanceof DashboardGridItem) { if (child instanceof DashboardGridItem) {
// handle panel repeater scenario // handle panel repeater scenario
if (child.state.variableName) { if (child.state.variableName) {
panels = panels.concat(panelRepeaterToPanels(child, state, isSnapshot)); panels = panels.concat(panelRepeaterToPanels(child, isSnapshot));
} else { } 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) { if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) {
continue; continue;
} }
gridRowToSaveModel(child, panels, state, isSnapshot); gridRowToSaveModel(child, panels, isSnapshot);
} }
} }
} }
@ -139,11 +139,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
return sortedDeepCloneWithoutNulls(dashboard); return sortedDeepCloneWithoutNulls(dashboard);
} }
export function gridItemToPanel( export function gridItemToPanel(gridItem: DashboardGridItem, isSnapshot = false): Panel {
gridItem: DashboardGridItem,
sceneState?: DashboardSceneState,
isSnapshot = false
): Panel {
let vizPanel: VizPanel | undefined; let vizPanel: VizPanel | undefined;
let x = 0, let x = 0,
y = 0, y = 0,
@ -152,19 +148,6 @@ export function gridItemToPanel(
let gridItem_ = gridItem; 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)) { if (!(gridItem_.state.body instanceof VizPanel)) {
throw new Error('DashboardGridItem body expected to be VizPanel'); throw new Error('DashboardGridItem body expected to be VizPanel');
} }
@ -325,13 +308,9 @@ function vizPanelDataToPanel(
return panel; return panel;
} }
export function panelRepeaterToPanels( export function panelRepeaterToPanels(repeater: DashboardGridItem, isSnapshot = false): Panel[] {
repeater: DashboardGridItem,
sceneState?: DashboardSceneState,
isSnapshot = false
): Panel[] {
if (!isSnapshot) { if (!isSnapshot) {
return [gridItemToPanel(repeater, sceneState)]; return [gridItemToPanel(repeater)];
} else { } else {
// return early if the repeated panel is a library panel // return early if the repeated panel is a library panel
if (repeater.state.body instanceof VizPanel && isLibraryPanel(repeater.state.body)) { if (repeater.state.body instanceof VizPanel && isLibraryPanel(repeater.state.body)) {
@ -388,12 +367,7 @@ export function panelRepeaterToPanels(
} }
} }
export function gridRowToSaveModel( export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array<Panel | RowPanel>, isSnapshot = false) {
gridRow: SceneGridRow,
panelsArray: Array<Panel | RowPanel>,
sceneState?: DashboardSceneState,
isSnapshot = false
) {
const collapsed = Boolean(gridRow.state.isCollapsed); const collapsed = Boolean(gridRow.state.isCollapsed);
const rowPanel: RowPanel = { const rowPanel: RowPanel = {
type: 'row', type: 'row',
@ -443,10 +417,10 @@ export function gridRowToSaveModel(
if (c instanceof DashboardGridItem) { if (c instanceof DashboardGridItem) {
if (c.state.variableName) { if (c.state.variableName) {
// Perform snapshot only for uncollapsed rows // Perform snapshot only for uncollapsed rows
panelsInsideRow = panelsInsideRow.concat(panelRepeaterToPanels(c, sceneState, !collapsed)); panelsInsideRow = panelsInsideRow.concat(panelRepeaterToPanels(c, !collapsed));
} else { } else {
// Perform snapshot only for uncollapsed panels // 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)) { if (!(c instanceof DashboardGridItem)) {
throw new Error('Row child expected to be 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); onOverrideChange(idx, override);
}; };
const onDynamicConfigValueAdd = (o: ConfigOverrideRule, value: SelectableValue<string>) => { const onDynamicConfigValueAdd = (override: ConfigOverrideRule, value: SelectableValue<string>) => {
const registryItem = registry.get(value.value!); const registryItem = registry.get(value.value!);
const propertyConfig: DynamicConfigValue = { const propertyConfig: DynamicConfigValue = {
id: registryItem.id, id: registryItem.id,
value: registryItem.defaultValue, value: registryItem.defaultValue,
}; };
if (override.properties) { const properties = override.properties ?? [];
o.properties.push(propertyConfig); properties.push(propertyConfig);
} else {
o.properties = [propertyConfig];
}
onOverrideChange(idx, o); onOverrideChange(idx, { ...override, properties });
}; };
/** /**
@ -158,13 +155,23 @@ export function getFieldOverrideCategories(
} }
const onPropertyChange = (value: DynamicConfigValue) => { const onPropertyChange = (value: DynamicConfigValue) => {
override.properties[propIdx].value = value; onOverrideChange(idx, {
onOverrideChange(idx, override); ...override,
properties: override.properties.map((prop, i) => {
if (i === propIdx) {
return { ...prop, value: value };
}
return prop;
}),
});
}; };
const onPropertyRemove = () => { const onPropertyRemove = () => {
override.properties.splice(propIdx, 1); onOverrideChange(idx, {
onOverrideChange(idx, override); ...override,
properties: override.properties.filter((_, i) => i !== propIdx),
});
}; };
/** /**

@ -44,14 +44,14 @@ export const RepeatRowSelect = ({ repeat, onChange, id }: Props) => {
}; };
interface Props2 { interface Props2 {
parent: SceneObject; sceneContext: SceneObject;
repeat: string | undefined; repeat: string | undefined;
id?: string; id?: string;
onChange: (name?: string) => void; onChange: (name?: string) => void;
} }
export const RepeatRowSelect2 = ({ parent, repeat, id, onChange }: Props2) => { export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2) => {
const sceneVars = useMemo(() => sceneGraph.getVariables(parent), [parent]); const sceneVars = useMemo(() => sceneGraph.getVariables(sceneContext.getRoot()), [sceneContext]);
const variables = sceneVars.useState().variables; const variables = sceneVars.useState().variables;
const variableOptions = useMemo(() => { const variableOptions = useMemo(() => {

@ -3,7 +3,6 @@ import { lastValueFrom } from 'rxjs';
import { VizPanel } from '@grafana/scenes'; import { VizPanel } from '@grafana/scenes';
import { LibraryPanel, defaultDashboard } from '@grafana/schema'; import { LibraryPanel, defaultDashboard } from '@grafana/schema';
import { DashboardModel } from 'app/features/dashboard/state'; 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 { DashboardGridItem } from 'app/features/dashboard-scene/scene/DashboardGridItem';
import { vizPanelToPanel } from 'app/features/dashboard-scene/serialization/transformSceneToSaveModel'; import { vizPanelToPanel } from 'app/features/dashboard-scene/serialization/transformSceneToSaveModel';
import { getLibraryPanelBehavior } from 'app/features/dashboard-scene/utils/utils'; import { getLibraryPanelBehavior } from 'app/features/dashboard-scene/utils/utils';
@ -146,10 +145,6 @@ export function libraryVizPanelToSaveModel(vizPanel: VizPanel) {
let gridItem = vizPanel.parent; let gridItem = vizPanel.parent;
if (gridItem instanceof VizPanelManager) {
gridItem = gridItem.state.sourcePanel.resolve().parent;
}
if (!gridItem || !(gridItem instanceof DashboardGridItem)) { if (!gridItem || !(gridItem instanceof DashboardGridItem)) {
throw new Error('Trying to save a library panel that does not have a DashboardGridItem parent'); 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', name: 'from history',
input: { from: '2024-07-22T18:30:00.000Z', to: '2024-07-22T19:30:00.000Z' }, 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', name: 'time change event with timezone',
@ -33,7 +33,7 @@ describe('DataTrailsHistory', () => {
}, },
])('$name', ({ input, expected }) => { ])('$name', ({ input, expected }) => {
const result = parseTimeTooltip(input); 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) { function isPanelInEdit(panelId: number, panelInEditId?: number) {
let idToCompareWith = panelInEditId; let idToCompareWith = panelInEditId;
if (window.__grafanaSceneContext && window.__grafanaSceneContext instanceof DashboardScene) { if (window.__grafanaSceneContext && window.__grafanaSceneContext instanceof DashboardScene) {
idToCompareWith = window.__grafanaSceneContext.state.editPanel?.state.panelId; idToCompareWith = window.__grafanaSceneContext.state.editPanel?.getPanelId();
} }
return panelId === idToCompareWith; return panelId === idToCompareWith;
} }

Loading…
Cancel
Save