New panel edit: data links edit (#22077)

* Move data links suggestions to grafana-data

* Data links -  field config and overrides

* Lint

* Fix test

* Add variable suggestions  to field override context

* Revert "Move data links suggestions to grafana-data"

This reverts commit 5d8d01a65e.

* Move FieldConfigEditor to core
This commit is contained in:
Dominik Prokop
2020-02-11 13:48:36 +01:00
committed by GitHub
parent 94b66258b5
commit e612d7a2f9
20 changed files with 378 additions and 142 deletions

View File

@@ -0,0 +1,255 @@
import React from 'react';
import cloneDeep from 'lodash/cloneDeep';
import {
FieldConfigEditorRegistry,
FieldConfigSource,
DataFrame,
FieldPropertyEditorItem,
DynamicConfigValue,
VariableSuggestionsScope,
} from '@grafana/data';
import {
standardFieldConfigEditorRegistry,
Forms,
fieldMatchersUI,
ControlledCollapse,
ValuePicker,
} from '@grafana/ui';
import { getDataLinksVariableSuggestions } from '../../../panel/panellinks/link_srv';
interface Props {
config: FieldConfigSource;
custom?: FieldConfigEditorRegistry; // custom fields
include?: string[]; // Ordered list of which fields should be shown/included
onChange: (config: FieldConfigSource) => void;
// Helpful for IntelliSense
data: DataFrame[];
}
/**
* Expects the container div to have size set and will fill it 100%
*/
export class FieldConfigEditor extends React.PureComponent<Props> {
private setDefaultValue = (name: string, value: any, custom: boolean) => {
const defaults = { ...this.props.config.defaults };
const remove = value === undefined || value === null || '';
if (custom) {
if (defaults.custom) {
if (remove) {
defaults.custom = { ...defaults.custom };
delete defaults.custom[name];
} else {
defaults.custom = { ...defaults.custom, [name]: value };
}
} else if (!remove) {
defaults.custom = { [name]: value };
}
} else if (remove) {
delete (defaults as any)[name];
} else {
(defaults as any)[name] = value;
}
this.props.onChange({
...this.props.config,
defaults,
});
};
onMatcherConfigChange = (index: number, matcherConfig?: any) => {
const { config } = this.props;
let overrides = cloneDeep(config.overrides);
if (matcherConfig === undefined) {
overrides = overrides.splice(index, 1);
} else {
overrides[index].matcher.options = matcherConfig;
}
this.props.onChange({ ...config, overrides });
};
onDynamicConfigValueAdd = (index: number, prop: string, custom?: boolean) => {
const { config } = this.props;
let overrides = cloneDeep(config.overrides);
const propertyConfig: DynamicConfigValue = {
prop,
custom,
};
if (overrides[index].properties) {
overrides[index].properties.push(propertyConfig);
} else {
overrides[index].properties = [propertyConfig];
}
this.props.onChange({ ...config, overrides });
};
onDynamicConfigValueChange = (overrideIndex: number, propertyIndex: number, value?: any) => {
const { config } = this.props;
let overrides = cloneDeep(config.overrides);
overrides[overrideIndex].properties[propertyIndex].value = value;
this.props.onChange({ ...config, overrides });
};
renderEditor(item: FieldPropertyEditorItem, custom: boolean) {
const { data } = this.props;
const config = this.props.config.defaults;
const value = custom ? (config.custom ? config.custom[item.id] : undefined) : (config as any)[item.id];
return (
<Forms.Field label={item.name} description={item.description} key={`${item.id}/${custom}`}>
<item.editor
item={item}
value={value}
onChange={v => this.setDefaultValue(item.id, v, custom)}
context={{
data,
getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(data, scope),
}}
/>
</Forms.Field>
);
}
renderStandardConfigs() {
const { include } = this.props;
if (include) {
return include.map(f => this.renderEditor(standardFieldConfigEditorRegistry.get(f), false));
}
return standardFieldConfigEditorRegistry.list().map(f => this.renderEditor(f, false));
}
renderCustomConfigs() {
const { custom } = this.props;
if (!custom) {
return null;
}
return custom.list().map(f => this.renderEditor(f, true));
}
renderOverrides() {
const { config, data, custom } = this.props;
if (config.overrides.length === 0) {
return null;
}
let configPropertiesOptions = standardFieldConfigEditorRegistry.list().map(i => ({
label: i.name,
value: i.id,
description: i.description,
custom: false,
}));
if (custom) {
configPropertiesOptions = configPropertiesOptions.concat(
custom.list().map(i => ({
label: i.name,
value: i.id,
description: i.description,
custom: true,
}))
);
}
return (
<div>
{config.overrides.map((o, i) => {
const matcherUi = fieldMatchersUI.get(o.matcher.id);
return (
<div key={`${o.matcher.id}/${i}`} style={{ border: `2px solid red`, marginBottom: '10px' }}>
<Forms.Field label={matcherUi.name} description={matcherUi.description}>
<>
<matcherUi.component
matcher={matcherUi.matcher}
data={data}
options={o.matcher.options}
onChange={option => this.onMatcherConfigChange(i, option)}
/>
<div style={{ border: `2px solid blue`, marginBottom: '5px' }}>
{o.properties.map((p, j) => {
const reg = p.custom ? custom : standardFieldConfigEditorRegistry;
const item = reg?.getIfExists(p.prop);
if (!item) {
return <div>Unknown property: {p.prop}</div>;
}
return (
<Forms.Field label={item.name} description={item.description}>
<item.override
value={p.value}
onChange={value => {
this.onDynamicConfigValueChange(i, j, value);
}}
item={item}
context={{
data,
getSuggestions: (scope?: VariableSuggestionsScope) =>
getDataLinksVariableSuggestions(data, scope),
}}
/>
</Forms.Field>
);
})}
<ValuePicker
icon="plus"
label="Set config property"
options={configPropertiesOptions}
onChange={o => {
this.onDynamicConfigValueAdd(i, o.value!, o.custom);
}}
/>
</div>
</>
</Forms.Field>
</div>
);
})}
</div>
);
}
renderAddOverride = () => {
return (
<ValuePicker
icon="plus"
label="Add override"
options={fieldMatchersUI.list().map(i => ({ label: i.name, value: i.id, description: i.description }))}
onChange={value => {
const { onChange, config } = this.props;
onChange({
...config,
overrides: [
...config.overrides,
{
matcher: {
id: value.value!,
},
properties: [],
},
],
});
}}
/>
);
};
render() {
return (
<div>
<ControlledCollapse label="Standard Field Configuration" collapsible>
{this.renderStandardConfigs()}
</ControlledCollapse>
{this.props.custom && (
<ControlledCollapse label="Standard Field Configuration">{this.renderCustomConfigs()}</ControlledCollapse>
)}
<ControlledCollapse label="Field Overrides" collapsible>
{this.renderOverrides()}
{this.renderAddOverride()}
</ControlledCollapse>
</div>
);
}
}
export default FieldConfigEditor;

View File

@@ -1,4 +1,4 @@
import React, { PureComponent, CSSProperties } from 'react';
import React, { PureComponent } from 'react';
import {
GrafanaTheme,
FieldConfigSource,
@@ -9,18 +9,10 @@ import {
SelectableValue,
TimeRange,
} from '@grafana/data';
import {
stylesFactory,
Forms,
FieldConfigEditor,
CustomScrollbar,
selectThemeVariant,
ControlledCollapse,
} from '@grafana/ui';
import { stylesFactory, Forms, CustomScrollbar, selectThemeVariant, ControlledCollapse } from '@grafana/ui';
import { css, cx } from 'emotion';
import config from 'app/core/config';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { PanelModel } from '../../state/PanelModel';
import { DashboardModel } from '../../state/DashboardModel';
@@ -36,6 +28,8 @@ import { DisplayMode, displayModes } from './types';
import { PanelEditorTabs } from './PanelEditorTabs';
import { DashNavTimeControls } from '../DashNav/DashNavTimeControls';
import { LocationState, CoreEvents } from 'app/types';
import { calculatePanelSize } from './utils';
import { FieldConfigEditor } from './FieldConfigEditor';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const handleColor = selectThemeVariant(
@@ -407,28 +401,6 @@ export class PanelEditor extends PureComponent<Props, State> {
}
}
function calculatePanelSize(mode: DisplayMode, width: number, height: number, panel: PanelModel): CSSProperties {
if (mode === DisplayMode.Fill) {
return { width, height };
}
const colWidth = (window.innerWidth - GRID_CELL_VMARGIN * 4) / GRID_COLUMN_COUNT;
const pWidth = colWidth * panel.gridPos.w;
const pHeight = GRID_CELL_HEIGHT * panel.gridPos.h;
const scale = Math.min(width / pWidth, height / pHeight);
if (mode === DisplayMode.Exact && pWidth <= width && pHeight <= height) {
return {
width: pWidth,
height: pHeight,
};
}
return {
width: pWidth * scale,
height: pHeight * scale,
};
}
const mapStateToProps = (state: StoreState) => ({
location: state.location,
});

View File

@@ -0,0 +1,26 @@
import { CSSProperties } from 'react';
import { PanelModel } from '../../state/PanelModel';
import { GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, GRID_CELL_HEIGHT } from 'app/core/constants';
import { DisplayMode } from './types';
export function calculatePanelSize(mode: DisplayMode, width: number, height: number, panel: PanelModel): CSSProperties {
if (mode === DisplayMode.Fill) {
return { width, height };
}
const colWidth = (window.innerWidth - GRID_CELL_VMARGIN * 4) / GRID_COLUMN_COUNT;
const pWidth = colWidth * panel.gridPos.w;
const pHeight = GRID_CELL_HEIGHT * panel.gridPos.h;
const scale = Math.min(width / pWidth, height / pHeight);
if (mode === DisplayMode.Exact && pWidth <= width && pHeight <= height) {
return {
width: pWidth,
height: pHeight,
};
}
return {
width: pWidth * scale,
height: pHeight * scale,
};
}

View File

@@ -6,7 +6,7 @@ import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url';
import { sanitizeUrl } from 'app/core/utils/text';
import { getConfig } from 'app/core/config';
import locationUtil from 'app/core/utils/location_util';
import { VariableSuggestion, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui';
import { DataLinkBuiltInVars } from '@grafana/ui';
import {
DataLink,
KeyValue,
@@ -16,6 +16,9 @@ import {
ScopedVars,
FieldType,
Field,
VariableSuggestion,
VariableOrigin,
VariableSuggestionsScope,
} from '@grafana/data';
const timeRangeVars = [
@@ -180,21 +183,33 @@ const getDataFrameVars = (dataFrames: DataFrame[]) => {
return suggestions;
};
export const getDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => {
export const getDataLinksVariableSuggestions = (
dataFrames: DataFrame[],
scope?: VariableSuggestionsScope
): VariableSuggestion[] => {
const valueTimeVar = {
value: `${DataLinkBuiltInVars.valueTime}`,
label: 'Time',
documentation: 'Time value of the clicked datapoint (in ms epoch)',
origin: VariableOrigin.Value,
};
return [
...seriesVars,
...getFieldVars(dataFrames),
...valueVars,
valueTimeVar,
...getDataFrameVars(dataFrames),
...getPanelLinksVariableSuggestions(),
];
const includeValueVars = scope === VariableSuggestionsScope.Values;
return includeValueVars
? [
...seriesVars,
...getFieldVars(dataFrames),
...valueVars,
valueTimeVar,
...getDataFrameVars(dataFrames),
...getPanelLinksVariableSuggestions(),
]
: [
...seriesVars,
...getFieldVars(dataFrames),
...getDataFrameVars(dataFrames),
...getPanelLinksVariableSuggestions(),
];
};
export const getCalculationValueDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => {

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { css } from 'emotion';
import { Button, FormField, VariableSuggestion, DataLinkInput, stylesFactory } from '@grafana/ui';
import { VariableSuggestion } from '@grafana/data';
import { Button, FormField, DataLinkInput, stylesFactory } from '@grafana/ui';
import { DataLinkConfig } from '../types';
const getStyles = stylesFactory(() => ({

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { css } from 'emotion';
import { Button, DataLinkBuiltInVars, stylesFactory, useTheme, VariableOrigin } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { Button, DataLinkBuiltInVars, stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme, VariableOrigin } from '@grafana/data';
import { DataLinkConfig } from '../types';
import { DataLink } from './DataLink';

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { css } from 'emotion';
import { Button, FormField, VariableSuggestion, DataLinkInput, stylesFactory } from '@grafana/ui';
import { Button, FormField, DataLinkInput, stylesFactory } from '@grafana/ui';
import { VariableSuggestion } from '@grafana/data';
import { DerivedFieldConfig } from '../types';
const getStyles = stylesFactory(() => ({

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { css } from 'emotion';
import { Button, DataLinkBuiltInVars, stylesFactory, useTheme, VariableOrigin } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { Button, DataLinkBuiltInVars, stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme, VariableOrigin } from '@grafana/data';
import { DerivedFieldConfig } from '../types';
import { DerivedField } from './DerivedField';
import { DebugSection } from './DebugSection';

View File

@@ -11,9 +11,15 @@ import { DataProcessor } from './data_processor';
import { axesEditorComponent } from './axes_editor';
import config from 'app/core/config';
import TimeSeries from 'app/core/time_series2';
import { VariableSuggestion } from '@grafana/ui';
import { getProcessedDataFrames } from 'app/features/dashboard/state/runRequest';
import { getColorFromHexRgbOrName, PanelEvents, DataFrame, DataLink, DateTimeInput } from '@grafana/data';
import {
getColorFromHexRgbOrName,
PanelEvents,
DataFrame,
DataLink,
DateTimeInput,
VariableSuggestion,
} from '@grafana/data';
import { GraphContextMenuCtrl } from './GraphContextMenuCtrl';
import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';