VisualizationSuggestions: Support image & text instead of real previews. Adds suggestions for all non data panels when there are no data (#42074)

* Make suggestion cards support img & text mode instead of only preview

* Generic solution for non data panels

* minor review tweaks
This commit is contained in:
Torkel Ödegaard 2021-11-25 10:52:01 +01:00 committed by GitHub
parent a897154017
commit 781067ee45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 163 additions and 145 deletions

View File

@ -201,12 +201,20 @@ export interface VisualizationSuggestion<TOptions = any, TFieldConfig = any> {
fieldConfig?: FieldConfigSource<Partial<TFieldConfig>>; fieldConfig?: FieldConfigSource<Partial<TFieldConfig>>;
/** Data transformations */ /** Data transformations */
transformations?: DataTransformerConfig[]; transformations?: DataTransformerConfig[];
/** Tweak for small preview */ /** Options for how to render suggestion card */
previewModifier?: (suggestion: VisualizationSuggestion) => void; cardOptions?: {
/** Tweak for small preview */
previewModifier?: (suggestion: VisualizationSuggestion) => void;
icon?: string;
imgSrc?: string;
};
/** A value between 0-100 how suitable suggestion is */ /** A value between 0-100 how suitable suggestion is */
score?: VisualizationSuggestionScore; score?: VisualizationSuggestionScore;
} }
/**
* @alpha
*/
export enum VisualizationSuggestionScore { export enum VisualizationSuggestionScore {
/** We are pretty sure this is the best possible option */ /** We are pretty sure this is the best possible option */
Best = 100, Best = 100,

View File

@ -57,21 +57,6 @@ export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
dispatch(toggleVizPicker(false)); dispatch(toggleVizPicker(false));
}; };
// const onKeyPress = useCallback(
// (e: React.KeyboardEvent<HTMLInputElement>) => {
// if (e.key === 'Enter') {
// const query = e.currentTarget.value;
// const plugins = getAllPanelPluginMeta();
// const match = filterPluginList(plugins, query, plugin.meta);
// if (match && match.length) {
// onPluginTypeChange(match[0], false);
// }
// }
// },
// [onPluginTypeChange, plugin.meta]
// );
if (!plugin) { if (!plugin) {
return null; return null;
} }

View File

