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
 | 
					   * @param field
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  shouldApply?: (field: Field) => boolean;
 | 
					  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>
 | 
					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 */
 | 
					  /** true for plugin field config properties */
 | 
				
			||||||
  isCustom?: boolean;
 | 
					  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;
 | 
					  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;
 | 
					  shouldApply: (field: Field) => boolean;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -64,6 +64,9 @@ export const Components = {
 | 
				
			||||||
    DataPane: {
 | 
					    DataPane: {
 | 
				
			||||||
      content: 'Panel editor data pane content',
 | 
					      content: 'Panel editor data pane content',
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    FieldOptions: {
 | 
				
			||||||
 | 
					      propertyEditor: (type: string) => `${type} field property editor`,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelInspector: {
 | 
					  PanelInspector: {
 | 
				
			||||||
    Data: {
 | 
					    Data: {
 | 
				
			||||||
| 
						 | 
					@ -151,6 +154,7 @@ export const Components = {
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  QueryField: { container: 'Query field' },
 | 
					  QueryField: { container: 'Query field' },
 | 
				
			||||||
  ValuePicker: {
 | 
					  ValuePicker: {
 | 
				
			||||||
 | 
					    button: 'Value picker add button',
 | 
				
			||||||
    select: (name: string) => `Value picker select ${name}`,
 | 
					    select: (name: string) => `Value picker select ${name}`,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  Search: {
 | 
					  Search: {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,11 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React, { HTMLAttributes } from 'react';
 | 
				
			||||||
import { Label } from './Label';
 | 
					import { Label } from './Label';
 | 
				
			||||||
import { stylesFactory, useTheme } from '../../themes';
 | 
					import { stylesFactory, useTheme } from '../../themes';
 | 
				
			||||||
import { css, cx } from 'emotion';
 | 
					import { css, cx } from 'emotion';
 | 
				
			||||||
import { GrafanaTheme } from '@grafana/data';
 | 
					import { GrafanaTheme } from '@grafana/data';
 | 
				
			||||||
import { FieldValidationMessage } from './FieldValidationMessage';
 | 
					import { FieldValidationMessage } from './FieldValidationMessage';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface FieldProps {
 | 
					export interface FieldProps extends HTMLAttributes<HTMLDivElement> {
 | 
				
			||||||
  /** Form input element, i.e Input or Switch */
 | 
					  /** Form input element, i.e Input or Switch */
 | 
				
			||||||
  children: React.ReactElement;
 | 
					  children: React.ReactElement;
 | 
				
			||||||
  /** Label for the field */
 | 
					  /** Label for the field */
 | 
				
			||||||
| 
						 | 
					@ -59,6 +59,7 @@ export const Field: React.FC<FieldProps> = ({
 | 
				
			||||||
  error,
 | 
					  error,
 | 
				
			||||||
  children,
 | 
					  children,
 | 
				
			||||||
  className,
 | 
					  className,
 | 
				
			||||||
 | 
					  ...otherProps
 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
  const theme = useTheme();
 | 
					  const theme = useTheme();
 | 
				
			||||||
  let inputId;
 | 
					  let inputId;
 | 
				
			||||||
| 
						 | 
					@ -81,7 +82,7 @@ export const Field: React.FC<FieldProps> = ({
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className={cx(styles.field, horizontal && styles.fieldHorizontal, className)}>
 | 
					    <div className={cx(styles.field, horizontal && styles.fieldHorizontal, className)} {...otherProps}>
 | 
				
			||||||
      {labelElement}
 | 
					      {labelElement}
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        {React.cloneElement(children, { invalid, disabled, loading })}
 | 
					        {React.cloneElement(children, { invalid, disabled, loading })}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -39,7 +39,13 @@ export function ValuePicker<T>({
 | 
				
			||||||
  const [isPicking, setIsPicking] = useState(false);
 | 
					  const [isPicking, setIsPicking] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const buttonEl = (
 | 
					  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}
 | 
					      {label}
 | 
				
			||||||
    </Button>
 | 
					    </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 { FieldConfigSource, GrafanaTheme, PanelPlugin, SelectableValue } from '@grafana/data';
 | 
				
			||||||
import { DashboardModel, PanelModel } from '../../state';
 | 
					import { DashboardModel, PanelModel } from '../../state';
 | 
				
			||||||
import { CustomScrollbar, Icon, Input, Select, stylesFactory, Tab, TabContent, TabsBar, useTheme } from '@grafana/ui';
 | 
					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 { css } from 'emotion';
 | 
				
			||||||
import { PanelOptionsTab } from './PanelOptionsTab';
 | 
					import { PanelOptionsTab } from './PanelOptionsTab';
 | 
				
			||||||
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
 | 
					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]
 | 
					    [override, onChange]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let configPropertiesOptions = registry.list().map(item => {
 | 
					  let configPropertiesOptions = registry
 | 
				
			||||||
    let label = item.name;
 | 
					    .list()
 | 
				
			||||||
    if (item.category && item.category.length > 1) {
 | 
					    .filter(o => !o.hideFromOverrides)
 | 
				
			||||||
      label = [...item.category!.slice(1), item.name].join(' > ');
 | 
					    .map(item => {
 | 
				
			||||||
    }
 | 
					      let label = item.name;
 | 
				
			||||||
    return {
 | 
					      if (item.category && item.category.length > 1) {
 | 
				
			||||||
      label,
 | 
					        label = [...item.category!.slice(1), item.name].join(' > ');
 | 
				
			||||||
      value: item.id,
 | 
					      }
 | 
				
			||||||
      description: item.description,
 | 
					      return {
 | 
				
			||||||
    };
 | 
					        label,
 | 
				
			||||||
  });
 | 
					        value: item.id,
 | 
				
			||||||
 | 
					        description: item.description,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const renderOverrideTitle = (isExpanded: boolean) => {
 | 
					  const renderOverrideTitle = (isExpanded: boolean) => {
 | 
				
			||||||
    const overriddenProperites = override.properties.map(p => registry.get(p.id).name).join(', ');
 | 
					    const overriddenProperites = override.properties.map(p => registry.get(p.id).name).join(', ');
 | 
				
			||||||
| 
						 | 
					@ -151,6 +154,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
 | 
				
			||||||
          if (!item) {
 | 
					          if (!item) {
 | 
				
			||||||
            return <div>Unknown property: {p.id}</div>;
 | 
					            return <div>Unknown property: {p.id}</div>;
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          const isCollapsible =
 | 
					          const isCollapsible =
 | 
				
			||||||
            Array.isArray(p.value) || COLLECTION_STANDARD_PROPERTIES.includes(p.id as FieldConfigProperty);
 | 
					            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 {
 | 
					export interface PanelEditorTab {
 | 
				
			||||||
  id: string;
 | 
					  id: string;
 | 
				
			||||||
  text: string;
 | 
					  text: string;
 | 
				
			||||||
| 
						 | 
					@ -23,3 +25,12 @@ export const displayModes = [
 | 
				
			||||||
  { value: DisplayMode.Fit, label: 'Fit', description: 'Fit in the space keeping ratio' },
 | 
					  { value: DisplayMode.Fit, label: 'Fit', description: 'Fit in the space keeping ratio' },
 | 
				
			||||||
  { value: DisplayMode.Exact, label: 'Exact', description: 'Same size as the dashboard' },
 | 
					  { 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 { getPanelPlugin } from '../../plugins/__mocks__/pluginMocks';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  FieldConfigProperty,
 | 
					  FieldConfigProperty,
 | 
				
			||||||
  identityOverrideProcessor,
 | 
					 | 
				
			||||||
  PanelProps,
 | 
					  PanelProps,
 | 
				
			||||||
  standardEditorsRegistry,
 | 
					  standardEditorsRegistry,
 | 
				
			||||||
  standardFieldConfigEditorRegistry,
 | 
					  standardFieldConfigEditorRegistry,
 | 
				
			||||||
| 
						 | 
					@ -19,9 +18,10 @@ import { TemplateSrv } from '../../templating/template_srv';
 | 
				
			||||||
import { setTemplateSrv } from '@grafana/runtime';
 | 
					import { setTemplateSrv } from '@grafana/runtime';
 | 
				
			||||||
import { variableAdapters } from '../../variables/adapters';
 | 
					import { variableAdapters } from '../../variables/adapters';
 | 
				
			||||||
import { createQueryVariableAdapter } from '../../variables/query/adapter';
 | 
					import { createQueryVariableAdapter } from '../../variables/query/adapter';
 | 
				
			||||||
 | 
					import { mockStandardFieldConfigOptions } from '../../../../test/helpers/fieldConfig';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
standardFieldConfigEditorRegistry.setInit(() => mockStandardProperties());
 | 
					standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions());
 | 
				
			||||||
standardEditorsRegistry.setInit(() => mockStandardProperties());
 | 
					standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
setTimeSrv({
 | 
					setTimeSrv({
 | 
				
			||||||
  timeRangeForUrl: () => ({
 | 
					  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 = [
 | 
					const variablesMock = [
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    type: 'query',
 | 
					    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