VisualizationSelection: Real previews of suitable visualisation and options based on current data (#40527)

* Initial pass to move panel state to it's own, and make it by key not panel.id

* Progress

* Not making much progress, having panel.key be mutable is causing a lot of issues

* Think this is starting to work

* Began fixing tests

* Add selector

* Bug fixes and changes to cleanup, and fixing all flicking when switching library panels

* Removed console.log

* fixes after merge

* fixing tests

* fixing tests

* Added new test for changePlugin thunk

* Initial struture in place

* responding to state changes in another part of the state

* bha

* going in a different direction

* This is getting exciting

* minor

* More structure

* More real

* Added builder to reduce boiler plate

* Lots of progress

* Adding more visualizations

* More smarts

* tweaks

* suggestions

* Move to separate view

* Refactoring to builder concept

* Before hover preview test

* Increase line width in preview

* More suggestions

* Removed old elements of onSuggestVisualizations

* Don't call suggestion suppliers if there is no data

* Restore card styles to only borders

* Changing supplier interface to support data vs option suggestion scenario

* Renamed functions

* Add dynamic width support

* not sure about this

* Improve suggestions

* Improve suggestions

* Single grid/list

* Store vis select pane & size

* Prep for option suggestions

* more suggestions

* Name/title option for preview cards

* Improve barchart suggestions

* Support suggestions when there are no data

* Minor change

* reverted some changes

* Improve suggestions for stacking

* Removed size option

* starting on unit tests, hit cyclic dependency issue

* muuu

* First test for getting suggestion seems to work, going to bed

* add missing file

* A basis for more unit tests

* More tests

* More unit tests

* Fixed unit tests

* Update

* Some extreme scenarios

* Added basic e2e test

* Added another unit test for changePanelPlugin action

* More cleanup

* Minor tweak

* add wait to e2e test

* Renamed function and cleanup of unused function

* Adding search support and adding search test to e2e test
pull/40518/head
Torkel Ödegaard 4 years ago committed by GitHub
parent 91c0b5a47f
commit 54af57b8e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 32
      e2e/suite1/specs/visualization-suggestions.ts
  2. 19
      packages/grafana-data/src/panel/PanelPlugin.ts
  3. 2
      packages/grafana-data/src/types/dashboard.ts
  4. 2
      packages/grafana-data/src/types/dataFrame.ts
  5. 2
      packages/grafana-data/src/types/fieldOverrides.ts
  6. 140
      packages/grafana-data/src/types/panel.ts
  7. 3
      packages/grafana-e2e-selectors/src/selectors/components.ts
  8. 4
      packages/grafana-runtime/src/components/PanelRenderer.tsx
  9. 77
      packages/grafana-ui/src/components/FilterInput/FilterInput.tsx
  10. 21
      packages/grafana-ui/src/utils/useCombinedRefs.ts
  11. 2
      public/app/angular/AngularApp.ts
  12. 0
      public/app/angular/partials.ts
  13. 2
      public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx
  14. 2
      public/app/core/config.ts
  15. 1
      public/app/core/core.ts
  16. 2
      public/app/core/reducers/root.ts
  17. 2
      public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx
  18. 2
      public/app/features/dashboard/components/PanelEditor/OptionsPane.tsx
  19. 1
      public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx
  20. 31
      public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx
  21. 86
      public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx
  22. 4
      public/app/features/dashboard/components/PanelEditor/state/reducers.ts
  23. 8
      public/app/features/dashboard/dashgrid/DashboardPanel.tsx
  24. 2
      public/app/features/dashboard/dashgrid/PanelChrome.test.tsx
  25. 8
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  26. 2
      public/app/features/dashboard/state/PanelModel.ts
  27. 2
      public/app/features/dashboard/state/reducers.ts
  28. 6
      public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx
  29. 51
      public/app/features/panel/components/CannotVisualizeData.tsx
  30. 128
      public/app/features/panel/components/VizTypePicker/VisualizationPreview.tsx
  31. 106
      public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx
  32. 123
      public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx
  33. 8
      public/app/features/panel/components/VizTypePicker/types.ts
  34. 40
      public/app/features/panel/state/actions.test.ts
  35. 41
      public/app/features/panel/state/actions.ts
  36. 305
      public/app/features/panel/state/getAllSuggestions.test.ts
  37. 30
      public/app/features/panel/state/getAllSuggestions.ts
  38. 17
      public/app/features/panel/state/getOptionSuggestions.ts
  39. 47
      public/app/features/panel/state/util.ts
  40. 4
      public/app/plugins/panel/alertlist/module.tsx
  41. 20
      public/app/plugins/panel/alertlist/suggestions.ts
  42. 4
      public/app/plugins/panel/barchart/module.tsx
  43. 94
      public/app/plugins/panel/barchart/suggestions.ts
  44. 4
      public/app/plugins/panel/bargauge/module.tsx
  45. 115
      public/app/plugins/panel/bargauge/suggestions.ts
  46. 4
      public/app/plugins/panel/dashlist/module.tsx
  47. 20
      public/app/plugins/panel/dashlist/suggestions.ts
  48. 2
      public/app/plugins/panel/gauge/module.tsx
  49. 85
      public/app/plugins/panel/gauge/suggestions.ts
  50. 2
      public/app/plugins/panel/graph/module.ts
  51. 4
      public/app/plugins/panel/piechart/module.tsx
  52. 80
      public/app/plugins/panel/piechart/suggestions.ts
  53. 2
      public/app/plugins/panel/stat/module.tsx
  54. 77
      public/app/plugins/panel/stat/suggestions.ts
  55. 4
      public/app/plugins/panel/state-timeline/module.tsx
  56. 38
      public/app/plugins/panel/state-timeline/suggestions.ts
  57. 4
      public/app/plugins/panel/table/module.tsx
  58. 22
      public/app/plugins/panel/table/suggestions.ts
  59. 4
      public/app/plugins/panel/text/module.tsx
  60. 29
      public/app/plugins/panel/text/suggestions.ts
  61. 4
      public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx
  62. 2
      public/app/plugins/panel/timeseries/module.tsx
  63. 169
      public/app/plugins/panel/timeseries/suggestions.ts
  64. 21
      public/app/plugins/panel/timeseries/utils.ts
  65. 25
      public/app/types/suggestions.ts
  66. 1
      public/test/jest-setup.ts

@ -0,0 +1,32 @@
import { e2e } from '@grafana/e2e';
const PANEL_UNDER_TEST = 'Interpolation: linear';
e2e.scenario({
describeName: 'Visualization suggestions',
itName: 'Should be shown and clickable',
addScenarioDataSource: false,
addScenarioDashBoard: false,
skipScenario: false,
scenario: () => {
e2e.flows.openDashboard({ uid: 'TkZXxlNG3' });
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST);
// Try visualization suggestions
e2e.components.PanelEditor.toggleVizPicker().click();
e2e().contains('Suggestions').click();
cy.wait(1000);
// Verify we see suggestions
e2e.components.VisualizationPreview.card('Line chart').should('be.visible');
// Verify search works
e2e().get('[placeholder="Search for..."]').type('Table');
// Should no longer see line chart
e2e.components.VisualizationPreview.card('Line chart').should('not.exist');
// Select a visualisation
e2e.components.VisualizationPreview.card('Table').click();
e2e.components.Panels.Visualization.Table.header().should('be.visible');
},
});

@ -8,6 +8,7 @@ import {
PanelTypeChangedHandler,
FieldConfigProperty,
PanelPluginDataSupport,
VisualizationSuggestionsSupplier,
} from '../types';
import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
import { ComponentClass, ComponentType } from 'react';
@ -104,6 +105,7 @@ export class PanelPlugin<
};
private optionsSupplier?: PanelOptionsSupplier<TOptions>;
private suggestionsSupplier?: VisualizationSuggestionsSupplier;
panel: ComponentType<PanelProps<TOptions>> | null;
editor?: ComponentClass<PanelEditorProps<TOptions>>;
@ -354,4 +356,21 @@ export class PanelPlugin<
return this;
}
/**
* Sets function that can return visualization examples and suggestions.
* @alpha
*/
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier) {
this.suggestionsSupplier = supplier;
return this;
}
/**
* Returns the suggestions supplier
* @alpha
*/
getSuggestionsSupplier(): VisualizationSuggestionsSupplier | undefined {
return this.suggestionsSupplier;
}
}

