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:
Torkel Ödegaard
2021-10-25 13:55:06 +02:00
committed by GitHub
parent 91c0b5a47f
commit 54af57b8e6
66 changed files with 1968 additions and 233 deletions
@@ -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;
}
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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>;
+138 -2
View File
@@ -2,7 +2,7 @@ import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource
import { PluginMeta } from './plugin';
import { ScopedVars } from './ScopedVars';
import { LoadingState } from './data';
import { DataFrame } from './dataFrame';
import { DataFrame, FieldType } from './dataFrame';
import { AbsoluteTimeRange, TimeRange, TimeZone } from './time';
import { EventBus } from '../events';
import { FieldConfigSource } from './fieldOverrides';
@@ -12,6 +12,8 @@ import { OptionsEditorItem } from './OptionsUIRegistryBuilder';
import { OptionEditorConfig } from './options';
import { AlertStateInfo } from './alerts';
import { PanelModel } from './dashboard';
import { DataTransformerConfig } from './transformations';
import { defaultsDeep } from 'lodash';
export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
@@ -58,7 +60,7 @@ export interface PanelData {
timeRange: TimeRange;
}
export interface PanelProps<T = any, S = any> {
export interface PanelProps<T = any> {
/** ID of the panel within the current dashboard */
id: number;
@@ -182,3 +184,137 @@ export interface PanelPluginDataSupport {
annotations: boolean;
alertStates: boolean;
}
/**
* @alpha
*/
export interface VisualizationSuggestion<TOptions = any, TFieldConfig = any> {
/** Name of suggestion */
name: string;
/** Description */
description?: string;
/** Panel plugin id */
pluginId: string;
/** Panel plugin options */
options?: Partial<TOptions>;
/** Panel plugin field options */
fieldConfig?: FieldConfigSource<Partial<TFieldConfig>>;
/** Data transformations */
transformations?: DataTransformerConfig[];
/** Tweak for small preview */
previewModifier?: (suggestion: VisualizationSuggestion) => void;
}
/**
* @alpha
*/
export interface PanelDataSummary {
hasData?: boolean;
rowCountTotal: number;
rowCountMax: number;
frameCount: number;
numberFieldCount: number;
timeFieldCount: number;
stringFieldCount: number;
hasNumberField?: boolean;
hasTimeField?: boolean;
hasStringField?: boolean;
}
/**
* @alpha
*/
export class VisualizationSuggestionsBuilder {
/** Current data */
data?: PanelData;
/** Current panel & options */
panel?: PanelModel;
/** Summary stats for current data */
dataSummary: PanelDataSummary;
private list: VisualizationSuggestion[] = [];
constructor(data?: PanelData, panel?: PanelModel) {
this.data = data;
this.panel = panel;
this.dataSummary = this.computeDataSummary();
}
getListAppender<TOptions, TFieldConfig>(defaults: VisualizationSuggestion<TOptions, TFieldConfig>) {
return new VisualizationSuggestionsListAppender<TOptions, TFieldConfig>(this.list, defaults);
}
private computeDataSummary() {
const frames = this.data?.series || [];
let numberFieldCount = 0;
let timeFieldCount = 0;
let stringFieldCount = 0;
let rowCountTotal = 0;
let rowCountMax = 0;
for (const frame of frames) {
rowCountTotal += frame.length;
for (const field of frame.fields) {
switch (field.type) {
case FieldType.number:
numberFieldCount += 1;
break;
case FieldType.time:
timeFieldCount += 1;
break;
case FieldType.string:
stringFieldCount += 1;
break;
}
}
if (frame.length > rowCountMax) {
rowCountMax = frame.length;
}
}
return {
numberFieldCount,
timeFieldCount,
stringFieldCount,
rowCountTotal,
rowCountMax,
frameCount: frames.length,
hasData: rowCountTotal > 0,
hasTimeField: timeFieldCount > 0,
hasNumberField: numberFieldCount > 0,
hasStringField: stringFieldCount > 0,
};
}
getList() {
return this.list;
}
}
/**
* @alpha
*/
export type VisualizationSuggestionsSupplier = {
/**
* Adds good suitable suggestions for the current data
*/
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void;
};
/**
* Helps with typings and defaults
* @alpha
*/
export class VisualizationSuggestionsListAppender<TOptions, TFieldConfig> {
constructor(
private list: VisualizationSuggestion[],
private defaults: VisualizationSuggestion<TOptions, TFieldConfig>
) {}
append(overrides: Partial<VisualizationSuggestion<TOptions, TFieldConfig>>) {
this.list.push(defaultsDeep(overrides, this.defaults));
}
}
@@ -262,4 +262,7 @@ export const Components = {
PanelAlertTabContent: {
content: 'Unified alert editor tab content',
},
VisualizationPreview: {
card: (name: string) => `data-testid suggestion-${name}`,
},
};
@@ -13,10 +13,10 @@ export interface PanelRendererProps<P extends object = any, F extends object = a
data: PanelData;
pluginId: string;
title: string;
options?: P;
options?: Partial<P>;
onOptionsChange?: (options: P) => void;
onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void;
fieldConfig?: FieldConfigSource<F>;
fieldConfig?: FieldConfigSource<Partial<F>>;
timeZone?: string;
width: number;
height: number;
@@ -1,47 +1,48 @@
import React, { FC } from 'react';
import React, { HTMLProps } from 'react';
import { escapeStringForRegex, unEscapeStringFromRegex } from '@grafana/data';
import { Button, Icon, Input } from '..';
import { useFocus } from '../Input/utils';
import { useCombinedRefs } from '../../utils/useCombinedRefs';
export interface Props {
export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'onChange'> {
value: string | undefined;
placeholder?: string;
width?: number;
onChange: (value: string) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
}
export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, onKeyDown, autoFocus }) => {
const [inputRef, setInputFocus] = useFocus();
const suffix =
value !== '' ? (
<Button
icon="times"
fill="text"
size="sm"
onClick={(e) => {
setInputFocus();
onChange('');
e.stopPropagation();
}}
>
Clear
</Button>
) : null;
export const FilterInput = React.forwardRef<HTMLInputElement, Props>(
({ value, width, onChange, ...restProps }, ref) => {
const innerRef = React.useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, innerRef) as React.Ref<HTMLInputElement>;
return (
<Input
autoFocus={autoFocus ?? false}
prefix={<Icon name="search" />}
ref={inputRef}
suffix={suffix}
width={width}
type="text"
value={value ? unEscapeStringFromRegex(value) : ''}
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))}
onKeyDown={onKeyDown}
placeholder={placeholder}
/>
);
};
const suffix =
value !== '' ? (
<Button
icon="times"
fill="text"
size="sm"
onClick={(e) => {
innerRef.current?.focus();
onChange('');
e.stopPropagation();
}}
>
Clear
</Button>
) : null;
return (
<Input
prefix={<Icon name="search" />}
suffix={suffix}
width={width}
type="text"
value={value ? unEscapeStringFromRegex(value) : ''}
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))}
{...restProps}
ref={combinedRef}
/>
);
}
);
FilterInput.displayName = 'FilterInput';
@@ -0,0 +1,21 @@
import React from 'react';
export function useCombinedRefs<T>(...refs: any) {
const targetRef = React.useRef<T>(null);
React.useEffect(() => {
refs.forEach((ref: any) => {
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(targetRef.current);
} else {
ref.current = targetRef.current;
}
});
}, [refs]);
return targetRef;
}