Commit 332f2f1a by Dominik Prokop Committed by GitHub

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
parent 7adccf1e
......@@ -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: {
......
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>
);
......
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);
});
});
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;
};
......@@ -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';
......
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,7 +97,10 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
[override, onChange]
);
let configPropertiesOptions = registry.list().map(item => {
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(' > ');
......@@ -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);
......
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 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 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[];
}
import { Props } from './types';
/**
* Expects the container div to have size set and will fill it 100%
......@@ -128,98 +109,3 @@ export const OverrideFieldConfigEditor: React.FC<Props> = props => {
</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;
};
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',
......
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];
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment