Scenes: Add ability to change panel viz type (#78477)

* Scenes: Add ability to change panel viz type

---------

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
kay delaney
2023-12-06 16:14:54 +00:00
committed by GitHub
parent 9ab8292949
commit 7a38a2e48b
13 changed files with 273 additions and 83 deletions

View File

@ -4179,9 +4179,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"]
],
"public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/panel/panellinks/linkSuppliers.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -4,7 +4,6 @@ import { NavIndex } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import {
getUrlSyncManager,
SceneFlexItem,
SceneFlexLayout,
SceneObject,
SceneObjectBase,
@ -20,6 +19,8 @@ import { getDashboardUrl } from '../utils/urlBuilders';
import { PanelEditorRenderer } from './PanelEditorRenderer';
import { PanelOptionsPane } from './PanelOptionsPane';
import { PanelVizTypePicker } from './PanelVizTypePicker';
import { VizPanelManager } from './VizPanelManager';
export interface PanelEditorState extends SceneObjectState {
body: SceneObject;
@ -32,7 +33,7 @@ export interface PanelEditorState extends SceneObjectState {
dashboardRef: SceneObjectRef<DashboardScene>;
sourcePanelRef: SceneObjectRef<VizPanel>;
panelRef: SceneObjectRef<VizPanel>;
panelRef: SceneObjectRef<VizPanelManager>;
}
export class PanelEditor extends SceneObjectBase<PanelEditorState> {
@ -111,12 +112,13 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
export function buildPanelEditScene(dashboard: DashboardScene, panel: VizPanel): PanelEditor {
const panelClone = panel.clone();
const vizPanelMgr = new VizPanelManager(panelClone);
const dashboardStateCloned = sceneUtils.cloneSceneObjectState(dashboard.state);
return new PanelEditor({
dashboardRef: dashboard.getRef(),
sourcePanelRef: panel.getRef(),
panelRef: panelClone.getRef(),
panelRef: vizPanelMgr.getRef(),
controls: dashboardStateCloned.controls,
$variables: dashboardStateCloned.$variables,
$timeRange: dashboardStateCloned.$timeRange,
@ -124,11 +126,11 @@ export function buildPanelEditScene(dashboard: DashboardScene, panel: VizPanel):
direction: 'row',
primary: new SceneFlexLayout({
direction: 'column',
children: [panelClone],
children: [vizPanelMgr],
}),
secondary: new SceneFlexItem({
width: '300px',
body: new PanelOptionsPane(panelClone),
secondary: new SceneFlexLayout({
direction: 'column',
children: [new PanelOptionsPane(vizPanelMgr), new PanelVizTypePicker(vizPanelMgr)],
}),
}),
});

View File

@ -2,22 +2,25 @@ import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { Field, Input, useStyles2 } from '@grafana/ui';
import { VizPanelManager } from './VizPanelManager';
export interface PanelOptionsPaneState extends SceneObjectState {}
export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
public panel: VizPanel;
public panelManager: VizPanelManager;
public constructor(panel: VizPanel) {
public constructor(panelMgr: VizPanelManager) {
super({});
this.panel = panel;
this.panelManager = panelMgr;
}
static Component = ({ model }: SceneComponentProps<PanelOptionsPane>) => {
const { panel } = model;
const { panelManager } = model;
const { panel } = panelManager.state;
const { title } = panel.useState();
const styles = useStyles2(getStyles);
@ -37,9 +40,6 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2),
flexBasis: '100%',
flexGrow: 1,
minHeight: 0,
}),
};
}

View File

@ -0,0 +1,47 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { CustomScrollbar, FilterInput, useStyles2 } from '@grafana/ui';
import { VizTypePicker } from 'app/features/panel/components/VizTypePicker/VizTypePicker';
import { VizPanelManager } from './VizPanelManager';
export interface PanelVizTypePickerState extends SceneObjectState {}
export class PanelVizTypePicker extends SceneObjectBase<PanelVizTypePickerState> {
public constructor(public panelManager: VizPanelManager) {
super({});
}
static Component = ({ model }: SceneComponentProps<PanelVizTypePicker>) => {
const { panelManager } = model;
const { panel } = panelManager.useState();
const styles = useStyles2(getStyles);
const [searchQuery, setSearchQuery] = useState('');
return (
<CustomScrollbar autoHeightMin="100%">
<div className={styles.wrapper}>
<FilterInput value={searchQuery} onChange={setSearchQuery} autoFocus={true} placeholder="Search for..." />
<VizTypePicker
pluginId={panel.state.pluginId}
searchQuery={searchQuery}
onChange={(options) => {
panelManager.changePluginType(options.pluginId);
}}
/>
</div>
</CustomScrollbar>
);
};
}
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
});