@ -1,11 +1,11 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { GrafanaTheme2, PanelData, VisualizationSuggestion } from '@grafana/data'; import { GrafanaTheme2, PanelData, VisualizationSuggestion } from '@grafana/data';
import { PanelRenderer } from '../PanelRenderer'; import { PanelRenderer } from '../PanelRenderer';
import { css } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { Tooltip, useStyles2 } from '@grafana/ui'; import { Tooltip, useStyles2 } from '@grafana/ui';
import { VizTypeChangeDetails } from './types'; import { VizTypeChangeDetails } from './types';
import { cloneDeep } from 'lodash';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { cloneDeep } from 'lodash';
export interface Props { export interface Props {
data: PanelData; data: PanelData;
@ -15,50 +15,59 @@ export interface Props {
onChange: (details: VizTypeChangeDetails) => void; onChange: (details: VizTypeChangeDetails) => void;
} }
export function VisualizationPreview({ data, suggestion, onChange, width, showTitle }: Props) { export function VisualizationSuggestionCard({ data, suggestion, onChange, width, showTitle }: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { innerStyles, outerStyles, renderWidth, renderHeight } = getPreviewDimensionsAndStyles(width); const { innerStyles, outerStyles, renderWidth, renderHeight } = getPreviewDimensionsAndStyles(width);
const cardOptions = suggestion.cardOptions ?? {};
const onClick = () => { const commonButtonProps = {
onChange({ 'aria-label': suggestion.name,
pluginId: suggestion.pluginId, className: styles.vizBox,
options: suggestion.options, 'data-testid': selectors.components.VisualizationPreview.card(suggestion.name),
fieldConfig: suggestion.fieldConfig, style: outerStyles,
}); onClick: () => {
onChange({
pluginId: suggestion.pluginId,
options: suggestion.options,
fieldConfig: suggestion.fieldConfig,
});
},
}; };
if (cardOptions.imgSrc) {
return (
<Tooltip content={suggestion.description ?? suggestion.name}>
<button {...commonButtonProps} className={cx(styles.vizBox, styles.imgBox)}>
<div className={styles.name}>{suggestion.name}</div>
<img className={styles.img} src={cardOptions.imgSrc} alt={suggestion.name} />
</button>
</Tooltip>
);
}
let preview = suggestion; let preview = suggestion;
if (suggestion.previewModifier) { if (suggestion.cardOptions?.previewModifier) {
preview = cloneDeep(suggestion); preview = cloneDeep(suggestion);
suggestion.previewModifier(preview); suggestion.cardOptions.previewModifier(preview);
} }
return ( return (
<div> <button {...commonButtonProps}>
{showTitle && <div className={styles.name}>{suggestion.name}</div>} <Tooltip content={suggestion.name}>
<button <div style={innerStyles} className={styles.renderContainer}>
aria-label={suggestion.name} <PanelRenderer
className={styles.vizBox} title=""
data-testid={selectors.components.VisualizationPreview.card(suggestion.name)} data={data}
style={outerStyles} pluginId={suggestion.pluginId}
onClick={onClick} width={renderWidth}
> height={renderHeight}
<Tooltip content={suggestion.name}> options={preview.options}
<div style={innerStyles} className={styles.renderContainer}> fieldConfig={preview.fieldConfig}
<PanelRenderer />
title="" <div className={styles.hoverPane} />
data={data} </div>
pluginId={suggestion.pluginId} </Tooltip>
width={renderWidth} </button>
height={renderHeight}
options={preview.options}
fieldConfig={preview.fieldConfig}
/>
<div className={styles.hoverPane} />
</div>
</Tooltip>
</button>
</div>
); );
} }
@ -77,8 +86,7 @@ const getStyles = (theme: GrafanaTheme2) => {
background: none; background: none;
border-radius: ${theme.shape.borderRadius(1)}; border-radius: ${theme.shape.borderRadius(1)};
cursor: pointer; cursor: pointer;
text-align: left; border: 1px solid ${theme.colors.border.medium};
border: 1px solid ${theme.colors.border.strong};
transition: ${theme.transitions.create(['background'], { transition: ${theme.transitions.create(['background'], {
duration: theme.transitions.duration.short, duration: theme.transitions.duration.short,
@ -88,7 +96,23 @@ const getStyles = (theme: GrafanaTheme2) => {
background: ${theme.colors.background.secondary}; background: ${theme.colors.background.secondary};
} }
`, `,
imgBox: css`
display: flex;
flex-direction: column;
height: 100%;
justify-self: center;
color: ${theme.colors.text.primary};
width: 100%;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
`,
name: css` name: css`
padding-bottom: ${theme.spacing(0.5)};
margin-top: ${theme.spacing(-1)};
font-size: ${theme.typography.bodySmall.fontSize}; font-size: ${theme.typography.bodySmall.fontSize};
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -96,6 +120,10 @@ const getStyles = (theme: GrafanaTheme2) => {
font-weight: ${theme.typography.fontWeightMedium}; font-weight: ${theme.typography.fontWeightMedium};
text-overflow: ellipsis; text-overflow: ellipsis;
`, `,
img: css`
max-width: ${theme.spacing(8)};
max-height: ${theme.spacing(8)};
`,
renderContainer: css` renderContainer: css`
position: absolute; position: absolute;
transform-origin: left top; transform-origin: left top;

View File

@ -3,7 +3,7 @@ import { useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, PanelData, PanelPluginMeta, PanelModel, VisualizationSuggestion } from '@grafana/data'; import { GrafanaTheme2, PanelData, PanelPluginMeta, PanelModel, VisualizationSuggestion } from '@grafana/data';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { VizTypeChangeDetails } from './types'; import { VizTypeChangeDetails } from './types';
import { VisualizationPreview } from './VisualizationPreview'; import { VisualizationSuggestionCard } from './VisualizationSuggestionCard';
import { getAllSuggestions } from '../../state/getAllSuggestions'; import { getAllSuggestions } from '../../state/getAllSuggestions';
import { useAsync, useLocalStorage } from 'react-use'; import { useAsync, useLocalStorage } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
@ -44,7 +44,7 @@ export function VisualizationSuggestions({ onChange, data, panel, searchQuery }:
</div> </div>
<div className={styles.grid} style={{ gridTemplateColumns: `repeat(auto-fill, ${previewWidth - 1}px)` }}> <div className={styles.grid} style={{ gridTemplateColumns: `repeat(auto-fill, ${previewWidth - 1}px)` }}>
{filteredSuggestions.map((suggestion, index) => ( {filteredSuggestions.map((suggestion, index) => (
<VisualizationPreview <VisualizationSuggestionCard
key={index} key={index}
data={data!} data={data!}
suggestion={suggestion} suggestion={suggestion}

View File

@ -4,6 +4,7 @@ import {
getDefaultTimeRange, getDefaultTimeRange,
LoadingState, LoadingState,
PanelData, PanelData,
PanelPluginMeta,
toDataFrame, toDataFrame,
VisualizationSuggestion, VisualizationSuggestion,
} from '@grafana/data'; } from '@grafana/data';
@ -20,6 +21,16 @@ for (const pluginId of panelsToCheckFirst) {
} as any; } as any;
} }
config.panels['text'] = {
id: 'text',
name: 'Text',
skipDataQuery: true,
info: {
description: 'pretty decent plugin',
logos: { small: 'small/logo', large: 'large/logo' },
},
} as PanelPluginMeta;
class ScenarioContext { class ScenarioContext {
data: DataFrame[] = []; data: DataFrame[] = [];
suggestions: VisualizationSuggestion[] = []; suggestions: VisualizationSuggestion[] = [];
@ -58,7 +69,7 @@ scenario('No series', (ctx) => {
ctx.setData([]); ctx.setData([]);
it('should return correct suggestions', () => { it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel, SuggestionName.DashboardList]); expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel]);
}); });
}); });
@ -73,7 +84,7 @@ scenario('No rows', (ctx) => {
]); ]);
it('should return correct suggestions', () => { it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel, SuggestionName.DashboardList]); expect(ctx.names()).toEqual([SuggestionName.Table]);
}); });
}); });

