VizSuggestions: Empty state (#114049)

* newVizSuggestions feature toggle

* panel empty state

* add data check

* select first suggestion

* update text

* PanelEmptyState component

* add test

* ?

* remove fake translation

* updates

* move empty state to UnconfiguredPanel
This commit is contained in:
Adela Almasan
2025-11-21 13:42:41 -05:00
committed by GitHub
parent 0e460a267e
commit 8cf777b5e8
5 changed files with 144 additions and 13 deletions
@@ -13,6 +13,7 @@ import {
SceneObjectState, SceneObjectState,
SceneObjectStateChangedEvent, SceneObjectStateChangedEvent,
SceneQueryRunner, SceneQueryRunner,
sceneGraph,
sceneUtils, sceneUtils,
VizPanel, VizPanel,
isSceneObject, isSceneObject,
@@ -21,6 +22,7 @@ import { Panel } from '@grafana/schema';
import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions';
import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard';
import { saveLibPanel } from 'app/features/library-panels/state/api'; import { saveLibPanel } from 'app/features/library-panels/state/api';
import { getAllSuggestions } from 'app/features/panel/suggestions/getAllSuggestions';
import { DashboardEditActionEvent } from '../edit-pane/shared'; import { DashboardEditActionEvent } from '../edit-pane/shared';
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
@@ -29,6 +31,7 @@ import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem'; import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { PanelModelCompatibilityWrapper } from '../utils/PanelModelCompatibilityWrapper';
import { import {
activateSceneObjectAndParentTree, activateSceneObjectAndParentTree,
getDashboardSceneFor, getDashboardSceneFor,
@@ -84,7 +87,11 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
const panel = this.state.panelRef.resolve(); const panel = this.state.panelRef.resolve();
if (panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { if (panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) {
panel.changePluginType('timeseries'); if (config.featureToggles.newVizSuggestions) {
this._autoSelectVisualization(panel);
} else {
panel.changePluginType('timeseries');
}
} }
this._subs.add( this._subs.add(
@@ -117,6 +124,31 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
}; };
} }
private async _autoSelectVisualization(panel: VizPanel) {
const dataObject = sceneGraph.getData(panel);
this._subs.add(
dataObject.subscribeToState(async () => {
const { data } = dataObject.state;
const hasData = data && data.series && data.series.length > 0 && data.series.some((frame) => frame.length > 0);
if (hasData && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) {
const panelModel = new PanelModelCompatibilityWrapper(panel);
const suggestions = await getAllSuggestions(data, panelModel);
if (suggestions.length > 0) {
const defaultFirstSuggestion = suggestions[0];
await panel.changePluginType(
defaultFirstSuggestion.pluginId,
defaultFirstSuggestion.options,
defaultFirstSuggestion.fieldConfig
);
}
}
})
);
}
private commitChanges() { private commitChanges() {
if (!this.state.isDirty && !this._changesHaveBeenMade) { if (!this.state.isDirty && !this._changesHaveBeenMade) {
// Nothing to commit // Nothing to commit
@@ -228,12 +260,17 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
}) })
); );
const isUnconfigured = Boolean(
config.featureToggles.newVizSuggestions && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID
);
// Setup options pane // Setup options pane
this.setState({ this.setState({
optionsPane: new PanelOptionsPane({ optionsPane: new PanelOptionsPane({
panelRef: this.state.panelRef, panelRef: this.state.panelRef,
searchQuery: '', searchQuery: '',
listMode: OptionFilter.All, listMode: OptionFilter.All,
isVizPickerOpen: isUnconfigured,
}), }),
isInitializing: false, isInitializing: false,
}); });
@@ -1,11 +1,13 @@
import { useCallback, useState } from 'react'; import { css } from '@emotion/css';
import { useCallback, useEffect, useState } from 'react';
import { PanelPlugin, PanelProps } from '@grafana/data'; import { CoreApp, GrafanaTheme2, PanelPlugin, PanelProps } from '@grafana/data';
import { Trans, t } from '@grafana/i18n'; import { Trans, t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { sceneUtils } from '@grafana/scenes'; import { sceneUtils } from '@grafana/scenes';
import { Box, Button, ButtonGroup, Dropdown, Menu, Stack } from '@grafana/ui'; import { Box, Button, ButtonGroup, Dropdown, Icon, Menu, Stack, Text, usePanelContext, useStyles2 } from '@grafana/ui';
import { NEW_PANEL_TITLE } from '../../dashboard/utils/dashboard';
import { DashboardInteractions } from '../utils/interactions'; import { DashboardInteractions } from '../utils/interactions';
import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../utils/utils'; import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../utils/utils';
@@ -16,6 +18,8 @@ const UnconfiguredPanel = new PanelPlugin(UnconfiguredPanelComp);
function UnconfiguredPanelComp(props: PanelProps) { function UnconfiguredPanelComp(props: PanelProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const panelContext = usePanelContext();
const styles = useStyles2(getStyles);
const onMenuClick = useCallback((isOpen: boolean) => { const onMenuClick = useCallback((isOpen: boolean) => {
setIsOpen(isOpen); setIsOpen(isOpen);
@@ -26,14 +30,15 @@ function UnconfiguredPanelComp(props: PanelProps) {
DashboardInteractions.panelActionClicked('configure', props.id, 'panel'); DashboardInteractions.panelActionClicked('configure', props.id, 'panel');
}; };
const onUseLibraryPanel = () => { const dashboard = window.__grafanaSceneContext;
const dashboard = window.__grafanaSceneContext; const panel =
dashboard instanceof DashboardScene ? findVizPanelByKey(dashboard, getVizPanelKeyForPanelId(props.id)) : null;
if (!(dashboard instanceof DashboardScene)) { const onUseLibraryPanel = () => {
if (!dashboard || !(dashboard instanceof DashboardScene)) {
throw new Error('DashboardScene not found'); throw new Error('DashboardScene not found');
} }
const panel = findVizPanelByKey(dashboard, getVizPanelKeyForPanelId(props.id));
if (!panel) { if (!panel) {
throw new Error('Panel not found'); throw new Error('Panel not found');
} }
@@ -41,6 +46,18 @@ function UnconfiguredPanelComp(props: PanelProps) {
dashboard.onShowAddLibraryPanelDrawer(panel.getRef()); dashboard.onShowAddLibraryPanelDrawer(panel.getRef());
}; };
useEffect(() => {
if (!panel || !config.featureToggles.newVizSuggestions) {
return;
}
if (panelContext.app === CoreApp.PanelEditor) {
panel.setState({ title: '' });
} else if (!panel.state.title) {
panel.setState({ title: NEW_PANEL_TITLE });
}
}, [panel, panelContext.app]);
const MenuActions = () => ( const MenuActions = () => (
<Menu> <Menu>
<Menu.Item <Menu.Item
@@ -56,6 +73,25 @@ function UnconfiguredPanelComp(props: PanelProps) {
</Menu> </Menu>
); );
const showEmptyState = config.featureToggles.newVizSuggestions && panelContext.app === CoreApp.PanelEditor;
if (showEmptyState) {
const defaultContent = (
<Trans i18nKey="dashboard.new-panel.empty-state-message">
Run a query to visualize it here or go to all visualizations to add other panel types
</Trans>
);
return (
<div className={styles.emptyStateWrapper}>
<Icon name="chart-line" size="xxxl" className={styles.emptyStateIcon} />
<Text element="p" textAlignment="center" color="secondary">
{defaultContent}
</Text>
</div>
);
}
return ( return (
<Stack direction={'row'} alignItems={'center'} height={'100%'} justifyContent={'center'}> <Stack direction={'row'} alignItems={'center'} height={'100%'} justifyContent={'center'}>
<Box paddingBottom={2}> <Box paddingBottom={2}>
@@ -79,3 +115,20 @@ sceneUtils.registerRuntimePanelPlugin({
pluginId: UNCONFIGURED_PANEL_PLUGIN_ID, pluginId: UNCONFIGURED_PANEL_PLUGIN_ID,
plugin: UnconfiguredPanel, plugin: UnconfiguredPanel,
}); });
function getStyles(theme: GrafanaTheme2) {
return {
emptyStateWrapper: css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
textAlign: 'center',
}),
emptyStateIcon: css({
color: theme.colors.text.secondary,
marginBottom: theme.spacing(2),
}),
};
}
@@ -96,6 +96,7 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP
return null; return null;
} }
export function findEditPanel(scene: SceneObject, key: string | undefined): VizPanel | null { export function findEditPanel(scene: SceneObject, key: string | undefined): VizPanel | null {
if (!key) { if (!key) {
return null; return null;
@@ -258,8 +259,15 @@ export function getClosestVizPanel(sceneObject: SceneObject): VizPanel | null {
} }
export function getDefaultVizPanel(): VizPanel { export function getDefaultVizPanel(): VizPanel {
const defaultPluginId = config.featureToggles.dashboardNewLayouts ? UNCONFIGURED_PANEL_PLUGIN_ID : 'timeseries'; const defaultPluginId =
const newPanelTitle = t('dashboard.new-panel-title', 'New panel'); config.featureToggles.dashboardNewLayouts || config.featureToggles.newVizSuggestions
? UNCONFIGURED_PANEL_PLUGIN_ID
: 'timeseries';
const newPanelTitle =
config.featureToggles.newVizSuggestions && defaultPluginId === UNCONFIGURED_PANEL_PLUGIN_ID
? ''
: t('dashboard.new-panel-title', 'New panel');
return new VizPanel({ return new VizPanel({
title: newPanelTitle, title: newPanelTitle,
@@ -5,7 +5,8 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2, PanelData, PanelModel, PanelPluginVisualizationSuggestion } from '@grafana/data'; import { GrafanaTheme2, PanelData, PanelModel, PanelPluginVisualizationSuggestion } from '@grafana/data';
import { Trans } from '@grafana/i18n'; import { Trans } from '@grafana/i18n';
import { useStyles2 } from '@grafana/ui'; import { config } from '@grafana/runtime';
import { Icon, Text, useStyles2 } from '@grafana/ui';
import { getAllSuggestions } from '../../suggestions/getAllSuggestions'; import { getAllSuggestions } from '../../suggestions/getAllSuggestions';
@@ -31,6 +32,21 @@ export function VisualizationSuggestions({ searchQuery, onChange, data, panel, t
return result; return result;
}, [searchQuery, suggestions, trackSearch]); }, [searchQuery, suggestions, trackSearch]);
const hasData = data?.series && data.series.length > 0 && !data.series.every((frame) => frame.length === 0);
if (config.featureToggles.newVizSuggestions && !hasData && !searchQuery) {
return (
<div className={styles.emptyStateWrapper}>
<Icon name="chart-line" size="xxxl" className={styles.emptyStateIcon} />
<Text element="p" textAlignment="center" color="secondary">
<Trans i18nKey="dashboard.new-panel.suggestions.empty-state-message">
Run a query to start seeing suggested visualizations
</Trans>
</Text>
</div>
);
}
return ( return (
// This div is needed in some places to make AutoSizer work // This div is needed in some places to make AutoSizer work
<div> <div>
@@ -116,5 +132,18 @@ const getStyles = (theme: GrafanaTheme2) => {
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
justifyContent: 'space-evenly', justifyContent: 'space-evenly',
}), }),
emptyStateWrapper: css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(4),
textAlign: 'center',
minHeight: '200px',
}),
emptyStateIcon: css({
color: theme.colors.text.secondary,
marginBottom: theme.spacing(2),
}),
}; };
}; };
+5 -1
View File
@@ -5100,8 +5100,12 @@
"new-panel": { "new-panel": {
"configure-button": "Configure", "configure-button": "Configure",
"configure-button-menu": "Toggle menu", "configure-button-menu": "Toggle menu",
"empty-state-message": "Run a query to visualize it here or go to all visualizations to add other panel types",
"menu-open-panel-editor": "Configure", "menu-open-panel-editor": "Configure",
"menu-use-library-panel": "Use library panel" "menu-use-library-panel": "Use library panel",
"suggestions": {
"empty-state-message": "Run a query to start seeing suggested visualizations"
}
}, },
"new-panel-title": "New panel", "new-panel-title": "New panel",
"on-create-new-row": { "on-create-new-row": {