View File

@ -0,0 +1,77 @@
import { FieldConfigSource } from '@grafana/data';
import { DeepPartial, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { VizPanelManager } from './VizPanelManager';
describe('VizPanelManager', () => {
describe('changePluginType', () => {
it('Should successfully change from one viz type to another', () => {
const vizPanelManager = getVizPanelManager();
expect(vizPanelManager.state.panel.state.pluginId).toBe('table');
vizPanelManager.changePluginType('timeseries');
expect(vizPanelManager.state.panel.state.pluginId).toBe('timeseries');
});
it('Should clear custom options', () => {
const overrides = [
{
matcher: { id: 'matcherOne' },
properties: [{ id: 'custom.propertyOne' }, { id: 'custom.propertyTwo' }, { id: 'standardProperty' }],
},
];
const vizPanelManager = getVizPanelManager(undefined, {
defaults: {
custom: 'Custom',
},
overrides,
});
expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toBe('Custom');
expect(vizPanelManager.state.panel.state.fieldConfig.overrides).toBe(overrides);
vizPanelManager.changePluginType('timeseries');
expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toStrictEqual({});
expect(vizPanelManager.state.panel.state.fieldConfig.overrides[0].properties).toHaveLength(1);
expect(vizPanelManager.state.panel.state.fieldConfig.overrides[0].properties[0].id).toBe('standardProperty');
});
it('Should restore cached options/fieldConfig if they exist', () => {
const vizPanelManager = getVizPanelManager(
{
customOption: 'A',
},
{ defaults: { custom: 'Custom' }, overrides: [] }
);
vizPanelManager.changePluginType('timeseries');
//@ts-ignore
expect(vizPanelManager.state.panel.state.options['customOption']).toBeUndefined();
expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toStrictEqual({});
vizPanelManager.changePluginType('table');
//@ts-ignore
expect(vizPanelManager.state.panel.state.options['customOption']).toBe('A');
expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toBe('Custom');
});
});
});
function getVizPanelManager(
options: {} = {},
fieldConfig: FieldConfigSource<DeepPartial<{}>> = { overrides: [], defaults: {} }
) {
const vizPanel = new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
options,
fieldConfig,
});
const vizPanelManager = new VizPanelManager(vizPanel);
return vizPanelManager;
}

View File

@ -0,0 +1,89 @@
import React from 'react';
import {
FieldConfigSource,
PanelModel,
filterFieldConfigOverrides,
isStandardFieldProp,
restoreCustomOverrideRules,
} from '@grafana/data';
import {
SceneObjectState,
VizPanel,
SceneObjectBase,
SceneComponentProps,
sceneUtils,
DeepPartial,
} from '@grafana/scenes';
import { getPluginVersion } from 'app/features/dashboard/state/PanelModel';
interface VizPanelManagerState extends SceneObjectState {
panel: VizPanel;
}
export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
private _cachedPluginOptions: Record<
string,
{ options: DeepPartial<{}>; fieldConfig: FieldConfigSource<DeepPartial<{}>> } | undefined
> = {};
public constructor(panel: VizPanel) {
super({ panel });
}
public changePluginType(pluginType: string) {
const {
options: prevOptions,
fieldConfig: prevFieldConfig,
pluginId: prevPluginId,
...restOfOldState
} = sceneUtils.cloneSceneObjectState(this.state.panel.state);
// clear custom options
let newFieldConfig = { ...prevFieldConfig };
newFieldConfig.defaults = {
...newFieldConfig.defaults,
custom: {},
};
newFieldConfig.overrides = filterFieldConfigOverrides(newFieldConfig.overrides, isStandardFieldProp);
this._cachedPluginOptions[prevPluginId] = { options: prevOptions, fieldConfig: prevFieldConfig };
const cachedOptions = this._cachedPluginOptions[pluginType]?.options;
const cachedFieldConfig = this._cachedPluginOptions[pluginType]?.fieldConfig;
if (cachedFieldConfig) {
newFieldConfig = restoreCustomOverrideRules(newFieldConfig, cachedFieldConfig);
}
const newPanel = new VizPanel({
options: cachedOptions ?? {},
fieldConfig: newFieldConfig,
pluginId: pluginType,
...restOfOldState,
});
const newPlugin = newPanel.getPlugin();
const panel: PanelModel = {
title: newPanel.state.title,
options: newPanel.state.options,
fieldConfig: newPanel.state.fieldConfig,
id: 1,
type: pluginType,
};
const newOptions = newPlugin?.onPanelTypeChanged?.(panel, prevPluginId, prevOptions, prevFieldConfig);
if (newOptions) {
newPanel.onOptionsChange(newOptions, true);
}
if (newPlugin?.onPanelMigration) {
newPanel.setState({ pluginVersion: getPluginVersion(newPlugin) });
}
this.setState({ panel: newPanel });
}
public static Component = ({ model }: SceneComponentProps<VizPanelManager>) => {
const { panel } = model.useState();
return <panel.Component model={panel} />;
};
}

