diff --git a/.betterer.results b/.betterer.results index 553a6b1ae58..86d3ab34569 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index b63244c728c..278982ecd16 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -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; sourcePanelRef: SceneObjectRef; - panelRef: SceneObjectRef; + panelRef: SceneObjectRef; } export class PanelEditor extends SceneObjectBase { @@ -111,12 +112,13 @@ export class PanelEditor extends SceneObjectBase { 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)], }), }), }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index 32ca6be4802..e76751a1414 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -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 { - 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) => { - 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, }), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx new file mode 100644 index 00000000000..0899d3df1e2 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -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 { + public constructor(public panelManager: VizPanelManager) { + super({}); + } + + static Component = ({ model }: SceneComponentProps) => { + const { panelManager } = model; + const { panel } = panelManager.useState(); + const styles = useStyles2(getStyles); + const [searchQuery, setSearchQuery] = useState(''); + + return ( + +
+ + { + panelManager.changePluginType(options.pluginId); + }} + /> +
+
+ ); + }; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }), +}); diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx new file mode 100644 index 00000000000..2a74318ad32 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx @@ -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> = { 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; +} diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx new file mode 100644 index 00000000000..0fe2b61b01f --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx @@ -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 { + private _cachedPluginOptions: Record< + string, + { options: DeepPartial<{}>; fieldConfig: FieldConfigSource> } | 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) => { + const { panel } = model.useState(); + + return ; + }; +} diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 6cfa50f696c..b30d0a58752 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -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; } diff --git a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx index ade128c9b95..972e6f82edb 100644 --- a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx +++ b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx @@ -115,34 +115,14 @@ export const VisualizationSelectPane = ({ panel, data }: Props) => {
{listMode === VisualizationSelectPaneTab.Visualizations && ( - {}} - /> + )} {listMode === VisualizationSelectPaneTab.Widgets && ( - {}} - isWidget - /> + )} {listMode === VisualizationSelectPaneTab.Suggestions && ( - {}} - /> + )} {listMode === VisualizationSelectPaneTab.LibraryPanels && ( 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); diff --git a/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx b/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx index 61e575ce82b..cf7226261ae 100644 --- a/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx +++ b/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx @@ -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 Could not find anything matching your query; @@ -41,16 +37,16 @@ export function VizTypePicker({ searchQuery, onChange, current, data, isWidget = return (
- {filteredPluginTypes.map((plugin, index) => ( + {filteredPluginTypes.map((plugin) => ( 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), + }), +}); diff --git a/public/app/features/panel/state/util.ts b/public/app/features/panel/state/util.ts index d4892847ed7..87f674479a8 100644 --- a/public/app/features/panel/state/util.ts +++ b/public/app/features/panel/state/util.ts @@ -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; } diff --git a/public/app/features/panel/state/utils.test.ts b/public/app/features/panel/state/utils.test.ts index d08da0b2194..b6e49d15031 100644 --- a/public/app/features/panel/state/utils.test.ts +++ b/public/app/features/panel/state/utils.test.ts @@ -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']); }); });