View File

@ -5,6 +5,7 @@ import {
PanelModel, PanelModel,
VisualizationSuggestionScore, VisualizationSuggestionScore,
} from '@grafana/data'; } from '@grafana/data';
import { config } from '@grafana/runtime';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
export const panelsToCheckFirst = [ export const panelsToCheckFirst = [
@ -17,8 +18,6 @@ export const panelsToCheckFirst = [
'table', 'table',
'state-timeline', 'state-timeline',
'status-history', 'status-history',
'text',
'dashlist',
'logs', 'logs',
'candlestick', 'candlestick',
]; ];
@ -35,7 +34,26 @@ export async function getAllSuggestions(data?: PanelData, panel?: PanelModel): P
} }
} }
return builder.getList().sort((a, b) => { const list = builder.getList();
if (builder.dataSummary.fieldCount === 0) {
for (const plugin of Object.values(config.panels)) {
if (!plugin.skipDataQuery || plugin.hideFromList) {
continue;
}
list.push({
name: plugin.name,
pluginId: plugin.id,
description: plugin.info.description,
cardOptions: {
imgSrc: plugin.info.logos.small,
},
});
}
}
return list.sort((a, b) => {
return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK); return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK);
}); });
} }

View File

@ -22,8 +22,10 @@ export class BarChartSuggestionsSupplier {
}, },
overrides: [], overrides: [],
}, },
previewModifier: (s) => { cardOptions: {
s.options!.barWidth = 0.8; previewModifier: (s) => {
s.options!.barWidth = 0.8;
},
}, },
}); });
} }

View File

@ -21,7 +21,6 @@ export class BarGaugeSuggestionsSupplier {
}, },
overrides: [], overrides: [],
}, },
previewModifier: (s) => {},
}); });
// This is probably not a good option for many numeric fields // This is probably not a good option for many numeric fields

View File

@ -38,7 +38,6 @@ export class CandlestickSuggestionsSupplier {
}, },
overrides: [], overrides: [],
}, },
previewModifier: (s) => {},
}); });
list.append({ list.append({

View File

@ -8,7 +8,6 @@ import {
GENERAL_FOLDER, GENERAL_FOLDER,
ReadonlyFolderPicker, ReadonlyFolderPicker,
} from '../../../core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker'; } from '../../../core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker';
import { DashListSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<DashListOptions>(DashList) export const plugin = new PanelPlugin<DashListOptions>(DashList)
.setPanelOptions((builder) => { .setPanelOptions((builder) => {
@ -88,5 +87,4 @@ export const plugin = new PanelPlugin<DashListOptions>(DashList)
} }
return newOptions; return newOptions;
}) });
.setSuggestionsSupplier(new DashListSuggestionsSupplier());

View File

@ -1,20 +0,0 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import { PanelOptions } from './models.gen';
export class DashListSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const { dataSummary } = builder;
if (dataSummary.hasData) {
return;
}
const list = builder.getListAppender<PanelOptions, {}>({
name: 'Dashboard list',
pluginId: 'dashlist',
options: {},
});
list.append({});
}
}

View File

@ -33,10 +33,12 @@ export class GaugeSuggestionsSupplier {
}, },
overrides: [], overrides: [],
}, },
previewModifier: (s) => { cardOptions: {
if (s.options!.reduceOptions.values) { previewModifier: (s) => {
s.options!.reduceOptions.limit = 2; if (s.options!.reduceOptions.values) {
} s.options!.reduceOptions.limit = 2;
}
},
}, },
}); });

View File

@ -14,7 +14,6 @@ export class LogsPanelSuggestionsSupplier {
}, },
overrides: [], overrides: [],
}, },
previewModifier: () => {},
}); });
const { dataSummary: ds } = builder; const { dataSummary: ds } = builder;

View File

@ -19,9 +19,11 @@ export class PieChartSuggestionsSupplier {
values: [], values: [],
} as any, } as any,
}, },
previewModifier: (s) => { cardOptions: {
// Hide labels in preview previewModifier: (s) => {
s.options!.legend.displayMode = LegendDisplayMode.Hidden; // Hide labels in preview
s.options!.legend.displayMode = LegendDisplayMode.Hidden;
},
}, },
}); });

