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:
co-authored by
Paul Marbach
parent
fb05096d48
commit
ede756f5a8
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
+16
-15
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user