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:
@@ -8,6 +8,7 @@ import {
|
||||
PanelTypeChangedHandler,
|
||||
FieldConfigProperty,
|
||||
PanelPluginDataSupport,
|
||||
VisualizationSuggestionsSupplier,
|
||||
} from '../types';
|
||||
import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
|
||||
import { ComponentClass, ComponentType } from 'react';
|
||||
@@ -104,6 +105,7 @@ export class PanelPlugin<
|
||||
};
|
||||
|
||||
private optionsSupplier?: PanelOptionsSupplier<TOptions>;
|
||||
private suggestionsSupplier?: VisualizationSuggestionsSupplier;
|
||||
|
||||
panel: ComponentType<PanelProps<TOptions>> | null;
|
||||
editor?: ComponentClass<PanelEditorProps<TOptions>>;
|
||||
@@ -354,4 +356,21 @@ export class PanelPlugin<
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets function that can return visualization examples and suggestions.
|
||||
* @alpha
|
||||
*/
|
||||
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier) {
|
||||
this.suggestionsSupplier = supplier;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the suggestions supplier
|
||||
* @alpha
|
||||
*/
|
||||
getSuggestionsSupplier(): VisualizationSuggestionsSupplier | undefined {
|
||||
return this.suggestionsSupplier;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export enum DashboardCursorSync {
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface PanelModel<TOptions = any, TCustomFieldConfig extends object = any> {
|
||||
export interface PanelModel<TOptions = any, TCustomFieldConfig = any> {
|
||||
/** ID of the panel within the current dashboard */
|
||||
id: number;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export enum FieldType {
|
||||
*
|
||||
* Plugins may extend this with additional properties. Something like series overrides
|
||||
*/
|
||||
export interface FieldConfig<TOptions extends object = any> {
|
||||
export interface FieldConfig<TOptions = any> {
|
||||
/**
|
||||
* The display value for this field. This supports template variables blank is auto
|
||||
*/
|
||||
|
||||
@@ -49,7 +49,7 @@ export const isSystemOverride = (override: ConfigOverrideRule): override is Syst
|
||||
return typeof (override as SystemConfigOverrideRule)?.__systemRef === 'string';
|
||||
};
|
||||
|
||||
export interface FieldConfigSource<TOptions extends object = any> {
|
||||
export interface FieldConfigSource<TOptions = any> {
|
||||
// Defaults applied to all numeric fields
|
||||
defaults: FieldConfig<TOptions>;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource
|
||||
import { PluginMeta } from './plugin';
|
||||
import { ScopedVars } from './ScopedVars';
|
||||
import { LoadingState } from './data';
|
||||
import { DataFrame } from './dataFrame';
|
||||
import { DataFrame, FieldType } from './dataFrame';
|
||||
import { AbsoluteTimeRange, TimeRange, TimeZone } from './time';
|
||||
import { EventBus } from '../events';
|
||||
import { FieldConfigSource } from './fieldOverrides';
|
||||
@@ -12,6 +12,8 @@ import { OptionsEditorItem } from './OptionsUIRegistryBuilder';
|
||||
import { OptionEditorConfig } from './options';
|
||||
import { AlertStateInfo } from './alerts';
|
||||
import { PanelModel } from './dashboard';
|
||||
import { DataTransformerConfig } from './transformations';
|
||||
import { defaultsDeep } from 'lodash';
|
||||
|
||||
export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
|
||||
|
||||
@@ -58,7 +60,7 @@ export interface PanelData {
|
||||
timeRange: TimeRange;
|
||||
}
|
||||
|
||||
export interface PanelProps<T = any, S = any> {
|
||||
export interface PanelProps<T = any> {
|
||||
/** ID of the panel within the current dashboard */
|
||||
id: number;
|
||||
|
||||
@@ -182,3 +184,137 @@ export interface PanelPluginDataSupport {
|
||||
annotations: boolean;
|
||||
alertStates: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export interface VisualizationSuggestion<TOptions = any, TFieldConfig = any> {
|
||||
/** Name of suggestion */
|
||||
name: string;
|
||||
/** Description */
|
||||
description?: string;
|
||||
/** Panel plugin id */
|
||||
pluginId: string;
|
||||
/** Panel plugin options */
|
||||
options?: Partial<TOptions>;
|
||||
/** Panel plugin field options */
|
||||
fieldConfig?: FieldConfigSource<Partial<TFieldConfig>>;
|
||||
/** Data transformations */
|
||||
transformations?: DataTransformerConfig[];
|
||||
/** Tweak for small preview */
|
||||
previewModifier?: (suggestion: VisualizationSuggestion) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export interface PanelDataSummary {
|
||||
hasData?: boolean;
|
||||
rowCountTotal: number;
|
||||
rowCountMax: number;
|
||||
frameCount: number;
|
||||
numberFieldCount: number;
|
||||
timeFieldCount: number;
|
||||
stringFieldCount: number;
|
||||
hasNumberField?: boolean;
|
||||
hasTimeField?: boolean;
|
||||
hasStringField?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export class VisualizationSuggestionsBuilder {
|
||||
/** Current data */
|
||||
data?: PanelData;
|
||||
/** Current panel & options */
|
||||
panel?: PanelModel;
|
||||
/** Summary stats for current data */
|
||||
dataSummary: PanelDataSummary;
|
||||
|
||||
private list: VisualizationSuggestion[] = [];
|
||||
|
||||
constructor(data?: PanelData, panel?: PanelModel) {
|
||||
this.data = data;
|
||||
this.panel = panel;
|
||||
this.dataSummary = this.computeDataSummary();
|
||||
}
|
||||
|
||||
getListAppender<TOptions, TFieldConfig>(defaults: VisualizationSuggestion<TOptions, TFieldConfig>) {
|
||||
return new VisualizationSuggestionsListAppender<TOptions, TFieldConfig>(this.list, defaults);
|
||||
}
|
||||
|
||||
private computeDataSummary() {
|
||||
const frames = this.data?.series || [];
|
||||
|
||||
let numberFieldCount = 0;
|
||||
let timeFieldCount = 0;
|
||||
let stringFieldCount = 0;
|
||||
let rowCountTotal = 0;
|
||||
let rowCountMax = 0;
|
||||
|
||||
for (const frame of frames) {
|
||||
rowCountTotal += frame.length;
|
||||
|
||||
for (const field of frame.fields) {
|
||||
switch (field.type) {
|
||||
case FieldType.number:
|
||||
numberFieldCount += 1;
|
||||
break;
|
||||
case FieldType.time:
|
||||
timeFieldCount += 1;
|
||||
break;
|
||||
case FieldType.string:
|
||||
stringFieldCount += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.length > rowCountMax) {
|
||||
rowCountMax = frame.length;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
numberFieldCount,
|
||||
timeFieldCount,
|
||||
stringFieldCount,
|
||||
rowCountTotal,
|
||||
rowCountMax,
|
||||
frameCount: frames.length,
|
||||
hasData: rowCountTotal > 0,
|
||||
hasTimeField: timeFieldCount > 0,
|
||||
hasNumberField: numberFieldCount > 0,
|
||||
hasStringField: stringFieldCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
getList() {
|
||||
return this.list;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export type VisualizationSuggestionsSupplier = {
|
||||
/**
|
||||
* Adds good suitable suggestions for the current data
|
||||
*/
|
||||
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helps with typings and defaults
|
||||
* @alpha
|
||||
*/
|
||||
export class VisualizationSuggestionsListAppender<TOptions, TFieldConfig> {
|
||||
constructor(
|
||||
private list: VisualizationSuggestion[],
|
||||
private defaults: VisualizationSuggestion<TOptions, TFieldConfig>
|
||||
) {}
|
||||
|
||||
append(overrides: Partial<VisualizationSuggestion<TOptions, TFieldConfig>>) {
|
||||
this.list.push(defaultsDeep(overrides, this.defaults));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,4 +262,7 @@ export const Components = {
|
||||
PanelAlertTabContent: {
|
||||
content: 'Unified alert editor tab content',
|
||||
},
|
||||
VisualizationPreview: {
|
||||
card: (name: string) => `data-testid suggestion-${name}`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,10 +13,10 @@ export interface PanelRendererProps<P extends object = any, F extends object = a
|
||||
data: PanelData;
|
||||
pluginId: string;
|
||||
title: string;
|
||||
options?: P;
|
||||
options?: Partial<P>;
|
||||
onOptionsChange?: (options: P) => void;
|
||||
onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void;
|
||||
fieldConfig?: FieldConfigSource<F>;
|
||||
fieldConfig?: FieldConfigSource<Partial<F>>;
|
||||
timeZone?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
import React, { FC } from 'react';
|
||||
import React, { HTMLProps } from 'react';
|
||||
import { escapeStringForRegex, unEscapeStringFromRegex } from '@grafana/data';
|
||||
import { Button, Icon, Input } from '..';
|
||||
import { useFocus } from '../Input/utils';
|
||||
import { useCombinedRefs } from '../../utils/useCombinedRefs';
|
||||
|
||||
export interface Props {
|
||||
export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'onChange'> {
|
||||
value: string | undefined;
|
||||
placeholder?: string;
|
||||
width?: number;
|
||||
onChange: (value: string) => void;
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, onKeyDown, autoFocus }) => {
|
||||
const [inputRef, setInputFocus] = useFocus();
|
||||
const suffix =
|
||||
value !== '' ? (
|
||||
<Button
|
||||
icon="times"
|
||||
fill="text"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
setInputFocus();
|
||||
onChange('');
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null;
|
||||
export const FilterInput = React.forwardRef<HTMLInputElement, Props>(
|
||||
({ value, width, onChange, ...restProps }, ref) => {
|
||||
const innerRef = React.useRef<HTMLInputElement>(null);
|
||||
const combinedRef = useCombinedRefs(ref, innerRef) as React.Ref<HTMLInputElement>;
|
||||
|
||||
return (
|
||||
<Input
|
||||
autoFocus={autoFocus ?? false}
|
||||
prefix={<Icon name="search" />}
|
||||
ref={inputRef}
|
||||
suffix={suffix}
|
||||
width={width}
|
||||
type="text"
|
||||
value={value ? unEscapeStringFromRegex(value) : ''}
|
||||
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const suffix =
|
||||
value !== '' ? (
|
||||
<Button
|
||||
icon="times"
|
||||
fill="text"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
innerRef.current?.focus();
|
||||
onChange('');
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Input
|
||||
prefix={<Icon name="search" />}
|
||||
suffix={suffix}
|
||||
width={width}
|
||||
type="text"
|
||||
value={value ? unEscapeStringFromRegex(value) : ''}
|
||||
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))}
|
||||
{...restProps}
|
||||
ref={combinedRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FilterInput.displayName = 'FilterInput';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export function useCombinedRefs<T>(...refs: any) {
|
||||
const targetRef = React.useRef<T>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
refs.forEach((ref: any) => {
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
ref(targetRef.current);
|
||||
} else {
|
||||
ref.current = targetRef.current;
|
||||
}
|
||||
});
|
||||
}, [refs]);
|
||||
|
||||
return targetRef;
|
||||
}
|
||||
Reference in New Issue
Block a user