View File

@ -22,10 +22,12 @@ export class StatSuggestionsSupplier {
}, },
overrides: [], overrides: [],
}, },
previewModifier: (s) => { cardOptions: {
if (s.options!.reduceOptions.values) { previewModifier: (s) => {
s.options!.reduceOptions.limit = 1; if (s.options!.reduceOptions.values) {
} s.options!.reduceOptions.limit = 1;
}
},
}, },
}); });

View File

@ -35,7 +35,6 @@ export class StatTimelineSuggestionsSupplier {
}, },
overrides: [], overrides: [],
}, },
previewModifier: (s) => {},
}); });
list.append({ name: SuggestionName.StateTimeline }); list.append({ name: SuggestionName.StateTimeline });

View File

@ -43,8 +43,10 @@ export class StatusHistorySuggestionsSupplier {
}, },
overrides: [], overrides: [],
}, },
previewModifier: (s) => { cardOptions: {
s.options!.colWidth = 0.7; previewModifier: (s) => {
s.options!.colWidth = 0.7;
},
}, },
}); });

View File

@ -6,7 +6,7 @@ import { PanelOptions } from './models.gen';
export class TableSuggestionsSupplier { export class TableSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const list = builder.getListAppender<PanelOptions, TableFieldOptions>({ const list = builder.getListAppender<PanelOptions, TableFieldOptions>({
name: '', name: SuggestionName.Table,
pluginId: 'table', pluginId: 'table',
options: {}, options: {},
fieldConfig: { fieldConfig: {
@ -15,9 +15,22 @@ export class TableSuggestionsSupplier {
}, },
overrides: [], overrides: [],
}, },
previewModifier: (s) => {}, cardOptions: {
previewModifier: (s) => {
s.fieldConfig!.defaults.custom!.minWidth = 50;
},
},
}); });
list.append({ name: SuggestionName.Table }); // If there are not data suggest table anyway but use icon instead of real preview
if (builder.dataSummary.fieldCount === 0) {
list.append({
cardOptions: {
imgSrc: 'public/app/plugins/panel/table/img/icn-table-panel.svg',
},
});
} else {
list.append({});
}
} }
} }

View File

@ -4,7 +4,6 @@ import { TextPanel } from './TextPanel';
import { textPanelMigrationHandler } from './textPanelMigrationHandler'; import { textPanelMigrationHandler } from './textPanelMigrationHandler';
import { TextPanelEditor } from './TextPanelEditor'; import { TextPanelEditor } from './TextPanelEditor';
import { defaultPanelOptions, PanelOptions, TextMode } from './models.gen'; import { defaultPanelOptions, PanelOptions, TextMode } from './models.gen';
import { TextPanelSuggestionSupplier } from './suggestions';
export const plugin = new PanelPlugin<PanelOptions>(TextPanel) export const plugin = new PanelPlugin<PanelOptions>(TextPanel)
.setPanelOptions((builder) => { .setPanelOptions((builder) => {
@ -30,5 +29,4 @@ export const plugin = new PanelPlugin<PanelOptions>(TextPanel)
defaultValue: defaultPanelOptions.content, defaultValue: defaultPanelOptions.content,
}); });
}) })
.setMigrationHandler(textPanelMigrationHandler) .setMigrationHandler(textPanelMigrationHandler);
.setSuggestionsSupplier(new TextPanelSuggestionSupplier());

View File

@ -1,29 +0,0 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import { PanelOptions } from './models.gen';
export class TextPanelSuggestionSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const { dataSummary } = builder;
if (dataSummary.hasData) {
return;
}
const list = builder.getListAppender<PanelOptions, {}>({
name: 'Text panel',
pluginId: 'text',
options: {
content: `
# Title
For markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)
* First item
* Second item
* Third item`,
},
});
list.append({});
}
}

View File

@ -30,12 +30,14 @@ export class TimeSeriesSuggestionsSupplier {
}, },
overrides: [], overrides: [],
}, },
previewModifier: (s) => { cardOptions: {
s.options!.legend.displayMode = LegendDisplayMode.Hidden; previewModifier: (s) => {
s.options!.legend.displayMode = LegendDisplayMode.Hidden;
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
} }
},
}, },
}); });

View File

@ -24,7 +24,7 @@ export enum SuggestionName {
Table = 'Table', Table = 'Table',
StateTimeline = 'State timeline', StateTimeline = 'State timeline',
StatusHistory = 'Status history', StatusHistory = 'Status history',
TextPanel = 'Text panel', TextPanel = 'Text',
DashboardList = 'Dashboard list', DashboardList = 'Dashboard list',
Logs = 'Logs', Logs = 'Logs',
} }