DashboardScene: Panel edit visualization suggestions (#82296)

* DashboardScene: Panel edit visualization suggestions

* Tweak

* tweak

* Update betterer

* Update
This commit is contained in:
Torkel Ödegaard 2024-02-13 11:37:49 +01:00 committed by GitHub
parent 8a90c0f868
commit f7a425d352
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 176 additions and 121 deletions

View File

@ -2636,6 +2636,9 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard-scene/utils/test-utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],

View File

@ -51,8 +51,7 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
const { panelManager } = model;
const { panel } = panelManager.state;
const dataObject = sceneGraph.getData(panel);
const rawData = dataObject.useState();
const dataWithFieldConfig = panel.applyFieldConfig(rawData.data!);
const { data } = dataObject.useState();
const { pluginId, options, fieldConfig } = panel.useState();
const styles = useStyles2(getStyles);
const panelFrameOptions = useMemo(() => getPanelFrameCategory2(panel), [panel]);
@ -77,7 +76,7 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
getFieldOverrideCategories(
fieldConfig,
panel.getPlugin()?.fieldConfigRegistry!,
dataWithFieldConfig.series,
data?.series ?? [],
searchQuery,
(newConfig) => {
panel.setState({
@ -135,7 +134,9 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
/>
</Box>
)}
{isVizPickerOpen && <PanelVizTypePicker panelManager={panelManager} onChange={model.onToggleVizPicker} />}
{isVizPickerOpen && (
<PanelVizTypePicker panelManager={panelManager} onChange={model.onToggleVizPicker} data={data} />
)}
{!isVizPickerOpen && (
<>
<div className={styles.top}>

View File

@ -1,26 +1,52 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneObjectState } from '@grafana/scenes';
import { CustomScrollbar, FilterInput, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data';
import { CustomScrollbar, Field, FilterInput, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { LS_VISUALIZATION_SELECT_TAB_KEY, LS_WIDGET_SELECT_TAB_KEY } from 'app/core/constants';
import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types';
import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions';
import { VizTypePicker } from 'app/features/panel/components/VizTypePicker/VizTypePicker';
import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types';
import { PanelModelCompatibilityWrapper } from '../utils/PanelModelCompatibilityWrapper';
import { VizPanelManager } from './VizPanelManager';
export interface PanelVizTypePickerState extends SceneObjectState {}
export function PanelVizTypePicker({
panelManager,
onChange,
}: {
export interface Props {
data?: PanelData;
panelManager: VizPanelManager;
onChange: () => void;
}) {
}
export function PanelVizTypePicker({ panelManager, data, onChange }: Props) {
const { panel } = panelManager.useState();
const styles = useStyles2(getStyles);
const [searchQuery, setSearchQuery] = useState('');
const isWidgetEnabled = false;
const tabKey = isWidgetEnabled ? LS_WIDGET_SELECT_TAB_KEY : LS_VISUALIZATION_SELECT_TAB_KEY;
const defaultTab = isWidgetEnabled ? VisualizationSelectPaneTab.Widgets : VisualizationSelectPaneTab.Visualizations;
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
const [listMode, setListMode] = useLocalStorage(tabKey, defaultTab);
const radioOptions: Array<SelectableValue<VisualizationSelectPaneTab>> = [
{ label: 'Visualizations', value: VisualizationSelectPaneTab.Visualizations },
{ label: 'Suggestions', value: VisualizationSelectPaneTab.Suggestions },
// {
// label: 'Library panels',
// value: VisualizationSelectPaneTab.LibraryPanels,
// description: 'Reusable panels you can share between multiple dashboards.',
// },
];
const onVizTypeChange = (options: VizTypeChangeDetails) => {
panelManager.changePluginType(options.pluginId);
onChange();
};
return (
<div className={styles.wrapper}>
<FilterInput
@ -30,15 +56,24 @@ export function PanelVizTypePicker({
autoFocus={true}
placeholder="Search for..."
/>
<CustomScrollbar>
<VizTypePicker
pluginId={panel.state.pluginId}
searchQuery={searchQuery}
onChange={(options) => {
panelManager.changePluginType(options.pluginId);
onChange();
}}
/>
<Field className={styles.customFieldMargin}>
<RadioButtonGroup options={radioOptions} value={listMode} onChange={setListMode} fullWidth />
</Field>
<CustomScrollbar autoHeightMin="100%">
{listMode === VisualizationSelectPaneTab.Visualizations && (
<VizTypePicker pluginId={panel.state.pluginId} searchQuery={searchQuery} onChange={onVizTypeChange} />
)}
{/* {listMode === VisualizationSelectPaneTab.Widgets && (
<VizTypePicker pluginId={plugin.meta.id} onChange={onVizChange} searchQuery={searchQuery} isWidget />
)} */}
{listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions
onChange={onVizTypeChange}
searchQuery={searchQuery}
panel={panelModel}
data={data}
/>
)}
</CustomScrollbar>
</div>
);
@ -57,6 +92,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
borderBottom: 'none',
borderTopLeftRadius: theme.shape.radius.default,
}),
customFieldMargin: css({
marginBottom: theme.spacing(1),
}),
filter: css({
minHeight: theme.spacing(4),
}),

View File

@ -5,7 +5,6 @@ import { TimeRangeUpdatedEvent } from '@grafana/runtime';
import {
behaviors,
SceneDataLayers,
SceneDataTransformer,
sceneGraph,
SceneGridItem,
SceneGridLayout,
@ -13,14 +12,13 @@ import {
SceneObject,
VizPanel,
} from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { dataLayersToAnnotations } from '../serialization/dataLayersToAnnotations';
import { PanelModelCompatibilityWrapper } from './PanelModelCompatibilityWrapper';
import { dashboardSceneGraph } from './dashboardSceneGraph';
import { findVizPanelByKey, getPanelIdForVizPanel, getQueryRunnerFor, getVizPanelKeyForPanelId } from './utils';
import { findVizPanelByKey, getVizPanelKeyForPanelId } from './utils';
/**
* Will move this to make it the main way we remain somewhat compatible with getDashboardSrv().getCurrent
@ -107,7 +105,7 @@ export class DashboardModelCompatibilityWrapper {
const panels = findAllObjects(this._scene, (o) => {
return Boolean(o instanceof VizPanel);
});
return panels.map((p) => new PanelCompatibilityWrapper(p as VizPanel));
return panels.map((p) => new PanelModelCompatibilityWrapper(p as VizPanel));
}
/**
@ -159,10 +157,10 @@ export class DashboardModelCompatibilityWrapper {
});
}
public getPanelById(id: number): PanelCompatibilityWrapper | null {
public getPanelById(id: number): PanelModelCompatibilityWrapper | null {
const vizPanel = findVizPanelByKey(this._scene, getVizPanelKeyForPanelId(id));
if (vizPanel) {
return new PanelCompatibilityWrapper(vizPanel);
return new PanelModelCompatibilityWrapper(vizPanel);
}
return null;
@ -171,7 +169,7 @@ export class DashboardModelCompatibilityWrapper {
/**
* Mainly implemented to support Getting started panel's dissmis button.
*/
public removePanel(panel: PanelCompatibilityWrapper) {
public removePanel(panel: PanelModelCompatibilityWrapper) {
const vizPanel = findVizPanelByKey(this._scene, getVizPanelKeyForPanelId(panel.id));
if (!vizPanel) {
console.error('Trying to remove a panel that was not found in scene', panel);
@ -237,65 +235,6 @@ export class DashboardModelCompatibilityWrapper {
}
}
class PanelCompatibilityWrapper {
constructor(private _vizPanel: VizPanel) {}
public get id() {
const id = getPanelIdForVizPanel(
this._vizPanel.parent instanceof LibraryVizPanel ? this._vizPanel.parent : this._vizPanel
);
if (isNaN(id)) {
console.error('VizPanel key could not be translated to a legacy numeric panel id', this._vizPanel);
return 0;
}
return id;
}
public get type() {
return this._vizPanel.state.pluginId;
}
public get title() {
return this._vizPanel.state.title;
}
public get transformations() {
if (this._vizPanel.state.$data instanceof SceneDataTransformer) {
return this._vizPanel.state.$data.state.transformations;
}
return [];
}
public get targets() {
const queryRunner = getQueryRunnerFor(this._vizPanel);
if (!queryRunner) {
return [];
}
return queryRunner.state.queries;
}
public get datasource(): DataSourceRef | null | undefined {
const queryRunner = getQueryRunnerFor(this._vizPanel);
return queryRunner?.state.datasource;
}
public refresh() {
console.error('Scenes PanelCompatibilityWrapper.refresh no implemented (yet)');
}
public render() {
console.error('Scenes PanelCompatibilityWrapper.render no implemented (yet)');
}
public getQueryRunner() {
console.error('Scenes PanelCompatibilityWrapper.getQueryRunner no implemented (yet)');
}
}
function findAllObjects(root: SceneObject, check: (o: SceneObject) => boolean) {
let result: SceneObject[] = [];
root.forEachChild((child) => {

View File

@ -0,0 +1,70 @@
import { PanelModel } from '@grafana/data';
import { SceneDataTransformer, VizPanel } from '@grafana/scenes';
import { DataSourceRef, DataTransformerConfig } from '@grafana/schema';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { getPanelIdForVizPanel, getQueryRunnerFor } from './utils';
export class PanelModelCompatibilityWrapper implements PanelModel {
constructor(private _vizPanel: VizPanel) {}
public get id() {
const id = getPanelIdForVizPanel(
this._vizPanel.parent instanceof LibraryVizPanel ? this._vizPanel.parent : this._vizPanel
);
if (isNaN(id)) {
console.error('VizPanel key could not be translated to a legacy numeric panel id', this._vizPanel);
return 0;
}
return id;
}
public get description() {
return this._vizPanel.state.description;
}
public get type() {
return this._vizPanel.state.pluginId;
}
public get title() {
return this._vizPanel.state.title;
}
public get transformations() {
if (this._vizPanel.state.$data instanceof SceneDataTransformer) {
return this._vizPanel.state.$data.state.transformations as DataTransformerConfig[];
}
return [];
}
public get targets() {
const queryRunner = getQueryRunnerFor(this._vizPanel);
if (!queryRunner) {
return [];
}
return queryRunner.state.queries;
}
public get datasource(): DataSourceRef | null | undefined {
const queryRunner = getQueryRunnerFor(this._vizPanel);
return queryRunner?.state.datasource;
}
public get options() {
return this._vizPanel.state.options;
}
public get fieldConfig() {
return this._vizPanel.state.fieldConfig;
}
public get pluginVersion() {
return this._vizPanel.state.pluginVersion;
}
}

View File

@ -24,39 +24,43 @@ export function VisualizationSuggestions({ searchQuery, onChange, data, panel }:
const filteredSuggestions = filterSuggestionsBySearch(searchQuery, suggestions);
return (
<AutoSizer disableHeight style={{ width: '100%', height: '100%' }}>
{({ width }) => {
if (!width) {
return null;
}
// This div is needed in some places to make AutoSizer work
<div>
<AutoSizer disableHeight style={{ width: '100%', height: '100%' }}>
{({ width }) => {
if (!width) {
return null;
}
const columnCount = Math.floor(width / 170);
const spaceBetween = 8 * (columnCount! - 1);
const previewWidth = (width - spaceBetween) / columnCount!;
width = width - 1;
const columnCount = Math.floor(width / 200);
const spaceBetween = 8 * (columnCount! - 1);
const previewWidth = Math.floor((width - spaceBetween) / columnCount!);
return (
<div>
<div className={styles.filterRow}>
<div className={styles.infoText}>Based on current data</div>
return (
<div>
<div className={styles.filterRow}>
<div className={styles.infoText}>Based on current data</div>
</div>
<div className={styles.grid} style={{ gridTemplateColumns: `repeat(auto-fill, ${previewWidth}px)` }}>
{filteredSuggestions.map((suggestion, index) => (
<VisualizationSuggestionCard
key={index}
data={data!}
suggestion={suggestion}
onChange={onChange}
width={previewWidth - 1}
/>
))}
{searchQuery && filteredSuggestions.length === 0 && (
<div className={styles.infoText}>No results matched your query</div>
)}
</div>
</div>
<div className={styles.grid} style={{ gridTemplateColumns: `repeat(auto-fill, ${previewWidth - 1}px)` }}>
{filteredSuggestions.map((suggestion, index) => (
<VisualizationSuggestionCard
key={index}
data={data!}
suggestion={suggestion}
onChange={onChange}
width={previewWidth}
/>
))}
{searchQuery && filteredSuggestions.length === 0 && (
<div className={styles.infoText}>No results matched your query</div>
)}
</div>
</div>
);
}}
</AutoSizer>
);
}}
</AutoSizer>
</div>
);
}