@ -10,7 +10,7 @@ export enum DashboardCursorSync {
/**
* @public
*/
export interface PanelModel<TOptions = any, TCustomFieldConfig extends object = any> {
export interface PanelModel<TOptions = any, TCustomFieldConfig = any> {
/** ID of the panel within the current dashboard */
id: number;

@ -24,7 +24,7 @@ export enum FieldType {
*
* Plugins may extend this with additional properties. Something like series overrides
*/
export interface FieldConfig<TOptions extends object = any> {
export interface FieldConfig<TOptions = any> {
/**
* The display value for this field. This supports template variables blank is auto
*/

@ -49,7 +49,7 @@ export const isSystemOverride = (override: ConfigOverrideRule): override is Syst
return typeof (override as SystemConfigOverrideRule)?.__systemRef === 'string';
};
export interface FieldConfigSource<TOptions extends object = any> {
export interface FieldConfigSource<TOptions = any> {
// Defaults applied to all numeric fields
defaults: FieldConfig<TOptions>;

@ -2,7 +2,7 @@ import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource
import { PluginMeta } from './plugin';
import { ScopedVars } from './ScopedVars';
import { LoadingState } from './data';
import { DataFrame } from './dataFrame';
import { DataFrame, FieldType } from './dataFrame';
import { AbsoluteTimeRange, TimeRange, TimeZone } from './time';
import { EventBus } from '../events';
import { FieldConfigSource } from './fieldOverrides';
@ -12,6 +12,8 @@ import { OptionsEditorItem } from './OptionsUIRegistryBuilder';
import { OptionEditorConfig } from './options';
import { AlertStateInfo } from './alerts';
import { PanelModel } from './dashboard';
import { DataTransformerConfig } from './transformations';
import { defaultsDeep } from 'lodash';
export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
@ -58,7 +60,7 @@ export interface PanelData {
timeRange: TimeRange;
}
export interface PanelProps<T = any, S = any> {
export interface PanelProps<T = any> {
/** ID of the panel within the current dashboard */
id: number;
@ -182,3 +184,137 @@ export interface PanelPluginDataSupport {
annotations: boolean;
alertStates: boolean;
}
/**
* @alpha
*/
export interface VisualizationSuggestion<TOptions = any, TFieldConfig = any> {
/** Name of suggestion */
name: string;
/** Description */
description?: string;
/** Panel plugin id */
pluginId: string;
/** Panel plugin options */
options?: Partial<TOptions>;
/** Panel plugin field options */
fieldConfig?: FieldConfigSource<Partial<TFieldConfig>>;
/** Data transformations */
transformations?: DataTransformerConfig[];
/** Tweak for small preview */
previewModifier?: (suggestion: VisualizationSuggestion) => void;
}
/**
* @alpha
*/
export interface PanelDataSummary {
hasData?: boolean;
rowCountTotal: number;
rowCountMax: number;
frameCount: number;
numberFieldCount: number;
timeFieldCount: number;
stringFieldCount: number;
hasNumberField?: boolean;
hasTimeField?: boolean;
hasStringField?: boolean;
}
/**
* @alpha
*/
export class VisualizationSuggestionsBuilder {
/** Current data */
data?: PanelData;
/** Current panel & options */
panel?: PanelModel;
/** Summary stats for current data */
dataSummary: PanelDataSummary;
private list: VisualizationSuggestion[] = [];
constructor(data?: PanelData, panel?: PanelModel) {
this.data = data;
this.panel = panel;
this.dataSummary = this.computeDataSummary();
}
getListAppender<TOptions, TFieldConfig>(defaults: VisualizationSuggestion<TOptions, TFieldConfig>) {
return new VisualizationSuggestionsListAppender<TOptions, TFieldConfig>(this.list, defaults);
}
private computeDataSummary() {
const frames = this.data?.series || [];
let numberFieldCount = 0;
let timeFieldCount = 0;
let stringFieldCount = 0;
let rowCountTotal = 0;
let rowCountMax = 0;
for (const frame of frames) {
rowCountTotal += frame.length;
for (const field of frame.fields) {
switch (field.type) {
case FieldType.number:
numberFieldCount += 1;
break;
case FieldType.time:
timeFieldCount += 1;
break;
case FieldType.string:
stringFieldCount += 1;
break;
}
}
if (frame.length > rowCountMax) {
rowCountMax = frame.length;
}
}
return {
numberFieldCount,
timeFieldCount,
stringFieldCount,
rowCountTotal,
rowCountMax,
frameCount: frames.length,
hasData: rowCountTotal > 0,
hasTimeField: timeFieldCount > 0,
hasNumberField: numberFieldCount > 0,
hasStringField: stringFieldCount > 0,
};
}
getList() {
return this.list;
}
}
/**
* @alpha
*/
export type VisualizationSuggestionsSupplier = {
/**
* Adds good suitable suggestions for the current data
*/
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void;
};
/**
* Helps with typings and defaults
* @alpha
*/
export class VisualizationSuggestionsListAppender<TOptions, TFieldConfig> {
constructor(
private list: VisualizationSuggestion[],
private defaults: VisualizationSuggestion<TOptions, TFieldConfig>
) {}
append(overrides: Partial<VisualizationSuggestion<TOptions, TFieldConfig>>) {
this.list.push(defaultsDeep(overrides, this.defaults));
}
}

@ -262,4 +262,7 @@ export const Components = {
PanelAlertTabContent: {
content: 'Unified alert editor tab content',
},
VisualizationPreview: {
card: (name: string) => `data-testid suggestion-${name}`,
},
};

@ -13,10 +13,10 @@ export interface PanelRendererProps<P extends object = any, F extends object = a
data: PanelData;
pluginId: string;
title: string;
options?: P;
options?: Partial<P>;
onOptionsChange?: (options: P) => void;
onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void;
fieldConfig?: FieldConfigSource<F>;
fieldConfig?: FieldConfigSource<Partial<F>>;
timeZone?: string;
width: number;
height: number;

@ -1,47 +1,48 @@
import React, { FC } from 'react';
import React, { HTMLProps } from 'react';
import { escapeStringForRegex, unEscapeStringFromRegex } from '@grafana/data';
import { Button, Icon, Input } from '..';
import { useFocus } from '../Input/utils';
import { useCombinedRefs } from '../../utils/useCombinedRefs';
export interface Props {
export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'onChange'> {
value: string | undefined;
placeholder?: string;
width?: number;
onChange: (value: string) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
}
export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, onKeyDown, autoFocus }) => {
const [inputRef, setInputFocus] = useFocus();
const suffix =
value !== '' ? (
<Button
icon="times"
fill="text"
size="sm"
onClick={(e) => {
setInputFocus();
onChange('');
e.stopPropagation();
}}
>
Clear
</Button>
) : null;
export const FilterInput = React.forwardRef<HTMLInputElement, Props>(
({ value, width, onChange, ...restProps }, ref) => {
const innerRef = React.useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, innerRef) as React.Ref<HTMLInputElement>;
return (
<Input
autoFocus={autoFocus ?? false}
prefix={<Icon name="search" />}
ref={inputRef}
suffix={suffix}
width={width}
type="text"
value={value ? unEscapeStringFromRegex(value) : ''}
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))}
onKeyDown={onKeyDown}
placeholder={placeholder}
/>
);
};
const suffix =
value !== '' ? (
<Button
icon="times"
fill="text"
size="sm"
onClick={(e) => {
innerRef.current?.focus();
onChange('');
e.stopPropagation();
}}
>
Clear
</Button>
) : null;
return (
<Input
prefix={<Icon name="search" />}
suffix={suffix}
width={width}
type="text"
value={value ? unEscapeStringFromRegex(value) : ''}
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))}
{...restProps}
ref={combinedRef}
/>
);
}
);
FilterInput.displayName = 'FilterInput';

@ -0,0 +1,21 @@
import React from 'react';
export function useCombinedRefs<T>(...refs: any) {
const targetRef = React.useRef<T>(null);
React.useEffect(() => {
refs.forEach((ref: any) => {
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(targetRef.current);
} else {
ref.current = targetRef.current;
}
});
}, [refs]);
return targetRef;
}