View File

@ -9,6 +9,7 @@ import {
} from '@grafana/scenes';
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
import { PanelEditor } from '../panel-edit/PanelEditor';
import { DashboardScene } from '../scene/DashboardScene';
export function getVizPanelKeyForPanelId(panelId: number) {
@ -164,6 +165,11 @@ export function getQueryRunnerFor(sceneObject: SceneObject | undefined): SceneQu
export function getDashboardSceneFor(sceneObject: SceneObject): DashboardScene {
const root = sceneObject.getRoot();
if (root instanceof PanelEditor) {
return root.state.dashboardRef.resolve();
}
if (root instanceof DashboardScene) {
return root;
}

View File

@ -115,34 +115,14 @@ export const VisualizationSelectPane = ({ panel, data }: Props) => {
<CustomScrollbar autoHeightMin="100%">
<div className={styles.scrollContent}>
{listMode === VisualizationSelectPaneTab.Visualizations && (
<VizTypePicker
current={plugin.meta}
onChange={onVizChange}
searchQuery={searchQuery}
data={data}
onClose={() => {}}
/>
<VizTypePicker pluginId={plugin.meta.id} onChange={onVizChange} searchQuery={searchQuery} />
)}
{listMode === VisualizationSelectPaneTab.Widgets && (
<VizTypePicker
current={plugin.meta}
onChange={onVizChange}
searchQuery={searchQuery}
data={data}
onClose={() => {}}
isWidget
/>
<VizTypePicker pluginId={plugin.meta.id} onChange={onVizChange} searchQuery={searchQuery} isWidget />
)}
{listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions
current={plugin.meta}
onChange={onVizChange}
searchQuery={searchQuery}
panel={panel}
data={data}
onClose={() => {}}
/>
<VisualizationSuggestions onChange={onVizChange} searchQuery={searchQuery} panel={panel} data={data} />
)}
{listMode === VisualizationSelectPaneTab.LibraryPanels && (
<PanelLibraryOptionsGroup

View File

@ -694,7 +694,7 @@ export class PanelModel implements DataConfigSource, IPanelModel {
}
}
function getPluginVersion(plugin: PanelPlugin): string {
export function getPluginVersion(plugin: PanelPlugin): string {
return plugin && plugin.meta.info.version ? plugin.meta.info.version : config.buildInfo.version;
}

View File

@ -3,7 +3,7 @@ import React from 'react';
import { useAsync } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2, PanelData, PanelPluginMeta, PanelModel, VisualizationSuggestion } from '@grafana/data';
import { GrafanaTheme2, PanelData, PanelModel, VisualizationSuggestion } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { getAllSuggestions } from '../../state/getAllSuggestions';
@ -12,15 +12,13 @@ import { VisualizationSuggestionCard } from './VisualizationSuggestionCard';
import { VizTypeChangeDetails } from './types';
export interface Props {
current: PanelPluginMeta;
searchQuery: string;
onChange: (options: VizTypeChangeDetails) => void;
data?: PanelData;
panel?: PanelModel;
onChange: (options: VizTypeChangeDetails) => void;
searchQuery: string;
onClose: () => void;
}
export function VisualizationSuggestions({ onChange, data, panel, searchQuery }: Props) {
export function VisualizationSuggestions({ searchQuery, onChange, data, panel }: Props) {
const styles = useStyles2(getStyles);
const { value: suggestions } = useAsync(() => getAllSuggestions(data, panel), [data, panel]);
const filteredSuggestions = filterSuggestionsBySearch(searchQuery, suggestions);

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import { GrafanaTheme2, PanelData, PanelPluginMeta } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { EmptySearchResult, useStyles2 } from '@grafana/ui';
@ -11,29 +11,25 @@ import { VizTypePickerPlugin } from './VizTypePickerPlugin';
import { VizTypeChangeDetails } from './types';
export interface Props {
current: PanelPluginMeta;
data?: PanelData;
onChange: (options: VizTypeChangeDetails) => void;
pluginId: string;
searchQuery: string;
onClose: () => void;
onChange: (options: VizTypeChangeDetails) => void;
isWidget?: boolean;
}
export function VizTypePicker({ searchQuery, onChange, current, data, isWidget = false }: Props) {
export function VizTypePicker({ pluginId, searchQuery, onChange, isWidget = false }: Props) {
const styles = useStyles2(getStyles);
const pluginsList: PanelPluginMeta[] = useMemo(() => {
const pluginsList = useMemo(() => {
if (config.featureToggles.vizAndWidgetSplit) {
if (isWidget) {
return getWidgetPluginMeta();
}
return getVizPluginMeta();
return isWidget ? getWidgetPluginMeta() : getVizPluginMeta();
}
return getAllPanelPluginMeta();
}, [isWidget]);
const filteredPluginTypes = useMemo((): PanelPluginMeta[] => {
return filterPluginList(pluginsList, searchQuery, current);
}, [current, pluginsList, searchQuery]);
const filteredPluginTypes = useMemo(
() => filterPluginList(pluginsList, searchQuery, pluginId),
[pluginsList, searchQuery, pluginId]
);
if (filteredPluginTypes.length === 0) {
return <EmptySearchResult>Could not find anything matching your query</EmptySearchResult>;
@ -41,16 +37,16 @@ export function VizTypePicker({ searchQuery, onChange, current, data, isWidget =
return (
<div className={styles.grid}>
{filteredPluginTypes.map((plugin, index) => (
{filteredPluginTypes.map((plugin) => (
<VizTypePickerPlugin
disabled={false}
key={plugin.id}
isCurrent={plugin.id === current.id}
isCurrent={plugin.id === pluginId}
plugin={plugin}
onClick={(e) =>
onChange({
pluginId: plugin.id,
withModKey: Boolean(e.metaKey || e.ctrlKey || e.altKey),
withModKey: e.metaKey || e.ctrlKey || e.altKey,
})
}
/>
@ -59,16 +55,14 @@ export function VizTypePicker({ searchQuery, onChange, current, data, isWidget =
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
grid: css`
max-width: 100%;
display: grid;
grid-gap: ${theme.spacing(0.5)};
`,
heading: css({
...theme.typography.h5,
margin: theme.spacing(0, 0.5, 1),
}),
};
};
const getStyles = (theme: GrafanaTheme2) => ({
grid: css({
maxWidth: '100%',
display: 'grid',
gridGap: theme.spacing(0.5),
}),
heading: css({
...theme.typography.h5,
margin: theme.spacing(0, 0.5, 1),
}),
});

View File

@ -21,12 +21,12 @@ export function getVizPluginMeta(): PanelPluginMeta[] {
export function filterPluginList(
pluginsList: PanelPluginMeta[],
searchQuery: string, // Note: this will be an escaped regex string as it comes from `FilterInput`
current?: PanelPluginMeta
pluginId?: string
): PanelPluginMeta[] {
if (!searchQuery.length) {
return pluginsList.filter((p) => {
if (p.state === PluginState.deprecated) {
return current?.id === p.id;
return pluginId === p.id;
}
return true;
});
@ -38,7 +38,7 @@ export function filterPluginList(
const isGraphQuery = 'graph'.startsWith(query);
for (const item of pluginsList) {
if (item.state === PluginState.deprecated && current?.id !== item.id) {
if (item.state === PluginState.deprecated && pluginId !== item.id) {
continue;
}

View File

@ -9,7 +9,7 @@ describe('panel state utils', () => {
{ id: 'timeseries', name: 'Graph (old)' },
{ id: 'timeline', name: 'Timeline' },
] as PanelPluginMeta[];
const found = filterPluginList(pluginsList, escapeStringForRegex('gra'), { id: 'xyz' } as PanelPluginMeta);
const found = filterPluginList(pluginsList, escapeStringForRegex('gra'), 'xyz');
expect(found.map((v) => v.id)).toEqual(['graph', 'timeseries']);
});
@ -20,7 +20,7 @@ describe('panel state utils', () => {
{ id: 'timeline', name: 'Timeline' },
{ id: 'panelwithdashes', name: 'Panel-With-Dashes' },
] as PanelPluginMeta[];
const found = filterPluginList(pluginsList, escapeStringForRegex('panel-'), { id: 'xyz' } as PanelPluginMeta);
const found = filterPluginList(pluginsList, escapeStringForRegex('panel-'), 'xyz');
expect(found.map((v) => v.id)).toEqual(['panelwithdashes']);
});
});