diff --git a/e2e-playwright/various-suite/visualization-suggestions.spec.ts b/e2e-playwright/various-suite/visualization-suggestions.spec.ts index 57ccfccc475..d01447b7eb7 100644 --- a/e2e-playwright/various-suite/visualization-suggestions.spec.ts +++ b/e2e-playwright/various-suite/visualization-suggestions.spec.ts @@ -17,10 +17,7 @@ test.describe( // Try visualization suggestions await panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click(); - await panelEditPage - .getByGrafanaSelector(selectors.components.RadioButton.container) - .filter({ hasText: 'Suggestions' }) - .click(); + await panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('Suggestions')).click(); // Verify we see suggestions const lineChartCard = panelEditPage.getByGrafanaSelector( @@ -28,12 +25,7 @@ test.describe( ); await expect(lineChartCard).toBeVisible(); - // Verify search works - const searchInput = page.getByPlaceholder('Search for...'); - await searchInput.fill('Table'); - - // Should no longer see line chart - await expect(lineChartCard).toBeHidden(); + // TODO: in this part of the test, we will change the query and the transforms and observe suggestions being updated. // Select a visualization await panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Table')).click(); diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b3eaa1feb52..e9af258606b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1955,11 +1955,6 @@ "count": 1 } }, - "public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, "public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx": { "no-restricted-syntax": { "count": 1 diff --git a/packages/grafana-data/src/types/suggestions.test.ts b/packages/grafana-data/src/types/suggestions.test.ts new file mode 100644 index 00000000000..502590952d9 --- /dev/null +++ b/packages/grafana-data/src/types/suggestions.test.ts @@ -0,0 +1,75 @@ +import { getSuggestionHash, PanelPluginVisualizationSuggestion } from './suggestions'; + +interface FakeOptions { + foo?: number; + bar?: string; +} + +interface FakeFieldOptions { + foo?: { + a: number; + b: number; + }; + bar?: string; +} + +describe('getSuggestionHash', () => { + it.each<{ + name: string; + a: Omit, 'hash'>; + b: Omit, 'hash'>; + expectEqual: boolean; + }>([ + { + name: 'should create different hashes for different pluginIds', + a: { pluginId: 'a', name: 'my suggestion' }, + b: { pluginId: 'b', name: 'my suggestion' }, + expectEqual: false, + }, + { + name: 'should create different hashes for different options', + a: { pluginId: 'a', name: 'my suggestion', options: { foo: 1 } }, + b: { pluginId: 'a', name: 'my suggestion', options: { foo: 2 } }, + expectEqual: false, + }, + { + name: 'should create different hashes for different fieldConfig', + a: { + pluginId: 'a', + name: 'my suggestion', + fieldConfig: { defaults: { custom: { bar: 'x', foo: { a: 1, b: 2 } } }, overrides: [] }, + }, + b: { + pluginId: 'a', + name: 'my suggestion', + fieldConfig: { defaults: { custom: { bar: 'y', foo: { a: 1, b: 2 } } }, overrides: [] }, + }, + expectEqual: false, + }, + { + name: 'should create same hashes for same suggestions', + a: { + pluginId: 'a', + name: 'my suggestion', + options: { foo: 1, bar: 'x' }, + fieldConfig: { defaults: { custom: { bar: 'x', foo: { a: 1, b: 2 } } }, overrides: [] }, + }, + b: { + pluginId: 'a', + name: 'my suggestion', + options: { bar: 'x', foo: 1 }, + fieldConfig: { defaults: { custom: { foo: { b: 2, a: 1 }, bar: 'x' } }, overrides: [] }, + }, + expectEqual: true, + }, + ])('$name', ({ a, b, expectEqual }) => { + const hashA = getSuggestionHash(a); + const hashB = getSuggestionHash(b); + + if (expectEqual) { + expect(hashA).toEqual(hashB); + } else { + expect(hashA).not.toEqual(hashB); + } + }); +}); diff --git a/packages/grafana-data/src/types/suggestions.ts b/packages/grafana-data/src/types/suggestions.ts index 1765e5406f2..475495cc24a 100644 --- a/packages/grafana-data/src/types/suggestions.ts +++ b/packages/grafana-data/src/types/suggestions.ts @@ -8,6 +8,47 @@ import { PanelModel } from './dashboard'; import { FieldConfigSource } from './fieldOverrides'; import { PanelData } from './panel'; +/** + * @internal + * generates a hash for a suggestion based for use by the UI. + */ +export function getSuggestionHash(suggestion: Omit): string { + return deterministicObjectHash(suggestion); +} + +function strHash(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; // Convert to 32bit integer + } + return hash.toString(36); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function deterministicObjectHash>(obj: T): string { + let result = ''; + for (const key of Object.keys(obj).sort()) { + const value = obj[key]; + if (value === undefined) { + continue; + } + result += key + ':'; + if (typeof value === 'object' && value !== null) { + result += deterministicObjectHash(value); + } else if (Array.isArray(value)) { + result += value + .map((v) => (typeof value === 'object' && value !== null ? deterministicObjectHash(v) : String(v))) + .join(','); + } else { + result += String(value); + } + result += ';'; + } + return strHash(result); +} + /** * @alpha * A suggestion for a visualization given some data. This represents the shape of the panel (including options and field config) @@ -49,6 +90,8 @@ export interface PanelPluginVisualizationSuggestion( - defaults: PanelPluginVisualizationSuggestion + defaults: Omit, 'hash'> ) { return new VisualizationSuggestionsListAppender(this.list, defaults); } @@ -127,10 +170,15 @@ export class VisualizationSuggestionsListAppender) { - this.list.push(defaultsDeep(suggestion, this.defaults)); + this.appendAll([suggestion]); } appendAll(suggestions: Array>) { - this.list.push(...suggestions.map((o) => defaultsDeep(o, this.defaults))); + this.list.push( + ...suggestions.map((s): PanelPluginVisualizationSuggestion => { + const suggestionWithDefaults = defaultsDeep(s, this.defaults); + return Object.assign(suggestionWithDefaults, { hash: getSuggestionHash(suggestionWithDefaults) }); + }) + ); } } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 1ff7eef89ca..903e871366d 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -23,6 +23,7 @@ import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/Opti import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; import { saveLibPanel } from 'app/features/library-panels/state/api'; import { getAllSuggestions } from 'app/features/panel/suggestions/getAllSuggestions'; +import { hasData } from 'app/features/panel/suggestions/utils'; import { DashboardEditActionEvent } from '../edit-pane/shared'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; @@ -130,9 +131,7 @@ export class PanelEditor extends SceneObjectBase { 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) { + if (hasData(data) && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { const panelModel = new PanelModelCompatibilityWrapper(panel); const suggestions = await getAllSuggestions(data, panelModel); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index f71d1238971..4c5b7729745 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -99,7 +99,10 @@ export class PanelOptionsPane extends SceneObjectBase { panel.onFieldConfigChange(fieldConfigWithOverrides, true); } - this.onToggleVizPicker(); + // Handle preview suggestions + if (!options.withModKey) { + this.onToggleVizPicker(); + } }; onSetSearchQuery = (searchQuery: string) => { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx index a57aac9d427..d5b236fdd58 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -1,14 +1,13 @@ import { css } from '@emotion/css'; import { debounce } from 'lodash'; -import { useEffect, useMemo, useState } from 'react'; -import { useLocalStorage } from 'react-use'; +import { useCallback, useMemo, useState } from 'react'; +import { useSessionStorage } from 'react-use'; -import { GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; +import { GrafanaTheme2, PanelData } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { VizPanel } from '@grafana/scenes'; -import { Button, Field, FilterInput, RadioButtonGroup, ScrollContainer, useStyles2 } from '@grafana/ui'; +import { FilterInput, ScrollContainer, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui'; import { LS_VISUALIZATION_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'; @@ -26,8 +25,25 @@ export interface Props { onClose: () => void; } +const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> => { + const suggestionsTab = { + label: t('dashboard-scene.panel-viz-type-picker.radio-options.label.suggestions', 'Suggestions'), + value: VisualizationSelectPaneTab.Suggestions, + }; + const allVisualizationsTab = { + label: t('dashboard-scene.panel-viz-type-picker.radio-options.label.all-visualizations', 'All visualizations'), + value: VisualizationSelectPaneTab.Visualizations, + }; + return config.featureToggles.newVizSuggestions + ? [suggestionsTab, allVisualizationsTab] + : [allVisualizationsTab, suggestionsTab]; +}; + export function PanelVizTypePicker({ panel, data, onChange, onClose }: Props) { const styles = useStyles2(getStyles); + const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]); + + /** SEARCH */ const [searchQuery, setSearchQuery] = useState(''); const trackSearch = useMemo( () => @@ -44,87 +60,74 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose }: Props) { }, 300), [] ); - const handleSearchChange = (value: string) => { setSearchQuery(value); }; - const tabKey = LS_VISUALIZATION_SELECT_TAB_KEY; - const defaultTab = VisualizationSelectPaneTab.Visualizations; - const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]); + /** TABS */ + const tabs = useMemo(getTabs, []); + const defaultTab = tabs[0].value; + const [listMode, setListMode] = useSessionStorage(LS_VISUALIZATION_SELECT_TAB_KEY, defaultTab); - const supportedListModes = useMemo( - () => new Set([VisualizationSelectPaneTab.Visualizations, VisualizationSelectPaneTab.Suggestions]), - [] + const handleListModeChange = useCallback( + (value: VisualizationSelectPaneTab) => { + reportInteraction(INTERACTION_EVENT_NAME, { + item: INTERACTION_ITEM.CHANGE_TAB, + tab: VisualizationSelectPaneTab[value], + creator_team: 'grafana_plugins_catalog', + schema_version: '1.0.0', + }); + setListMode(value); + }, + [setListMode] ); - const [listMode, setListMode] = useLocalStorage(tabKey, defaultTab); - const handleListModeChange = (value: VisualizationSelectPaneTab) => { - reportInteraction(INTERACTION_EVENT_NAME, { - item: INTERACTION_ITEM.CHANGE_TAB, - tab: VisualizationSelectPaneTab[value], - creator_team: 'grafana_plugins_catalog', - schema_version: '1.0.0', - }); - setListMode(value); - }; - - useEffect(() => { - if (listMode && !supportedListModes.has(listMode)) { - setListMode(defaultTab); - } - }, [defaultTab, listMode, setListMode, supportedListModes]); - - const radioOptions: Array> = [ - { - label: t('dashboard-scene.panel-viz-type-picker.radio-options.label.visualizations', 'Visualizations'), - value: VisualizationSelectPaneTab.Visualizations, - }, - { - label: t('dashboard-scene.panel-viz-type-picker.radio-options.label.suggestions', 'Suggestions'), - value: VisualizationSelectPaneTab.Suggestions, - }, - ]; return (
-
- -
- - - + {/*@TODO: Re-enable/move close button*/} + {/**/} + + {tabs.map((tab) => ( + handleListModeChange(tab.value)} + /> + ))} + - {listMode === VisualizationSelectPaneTab.Visualizations && ( - - )} - {listMode === VisualizationSelectPaneTab.Suggestions && ( - - )} + + {listMode === VisualizationSelectPaneTab.Suggestions && ( + + )} + {listMode === VisualizationSelectPaneTab.Visualizations && ( + <> +
+ +
+ + + )} +
); @@ -141,10 +144,18 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), searchRow: css({ display: 'flex', - marginBottom: theme.spacing(1), + marginBottom: theme.spacing(2), + }), + tabs: css({ + width: '100%', + }), + tab: css({ + flexGrow: 1, + justifyContent: 'center', + textAlign: 'center', }), closeButton: css({ - marginLeft: theme.spacing(1), + marginLeft: 'auto', }), customFieldMargin: css({ marginBottom: theme.spacing(1), diff --git a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx index ffa32a42254..779dfe3d360 100644 --- a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx +++ b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx @@ -108,7 +108,7 @@ export const VisualizationSelectPane = ({ panel, data }: Props) => { )} {listMode === VisualizationSelectPaneTab.Suggestions && ( - + )} {listMode === VisualizationSelectPaneTab.LibraryPanels && ( diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx index 32eae1afc87..7f04c8a9dea 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx @@ -1,45 +1,42 @@ import { css, cx } from '@emotion/css'; import { cloneDeep } from 'lodash'; -import { CSSProperties } from 'react'; +import { CSSProperties, HTMLAttributes } from 'react'; import { GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { Tooltip, useStyles2 } from '@grafana/ui'; import { PanelRenderer } from '../PanelRenderer'; -import { VizTypeChangeDetails } from './types'; - -export interface Props { +export interface Props extends HTMLAttributes { data: PanelData; width: number; suggestion: PanelPluginVisualizationSuggestion; - onChange: (details: VizTypeChangeDetails) => void; + isSelected?: boolean; } -export function VisualizationSuggestionCard({ data, suggestion, onChange, width }: Props) { +export function VisualizationSuggestionCard({ data, suggestion, width, isSelected = false, onClick }: Props) { const styles = useStyles2(getStyles); const { innerStyles, outerStyles, renderWidth, renderHeight } = getPreviewDimensionsAndStyles(width); const cardOptions = suggestion.cardOptions ?? {}; + const isNewVizSuggestionsEnabled = config.featureToggles.newVizSuggestions; const commonButtonProps = { 'aria-label': suggestion.name, - className: styles.vizBox, + className: cx(styles.vizBox, isNewVizSuggestionsEnabled && isSelected && styles.selectedBox), 'data-testid': selectors.components.VisualizationPreview.card(suggestion.name), style: outerStyles, - onClick: () => { - onChange({ - pluginId: suggestion.pluginId, - options: suggestion.options, - fieldConfig: suggestion.fieldConfig, - }); - }, + onClick, }; if (cardOptions.imgSrc) { return ( - @@ -100,6 +97,10 @@ const getStyles = (theme: GrafanaTheme2) => { background: theme.colors.background.secondary, }, }), + selectedBox: css({ + border: `2px solid ${theme.colors.primary.main}`, + boxShadow: `0 0 0 1px ${theme.colors.primary.main}`, + }), imgBox: css({ display: 'flex', flexDirection: 'column', diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index 46232beff41..ad4583fc55f 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -1,118 +1,137 @@ import { css } from '@emotion/css'; -import { useMemo } from 'react'; -import { useAsync } from 'react-use'; +import { useState, useEffect, useCallback } from 'react'; +import { useAsync, useMeasure } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2, PanelData, PanelModel, PanelPluginVisualizationSuggestion } from '@grafana/data'; -import { Trans } from '@grafana/i18n'; +import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; -import { Icon, Text, useStyles2 } from '@grafana/ui'; +import { Button, Icon, Text, useStyles2 } from '@grafana/ui'; +import { UNCONFIGURED_PANEL_PLUGIN_ID } from 'app/features/dashboard-scene/scene/UnconfiguredPanel'; import { getAllSuggestions } from '../../suggestions/getAllSuggestions'; +import { hasData } from '../../suggestions/utils'; import { VisualizationSuggestionCard } from './VisualizationSuggestionCard'; import { VizTypeChangeDetails } from './types'; export interface Props { - searchQuery: string; onChange: (options: VizTypeChangeDetails) => void; data?: PanelData; panel?: PanelModel; - trackSearch?: (q: string, count: number) => void; } -export function VisualizationSuggestions({ searchQuery, onChange, data, panel, trackSearch }: Props) { +const MIN_COLUMN_SIZE = 260; + +export function VisualizationSuggestions({ onChange, data, panel }: Props) { const styles = useStyles2(getStyles); const { value: suggestions } = useAsync(() => getAllSuggestions(data, panel), [data, panel]); - const filteredSuggestions = useMemo(() => { - const result = filterSuggestionsBySearch(searchQuery, suggestions); - if (trackSearch) { - trackSearch(searchQuery, result.length); + const [suggestionHash, setSuggestionHash] = useState(null); + const [firstCardRef, { width }] = useMeasure(); + const [firstCardHash, setFirstCardHash] = useState(null); + + const isNewVizSuggestionsEnabled = config.featureToggles.newVizSuggestions; + + const isUnconfiguredPanel = panel?.type === UNCONFIGURED_PANEL_PLUGIN_ID; + + const applySuggestion = useCallback( + (suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => { + onChange({ + pluginId: suggestion.pluginId, + options: suggestion.options, + fieldConfig: suggestion.fieldConfig, + withModKey: isPreview, + }); + + if (isPreview) { + setSuggestionHash(suggestion.hash); + } + }, + [onChange] + ); + + useEffect(() => { + if (!isNewVizSuggestionsEnabled || !suggestions || suggestions.length === 0) { + return; } - return result; - }, [searchQuery, suggestions, trackSearch]); - const hasData = data?.series && data.series.length > 0 && !data.series.every((frame) => frame.length === 0); + // if the first suggestion has changed, we're going to change the currently selected suggestion and + // set the firstCardHash to the new first suggestion's hash. We also choose the first suggestion if + // the previously selected suggestion is no longer present in the list. + const newFirstCardHash = suggestions?.[0]?.hash ?? null; + if (firstCardHash !== newFirstCardHash || suggestions.every((s) => s.hash !== suggestionHash)) { + applySuggestion(suggestions[0], true); + setFirstCardHash(newFirstCardHash); + return; + } + }, [suggestions, suggestionHash, firstCardHash, isNewVizSuggestionsEnabled, isUnconfiguredPanel, applySuggestion]); - if (config.featureToggles.newVizSuggestions && !hasData && !searchQuery) { - return ( -
- - - - Run a query to start seeing suggested visualizations - - -
- ); + const renderEmptyState = () => ( +
+ + + + Run a query to start seeing suggested visualizations + + +
+ ); + + if (isNewVizSuggestionsEnabled && (!data || !hasData(data))) { + return renderEmptyState(); + } + + if (!data) { + return null; } return ( // This div is needed in some places to make AutoSizer work
- {({ width }) => { - if (!width) { - return null; - } + {() => ( +
+
+ {suggestions?.map((suggestion, index) => { + const isCardSelected = isNewVizSuggestionsEnabled && suggestionHash === suggestion.hash; - width = width - 1; - const columnCount = Math.floor(width / 200); - const spaceBetween = 8 * (columnCount! - 1); - const previewWidth = Math.floor((width - spaceBetween) / columnCount!); - - return ( -
-
-
- Based on current data -
-
-
- {filteredSuggestions.map((suggestion, index) => ( - - ))} - {searchQuery && filteredSuggestions.length === 0 && ( -
- - No results matched your query - + return ( +
+ {isCardSelected && ( + + )} + applySuggestion(suggestion, isNewVizSuggestionsEnabled)} + />
- )} -
+ ); + })}
- ); - }} +
+ )}
); } -function filterSuggestionsBySearch( - searchQuery: string, - suggestions?: PanelPluginVisualizationSuggestion[] -): PanelPluginVisualizationSuggestion[] { - if (!searchQuery || !suggestions) { - return suggestions || []; - } - - const regex = new RegExp(searchQuery, 'i'); - - return suggestions.filter((s) => regex.test(s.name) || regex.test(s.pluginId)); -} - const getStyles = (theme: GrafanaTheme2) => { return { - heading: css({ - ...theme.typography.h5, - margin: theme.spacing(0, 0.5, 1), - }), filterRow: css({ display: 'flex', flexDirection: 'row', @@ -128,7 +147,7 @@ const getStyles = (theme: GrafanaTheme2) => { grid: css({ display: 'grid', gridGap: theme.spacing(1), - gridTemplateColumns: 'repeat(auto-fill, 144px)', + gridTemplateColumns: `repeat(auto-fit, minmax(${MIN_COLUMN_SIZE}px, 1fr))`, marginBottom: theme.spacing(1), justifyContent: 'space-evenly', }), @@ -145,5 +164,16 @@ const getStyles = (theme: GrafanaTheme2) => { color: theme.colors.text.secondary, marginBottom: theme.spacing(2), }), + cardContainer: css({ + position: 'relative', + }), + applySuggestionButton: css({ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + zIndex: 10, + padding: '0 16px', + }), }; }; diff --git a/public/app/features/panel/suggestions/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts index 86fb0e17bde..ff6fbef50e8 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.ts @@ -52,6 +52,7 @@ export async function getAllSuggestions( name: plugin.name, pluginId: plugin.id, description: plugin.info.description, + hash: 'plugin-empty-' + plugin.id, cardOptions: { imgSrc: plugin.info.logos.small, }, diff --git a/public/app/features/panel/suggestions/utils.test.ts b/public/app/features/panel/suggestions/utils.test.ts index 905b59930a5..04d263415db 100644 --- a/public/app/features/panel/suggestions/utils.test.ts +++ b/public/app/features/panel/suggestions/utils.test.ts @@ -1,6 +1,14 @@ -import { createDataFrame, FieldType, getPanelDataSummary, PanelDataSummary } from '@grafana/data'; +import { + createDataFrame, + FieldType, + getPanelDataSummary, + PanelDataSummary, + PanelData, + LoadingState, + getDefaultTimeRange, +} from '@grafana/data'; -import { showDefaultSuggestion } from './utils'; +import { showDefaultSuggestion, hasData } from './utils'; describe('Suggestions utils', () => { describe('showDefaultSuggestion', () => { @@ -30,4 +38,32 @@ describe('Suggestions utils', () => { expect(result).toBeUndefined(); }); }); + + describe('hasData', () => { + it('should return false when data is undefined', () => { + expect(hasData(undefined)).toBe(false); + }); + + it('should return false when data has no series', () => { + const data: PanelData = { + series: [], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }; + expect(hasData(data)).toBe(false); + }); + + it('should return true when at least one series has data', () => { + const data: PanelData = { + state: LoadingState.Done, + series: [ + createDataFrame({ + fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3] }], + }), + ], + timeRange: getDefaultTimeRange(), + }; + expect(hasData(data)).toBe(true); + }); + }); }); diff --git a/public/app/features/panel/suggestions/utils.ts b/public/app/features/panel/suggestions/utils.ts index 8668ec2f654..ca5cc74bd77 100644 --- a/public/app/features/panel/suggestions/utils.ts +++ b/public/app/features/panel/suggestions/utils.ts @@ -1,4 +1,4 @@ -import { PanelDataSummary } from '@grafana/data'; +import { PanelData, PanelDataSummary } from '@grafana/data'; /** * @internal @@ -9,3 +9,13 @@ import { PanelDataSummary } from '@grafana/data'; export function showDefaultSuggestion(fn: (panelDataSummary: PanelDataSummary) => boolean | void) { return (panelDataSummary: PanelDataSummary) => (fn(panelDataSummary) ? [{}] : undefined); } + +/** + * @internal + * Checks if the panel has data + * @param data - PanelData + * @returns true if data exists and has at least one non-empty series + */ +export function hasData(data?: PanelData): boolean { + return Boolean(data && data.series && data.series.length > 0 && data.series.some((frame) => frame.length > 0)); +} diff --git a/public/app/plugins/panel/timeseries/suggestions.ts b/public/app/plugins/panel/timeseries/suggestions.ts index 8ca7d6148ed..d41179d3f36 100644 --- a/public/app/plugins/panel/timeseries/suggestions.ts +++ b/public/app/plugins/panel/timeseries/suggestions.ts @@ -231,6 +231,7 @@ export function getPrepareTimeseriesSuggestion(panelId: number): PanelPluginVisu return { name: 'Transform to wide time series format', + hash: 'timeseries-transform-prepare-wide', pluginId: 'timeseries', transformations, }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 474fde1a97d..fd2b50edb93 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6182,8 +6182,8 @@ "placeholder-search-for": "Search for...", "radio-options": { "label": { - "suggestions": "Suggestions", - "visualizations": "Visualizations" + "all-visualizations": "All visualizations", + "suggestions": "Suggestions" } }, "title-close": "Close" @@ -11050,8 +11050,8 @@ "tooltip-delete": "Delete" }, "visualization-suggestions": { - "based-on-current-data": "Based on current data", - "no-results-matched-your-query": "No results matched your query" + "apply-suggestion-aria-label": "Apply {{suggestionName}} visualization", + "use-this-suggestion": "Use this suggestion" }, "viz-type-picker": { "could-anything-matching-query": "Could not find anything matching your query"