Suggestions: Introduce V2 right sidebar (#114274)

* newVizSuggestions feature toggle

* panel empty state

* add data check

* select first suggestion

* update text

* PanelEmptyState component

* add test

* ?

* remove fake translation

* Visualizations -> All visualizations

* updates

* move empty state to UnconfiguredPanel

* default tab to Suggestions; new tabs and content

* select first suggestion; apply button; removed search functionaility from suggestions

* extract hasData

* refactor

* cleanup

* fix default selection

* translation

* hasData test

* update e2e

* fix width of suggestions tabs

* make sure we only show a max of two columns

* useMeasure with a static gridTemplateColumns def

* Suggestions: hashes on suggestions, update logic to select first suggestion

* reorganize comments a little bit

* fix types

* add tests for hashes

---------

Co-authored-by: Paul Marbach <paul.marbach@grafana.com>
This commit is contained in:
Adela Almasan
2025-11-25 00:59:25 +00:00
committed by GitHub
co-authored by Paul Marbach
parent fb05096d48
commit ede756f5a8
15 changed files with 404 additions and 202 deletions
@@ -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();
-5
View File
@@ -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
@@ -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<PanelPluginVisualizationSuggestion<FakeOptions, FakeFieldOptions>, 'hash'>;
b: Omit<PanelPluginVisualizationSuggestion<FakeOptions, FakeFieldOptions>, '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);
}
});
});
+51 -3
View File
@@ -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<PanelPluginVisualizationSuggestion, 'hash'>): 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<T extends Record<string, any>>(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<TOptions extends unknown = {
name: string;
/** Panel plugin id */
pluginId: string;
/** unique hash assigned by Grafana for use by the UI. */
hash: string;
}
/**
@@ -82,7 +125,7 @@ export class VisualizationSuggestionsBuilder {
}
getListAppender<TOptions extends unknown, TFieldConfig extends {} = {}>(
defaults: PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>
defaults: Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>, 'hash'>
) {
return new VisualizationSuggestionsListAppender<TOptions, TFieldConfig>(this.list, defaults);
}
@@ -127,10 +170,15 @@ export class VisualizationSuggestionsListAppender<TOptions extends unknown, TFie
) {}
append(suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) {
this.list.push(defaultsDeep(suggestion, this.defaults));
this.appendAll([suggestion]);
}
appendAll(suggestions: Array<VisualizationSuggestion<TOptions, TFieldConfig>>) {
this.list.push(...suggestions.map((o) => defaultsDeep(o, this.defaults)));
this.list.push(
...suggestions.map((s): PanelPluginVisualizationSuggestion<TOptions, TFieldConfig> => {
const suggestionWithDefaults = defaultsDeep(s, this.defaults);
return Object.assign(suggestionWithDefaults, { hash: getSuggestionHash(suggestionWithDefaults) });
})
);
}
}
@@ -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<PanelEditorState> {
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);
@@ -99,7 +99,10 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
panel.onFieldConfigChange(fieldConfigWithOverrides, true);
}
this.onToggleVizPicker();
// Handle preview suggestions
if (!options.withModKey) {
this.onToggleVizPicker();
}
};
onSetSearchQuery = (searchQuery: string) => {
@@ -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<SelectableValue<VisualizationSelectPaneTab>> = [
{
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 (
<div className={styles.wrapper}>
<div className={styles.searchRow}>
<FilterInput
className={styles.filter}
value={searchQuery}
onChange={handleSearchChange}
autoFocus={true}
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
/>
<Button
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
variant="secondary"
icon="angle-up"
className={styles.closeButton}
data-testid={selectors.components.PanelEditor.toggleVizPicker}
onClick={onClose}
/>
</div>
<Field className={styles.customFieldMargin}>
<RadioButtonGroup options={radioOptions} value={listMode} onChange={handleListModeChange} fullWidth />
</Field>
{/*@TODO: Re-enable/move close button*/}
{/*<Button*/}
{/* aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}*/}
{/* variant="secondary"*/}
{/* icon="angle-up"*/}
{/* className={styles.closeButton}*/}
{/* data-testid={selectors.components.PanelEditor.toggleVizPicker}*/}
{/* onClick={onClose}*/}
{/*/>*/}
<TabsBar className={styles.tabs} hideBorder={true}>
{tabs.map((tab) => (
<Tab
className={styles.tab}
key={tab.value}
label={tab.label}
active={listMode === tab.value}
onChangeTab={() => handleListModeChange(tab.value)}
/>
))}
</TabsBar>
<ScrollContainer>
{listMode === VisualizationSelectPaneTab.Visualizations && (
<VizTypePicker
pluginId={panel.state.pluginId}
searchQuery={searchQuery}
trackSearch={trackSearch}
onChange={onChange}
/>
)}
{listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions
onChange={onChange}
trackSearch={trackSearch}
searchQuery={searchQuery}
panel={panelModel}
data={data}
/>
)}
<TabContent>
{listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions onChange={onChange} panel={panelModel} data={data} />
)}
{listMode === VisualizationSelectPaneTab.Visualizations && (
<>
<div className={styles.searchRow}>
<FilterInput
className={styles.filter}
value={searchQuery}
onChange={handleSearchChange}
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
/>
</div>
<VizTypePicker
pluginId={panel.state.pluginId}
searchQuery={searchQuery}
trackSearch={trackSearch}
onChange={onChange}
/>
</>
)}
</TabContent>
</ScrollContainer>
</div>
);
@@ -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),
@@ -108,7 +108,7 @@ export const VisualizationSelectPane = ({ panel, data }: Props) => {
<VizTypePicker pluginId={plugin.meta.id} onChange={onVizChange} searchQuery={searchQuery} />
)}
{listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions onChange={onVizChange} searchQuery={searchQuery} panel={panel} data={data} />
<VisualizationSuggestions onChange={onVizChange} panel={panel} data={data} />
)}
{listMode === VisualizationSelectPaneTab.LibraryPanels && (
<PanelLibraryOptionsGroup searchQuery={searchQuery} panel={panel} key="Panel Library" />
@@ -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<HTMLButtonElement> {
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 (
<Tooltip content={suggestion.description ?? suggestion.name}>
<button {...commonButtonProps} className={cx(styles.vizBox, styles.imgBox)}>
<button
{...commonButtonProps}
className={cx(styles.vizBox, styles.imgBox, isNewVizSuggestionsEnabled && isSelected && styles.selectedBox)}
>
<div className={styles.name}>{suggestion.name}</div>
<img className={styles.img} src={cardOptions.imgSrc} alt={suggestion.name} />
</button>
@@ -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',
@@ -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<string | null>(null);
const [firstCardRef, { width }] = useMeasure<HTMLDivElement>();
const [firstCardHash, setFirstCardHash] = useState<string | null>(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 (
<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>
);
const renderEmptyState = () => (
<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>
);
if (isNewVizSuggestionsEnabled && (!data || !hasData(data))) {
return renderEmptyState();
}
if (!data) {
return null;
}
return (
// This div is needed in some places to make AutoSizer work
<div>
<AutoSizer disableHeight style={{ width: '100%', height: '100%' }}>
{({ width }) => {
if (!width) {
return null;
}
{() => (
<div>
<div className={styles.grid}>
{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 (
<div>
<div className={styles.filterRow}>
<div className={styles.infoText}>
<Trans i18nKey="panel.visualization-suggestions.based-on-current-data">Based on current data</Trans>
</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}>
<Trans i18nKey="panel.visualization-suggestions.no-results-matched-your-query">
No results matched your query
</Trans>
return (
<div key={index} className={styles.cardContainer} ref={index === 0 ? firstCardRef : undefined}>
{isCardSelected && (
<Button
variant="primary"
size={'md'}
onClick={() => applySuggestion(suggestion)}
className={styles.applySuggestionButton}
aria-label={t(
'panel.visualization-suggestions.apply-suggestion-aria-label',
'Apply {{suggestionName}} visualization',
{ suggestionName: suggestion.name }
)}
>
{t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')}
</Button>
)}
<VisualizationSuggestionCard
data={data}
suggestion={suggestion}
width={width}
isSelected={isCardSelected}
onClick={() => applySuggestion(suggestion, isNewVizSuggestionsEnabled)}
/>
</div>
)}
</div>
);
})}
</div>
);
}}
</div>
)}
</AutoSizer>
</div>
);
}
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',
}),
};
};
@@ -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,
},
@@ -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);
});
});
});
+11 -1
View File
@@ -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));
}
@@ -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,
};
+4 -4
View File
@@ -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"