@ -14,7 +14,7 @@ import { extend } from 'lodash';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { getTemplateSrv } from '@grafana/runtime';
import './panel/all';
import './partials';
export class AngularApp {
ngModuleDependencies: any[];
preBootModules: any[];

@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data';
import { getAllPanelPluginMeta } from '../../../features/panel/components/VizTypePicker/VizTypePicker';
import { getAllPanelPluginMeta } from 'app/features/panel/state/util';
import { Icon, resetSelectStyles, MultiSelect, useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';

@ -1,5 +1,5 @@
import { config, GrafanaBootConfig } from '@grafana/runtime';
import { PluginState } from '../../../packages/grafana-data/src';
import { PluginState } from '@grafana/data';
// Legacy binding paths
export { config, GrafanaBootConfig as Settings };

@ -8,7 +8,6 @@ import '../angular/rebuild_on_change';
import '../angular/give_focus';
import '../angular/diff-view';
import './jquery_extended';
import './partials';
import './components/jsontree/jsontree';
import './components/code_editor/code_editor';
import './components/colorpicker/spectrum_picker';

@ -15,6 +15,7 @@ import organizationReducers from 'app/features/org/state/reducers';
import ldapReducers from 'app/features/admin/state/reducers';
import templatingReducers from 'app/features/variables/state/reducers';
import importDashboardReducers from 'app/features/manage-dashboards/state/reducers';
import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
import panelsReducers from 'app/features/panel/state/reducers';
const rootReducers = {
@ -33,6 +34,7 @@ const rootReducers = {
...ldapReducers,
...templatingReducers,
...importDashboardReducers,
...panelEditorReducers,
...panelsReducers,
};

@ -83,7 +83,7 @@ export class AngularPanelOptionsUnconnected extends PureComponent<Props> {
const panelCtrl: PanelCtrl = scope.$$childHead.ctrl;
panelCtrl.initEditMode();
panelCtrl.onPluginTypeChange = (plugin: PanelPluginMeta) => {
changePanelPlugin(panel, plugin.id);
changePanelPlugin({ panel, pluginId: plugin.id });
};
let template = '';

@ -45,7 +45,7 @@ export const OptionsPane: React.FC<OptionPaneRenderProps> = ({
</div>
</>
)}
{isVizPickerOpen && <VisualizationSelectPane panel={panel} />}
{isVizPickerOpen && <VisualizationSelectPane panel={panel} data={data} />}
</div>
);
};

@ -35,6 +35,7 @@ export const OptionsPaneOptions: React.FC<OptionPaneRenderProps> = (props) => {
const mainBoxElements: React.ReactNode[] = [];
const isSearching = searchQuery.length > 0;
const optionRadioFilters = useMemo(getOptionRadioFilters, []);
const allOptions = isPanelModelLibraryPanel(panel)
? [libraryPanelOptions, panelFrameOptions, ...vizOptions]
: [panelFrameOptions, ...vizOptions];

@ -218,7 +218,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
updatePanelEditorUIState({ isPanelOptionsVisible: !uiState.isPanelOptionsVisible });
};
renderPanel(styles: EditorStyles, noTabsBelow: boolean) {
renderPanel(styles: EditorStyles, isOnlyPanel: boolean) {
const { dashboard, panel, uiState, tableViewEnabled } = this.props;
return (
@ -232,7 +232,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
}
// If no tabs limit height so panel does not extend to edge
if (noTabsBelow) {
if (isOnlyPanel) {
height -= config.theme2.spacing.gridSize * 2;
}
@ -270,21 +270,23 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
renderPanelAndEditor(styles: EditorStyles) {
const { panel, dashboard, plugin, tab } = this.props;
const tabs = getPanelEditorTabs(tab, plugin);
const isOnlyPanel = tabs.length === 0;
const panelPane = this.renderPanel(styles, isOnlyPanel);
if (tabs.length > 0) {
return [
this.renderPanel(styles, false),
<div
className={styles.tabsWrapper}
aria-label={selectors.components.PanelEditor.DataPane.content}
key="panel-editor-tabs"
>
<PanelEditorTabs panel={panel} dashboard={dashboard} tabs={tabs} onChangeTab={this.onChangeTab} />
</div>,
];
if (tabs.length === 0) {
return panelPane;
}
return this.renderPanel(styles, true);
return [
panelPane,
<div
className={styles.tabsWrapper}
aria-label={selectors.components.PanelEditor.DataPane.content}
key="panel-editor-tabs"
>
<PanelEditorTabs panel={panel} dashboard={dashboard} tabs={tabs} onChangeTab={this.onChangeTab} />
</div>,
];
}
renderTemplateVariables(styles: EditorStyles) {
@ -529,6 +531,7 @@ export const getStyles = stylesFactory((theme: GrafanaTheme, props: Props) => {
justify-content: center;
align-items: center;
position: relative;
flex-direction: column;
`,
};
});

@ -1,45 +1,43 @@
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme, PanelPluginMeta, SelectableValue } from '@grafana/data';
import { Button, CustomScrollbar, Icon, Input, RadioButtonGroup, useStyles } from '@grafana/ui';
import { GrafanaTheme, PanelData, SelectableValue } from '@grafana/data';
import { Button, CustomScrollbar, FilterInput, RadioButtonGroup, useStyles } from '@grafana/ui';
import { changePanelPlugin } from '../../../panel/state/actions';
import { PanelModel } from '../../state/PanelModel';
import { useDispatch, useSelector } from 'react-redux';
import {
filterPluginList,
getAllPanelPluginMeta,
VizTypePicker,
} from '../../../panel/components/VizTypePicker/VizTypePicker';
import { VizTypePicker } from '../../../panel/components/VizTypePicker/VizTypePicker';
import { Field } from '@grafana/ui/src/components/Forms/Field';
import { PanelLibraryOptionsGroup } from 'app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup';
import { toggleVizPicker } from './state/reducers';
import { selectors } from '@grafana/e2e-selectors';
import { getPanelPluginWithFallback } from '../../state/selectors';
import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types';
import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions';
import { useLocalStorage } from 'react-use';
interface Props {
panel: PanelModel;
data?: PanelData;
}
export const VisualizationSelectPane: FC<Props> = ({ panel }) => {
export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
const plugin = useSelector(getPanelPluginWithFallback(panel.type));
const [searchQuery, setSearchQuery] = useState('');
const [listMode, setListMode] = useState(ListMode.Visualizations);
const [listMode, setListMode] = useLocalStorage(`VisualizationSelectPane.ListMode`, ListMode.Visualizations);
const dispatch = useDispatch();
const styles = useStyles(getStyles);
const searchRef = useRef<HTMLInputElement | null>(null);
const onPluginTypeChange = useCallback(
(meta: PanelPluginMeta, withModKey: boolean) => {
if (meta.id !== plugin.meta.id) {
dispatch(changePanelPlugin(panel, meta.id));
}
const onVizChange = useCallback(
(pluginChange: VizTypeChangeDetails) => {
dispatch(changePanelPlugin({ panel: panel, ...pluginChange }));
// close viz picker unless a mod key is pressed while clicking
if (!withModKey) {
if (!pluginChange.withModKey) {
dispatch(toggleVizPicker(false));
}
},
[dispatch, panel, plugin.meta.id]
[dispatch, panel]
);
// Give Search input focus when using radio button switch list mode
@ -53,27 +51,20 @@ export const VisualizationSelectPane: FC<Props> = ({ panel }) => {
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]
);
// 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);
const suffix =
searchQuery !== '' ? (
<Button icon="times" fill="text" size="sm" onClick={() => setSearchQuery('')}>
Clear
</Button>
) : null;
// if (match && match.length) {
// onPluginTypeChange(match[0], false);
// }
// }
// },
// [onPluginTypeChange, plugin.meta]
// );
if (!plugin) {
return null;
@ -81,6 +72,7 @@ export const VisualizationSelectPane: FC<Props> = ({ panel }) => {
const radioOptions: Array<SelectableValue<ListMode>> = [
{ label: 'Visualizations', value: ListMode.Visualizations },
{ label: 'Suggestions', value: ListMode.Suggestions },
{
label: 'Library panels',
value: ListMode.LibraryPanels,
@ -92,13 +84,11 @@ export const VisualizationSelectPane: FC<Props> = ({ panel }) => {
<div className={styles.openWrapper}>
<div className={styles.formBox}>
<div className={styles.searchRow}>
<Input
<FilterInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
onKeyPress={onKeyPress}
prefix={<Icon name="search" />}
suffix={suffix}
onChange={setSearchQuery}
ref={searchRef}
autoFocus={true}
placeholder="Search for..."
/>
<Button
@ -120,8 +110,19 @@ export const VisualizationSelectPane: FC<Props> = ({ panel }) => {
{listMode === ListMode.Visualizations && (
<VizTypePicker
current={plugin.meta}
onTypeChange={onPluginTypeChange}
onChange={onVizChange}
searchQuery={searchQuery}
data={data}
onClose={() => {}}
/>
)}
{listMode === ListMode.Suggestions && (
<VisualizationSuggestions
current={plugin.meta}
onChange={onVizChange}
searchQuery={searchQuery}
panel={panel}
data={data}
onClose={() => {}}
/>
)}
@ -138,6 +139,7 @@ export const VisualizationSelectPane: FC<Props> = ({ panel }) => {
enum ListMode {
Visualizations,
LibraryPanels,
Suggestions,
}
VisualizationSelectPane.displayName = 'VisualizationSelectPane';

@ -126,3 +126,7 @@ export const {
} = pluginsSlice.actions;
export const panelEditorReducer = pluginsSlice.reducer;
export default {
panelEditor: panelEditorReducer,
};

@ -5,8 +5,8 @@ import { PanelChromeAngular } from './PanelChromeAngular';
import { DashboardModel, PanelModel } from '../state';
import { StoreState } from 'app/types';
import { PanelPlugin } from '@grafana/data';
import { cleanUpPanelState, setPanelInstanceState } from '../../panel/state/reducers';
import { initPanelState } from '../../panel/state/actions';
import { cleanUpPanelState } from '../../panel/state/reducers';
export interface OwnProps {
panel: PanelModel;
@ -39,6 +39,7 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => {
const mapDispatchToProps = {
initPanelState,
cleanUpPanelState,
setPanelInstanceState,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
@ -75,6 +76,10 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
}
}
onInstanceStateChange = (value: any) => {
this.props.setPanelInstanceState({ key: this.props.stateKey, value });
};
renderPanel(plugin: PanelPlugin) {
const { dashboard, panel, isViewing, isInView, isEditing, width, height } = this.props;
@ -103,6 +108,7 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
isInView={isInView}
width={width}
height={height}
onInstanceStateChange={this.onInstanceStateChange}
/>
);
}

@ -30,6 +30,7 @@ function setupTestContext(options: Partial<Props>) {
timeRange: jest.fn(),
} as unknown) as TimeSrv;
setTimeSrv(timeSrv);
const defaults: Props = {
panel: ({
id: 123,
@ -54,6 +55,7 @@ function setupTestContext(options: Partial<Props>) {
isInView: false,
width: 100,
height: 100,
onInstanceStateChange: () => {},
};
const props = { ...defaults, ...options };

@ -38,8 +38,6 @@ import { deleteAnnotation, saveAnnotation, updateAnnotation } from '../../annota
import { getDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
import { liveTimer } from './liveTimer';
import { isSoloRoute } from '../../../routes/utils';
import { setPanelInstanceState } from '../../panel/state/reducers';
import { store } from 'app/store/store';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
@ -52,6 +50,7 @@ export interface Props {
isInView: boolean;
width: number;
height: number;
onInstanceStateChange: (value: any) => void;
}
export interface State {
@ -97,15 +96,14 @@ export class PanelChrome extends PureComponent<Props, State> {
}
onInstanceStateChange = (value: any) => {
this.props.onInstanceStateChange(value);
this.setState({
context: {
...this.state.context,
instanceState: value,
},
});
// Set redux panel state so panel options can get notified
store.dispatch(setPanelInstanceState({ key: this.props.panel.key, value }));
};
getPanelContextApp() {

@ -434,8 +434,6 @@ export class PanelModel implements DataConfigSource, IPanelModel {
this.plugin = newPlugin;
this.configRev++;
// For some reason I need to rebind replace variables here, otherwise the viz repeater does not work
this.replaceVariables = this.replaceVariables.bind(this);
this.applyPluginOptionDefaults(newPlugin, true);
if (newPlugin.onPanelMigration) {

@ -8,7 +8,6 @@ import {
} from 'app/types';
import { AngularComponent } from '@grafana/runtime';
import { processAclItems } from 'app/core/utils/acl';
import { panelEditorReducer } from '../components/PanelEditor/state/reducers';
import { DashboardModel } from './DashboardModel';
import { PanelModel } from './PanelModel';
import { PanelPlugin } from '@grafana/data';
@ -100,5 +99,4 @@ export const dashboardReducer = dashbardSlice.reducer;
export default {
dashboard: dashboardReducer,
panelEditor: panelEditorReducer,
};

@ -8,7 +8,7 @@ import { LibraryPanelsSearch, LibraryPanelsSearchProps } from './LibraryPanelsSe
import * as api from '../../state/api';
import { LibraryElementKind, LibraryElementsSearchResult } from '../../types';
import { backendSrv } from '../../../../core/services/backend_srv';
import * as viztypepicker from '../../../panel/components/VizTypePicker/VizTypePicker';
import * as panelUtils from '../../../panel/state/util';
jest.mock('@grafana/runtime', () => ({
...((jest.requireActual('@grafana/runtime') as unknown) as object),
@ -64,9 +64,7 @@ async function getTestContext(
.spyOn(backendSrv, 'get')
.mockResolvedValue({ sortOptions: [{ displaName: 'Desc', name: 'alpha-desc' }] });
const getLibraryPanelsSpy = jest.spyOn(api, 'getLibraryPanels').mockResolvedValue(searchResult);
const getAllPanelPluginMetaSpy = jest
.spyOn(viztypepicker, 'getAllPanelPluginMeta')
.mockReturnValue([graph, timeseries]);
const getAllPanelPluginMetaSpy = jest.spyOn(panelUtils, 'getAllPanelPluginMeta').mockReturnValue([graph, timeseries]);
const props: LibraryPanelsSearchProps = {
onClick: jest.fn(),

@ -0,0 +1,51 @@
import React from 'react';
import { GrafanaTheme2, VisualizationSuggestion } from '@grafana/data';
import { useStyles2 } from '../../../../../packages/grafana-ui/src';
import { css } from '@emotion/css';
interface Props {
message: string;
suggestions?: VisualizationSuggestion[];
}
export function CannotVisualizeData({ message, suggestions }: Props) {
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<div className={styles.message}>{message}</div>
{
// suggestions && (
// <div className={styles.suggestions}>
// {suggestions.map((suggestion, index) => (
// <VisualizationPreview
// key={index}
// data={data!}
// suggestion={suggestion}
// onChange={onChange}
// width={150}
// />
// ))}
// </div>
// )
}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css`
display: flex;
align-items: center;
height: 100%;
width: 100%;
`,
message: css`
text-align: center;
color: $text-muted;
font-size: $font-size-lg;
width: 100%;
`,
};
};

@ -0,0 +1,128 @@
import React, { CSSProperties } from 'react';
import { GrafanaTheme2, PanelData, VisualizationSuggestion } from '@grafana/data';
import { PanelRenderer } from '../PanelRenderer';
import { css } from '@emotion/css';
import { Tooltip, useStyles2 } from '@grafana/ui';
import { VizTypeChangeDetails } from './types';
import { cloneDeep } from 'lodash';
import { selectors } from '@grafana/e2e-selectors';
export interface Props {
data: PanelData;
width: number;
suggestion: VisualizationSuggestion;
showTitle?: boolean;
onChange: (details: VizTypeChangeDetails) => void;
}
export function VisualizationPreview({ data, suggestion, onChange, width, showTitle }: Props) {
const styles = useStyles2(getStyles);
const { innerStyles, outerStyles, renderWidth, renderHeight } = getPreviewDimensionsAndStyles(width);
const onClick = () => {
onChange({
pluginId: suggestion.pluginId,
options: suggestion.options,
fieldConfig: suggestion.fieldConfig,
});
};
let preview = suggestion;
if (suggestion.previewModifier) {
preview = cloneDeep(suggestion);
suggestion.previewModifier(preview);
}
return (
<div onClick={onClick} data-testid={selectors.components.VisualizationPreview.card(suggestion.name)}>
{showTitle && <div className={styles.name}>{suggestion.name}</div>}
<div className={styles.vizBox} style={outerStyles}>
<Tooltip content={suggestion.name}>
<div style={innerStyles} className={styles.renderContainer}>
<PanelRenderer
title=""
data={data}
pluginId={suggestion.pluginId}
width={renderWidth}
height={renderHeight}
options={preview.options}
fieldConfig={preview.fieldConfig}
/>
<div className={styles.hoverPane} />
</div>
</Tooltip>
</div>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
hoverPane: css({
position: 'absolute',
top: 0,
right: 0,
left: 0,
borderRadius: theme.spacing(2),
bottom: 0,
}),
vizBox: css`
position: relative;
border-radius: ${theme.shape.borderRadius(1)};
cursor: pointer;
border: 1px solid ${theme.colors.border.strong};
transition: ${theme.transitions.create(['background'], {
duration: theme.transitions.duration.short,
})};
&:hover {
background: ${theme.colors.background.secondary};
}
`,
name: css`
font-size: ${theme.typography.bodySmall.fontSize};
white-space: nowrap;
overflow: hidden;
color: ${theme.colors.text.secondary};
font-weight: ${theme.typography.fontWeightMedium};
text-overflow: ellipsis;
`,
renderContainer: css`
position: absolute;
transform-origin: left top;
top: 6px;
left: 6px;
`,
};
};
interface PreviewDimensionsAndStyles {
renderWidth: number;
renderHeight: number;
innerStyles: CSSProperties;
outerStyles: CSSProperties;
}
function getPreviewDimensionsAndStyles(width: number): PreviewDimensionsAndStyles {
const aspectRatio = 16 / 10;
const showWidth = width;
const showHeight = width * (1 / aspectRatio);
const renderWidth = 350;
const renderHeight = renderWidth * (1 / aspectRatio);
const padding = 6;
const widthFactor = (showWidth - padding * 2) / renderWidth;
const heightFactor = (showHeight - padding * 2) / renderHeight;
return {
renderHeight,
renderWidth,
outerStyles: { width: showWidth, height: showHeight },
innerStyles: {
width: renderWidth,
height: renderHeight,
transform: `scale(${widthFactor}, ${heightFactor})`,
},
};
}

@ -0,0 +1,106 @@
import React from 'react';
import { useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, PanelData, PanelPluginMeta, PanelModel, VisualizationSuggestion } from '@grafana/data';
import { css } from '@emotion/css';
import { VizTypeChangeDetails } from './types';
import { VisualizationPreview } from './VisualizationPreview';
import { getAllSuggestions } from '../../state/getAllSuggestions';
import { useAsync, useLocalStorage } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
export interface Props {
current: PanelPluginMeta;
data?: PanelData;
panel?: PanelModel;
onChange: (options: VizTypeChangeDetails) => void;
searchQuery: string;
onClose: () => void;
}
export function VisualizationSuggestions({ onChange, data, panel, searchQuery }: Props) {
const styles = useStyles2(getStyles);
const { value: suggestions } = useAsync(() => getAllSuggestions(data, panel), [data, panel]);
// temp test
const [showTitle, setShowTitle] = useLocalStorage(`VisualizationSuggestions.showTitle`, false);
const filteredSuggestions = filterSuggestionsBySearch(searchQuery, suggestions);
return (
<AutoSizer disableHeight style={{ width: '100%', height: '100%' }}>
{({ width }) => {
if (!width) {
return null;
}
const columnCount = Math.floor(width / 170);
const spaceBetween = 8 * (columnCount! - 1);
const previewWidth = (width - spaceBetween) / columnCount!;
return (
<div>
<div className={styles.filterRow}>
<div className={styles.infoText} onClick={() => setShowTitle(!showTitle)}>
Based on current data
</div>
</div>
<div className={styles.grid} style={{ gridTemplateColumns: `repeat(auto-fill, ${previewWidth - 1}px)` }}>
{filteredSuggestions.map((suggestion, index) => (
<VisualizationPreview
key={index}
data={data!}
suggestion={suggestion}
onChange={onChange}
width={previewWidth}
showTitle={showTitle}
/>
))}
{searchQuery && filteredSuggestions.length === 0 && (
<div className={styles.infoText}>No results matched your query</div>
)}
</div>
</div>
);
}}
</AutoSizer>
);
}
function filterSuggestionsBySearch(
searchQuery: string,
suggestions?: VisualizationSuggestion[]
): VisualizationSuggestion[] {
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',
justifyContent: 'space-around',
alignItems: 'center',
paddingBottom: '8px',
}),
infoText: css({
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,
fontStyle: 'italic',
}),
grid: css({
display: 'grid',
gridGap: theme.spacing(1),
gridTemplateColumns: 'repeat(auto-fill, 144px)',
marginBottom: theme.spacing(1),
justifyContent: 'space-evenly',
}),
};
};

@ -1,118 +1,63 @@
import React, { useCallback, useMemo } from 'react';
import config from 'app/core/config';
import React, { useMemo } from 'react';
import { VizTypePickerPlugin } from './VizTypePickerPlugin';
import { EmptySearchResult, stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme, PanelPluginMeta, PluginState } from '@grafana/data';
import { EmptySearchResult, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, PanelData, PanelPluginMeta } from '@grafana/data';
import { css } from '@emotion/css';
import { filterPluginList, getAllPanelPluginMeta } from '../../state/util';
import { VizTypeChangeDetails } from './types';
export interface Props {
current: PanelPluginMeta;
onTypeChange: (newType: PanelPluginMeta, withModKey: boolean) => void;
data?: PanelData;
onChange: (options: VizTypeChangeDetails) => void;
searchQuery: string;
onClose: () => void;
}
export function getAllPanelPluginMeta(): PanelPluginMeta[] {
const allPanels = config.panels;
return Object.keys(allPanels)
.filter((key) => allPanels[key]['hideFromList'] === false)
.map((key) => allPanels[key])
.sort((a: PanelPluginMeta, b: PanelPluginMeta) => a.sort - b.sort);
}
export function filterPluginList(
pluginsList: PanelPluginMeta[],
searchQuery: string,
current: PanelPluginMeta
): PanelPluginMeta[] {
if (!searchQuery.length) {
return pluginsList.filter((p) => {
if (p.state === PluginState.deprecated) {
return current.id === p.id;
}
return true;
});
}
const query = searchQuery.toLowerCase();
const first: PanelPluginMeta[] = [];
const match: PanelPluginMeta[] = [];
for (const item of pluginsList) {
if (item.state === PluginState.deprecated && current.id !== item.id) {
continue;
}
const name = item.name.toLowerCase();
const idx = name.indexOf(query);
if (idx === 0) {
first.push(item);
} else if (idx > 0) {
match.push(item);
}
}
return first.concat(match);
}
export const VizTypePicker: React.FC<Props> = ({ searchQuery, onTypeChange, current }) => {
const theme = useTheme();
const styles = getStyles(theme);
export function VizTypePicker({ searchQuery, onChange, current, data }: Props) {
const styles = useStyles2(getStyles);
const pluginsList: PanelPluginMeta[] = useMemo(() => {
return getAllPanelPluginMeta();
}, []);
const getFilteredPluginList = useCallback((): PanelPluginMeta[] => {
const filteredPluginTypes = useMemo((): PanelPluginMeta[] => {
return filterPluginList(pluginsList, searchQuery, current);
}, [current, pluginsList, searchQuery]);
const renderVizPlugin = (plugin: PanelPluginMeta, index: number) => {
const isCurrent = plugin.id === current.id;
const filteredPluginList = getFilteredPluginList();
const matchesQuery = filteredPluginList.indexOf(plugin) > -1;
return (
<VizTypePickerPlugin
disabled={!matchesQuery && !!searchQuery}
key={plugin.id}
isCurrent={isCurrent}
plugin={plugin}
onClick={(e) => onTypeChange(plugin, Boolean(e.metaKey || e.ctrlKey || e.altKey))}
/>
);
};
const filteredPluginList = getFilteredPluginList();
const hasResults = filteredPluginList.length > 0;
const renderList = filteredPluginList.concat(pluginsList.filter((p) => filteredPluginList.indexOf(p) === -1));
if (filteredPluginTypes.length === 0) {
return <EmptySearchResult>Could not find anything matching your query</EmptySearchResult>;
}
return (
<div className={styles.grid}>
{hasResults ? (
renderList.map((plugin, index) => {
if (plugin.state === PluginState.deprecated) {
return null;
{filteredPluginTypes.map((plugin, index) => (
<VizTypePickerPlugin
disabled={false}
key={plugin.id}
isCurrent={plugin.id === current.id}
plugin={plugin}
onClick={(e) =>
onChange({
pluginId: plugin.id,
withModKey: Boolean(e.metaKey || e.ctrlKey || e.altKey),
})
}
return renderVizPlugin(plugin, index);
})
) : (
<EmptySearchResult>Could not find anything matching your query</EmptySearchResult>
)}
/>
))}
</div>
);
};
VizTypePicker.displayName = 'VizTypePicker';
}
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const getStyles = (theme: GrafanaTheme2) => {
return {
grid: css`
max-width: 100%;
display: grid;
grid-gap: ${theme.spacing.sm};
grid-gap: ${theme.spacing(0.5)};
`,
heading: css({
...theme.typography.h5,
margin: theme.spacing(0, 0.5, 1),
}),
};
});
};

@ -0,0 +1,8 @@
import { FieldConfigSource } from '@grafana/data';
export interface VizTypeChangeDetails {
pluginId: string;
options?: any;
fieldConfig?: FieldConfigSource;
withModKey?: boolean;
}

@ -4,6 +4,8 @@ import { changePanelPlugin } from './actions';
import { panelModelAndPluginReady } from './reducers';
import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks';
import { panelPluginLoaded } from 'app/features/plugins/admin/state/actions';
import { standardEditorsRegistry, standardFieldConfigEditorRegistry } from '@grafana/data';
import { mockStandardFieldConfigOptions } from 'test/helpers/fieldConfig';
jest.mock('app/features/plugins/importPanelPlugin', () => {
return {
@ -11,12 +13,15 @@ jest.mock('app/features/plugins/importPanelPlugin', () => {
return Promise.resolve(
getPanelPlugin({
id: 'table',
})
}).useFieldConfig()
);
},
};
});
standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions());
standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions());
describe('panel state actions', () => {
describe('changePanelPlugin', () => {
it('Should load plugin and call changePlugin', async () => {
@ -29,12 +34,43 @@ describe('panel state actions', () => {
panels: {},
})
.givenThunk(changePanelPlugin)
.whenThunkIsDispatched(sourcePanel, 'table');
.whenThunkIsDispatched({
panel: sourcePanel,
pluginId: 'table',
});
expect(dispatchedActions.length).toBe(2);
expect(dispatchedActions[0].type).toBe(panelPluginLoaded.type);
expect(dispatchedActions[1].type).toBe(panelModelAndPluginReady.type);
expect(sourcePanel.type).toBe('table');
});
it('Should apply options and fieldConfig', async () => {
const sourcePanel = new PanelModel({ id: 12, type: 'graph' });
await thunkTester({
plugins: {
panels: {},
},
panels: {},
})
.givenThunk(changePanelPlugin)
.whenThunkIsDispatched({
panel: sourcePanel,
pluginId: 'table',
options: {
showHeader: true,
},
fieldConfig: {
defaults: {
unit: 'short',
},
overrides: [],
},
});
expect(sourcePanel.options.showHeader).toBe(true);
expect(sourcePanel.fieldConfig.defaults.unit).toBe('short');
});
});
});

@ -6,6 +6,8 @@ import { panelModelAndPluginReady } from './reducers';
import { LibraryElementDTO } from 'app/features/library-panels/types';
import { toPanelModelLibraryPanel } from 'app/features/library-panels/utils';
import { PanelOptionsChangedEvent, PanelQueriesChangedEvent } from 'app/types/events';
import { DataTransformerConfig, FieldConfigSource } from '@grafana/data';
import { getPanelOptionsWithDefaults } from 'app/features/dashboard/state/getPanelOptionsWithDefaults';
export function initPanelState(panel: PanelModel): ThunkResult<void> {
return async (dispatch, getStore) => {
@ -29,10 +31,23 @@ export function initPanelState(panel: PanelModel): ThunkResult<void> {
};
}
export function changePanelPlugin(panel: PanelModel, pluginId: string): ThunkResult<void> {
export interface ChangePanelPluginAndOptionsArgs {
panel: PanelModel;
pluginId: string;
options?: any;
fieldConfig?: FieldConfigSource;
transformations?: DataTransformerConfig[];
}
export function changePanelPlugin({
panel,
pluginId,
options,
fieldConfig,
}: ChangePanelPluginAndOptionsArgs): ThunkResult<void> {
return async (dispatch, getStore) => {
// ignore action is no change
if (panel.type === pluginId) {
if (panel.type === pluginId && !options && !fieldConfig) {
return;
}
@ -43,12 +58,28 @@ export function changePanelPlugin(panel: PanelModel, pluginId: string): ThunkRes
plugin = await dispatch(loadPanelPlugin(pluginId));
}
const oldKey = panel.key;
let cleanUpKey = panel.key;
if (panel.type !== pluginId) {
panel.changePlugin(plugin);
}
if (options || fieldConfig) {
const newOptions = getPanelOptionsWithDefaults({
plugin,
currentOptions: options || panel.options,
currentFieldConfig: fieldConfig || panel.fieldConfig,
isAfterPluginChange: false,
});
panel.options = newOptions.options;
panel.fieldConfig = newOptions.fieldConfig;
panel.configRev++;
}
panel.changePlugin(plugin);
panel.generateNewKey();
dispatch(panelModelAndPluginReady({ key: panel.key, plugin, cleanUpKey: oldKey }));
dispatch(panelModelAndPluginReady({ key: panel.key, plugin, cleanUpKey }));
};
}

@ -0,0 +1,305 @@
import {
DataFrame,
FieldType,
getDefaultTimeRange,
LoadingState,
PanelData,
toDataFrame,
VisualizationSuggestion,
} from '@grafana/data';
import { config } from 'app/core/config';
import { SuggestionName } from 'app/types/suggestions';
import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions';
jest.unmock('app/core/core');
jest.unmock('app/features/plugins/plugin_loader');
for (const pluginId of panelsToCheckFirst) {
config.panels[pluginId] = {
module: `app/plugins/panel/${pluginId}/module`,
} as any;
}
class ScenarioContext {
data: DataFrame[] = [];
suggestions: VisualizationSuggestion[] = [];
setData(scenarioData: DataFrame[]) {
this.data = scenarioData;
beforeAll(async () => {
await this.run();
});
}
async run() {
const panelData: PanelData = {
series: this.data,
state: LoadingState.Done,
timeRange: getDefaultTimeRange(),
};
this.suggestions = await getAllSuggestions(panelData);
}
names() {
return this.suggestions.map((x) => x.name);
}
}
function scenario(name: string, setup: (ctx: ScenarioContext) => void) {
describe(name, () => {
const ctx = new ScenarioContext();
setup(ctx);
});
}
scenario('No series', (ctx) => {
ctx.setData([]);
it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel, SuggestionName.DashboardList]);
});
});
scenario('No rows', (ctx) => {
ctx.setData([
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [] },
{ name: 'Max', type: FieldType.number, values: [] },
],
}),
]);
it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel, SuggestionName.DashboardList]);
});
});
scenario('Single frame with time and number field', (ctx) => {
ctx.setData([
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2, 3, 4, 5] },
{ name: 'Max', type: FieldType.number, values: [1, 10, 50, 2, 5] },
],
}),
]);
it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([
SuggestionName.LineChart,
SuggestionName.LineChartSmooth,
SuggestionName.AreaChart,
SuggestionName.BarChart,
SuggestionName.Gauge,
SuggestionName.GaugeNoThresholds,
SuggestionName.Stat,
SuggestionName.StatColoredBackground,
SuggestionName.BarGaugeBasic,
SuggestionName.BarGaugeLCD,
SuggestionName.Table,
SuggestionName.StateTimeline,
]);
});
it('Bar chart suggestion should be using timeseries panel', () => {
expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)?.pluginId).toBe('timeseries');
});
it('Stat panels have reduce values disabled', () => {
for (const suggestion of ctx.suggestions) {
if (suggestion.options?.reduceOptions?.values) {
throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`);
}
}
});
});
scenario('Single frame with time 2 number fields', (ctx) => {
ctx.setData([
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2, 3, 4, 5] },
{ name: 'ServerA', type: FieldType.number, values: [1, 10, 50, 2, 5] },
{ name: 'ServerB', type: FieldType.number, values: [1, 10, 50, 2, 5] },
],
}),
]);
it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([
SuggestionName.LineChart,
SuggestionName.LineChartSmooth,
SuggestionName.AreaChartStacked,
SuggestionName.AreaChartStackedPercent,
SuggestionName.BarChartStacked,
SuggestionName.BarChartStackedPercent,
SuggestionName.Gauge,
SuggestionName.GaugeNoThresholds,
SuggestionName.Stat,
SuggestionName.StatColoredBackground,
SuggestionName.PieChart,
SuggestionName.PieChartDonut,
SuggestionName.BarGaugeBasic,
SuggestionName.BarGaugeLCD,
SuggestionName.Table,
SuggestionName.StateTimeline,
]);
});
it('Stat panels have reduceOptions.values disabled', () => {
for (const suggestion of ctx.suggestions) {
if (suggestion.options?.reduceOptions?.values) {
throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`);
}
}
});
});
scenario('Single time series with 100 data points', (ctx) => {
ctx.setData([
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [...Array(100).keys()] },
{ name: 'ServerA', type: FieldType.number, values: [...Array(100).keys()] },
],
}),
]);
it('should not suggest bar chart', () => {
expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)).toBe(undefined);
});
});
scenario('30 time series with 100 data points', (ctx) => {
ctx.setData(
repeatFrame(
30,
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [...Array(100).keys()] },
{ name: 'ServerA', type: FieldType.number, values: [...Array(100).keys()] },
],
})
)
);
it('should not suggest timeline', () => {
expect(ctx.suggestions.find((x) => x.pluginId === 'state-timeline')).toBe(undefined);
});
});
scenario('50 time series with 100 data points', (ctx) => {
ctx.setData(
repeatFrame(
50,
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [...Array(100).keys()] },
{ name: 'ServerA', type: FieldType.number, values: [...Array(100).keys()] },
],
})
)
);
it('should not suggest gauge', () => {
expect(ctx.suggestions.find((x) => x.pluginId === 'gauge')).toBe(undefined);
});
});
scenario('Single frame with string and number field', (ctx) => {
ctx.setData([
toDataFrame({
fields: [
{ name: 'Name', type: FieldType.string, values: ['Hugo', 'Dominik', 'Marcus'] },
{ name: 'ServerA', type: FieldType.number, values: [1, 2, 3] },
],
}),
]);
it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([
SuggestionName.BarChart,
SuggestionName.BarChartHorizontal,
SuggestionName.Gauge,
SuggestionName.GaugeNoThresholds,
SuggestionName.Stat,
SuggestionName.StatColoredBackground,
SuggestionName.PieChart,
SuggestionName.PieChartDonut,
SuggestionName.BarGaugeBasic,
SuggestionName.BarGaugeLCD,
SuggestionName.Table,
]);
});
it('Stat/Gauge/BarGauge/PieChart panels to have reduceOptions.values enabled', () => {
for (const suggestion of ctx.suggestions) {
if (suggestion.options?.reduceOptions && !suggestion.options?.reduceOptions?.values) {
throw new Error(`Suggestion ${suggestion.name} reduce.values set to false when it should be true`);
}
}
});
});
scenario('Single frame with string and 2 number field', (ctx) => {
ctx.setData([
toDataFrame({
fields: [
{ name: 'Name', type: FieldType.string, values: ['Hugo', 'Dominik', 'Marcus'] },
{ name: 'ServerA', type: FieldType.number, values: [1, 2, 3] },
{ name: 'ServerB', type: FieldType.number, values: [1, 2, 3] },
],
}),
]);
it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([
SuggestionName.BarChart,
SuggestionName.BarChartStacked,
SuggestionName.BarChartStackedPercent,
SuggestionName.BarChartHorizontal,
SuggestionName.BarChartHorizontalStacked,
SuggestionName.BarChartHorizontalStackedPercent,
SuggestionName.Gauge,
SuggestionName.GaugeNoThresholds,
SuggestionName.Stat,
SuggestionName.StatColoredBackground,
SuggestionName.PieChart,
SuggestionName.PieChartDonut,
SuggestionName.BarGaugeBasic,
SuggestionName.BarGaugeLCD,
SuggestionName.Table,
]);
});
});
scenario('Single frame with string with only string field', (ctx) => {
ctx.setData([
toDataFrame({
fields: [{ name: 'Name', type: FieldType.string, values: ['Hugo', 'Dominik', 'Marcus'] }],
}),
]);
it('should return correct suggestions', () => {
expect(ctx.names()).toEqual([SuggestionName.Stat, SuggestionName.StatColoredBackground, SuggestionName.Table]);
});
it('Stat panels have reduceOptions.fields set to show all fields', () => {
for (const suggestion of ctx.suggestions) {
if (suggestion.options?.reduceOptions) {
expect(suggestion.options.reduceOptions.fields).toBe('/.*/');
}
}
});
});
function repeatFrame(count: number, frame: DataFrame): DataFrame[] {
const frames: DataFrame[] = [];
for (let i = 0; i < count; i++) {
frames.push(frame);
}
return frames;
}

@ -0,0 +1,30 @@
import { PanelData, VisualizationSuggestion, VisualizationSuggestionsBuilder, PanelModel } from '@grafana/data';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
export const panelsToCheckFirst = [
'timeseries',
'barchart',
'gauge',
'stat',
'piechart',
'bargauge',
'table',
'state-timeline',
'text',
'dashlist',
];
export async function getAllSuggestions(data?: PanelData, panel?: PanelModel): Promise<VisualizationSuggestion[]> {
const builder = new VisualizationSuggestionsBuilder(data, panel);
for (const pluginId of panelsToCheckFirst) {
const plugin = await importPanelPlugin(pluginId);
const supplier = plugin.getSuggestionsSupplier();
if (supplier) {
supplier.getSuggestionsForData(builder);
}
}
return builder.getList();
}

@ -0,0 +1,17 @@
import { VisualizationSuggestion, PanelModel, PanelPlugin, PanelData } from '@grafana/data';
export function getOptionSuggestions(
plugin: PanelPlugin,
panel: PanelModel,
data?: PanelData
): VisualizationSuggestion[] {
// const supplier = plugin.getSuggestionsSupplier();
// if (supplier && supplier.getOptionSuggestions) {
// const builder = new VisualizationSuggestionsBuilder(data, panel);
// supplier.getOptionSuggestions(builder);
// return builder.getList();
// }
return [];
}

@ -0,0 +1,47 @@
import { PanelPluginMeta, PluginState } from '@grafana/data';
import { config } from 'app/core/config';
export function getAllPanelPluginMeta(): PanelPluginMeta[] {
const allPanels = config.panels;
return Object.keys(allPanels)
.filter((key) => allPanels[key]['hideFromList'] === false)
.map((key) => allPanels[key])
.sort((a: PanelPluginMeta, b: PanelPluginMeta) => a.sort - b.sort);
}
export function filterPluginList(
pluginsList: PanelPluginMeta[],
searchQuery: string,
current: PanelPluginMeta
): PanelPluginMeta[] {
if (!searchQuery.length) {
return pluginsList.filter((p) => {
if (p.state === PluginState.deprecated) {
return current.id === p.id;
}
return true;
});
}
const query = searchQuery.toLowerCase();
const first: PanelPluginMeta[] = [];
const match: PanelPluginMeta[] = [];
for (const item of pluginsList) {
if (item.state === PluginState.deprecated && current.id !== item.id) {
continue;
}
const name = item.name.toLowerCase();
const idx = name.indexOf(query);
if (idx === 0) {
first.push(item);
} else if (idx > 0) {
match.push(item);
}
}
return first.concat(match);
}

@ -12,6 +12,7 @@ import {
GENERAL_FOLDER,
ReadonlyFolderPicker,
} from '../../../core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker';
import { AlertListSuggestionsSupplier } from './suggestions';
function showIfCurrentState(options: AlertListOptions) {
return options.showOptions === ShowOption.Current;
@ -145,7 +146,8 @@ const alertList = new PanelPlugin<AlertListOptions>(AlertList)
showIf: showIfCurrentState,
});
})
.setMigrationHandler(alertListPanelMigrationHandler);
.setMigrationHandler(alertListPanelMigrationHandler)
.setSuggestionsSupplier(new AlertListSuggestionsSupplier());
const unifiedAlertList = new PanelPlugin<UnifiedAlertListOptions>(UnifiedAlertList).setPanelOptions((builder) => {
builder

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

@ -11,6 +11,7 @@ import { StackingMode, VisibilityMode } from '@grafana/schema';
import { graphFieldOptions, commonOptionsBuilder } from '@grafana/ui';
import { BarChartFieldConfig, BarChartOptions, defaultBarChartFieldConfig } from 'app/plugins/panel/barchart/types';
import { BarChartSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<BarChartOptions, BarChartFieldConfig>(BarChartPanel)
.useFieldConfig({
@ -125,7 +126,8 @@ export const plugin = new PanelPlugin<BarChartOptions, BarChartFieldConfig>(BarC
commonOptionsBuilder.addTooltipOptions(builder);
commonOptionsBuilder.addLegendOptions(builder);
commonOptionsBuilder.addTextSizeOptions(builder, false);
});
})
.setSuggestionsSupplier(new BarChartSuggestionsSupplier());
function countNumberFields(data?: DataFrame[]): number {
let count = 0;

@ -0,0 +1,94 @@
import { VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data';
import { LegendDisplayMode, StackingMode, VisibilityMode } from '@grafana/schema';
import { SuggestionName } from 'app/types/suggestions';
import { BarChartFieldConfig, BarChartOptions } from './types';
export class BarChartSuggestionsSupplier {
getListWithDefaults(builder: VisualizationSuggestionsBuilder) {
return builder.getListAppender<BarChartOptions, BarChartFieldConfig>({
name: SuggestionName.BarChart,
pluginId: 'barchart',
options: {
showValue: VisibilityMode.Never,
legend: {
displayMode: LegendDisplayMode.Hidden,
placement: 'right',
} as any,
},
fieldConfig: {
defaults: {
unit: 'short',
custom: {},
},
overrides: [],
},
previewModifier: (s) => {
s.options!.barWidth = 0.8;
},
});
}
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const list = this.getListWithDefaults(builder);
const { dataSummary } = builder;
if (dataSummary.frameCount !== 1) {
return;
}
if (!dataSummary.hasNumberField || !dataSummary.hasStringField) {
return;
}
// if you have this many rows barchart might not be a good fit
if (dataSummary.rowCountTotal > 50) {
return;
}
// Vertical bars
list.append({
name: SuggestionName.BarChart,
});
if (dataSummary.numberFieldCount > 1) {
list.append({
name: SuggestionName.BarChartStacked,
options: {
stacking: StackingMode.Normal,
},
});
list.append({
name: SuggestionName.BarChartStackedPercent,
options: {
stacking: StackingMode.Percent,
},
});
}
// horizontal bars
list.append({
name: SuggestionName.BarChartHorizontal,
options: {
orientation: VizOrientation.Horizontal,
},
});
if (dataSummary.numberFieldCount > 1) {
list.append({
name: SuggestionName.BarChartHorizontalStacked,
options: {
stacking: StackingMode.Normal,
orientation: VizOrientation.Horizontal,
},
});
list.append({
name: SuggestionName.BarChartHorizontalStackedPercent,
options: {
orientation: VizOrientation.Horizontal,
stacking: StackingMode.Percent,
},
});
}
}
}

@ -4,6 +4,7 @@ import { BarGaugePanel } from './BarGaugePanel';
import { BarGaugeOptions, displayModes } from './types';
import { addOrientationOption, addStandardDataReduceOptions } from '../stat/types';
import { barGaugePanelMigrationHandler } from './BarGaugeMigrations';
import { BarGaugeSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<BarGaugeOptions>(BarGaugePanel)
.useFieldConfig()
@ -30,4 +31,5 @@ export const plugin = new PanelPlugin<BarGaugeOptions>(BarGaugePanel)
});
})
.setPanelChangeHandler(sharedSingleStatPanelChangedHandler)
.setMigrationHandler(barGaugePanelMigrationHandler);
.setMigrationHandler(barGaugePanelMigrationHandler)
.setSuggestionsSupplier(new BarGaugeSuggestionsSupplier());

@ -0,0 +1,115 @@
import { VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data';
import { BarGaugeDisplayMode } from '@grafana/ui';
import { SuggestionName } from 'app/types/suggestions';
import { BarGaugeOptions } from './types';
export class BarGaugeSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const { dataSummary } = builder;
if (!dataSummary.hasData || !dataSummary.hasNumberField) {
return;
}
const list = builder.getListAppender<BarGaugeOptions, {}>({
name: '',
pluginId: 'bargauge',
options: {},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
previewModifier: (s) => {},
});
// This is probably not a good option for many numeric fields
if (dataSummary.numberFieldCount > 50) {
return;
}
// To use show individual row values we also need a string field to give each value a name
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 30) {
list.append({
name: SuggestionName.BarGaugeBasic,
options: {
reduceOptions: {
values: true,
calcs: [],
},
displayMode: BarGaugeDisplayMode.Basic,
orientation: VizOrientation.Horizontal,
},
fieldConfig: {
defaults: {
color: {
mode: 'continuous-GrYlRd',
},
},
overrides: [],
},
});
list.append({
name: SuggestionName.BarGaugeLCD,
options: {
reduceOptions: {
values: true,
calcs: [],
},
displayMode: BarGaugeDisplayMode.Lcd,
orientation: VizOrientation.Horizontal,
},
fieldConfig: {
defaults: {
color: {
mode: 'continuous-GrYlRd',
},
},
overrides: [],
},
});
} else {
list.append({
name: SuggestionName.BarGaugeBasic,
options: {
displayMode: BarGaugeDisplayMode.Basic,
orientation: VizOrientation.Horizontal,
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
},
fieldConfig: {
defaults: {
color: {
mode: 'continuous-GrYlRd',
},
},
overrides: [],
},
});
list.append({
name: SuggestionName.BarGaugeLCD,
options: {
displayMode: BarGaugeDisplayMode.Lcd,
orientation: VizOrientation.Horizontal,
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
},
fieldConfig: {
defaults: {
color: {
mode: 'continuous-GrYlRd',
},
},
overrides: [],
},
});
}
}
}

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

@ -0,0 +1,20 @@
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({});
}
}

@ -4,6 +4,7 @@ import { GaugeOptions } from './types';
import { addOrientationOption, addStandardDataReduceOptions } from '../stat/types';
import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations';
import { commonOptionsBuilder } from '@grafana/ui';
import { GaugeSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<GaugeOptions>(GaugePanel)
.useFieldConfig()
@ -28,4 +29,5 @@ export const plugin = new PanelPlugin<GaugeOptions>(GaugePanel)
commonOptionsBuilder.addTextSizeOptions(builder);
})
.setPanelChangeHandler(gaugePanelChangedHandler)
.setSuggestionsSupplier(new GaugeSuggestionsSupplier())
.setMigrationHandler(gaugePanelMigrationHandler);

@ -0,0 +1,85 @@
import { ThresholdsMode, VisualizationSuggestionsBuilder } from '@grafana/data';
import { SuggestionName } from 'app/types/suggestions';
import { GaugeOptions } from './types';
export class GaugeSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const { dataSummary } = builder;
if (!dataSummary.hasData || !dataSummary.hasNumberField) {
return;
}
// for many fields / series this is probably not a good fit
if (dataSummary.numberFieldCount >= 50) {
return;
}
const list = builder.getListAppender<GaugeOptions, {}>({
name: SuggestionName.Gauge,
pluginId: 'gauge',
options: {},
fieldConfig: {
defaults: {
thresholds: {
steps: [
{ value: -Infinity, color: 'green' },
{ value: 70, color: 'orange' },
{ value: 85, color: 'red' },
],
mode: ThresholdsMode.Percentage,
},
custom: {},
},
overrides: [],
},
previewModifier: (s) => {
if (s.options!.reduceOptions.values) {
s.options!.reduceOptions.limit = 2;
}
},
});
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) {
list.append({
name: SuggestionName.Gauge,
options: {
reduceOptions: {
values: true,
calcs: [],
},
},
});
list.append({
name: SuggestionName.GaugeNoThresholds,
options: {
reduceOptions: {
values: true,
calcs: [],
},
showThresholdMarkers: false,
},
});
} else {
list.append({
name: SuggestionName.Gauge,
options: {
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
},
});
list.append({
name: SuggestionName.GaugeNoThresholds,
options: {
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
showThresholdMarkers: false,
},
});
}
}
}

@ -233,7 +233,7 @@ export class GraphCtrl extends MetricsPanelCtrl {
tip: 'Data exists, but is not timeseries',
actionText: 'Switch to table view',
action: () => {
dispatch(changePanelPlugin(this.panel, 'table'));
dispatch(changePanelPlugin({ panel: this.panel, pluginId: 'table' }));
},
};
}

@ -5,6 +5,7 @@ import { LegendDisplayMode } from '@grafana/schema';
import { commonOptionsBuilder } from '@grafana/ui';
import { PieChartPanelChangedHandler } from './migrations';
import { addStandardDataReduceOptions } from '../stat/types';
import { PieChartSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<PieChartOptions>(PieChartPanel)
.setPanelChangeHandler(PieChartPanelChangedHandler)
@ -69,4 +70,5 @@ export const plugin = new PanelPlugin<PieChartOptions>(PieChartPanel)
},
showIf: (c) => c.legend.displayMode !== LegendDisplayMode.Hidden,
});
});
})
.setSuggestionsSupplier(new PieChartSuggestionsSupplier());

@ -0,0 +1,80 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import { LegendDisplayMode } from '@grafana/schema';
import { SuggestionName } from 'app/types/suggestions';
import { PieChartLabels, PieChartOptions, PieChartType } from './types';
export class PieChartSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const list = builder.getListAppender<PieChartOptions, {}>({
name: SuggestionName.PieChart,
pluginId: 'piechart',
options: {
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
displayLabels: [PieChartLabels.Percent],
legend: {
placement: 'right',
values: [],
} as any,
},
previewModifier: (s) => {
// Hide labels in preview
s.options!.legend.displayMode = LegendDisplayMode.Hidden;
s.options!.displayLabels = [];
},
});
const { dataSummary } = builder;
if (!dataSummary.hasNumberField) {
return;
}
if (dataSummary.hasStringField && dataSummary.frameCount === 1) {
// if many values this or single value PieChart is not a good option
if (dataSummary.rowCountTotal > 30 || dataSummary.rowCountTotal < 2) {
return;
}
list.append({
name: SuggestionName.PieChart,
options: {
reduceOptions: {
values: true,
calcs: [],
},
},
});
list.append({
name: SuggestionName.PieChartDonut,
options: {
reduceOptions: {
values: true,
calcs: [],
},
pieType: PieChartType.Donut,
},
});
return;
}
if (dataSummary.numberFieldCount > 30 || dataSummary.numberFieldCount < 2) {
return;
}
list.append({
name: SuggestionName.PieChart,
});
list.append({
name: SuggestionName.PieChartDonut,
options: {
pieType: PieChartType.Donut,
},
});
}
}

@ -8,6 +8,7 @@ import { PanelPlugin } from '@grafana/data';
import { addOrientationOption, addStandardDataReduceOptions, StatPanelOptions } from './types';
import { StatPanel } from './StatPanel';
import { statPanelChangedHandler } from './StatMigrations';
import { StatSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<StatPanelOptions>(StatPanel)
.useFieldConfig()
@ -77,4 +78,5 @@ export const plugin = new PanelPlugin<StatPanelOptions>(StatPanel)
})
.setNoPadding()
.setPanelChangeHandler(statPanelChangedHandler)
.setSuggestionsSupplier(new StatSuggestionsSupplier())
.setMigrationHandler(sharedSingleStatMigrationHandler);

@ -0,0 +1,77 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import { BigValueColorMode, BigValueGraphMode } from '@grafana/ui';
import { SuggestionName } from 'app/types/suggestions';
import { StatPanelOptions } from './types';
export class StatSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const { dataSummary } = builder;
if (!dataSummary.hasData) {
return;
}
const list = builder.getListAppender<StatPanelOptions, {}>({
name: SuggestionName.Stat,
pluginId: 'stat',
options: {},
fieldConfig: {
defaults: {
unit: 'short',
custom: {},
},
overrides: [],
},
previewModifier: (s) => {
if (s.options!.reduceOptions.values) {
s.options!.reduceOptions.limit = 1;
}
},
});
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) {
list.append({
name: SuggestionName.Stat,
options: {
reduceOptions: {
values: true,
calcs: [],
fields: dataSummary.hasNumberField ? undefined : '/.*/',
},
},
});
list.append({
name: SuggestionName.StatColoredBackground,
options: {
reduceOptions: {
values: true,
calcs: [],
fields: dataSummary.hasNumberField ? undefined : '/.*/',
},
colorMode: BigValueColorMode.Background,
},
});
} else if (dataSummary.hasNumberField) {
list.append({
options: {
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
},
});
list.append({
name: SuggestionName.StatColoredBackground,
options: {
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
graphMode: BigValueGraphMode.None,
colorMode: BigValueColorMode.Background,
},
});
}
}
}

@ -4,6 +4,7 @@ import { TimelineOptions, TimelineFieldConfig, defaultPanelOptions, defaultTimel
import { VisibilityMode } from '@grafana/schema';
import { commonOptionsBuilder } from '@grafana/ui';
import { timelinePanelChangedHandler } from './migrations';
import { StatTimelineSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(StateTimelinePanel)
.setPanelChangeHandler(timelinePanelChangedHandler)
@ -86,4 +87,5 @@ export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(Stat
commonOptionsBuilder.addLegendOptions(builder, false);
commonOptionsBuilder.addTooltipOptions(builder, true);
});
})
.setSuggestionsSupplier(new StatTimelineSuggestionsSupplier());

@ -0,0 +1,38 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import { SuggestionName } from 'app/types/suggestions';
import { TimelineFieldConfig, TimelineOptions } from './types';
export class StatTimelineSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const { dataSummary } = builder;
if (!dataSummary.hasData) {
return;
}
// This panel needs a time field and a string or number field
if (!dataSummary.hasTimeField || (!dataSummary.hasStringField && !dataSummary.hasNumberField)) {
return;
}
// If there are many series then they won't fit on y-axis so this panel is not good fit
if (dataSummary.numberFieldCount >= 30) {
return;
}
const list = builder.getListAppender<TimelineOptions, TimelineFieldConfig>({
name: '',
pluginId: 'state-timeline',
options: {},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
previewModifier: (s) => {},
});
list.append({ name: SuggestionName.StateTimeline });
}
}

@ -10,6 +10,7 @@ import { TablePanel } from './TablePanel';
import { PanelOptions, PanelFieldConfig, defaultPanelOptions, defaultPanelFieldConfig } from './models.gen';
import { tableMigrationHandler, tablePanelChangedHandler } from './migrations';
import { TableCellDisplayMode } from '@grafana/ui';
import { TableSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<PanelOptions, PanelFieldConfig>(TablePanel)
.setPanelChangeHandler(tablePanelChangedHandler)
@ -130,4 +131,5 @@ export const plugin = new PanelPlugin<PanelOptions, PanelFieldConfig>(TablePanel
defaultValue: '',
showIf: (cfg) => cfg.footer?.show,
});
});
})
.setSuggestionsSupplier(new TableSuggestionsSupplier());

@ -0,0 +1,22 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import { SuggestionName } from 'app/types/suggestions';
import { PanelOptions, PanelFieldConfig } from './models.gen';
export class TableSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const list = builder.getListAppender<PanelOptions, PanelFieldConfig>({
name: '',
pluginId: 'table',
options: {},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
previewModifier: (s) => {},
});
list.append({ name: SuggestionName.Table });
}
}

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

@ -0,0 +1,29 @@
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({});
}
}

@ -1,9 +1,8 @@
import React, { useMemo } from 'react';
import { Field, PanelProps } from '@grafana/data';
import { config } from '@grafana/runtime';
import { TooltipDisplayMode } from '@grafana/schema';
import { usePanelContext, TimeSeries, TooltipPlugin, ZoomPlugin } from '@grafana/ui';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import React, { useMemo } from 'react';
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
@ -11,6 +10,7 @@ import { TimeSeriesOptions } from './types';
import { prepareGraphableFields } from './utils';
import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin';
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
import { config } from 'app/core/config';
interface TimeSeriesPanelProps extends PanelProps<TimeSeriesOptions> {}

@ -5,6 +5,7 @@ import { TimeSeriesPanel } from './TimeSeriesPanel';
import { graphPanelChangedHandler } from './migrations';
import { TimeSeriesOptions } from './types';
import { defaultGraphConfig, getGraphFieldConfig } from './config';
import { TimeSeriesSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<TimeSeriesOptions, GraphFieldConfig>(TimeSeriesPanel)
.setPanelChangeHandler(graphPanelChangedHandler)
@ -13,4 +14,5 @@ export const plugin = new PanelPlugin<TimeSeriesOptions, GraphFieldConfig>(TimeS
commonOptionsBuilder.addTooltipOptions(builder);
commonOptionsBuilder.addLegendOptions(builder);
})
.setSuggestionsSupplier(new TimeSeriesSuggestionsSupplier())
.setDataSupport({ annotations: true, alertStates: true });

@ -0,0 +1,169 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import {
GraphDrawStyle,
GraphFieldConfig,
GraphGradientMode,
LegendDisplayMode,
LineInterpolation,
StackingMode,
} from '@grafana/schema';
import { SuggestionName } from 'app/types/suggestions';
import { TimeSeriesOptions } from './types';
export class TimeSeriesSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const { dataSummary } = builder;
if (!dataSummary.hasTimeField || !dataSummary.hasNumberField || dataSummary.rowCountTotal < 2) {
return;
}
const list = builder.getListAppender<TimeSeriesOptions, GraphFieldConfig>({
name: SuggestionName.LineChart,
pluginId: 'timeseries',
options: {
legend: {} as any,
},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
previewModifier: (s) => {
s.options!.legend.displayMode = LegendDisplayMode.Hidden;
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
s.fieldConfig!.defaults.custom!.lineWidth = 3;
}
},
});
const maxBarsCount = 100;
list.append({
name: SuggestionName.LineChart,
});
if (dataSummary.rowCountMax < 200) {
list.append({
name: SuggestionName.LineChartSmooth,
fieldConfig: {
defaults: {
custom: {
lineInterpolation: LineInterpolation.Smooth,
},
},
overrides: [],
},
});
}
// Single series suggestions
if (dataSummary.numberFieldCount === 1) {
list.append({
name: SuggestionName.AreaChart,
fieldConfig: {
defaults: {
custom: {
fillOpacity: 25,
},
},
overrides: [],
},
});
if (dataSummary.rowCountMax < maxBarsCount) {
list.append({
name: SuggestionName.BarChart,
fieldConfig: {
defaults: {
custom: {
drawStyle: GraphDrawStyle.Bars,
fillOpacity: 100,
lineWidth: 1,
gradientMode: GraphGradientMode.Hue,
},
},
overrides: [],
},
});
}
return;
}
// Multiple series suggestions
list.append({
name: SuggestionName.AreaChartStacked,
fieldConfig: {
defaults: {
custom: {
fillOpacity: 25,
stacking: {
mode: StackingMode.Normal,
group: 'A',
},
},
},
overrides: [],
},
});
list.append({
name: SuggestionName.AreaChartStackedPercent,
fieldConfig: {
defaults: {
custom: {
fillOpacity: 25,
stacking: {
mode: StackingMode.Percent,
group: 'A',
},
},
},
overrides: [],
},
});
if (dataSummary.rowCountTotal / dataSummary.numberFieldCount < maxBarsCount) {
list.append({
name: SuggestionName.BarChartStacked,
fieldConfig: {
defaults: {
custom: {
drawStyle: GraphDrawStyle.Bars,
fillOpacity: 100,
lineWidth: 1,
gradientMode: GraphGradientMode.Hue,
stacking: {
mode: StackingMode.Normal,
group: 'A',
},
},
},
overrides: [],
},
});
list.append({
name: SuggestionName.BarChartStackedPercent,
fieldConfig: {
defaults: {
custom: {
drawStyle: GraphDrawStyle.Bars,
fillOpacity: 100,
lineWidth: 1,
gradientMode: GraphGradientMode.Hue,
stacking: {
mode: StackingMode.Percent,
group: 'A',
},
},
},
overrides: [],
},
});
}
}
}

@ -9,16 +9,21 @@ import {
} from '@grafana/data';
import { GraphFieldConfig, LineInterpolation, StackingMode } from '@grafana/schema';
export interface GraphableFieldsResult {
frames?: DataFrame[];
warn?: string;
noTimeField?: boolean;
}
// This will return a set of frames with only graphable values included
export function prepareGraphableFields(
series: DataFrame[] | undefined,
theme: GrafanaTheme2
): { frames?: DataFrame[]; warn?: string } {
export function prepareGraphableFields(series: DataFrame[] | undefined, theme: GrafanaTheme2): GraphableFieldsResult {
if (!series?.length) {
return { warn: 'No data in response' };
}
let copy: Field;
let hasTimeseries = false;
const frames: DataFrame[] = [];
for (let frame of series) {
@ -63,10 +68,12 @@ export function prepareGraphableFields(
min: 0,
custom,
};
// smooth and linear do not make sense
if (custom.lineInterpolation !== LineInterpolation.StepBefore) {
custom.lineInterpolation = LineInterpolation.StepAfter;
}
copy = {
...field,
config,
@ -80,10 +87,12 @@ export function prepareGraphableFields(
})
),
};
if (!isBooleanUnit(config.unit)) {
config.unit = 'bool';
copy.display = getDisplayProcessor({ field: copy, theme });
}
fields.push(copy);
break;
default:
@ -105,10 +114,12 @@ export function prepareGraphableFields(
}
if (!hasTimeseries) {
return { warn: 'Data does not have a time field' };
return { warn: 'Data does not have a time field', noTimeField: true };
}
if (!frames.length) {
return { warn: 'No graphable fields' };
}
return { frames };
}

@ -0,0 +1,25 @@
export enum SuggestionName {
LineChart = 'Line chart',
LineChartSmooth = 'Line chart smooth',
AreaChart = 'Area chart',
AreaChartStacked = 'Area chart stacked',
AreaChartStackedPercent = 'Area chart 100% stacked',
BarChart = 'Bar chart',
BarChartStacked = 'Bar chart stacked',
BarChartStackedPercent = 'Bar chart 100% stacked',
BarChartHorizontal = 'Bar chart horizontal',
BarChartHorizontalStacked = 'Bar chart horizontal stacked',
BarChartHorizontalStackedPercent = 'Bar chart horizontal 100% stacked',
PieChart = 'Pie chart',
PieChartDonut = 'Pie chart donut',
Stat = 'Stat',
StatColoredBackground = 'Stat colored background',
Gauge = 'Gauge',
GaugeNoThresholds = 'Gauge no thresholds',
BarGaugeBasic = 'Bar gauge basic',
BarGaugeLCD = 'Bar gauge LCD',
Table = 'Table',
StateTimeline = 'StateTimeline',
TextPanel = 'Text panel',
DashboardList = 'Dashboard list',
}

@ -34,6 +34,7 @@ angular.module('grafana.filters', []);
angular.module('grafana.routes', ['ngRoute']);
jest.mock('app/core/core', () => ({}));
jest.mock('app/angular/partials', () => ({}));
jest.mock('app/features/plugins/plugin_loader', () => ({}));
configure({ adapter: new Adapter() });

Loading…
Cancel
Save