mirror of https://github.com/grafana/grafana.git
Field Config API: Add ability to hide field option or disable it from the overrides (#29879)
* Add ability to hide field option or disable it from the overrides * Rename options * Tests
This commit is contained in:
parent
7adccf1e67
commit
332f2f1ae8
|
|
@ -48,6 +48,12 @@ export interface FieldConfigEditorConfig<TOptions, TSettings = any, TValue = any
|
|||
* @param field
|
||||
*/
|
||||
shouldApply?: (field: Field) => boolean;
|
||||
|
||||
/** Indicates that option shoukd not be available in the Field config tab */
|
||||
hideFromDefaults?: boolean;
|
||||
|
||||
/** Indicates that option should not be available for the overrides */
|
||||
hideFromOverrides?: boolean;
|
||||
}
|
||||
|
||||
export interface FieldConfigPropertyItem<TOptions = any, TValue = any, TSettings extends {} = any>
|
||||
|
|
@ -58,10 +64,16 @@ export interface FieldConfigPropertyItem<TOptions = any, TValue = any, TSettings
|
|||
/** true for plugin field config properties */
|
||||
isCustom?: boolean;
|
||||
|
||||
// Convert the override value to a well typed value
|
||||
/** Hides option from the Field config tab */
|
||||
hideFromDefaults?: boolean;
|
||||
|
||||
/** Indicates that option should not be available for the overrides */
|
||||
hideFromOverrides?: boolean;
|
||||
|
||||
/** Convert the override value to a well typed value */
|
||||
process: (value: any, context: FieldOverrideContext, settings?: TSettings) => TValue | undefined | null;
|
||||
|
||||
// Checks if field should be processed
|
||||
/** Checks if field should be processed */
|
||||
shouldApply: (field: Field) => boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ export const Components = {
|
|||
DataPane: {
|
||||
content: 'Panel editor data pane content',
|
||||
},
|
||||
FieldOptions: {
|
||||
propertyEditor: (type: string) => `${type} field property editor`,
|
||||
},
|
||||
},
|
||||
PanelInspector: {
|
||||
Data: {
|
||||
|
|
@ -151,6 +154,7 @@ export const Components = {
|
|||
},
|
||||
QueryField: { container: 'Query field' },
|
||||
ValuePicker: {
|
||||
button: 'Value picker add button',
|
||||
select: (name: string) => `Value picker select ${name}`,
|
||||
},
|
||||
Search: {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React from 'react';
|
||||
import React, { HTMLAttributes } from 'react';
|
||||
import { Label } from './Label';
|
||||
import { stylesFactory, useTheme } from '../../themes';
|
||||
import { css, cx } from 'emotion';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { FieldValidationMessage } from './FieldValidationMessage';
|
||||
|
||||
export interface FieldProps {
|
||||
export interface FieldProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** Form input element, i.e Input or Switch */
|
||||
children: React.ReactElement;
|
||||
/** Label for the field */
|
||||
|
|
@ -59,6 +59,7 @@ export const Field: React.FC<FieldProps> = ({
|
|||
error,
|
||||
children,
|
||||
className,
|
||||
...otherProps
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
let inputId;
|
||||
|
|
@ -81,7 +82,7 @@ export const Field: React.FC<FieldProps> = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.field, horizontal && styles.fieldHorizontal, className)}>
|
||||
<div className={cx(styles.field, horizontal && styles.fieldHorizontal, className)} {...otherProps}>
|
||||
{labelElement}
|
||||
<div>
|
||||
{React.cloneElement(children, { invalid, disabled, loading })}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,13 @@ export function ValuePicker<T>({
|
|||
const [isPicking, setIsPicking] = useState(false);
|
||||
|
||||
const buttonEl = (
|
||||
<Button size={size || 'sm'} icon={icon || 'plus'} onClick={() => setIsPicking(true)} variant={variant}>
|
||||
<Button
|
||||
size={size || 'sm'}
|
||||
icon={icon || 'plus'}
|
||||
onClick={() => setIsPicking(true)}
|
||||
variant={variant}
|
||||
aria-label={selectors.components.ValuePicker.button}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { DefaultFieldConfigEditor } from './DefaultFieldConfigEditor';
|
||||
import {
|
||||
FieldConfigEditorConfig,
|
||||
FieldConfigSource,
|
||||
PanelPlugin,
|
||||
standardEditorsRegistry,
|
||||
standardFieldConfigEditorRegistry,
|
||||
} from '@grafana/data';
|
||||
import { mockStandardFieldConfigOptions } from '../../../../../test/helpers/fieldConfig';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
interface FakeFieldOptions {
|
||||
a: boolean;
|
||||
b: string;
|
||||
c: boolean;
|
||||
}
|
||||
|
||||
standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions());
|
||||
standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions());
|
||||
|
||||
const fieldConfigMock: FieldConfigSource<FakeFieldOptions> = {
|
||||
defaults: {
|
||||
custom: {
|
||||
a: true,
|
||||
b: 'test',
|
||||
c: true,
|
||||
},
|
||||
},
|
||||
overrides: [],
|
||||
};
|
||||
|
||||
describe('DefaultFieldConfigEditor', () => {
|
||||
it('should render custom options', () => {
|
||||
const plugin = new PanelPlugin(() => null).useFieldConfig({
|
||||
standardOptions: {},
|
||||
useCustomConfig: b => {
|
||||
b.addBooleanSwitch({
|
||||
name: 'a',
|
||||
path: 'a',
|
||||
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||
.addBooleanSwitch({
|
||||
name: 'c',
|
||||
path: 'c',
|
||||
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||
.addTextInput({
|
||||
name: 'b',
|
||||
path: 'b',
|
||||
} as FieldConfigEditorConfig<FakeFieldOptions>);
|
||||
},
|
||||
});
|
||||
|
||||
const { queryAllByLabelText } = render(
|
||||
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
|
||||
);
|
||||
|
||||
const editors = queryAllByLabelText(selectors.components.PanelEditor.FieldOptions.propertyEditor('Custom'));
|
||||
expect(editors).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should not render options that are marked as hidden from defaults', () => {
|
||||
const plugin = new PanelPlugin(() => null).useFieldConfig({
|
||||
standardOptions: {},
|
||||
useCustomConfig: b => {
|
||||
b.addBooleanSwitch({
|
||||
name: 'a',
|
||||
path: 'a',
|
||||
hideFromDefaults: true,
|
||||
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||
.addBooleanSwitch({
|
||||
name: 'c',
|
||||
path: 'c',
|
||||
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||
.addTextInput({
|
||||
name: 'b',
|
||||
path: 'b',
|
||||
} as FieldConfigEditorConfig<FakeFieldOptions>);
|
||||
},
|
||||
});
|
||||
|
||||
const { queryAllByLabelText } = render(
|
||||
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
|
||||
);
|
||||
|
||||
const editors = queryAllByLabelText(selectors.components.PanelEditor.FieldOptions.propertyEditor('Custom'));
|
||||
expect(editors).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useCallback, ReactNode } from 'react';
|
||||
import { get, groupBy } from 'lodash';
|
||||
import { Counter, Field, Label } from '@grafana/ui';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { updateDefaultFieldConfigValue } from './utils';
|
||||
import { FieldConfigPropertyItem, FieldConfigSource, VariableSuggestionsScope } from '@grafana/data';
|
||||
import { getDataLinksVariableSuggestions } from '../../../panel/panellinks/link_srv';
|
||||
import { OptionsGroup } from './OptionsGroup';
|
||||
import { Props } from './types';
|
||||
|
||||
export const DefaultFieldConfigEditor: React.FC<Props> = ({ data, onChange, config, plugin }) => {
|
||||
const onDefaultValueChange = useCallback(
|
||||
(name: string, value: any, isCustom: boolean | undefined) => {
|
||||
onChange(updateDefaultFieldConfigValue(config, name, value, isCustom));
|
||||
},
|
||||
[config, onChange]
|
||||
);
|
||||
|
||||
const renderEditor = useCallback(
|
||||
(item: FieldConfigPropertyItem, categoryItemCount: number) => {
|
||||
if (item.isCustom && item.showIf && !item.showIf(config.defaults.custom)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.hideFromDefaults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaults = config.defaults;
|
||||
const value = item.isCustom
|
||||
? defaults.custom
|
||||
? get(defaults.custom, item.path)
|
||||
: undefined
|
||||
: get(defaults, item.path);
|
||||
|
||||
let label: ReactNode | undefined = (
|
||||
<Label description={item.description} category={item.category?.slice(1)}>
|
||||
{item.name}
|
||||
</Label>
|
||||
);
|
||||
|
||||
// hide label if there is only one item and category name is same as item, name
|
||||
if (categoryItemCount === 1 && item.category?.[0] === item.name) {
|
||||
label = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
label={label}
|
||||
key={`${item.id}/${item.isCustom}`}
|
||||
aria-label={selectors.components.PanelEditor.FieldOptions.propertyEditor(
|
||||
item.isCustom ? 'Custom' : 'Default'
|
||||
)}
|
||||
>
|
||||
<item.editor
|
||||
item={item}
|
||||
value={value}
|
||||
onChange={v => onDefaultValueChange(item.path, v, item.isCustom)}
|
||||
context={{
|
||||
data,
|
||||
getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(data, scope),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
},
|
||||
[config]
|
||||
);
|
||||
|
||||
const groupedConfigs = groupBy(plugin.fieldConfigRegistry.list(), i => i.category && i.category[0]);
|
||||
|
||||
return (
|
||||
<div aria-label={selectors.components.FieldConfigEditor.content}>
|
||||
{Object.keys(groupedConfigs).map((k, i) => {
|
||||
const groupItemsCounter = countGroupItems(groupedConfigs[k], config);
|
||||
|
||||
return (
|
||||
<OptionsGroup
|
||||
renderTitle={isExpanded => {
|
||||
return (
|
||||
<>
|
||||
{k} {!isExpanded && groupItemsCounter && <Counter value={groupItemsCounter} />}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
id={`${k}/${i}`}
|
||||
key={`${k}/${i}`}
|
||||
>
|
||||
{groupedConfigs[k].map(c => {
|
||||
return renderEditor(c, groupedConfigs[k].length);
|
||||
})}
|
||||
</OptionsGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const countGroupItems = (group: FieldConfigPropertyItem[], config: FieldConfigSource) => {
|
||||
let counter = 0;
|
||||
|
||||
for (const item of group) {
|
||||
const value = item.isCustom
|
||||
? config.defaults.custom
|
||||
? config.defaults.custom[item.path]
|
||||
: undefined
|
||||
: (config.defaults as any)[item.path];
|
||||
if (item.getItemsCount && item.getItemsCount(value) > 0) {
|
||||
counter = counter + item.getItemsCount(value);
|
||||
}
|
||||
}
|
||||
|
||||
return counter === 0 ? undefined : counter;
|
||||
};
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
import React, { ReactNode, useCallback } from 'react';
|
||||
import { get as lodashGet, cloneDeep } from 'lodash';
|
||||
import {
|
||||
DataFrame,
|
||||
DocsId,
|
||||
FieldConfigPropertyItem,
|
||||
FieldConfigSource,
|
||||
PanelPlugin,
|
||||
SelectableValue,
|
||||
VariableSuggestionsScope,
|
||||
} from '@grafana/data';
|
||||
import { Container, Counter, FeatureInfoBox, Field, fieldMatchersUI, Label, useTheme, ValuePicker } from '@grafana/ui';
|
||||
import { getDataLinksVariableSuggestions } from '../../../panel/panellinks/link_srv';
|
||||
import { OverrideEditor } from './OverrideEditor';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { OptionsGroup } from './OptionsGroup';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { css } from 'emotion';
|
||||
import { getDocsLink } from 'app/core/utils/docsLinks';
|
||||
import { updateDefaultFieldConfigValue } from './utils';
|
||||
|
||||
interface Props {
|
||||
plugin: PanelPlugin;
|
||||
config: FieldConfigSource;
|
||||
onChange: (config: FieldConfigSource) => void;
|
||||
/* Helpful for IntelliSense */
|
||||
data: DataFrame[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expects the container div to have size set and will fill it 100%
|
||||
*/
|
||||
export const OverrideFieldConfigEditor: React.FC<Props> = props => {
|
||||
const theme = useTheme();
|
||||
const { config } = props;
|
||||
|
||||
const onOverrideChange = (index: number, override: any) => {
|
||||
const { config } = props;
|
||||
let overrides = cloneDeep(config.overrides);
|
||||
overrides[index] = override;
|
||||
props.onChange({ ...config, overrides });
|
||||
};
|
||||
|
||||
const onOverrideRemove = (overrideIndex: number) => {
|
||||
const { config } = props;
|
||||
let overrides = cloneDeep(config.overrides);
|
||||
overrides.splice(overrideIndex, 1);
|
||||
props.onChange({ ...config, overrides });
|
||||
};
|
||||
|
||||
const onOverrideAdd = (value: SelectableValue<string>) => {
|
||||
const { onChange, config } = props;
|
||||
onChange({
|
||||
...config,
|
||||
overrides: [
|
||||
...config.overrides,
|
||||
{
|
||||
matcher: {
|
||||
id: value.value!,
|
||||
},
|
||||
properties: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const renderOverrides = () => {
|
||||
const { config, data, plugin } = props;
|
||||
const { fieldConfigRegistry } = plugin;
|
||||
|
||||
if (config.overrides.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{config.overrides.map((o, i) => {
|
||||
// TODO: apply matcher to retrieve fields
|
||||
return (
|
||||
<OverrideEditor
|
||||
name={`Override ${i + 1}`}
|
||||
key={`${o.matcher.id}/${i}`}
|
||||
data={data}
|
||||
override={o}
|
||||
onChange={value => onOverrideChange(i, value)}
|
||||
onRemove={() => onOverrideRemove(i)}
|
||||
registry={fieldConfigRegistry}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAddOverride = () => {
|
||||
return (
|
||||
<Container padding="md">
|
||||
<ValuePicker
|
||||
icon="plus"
|
||||
label="Add an override for"
|
||||
variant="secondary"
|
||||
options={fieldMatchersUI
|
||||
.list()
|
||||
.map<SelectableValue<string>>(i => ({ label: i.name, value: i.id, description: i.description }))}
|
||||
onChange={value => onOverrideAdd(value)}
|
||||
isFullWidth={false}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div aria-label={selectors.components.OverridesConfigEditor.content}>
|
||||
{config.overrides.length === 0 && (
|
||||
<FeatureInfoBox
|
||||
title="Overrides"
|
||||
url={getDocsLink(DocsId.FieldConfigOverrides)}
|
||||
className={css`
|
||||
margin: ${theme.spacing.md};
|
||||
`}
|
||||
>
|
||||
Field override rules give you a fine grained control over how your data is displayed.
|
||||
</FeatureInfoBox>
|
||||
)}
|
||||
|
||||
{renderOverrides()}
|
||||
{renderAddOverride()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DefaultFieldConfigEditor: React.FC<Props> = ({ data, onChange, config, plugin }) => {
|
||||
const onDefaultValueChange = useCallback(
|
||||
(name: string, value: any, isCustom: boolean | undefined) => {
|
||||
onChange(updateDefaultFieldConfigValue(config, name, value, isCustom));
|
||||
},
|
||||
[config, onChange]
|
||||
);
|
||||
|
||||
const renderEditor = useCallback(
|
||||
(item: FieldConfigPropertyItem, categoryItemCount: number) => {
|
||||
if (item.isCustom && item.showIf && !item.showIf(config.defaults.custom)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaults = config.defaults;
|
||||
const value = item.isCustom
|
||||
? defaults.custom
|
||||
? lodashGet(defaults.custom, item.path)
|
||||
: undefined
|
||||
: lodashGet(defaults, item.path);
|
||||
|
||||
let label: ReactNode | undefined = (
|
||||
<Label description={item.description} category={item.category?.slice(1)}>
|
||||
{item.name}
|
||||
</Label>
|
||||
);
|
||||
|
||||
// hide label if there is only one item and category name is same as item, name
|
||||
if (categoryItemCount === 1 && item.category?.[0] === item.name) {
|
||||
label = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Field label={label} key={`${item.id}/${item.isCustom}`}>
|
||||
<item.editor
|
||||
item={item}
|
||||
value={value}
|
||||
onChange={v => onDefaultValueChange(item.path, v, item.isCustom)}
|
||||
context={{
|
||||
data,
|
||||
getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(data, scope),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
},
|
||||
[config]
|
||||
);
|
||||
|
||||
const groupedConfigs = groupBy(plugin.fieldConfigRegistry.list(), i => i.category && i.category[0]);
|
||||
|
||||
return (
|
||||
<div aria-label={selectors.components.FieldConfigEditor.content}>
|
||||
{Object.keys(groupedConfigs).map((k, i) => {
|
||||
const groupItemsCounter = countGroupItems(groupedConfigs[k], config);
|
||||
|
||||
return (
|
||||
<OptionsGroup
|
||||
renderTitle={isExpanded => {
|
||||
return (
|
||||
<>
|
||||
{k} {!isExpanded && groupItemsCounter && <Counter value={groupItemsCounter} />}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
id={`${k}/${i}`}
|
||||
key={`${k}/${i}`}
|
||||
>
|
||||
{groupedConfigs[k].map(c => {
|
||||
return renderEditor(c, groupedConfigs[k].length);
|
||||
})}
|
||||
</OptionsGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const countGroupItems = (group: FieldConfigPropertyItem[], config: FieldConfigSource) => {
|
||||
let counter = 0;
|
||||
|
||||
for (const item of group) {
|
||||
const value = item.isCustom
|
||||
? config.defaults.custom
|
||||
? config.defaults.custom[item.path]
|
||||
: undefined
|
||||
: (config.defaults as any)[item.path];
|
||||
if (item.getItemsCount && item.getItemsCount(value) > 0) {
|
||||
counter = counter + item.getItemsCount(value);
|
||||
}
|
||||
}
|
||||
|
||||
return counter === 0 ? undefined : counter;
|
||||
};
|
||||
|
|
@ -3,7 +3,8 @@ import Transition from 'react-transition-group/Transition';
|
|||
import { FieldConfigSource, GrafanaTheme, PanelPlugin, SelectableValue } from '@grafana/data';
|
||||
import { DashboardModel, PanelModel } from '../../state';
|
||||
import { CustomScrollbar, Icon, Input, Select, stylesFactory, Tab, TabContent, TabsBar, useTheme } from '@grafana/ui';
|
||||
import { DefaultFieldConfigEditor, OverrideFieldConfigEditor } from './FieldConfigEditor';
|
||||
import { OverrideFieldConfigEditor } from './OverrideFieldConfigEditor';
|
||||
import { DefaultFieldConfigEditor } from './DefaultFieldConfigEditor';
|
||||
import { css } from 'emotion';
|
||||
import { PanelOptionsTab } from './PanelOptionsTab';
|
||||
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import React from 'react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { FieldConfigOptionsRegistry } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { OverrideEditor } from './OverrideEditor';
|
||||
|
||||
describe('OverrideEditor', () => {
|
||||
let registry: FieldConfigOptionsRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new FieldConfigOptionsRegistry(() => {
|
||||
return [
|
||||
{
|
||||
id: 'lineColor',
|
||||
name: 'Line color',
|
||||
path: 'lineColor',
|
||||
isCustom: true,
|
||||
shouldApply: () => true,
|
||||
process: () => null,
|
||||
override: () => null,
|
||||
editor: () => null,
|
||||
},
|
||||
{
|
||||
id: 'lineWidth',
|
||||
name: 'Line width',
|
||||
path: 'lineWidth',
|
||||
isCustom: true,
|
||||
shouldApply: () => true,
|
||||
process: () => null,
|
||||
override: () => null,
|
||||
editor: () => null,
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
it('allow override option selection', () => {
|
||||
const { queryAllByLabelText, getByLabelText } = render(
|
||||
<OverrideEditor
|
||||
name={'test'}
|
||||
data={[]}
|
||||
override={{
|
||||
matcher: {
|
||||
id: 'byName',
|
||||
options: 'A-series',
|
||||
},
|
||||
properties: [],
|
||||
}}
|
||||
registry={registry}
|
||||
onChange={() => {}}
|
||||
onRemove={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText(selectors.components.ValuePicker.button));
|
||||
const selectOptions = queryAllByLabelText(selectors.components.Select.option);
|
||||
|
||||
expect(selectOptions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not allow override selection that marked as hidden from overrides', () => {
|
||||
registry.register({
|
||||
id: 'lineStyle',
|
||||
name: 'Line style',
|
||||
path: 'lineStyle',
|
||||
isCustom: true,
|
||||
shouldApply: () => true,
|
||||
process: () => null,
|
||||
override: () => null,
|
||||
editor: () => null,
|
||||
hideFromOverrides: true,
|
||||
});
|
||||
|
||||
const { queryAllByLabelText, getByLabelText } = render(
|
||||
<OverrideEditor
|
||||
name={'test'}
|
||||
data={[]}
|
||||
override={{
|
||||
matcher: {
|
||||
id: 'byName',
|
||||
options: 'A-series',
|
||||
},
|
||||
properties: [],
|
||||
}}
|
||||
registry={registry}
|
||||
onChange={() => {}}
|
||||
onRemove={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText(selectors.components.ValuePicker.button));
|
||||
const selectOptions = queryAllByLabelText(selectors.components.Select.option);
|
||||
|
||||
expect(selectOptions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -97,17 +97,20 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
|
|||
[override, onChange]
|
||||
);
|
||||
|
||||
let configPropertiesOptions = registry.list().map(item => {
|
||||
let label = item.name;
|
||||
if (item.category && item.category.length > 1) {
|
||||
label = [...item.category!.slice(1), item.name].join(' > ');
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value: item.id,
|
||||
description: item.description,
|
||||
};
|
||||
});
|
||||
let configPropertiesOptions = registry
|
||||
.list()
|
||||
.filter(o => !o.hideFromOverrides)
|
||||
.map(item => {
|
||||
let label = item.name;
|
||||
if (item.category && item.category.length > 1) {
|
||||
label = [...item.category!.slice(1), item.name].join(' > ');
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value: item.id,
|
||||
description: item.description,
|
||||
};
|
||||
});
|
||||
|
||||
const renderOverrideTitle = (isExpanded: boolean) => {
|
||||
const overriddenProperites = override.properties.map(p => registry.get(p.id).name).join(', ');
|
||||
|
|
@ -151,6 +154,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
|
|||
if (!item) {
|
||||
return <div>Unknown property: {p.id}</div>;
|
||||
}
|
||||
|
||||
const isCollapsible =
|
||||
Array.isArray(p.value) || COLLECTION_STANDARD_PROPERTIES.includes(p.id as FieldConfigProperty);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import React from 'react';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { DocsId, SelectableValue } from '@grafana/data';
|
||||
import { Container, FeatureInfoBox, fieldMatchersUI, useTheme, ValuePicker } from '@grafana/ui';
|
||||
import { OverrideEditor } from './OverrideEditor';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { css } from 'emotion';
|
||||
import { getDocsLink } from 'app/core/utils/docsLinks';
|
||||
import { Props } from './types';
|
||||
|
||||
/**
|
||||
* Expects the container div to have size set and will fill it 100%
|
||||
*/
|
||||
export const OverrideFieldConfigEditor: React.FC<Props> = props => {
|
||||
const theme = useTheme();
|
||||
const { config } = props;
|
||||
|
||||
const onOverrideChange = (index: number, override: any) => {
|
||||
const { config } = props;
|
||||
let overrides = cloneDeep(config.overrides);
|
||||
overrides[index] = override;
|
||||
props.onChange({ ...config, overrides });
|
||||
};
|
||||
|
||||
const onOverrideRemove = (overrideIndex: number) => {
|
||||
const { config } = props;
|
||||
let overrides = cloneDeep(config.overrides);
|
||||
overrides.splice(overrideIndex, 1);
|
||||
props.onChange({ ...config, overrides });
|
||||
};
|
||||
|
||||
const onOverrideAdd = (value: SelectableValue<string>) => {
|
||||
const { onChange, config } = props;
|
||||
onChange({
|
||||
...config,
|
||||
overrides: [
|
||||
...config.overrides,
|
||||
{
|
||||
matcher: {
|
||||
id: value.value!,
|
||||
},
|
||||
properties: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const renderOverrides = () => {
|
||||
const { config, data, plugin } = props;
|
||||
const { fieldConfigRegistry } = plugin;
|
||||
|
||||
if (config.overrides.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{config.overrides.map((o, i) => {
|
||||
// TODO: apply matcher to retrieve fields
|
||||
return (
|
||||
<OverrideEditor
|
||||
name={`Override ${i + 1}`}
|
||||
key={`${o.matcher.id}/${i}`}
|
||||
data={data}
|
||||
override={o}
|
||||
onChange={value => onOverrideChange(i, value)}
|
||||
onRemove={() => onOverrideRemove(i)}
|
||||
registry={fieldConfigRegistry}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAddOverride = () => {
|
||||
return (
|
||||
<Container padding="md">
|
||||
<ValuePicker
|
||||
icon="plus"
|
||||
label="Add an override for"
|
||||
variant="secondary"
|
||||
options={fieldMatchersUI
|
||||
.list()
|
||||
.map<SelectableValue<string>>(i => ({ label: i.name, value: i.id, description: i.description }))}
|
||||
onChange={value => onOverrideAdd(value)}
|
||||
isFullWidth={false}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div aria-label={selectors.components.OverridesConfigEditor.content}>
|
||||
{config.overrides.length === 0 && (
|
||||
<FeatureInfoBox
|
||||
title="Overrides"
|
||||
url={getDocsLink(DocsId.FieldConfigOverrides)}
|
||||
className={css`
|
||||
margin: ${theme.spacing.md};
|
||||
`}
|
||||
>
|
||||
Field override rules give you a fine grained control over how your data is displayed.
|
||||
</FeatureInfoBox>
|
||||
)}
|
||||
|
||||
{renderOverrides()}
|
||||
{renderAddOverride()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { DataFrame, FieldConfigSource, PanelPlugin } from '@grafana/data';
|
||||
|
||||
export interface PanelEditorTab {
|
||||
id: string;
|
||||
text: string;
|
||||
|
|
@ -23,3 +25,12 @@ export const displayModes = [
|
|||
{ value: DisplayMode.Fit, label: 'Fit', description: 'Fit in the space keeping ratio' },
|
||||
{ value: DisplayMode.Exact, label: 'Exact', description: 'Same size as the dashboard' },
|
||||
];
|
||||
|
||||
/** @internal */
|
||||
export interface Props {
|
||||
plugin: PanelPlugin;
|
||||
config: FieldConfigSource;
|
||||
onChange: (config: FieldConfigSource) => void;
|
||||
/* Helpful for IntelliSense */
|
||||
data: DataFrame[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { PanelModel } from './PanelModel';
|
|||
import { getPanelPlugin } from '../../plugins/__mocks__/pluginMocks';
|
||||
import {
|
||||
FieldConfigProperty,
|
||||
identityOverrideProcessor,
|
||||
PanelProps,
|
||||
standardEditorsRegistry,
|
||||
standardFieldConfigEditorRegistry,
|
||||
|
|
@ -19,9 +18,10 @@ import { TemplateSrv } from '../../templating/template_srv';
|
|||
import { setTemplateSrv } from '@grafana/runtime';
|
||||
import { variableAdapters } from '../../variables/adapters';
|
||||
import { createQueryVariableAdapter } from '../../variables/query/adapter';
|
||||
import { mockStandardFieldConfigOptions } from '../../../../test/helpers/fieldConfig';
|
||||
|
||||
standardFieldConfigEditorRegistry.setInit(() => mockStandardProperties());
|
||||
standardEditorsRegistry.setInit(() => mockStandardProperties());
|
||||
standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions());
|
||||
standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions());
|
||||
|
||||
setTimeSrv({
|
||||
timeRangeForUrl: () => ({
|
||||
|
|
@ -450,62 +450,6 @@ describe('PanelModel', () => {
|
|||
});
|
||||
});
|
||||
|
||||
export const mockStandardProperties = () => {
|
||||
const unit = {
|
||||
id: 'unit',
|
||||
path: 'unit',
|
||||
name: 'Unit',
|
||||
description: 'Value units',
|
||||
// @ts-ignore
|
||||
editor: () => null,
|
||||
// @ts-ignore
|
||||
override: () => null,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
const decimals = {
|
||||
id: 'decimals',
|
||||
path: 'decimals',
|
||||
name: 'Decimals',
|
||||
description: 'Number of decimal to be shown for a value',
|
||||
// @ts-ignore
|
||||
editor: () => null,
|
||||
// @ts-ignore
|
||||
override: () => null,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
const boolean = {
|
||||
id: 'boolean',
|
||||
path: 'boolean',
|
||||
name: 'Boolean',
|
||||
description: '',
|
||||
// @ts-ignore
|
||||
editor: () => null,
|
||||
// @ts-ignore
|
||||
override: () => null,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
const fieldColor = {
|
||||
id: 'color',
|
||||
path: 'color',
|
||||
name: 'color',
|
||||
description: '',
|
||||
// @ts-ignore
|
||||
editor: () => null,
|
||||
// @ts-ignore
|
||||
override: () => null,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
return [unit, decimals, boolean, fieldColor];
|
||||
};
|
||||
|
||||
const variablesMock = [
|
||||
{
|
||||
type: 'query',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { identityOverrideProcessor } from '@grafana/data';
|
||||
|
||||
export function mockStandardFieldConfigOptions() {
|
||||
const unit = {
|
||||
id: 'unit',
|
||||
path: 'unit',
|
||||
name: 'Unit',
|
||||
description: 'Value units',
|
||||
// @ts-ignore
|
||||
editor: () => null,
|
||||
// @ts-ignore
|
||||
override: () => null,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
const decimals = {
|
||||
id: 'decimals',
|
||||
path: 'decimals',
|
||||
name: 'Decimals',
|
||||
description: 'Number of decimal to be shown for a value',
|
||||
// @ts-ignore
|
||||
editor: () => null,
|
||||
// @ts-ignore
|
||||
override: () => null,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
const boolean = {
|
||||
id: 'boolean',
|
||||
path: 'boolean',
|
||||
name: 'Boolean',
|
||||
description: '',
|
||||
// @ts-ignore
|
||||
editor: () => null,
|
||||
// @ts-ignore
|
||||
override: () => null,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
const fieldColor = {
|
||||
id: 'color',
|
||||
path: 'color',
|
||||
name: 'color',
|
||||
description: '',
|
||||
// @ts-ignore
|
||||
editor: () => null,
|
||||
// @ts-ignore
|
||||
override: () => null,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
const text = {
|
||||
id: 'text',
|
||||
path: 'text',
|
||||
name: 'text',
|
||||
description: '',
|
||||
// @ts-ignore
|
||||
editor: () => null,
|
||||
// @ts-ignore
|
||||
override: () => null,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
const number = {
|
||||
id: 'number',
|
||||
path: 'number',
|
||||
name: 'number',
|
||||
description: '',
|
||||
// @ts-ignore
|
||||
editor: () => null,
|
||||
// @ts-ignore
|
||||
override: () => null,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
return [unit, decimals, boolean, fieldColor, text, number];
|
||||
}
|
||||
Loading…
Reference in New Issue