mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
32
e2e/suite1/specs/visualization-suggestions.ts
Normal file
32
e2e/suite1/specs/visualization-suggestions.ts
Normal file
@@ -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';
|
||||
|
21
packages/grafana-ui/src/utils/useCombinedRefs.ts
Normal file
21
packages/grafana-ui/src/utils/useCombinedRefs.ts
Normal file
@@ -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);
|
||||
// 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 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(),
|
||||
|
51
public/app/features/panel/components/CannotVisualizeData.tsx
Normal file
51
public/app/features/panel/components/CannotVisualizeData.tsx
Normal file
@@ -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 }));
|
||||
};
|
||||
}
|
||||
|
||||
|
305
public/app/features/panel/state/getAllSuggestions.test.ts
Normal file
305
public/app/features/panel/state/getAllSuggestions.test.ts
Normal file
@@ -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;
|
||||
}
|
30
public/app/features/panel/state/getAllSuggestions.ts
Normal file
30
public/app/features/panel/state/getAllSuggestions.ts
Normal file
@@ -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();
|
||||
}
|
17
public/app/features/panel/state/getOptionSuggestions.ts
Normal file
17
public/app/features/panel/state/getOptionSuggestions.ts
Normal file
@@ -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 [];
|
||||
}
|
47
public/app/features/panel/state/util.ts
Normal file
47
public/app/features/panel/state/util.ts
Normal file
@@ -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
|
||||
|
20
public/app/plugins/panel/alertlist/suggestions.ts
Normal file
20
public/app/plugins/panel/alertlist/suggestions.ts
Normal file
@@ -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;
|
||||
|
94
public/app/plugins/panel/barchart/suggestions.ts
Normal file
94
public/app/plugins/panel/barchart/suggestions.ts
Normal file
@@ -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());
|
||||
|
115
public/app/plugins/panel/bargauge/suggestions.ts
Normal file
115
public/app/plugins/panel/bargauge/suggestions.ts
Normal file
@@ -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());
|
||||
|
20
public/app/plugins/panel/dashlist/suggestions.ts
Normal file
20
public/app/plugins/panel/dashlist/suggestions.ts
Normal file
@@ -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);
|
||||
|
85
public/app/plugins/panel/gauge/suggestions.ts
Normal file
85
public/app/plugins/panel/gauge/suggestions.ts
Normal file
@@ -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());
|
||||
|
80
public/app/plugins/panel/piechart/suggestions.ts
Normal file
80
public/app/plugins/panel/piechart/suggestions.ts
Normal file
@@ -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);
|
||||
|
77
public/app/plugins/panel/stat/suggestions.ts
Normal file
77
public/app/plugins/panel/stat/suggestions.ts
Normal file
@@ -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());
|
||||
|
38
public/app/plugins/panel/state-timeline/suggestions.ts
Normal file
38
public/app/plugins/panel/state-timeline/suggestions.ts
Normal file
@@ -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());
|
||||
|
22
public/app/plugins/panel/table/suggestions.ts
Normal file
22
public/app/plugins/panel/table/suggestions.ts
Normal file
@@ -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());
|
||||
|
29
public/app/plugins/panel/text/suggestions.ts
Normal file
29
public/app/plugins/panel/text/suggestions.ts
Normal file
@@ -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 });
|
||||
|
169
public/app/plugins/panel/timeseries/suggestions.ts
Normal file
169
public/app/plugins/panel/timeseries/suggestions.ts
Normal file
@@ -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 };
|
||||
}
|
||||
|
25
public/app/types/suggestions.ts
Normal file
25
public/app/types/suggestions.ts
Normal file
@@ -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() });
|
||||
|
Reference in New Issue
Block a user