NewPanelEdit: Refactor value mappings UI to work better with new panel edit (#22808)
* Refactor value mappings UI to work better with new panel edit * TS fix
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
import { selectThemeVariant, stylesFactory, useTheme } from '../../themes';
|
||||
import Forms from '../Forms';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
interface FieldConfigItemHeaderTitleProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
transparent?: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const FieldConfigItemHeaderTitle: React.FC<FieldConfigItemHeaderTitleProps> = ({
|
||||
title,
|
||||
description,
|
||||
onRemove,
|
||||
children,
|
||||
transparent,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = getFieldConfigItemHeaderTitleStyles(theme);
|
||||
return (
|
||||
<div className={!transparent ? styles.headerWrapper : ''}>
|
||||
<div className={styles.header}>
|
||||
<Forms.Label description={description}>{title}</Forms.Label>
|
||||
<div className={styles.remove} onClick={() => onRemove()} aria-label="FieldConfigItemHeaderTitle remove button">
|
||||
<Icon name="trash" />
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getFieldConfigItemHeaderTitleStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
const headerBg = selectThemeVariant(
|
||||
{
|
||||
light: theme.colors.white,
|
||||
dark: theme.colors.dark1,
|
||||
},
|
||||
theme.type
|
||||
);
|
||||
|
||||
return {
|
||||
headerWrapper: css`
|
||||
background: ${headerBg};
|
||||
padding: ${theme.spacing.xs} 0;
|
||||
`,
|
||||
header: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.xs} 0 ${theme.spacing.xs};
|
||||
`,
|
||||
remove: css`
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: ${theme.colors.red88};
|
||||
`,
|
||||
};
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FieldOverrideContext, FieldOverrideEditorProps, FieldConfigEditorProps, ValueMapping } from '@grafana/data';
|
||||
import { LegacyValueMappingsEditor } from '..';
|
||||
import { ValueMappingsEditor } from '../ValueMappingsEditor/ValueMappingsEditor';
|
||||
|
||||
export interface ValueMappingFieldConfigSettings {}
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ValueMappingsValueEditor extends React.PureComponent<
|
||||
value = [];
|
||||
}
|
||||
|
||||
return <LegacyValueMappingsEditor valueMappings={value} onChange={onChange} />;
|
||||
return <ValueMappingsEditor valueMappings={value} onChange={onChange} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ export class ValueMappingsOverrideEditor extends React.PureComponent<
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>VALUE MAPPINGS OVERRIDE EDITOR {this.props.item.name}</div>;
|
||||
const { onChange } = this.props;
|
||||
let value = this.props.value;
|
||||
if (!value) {
|
||||
value = [];
|
||||
}
|
||||
|
||||
return <ValueMappingsEditor valueMappings={value} onChange={onChange} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { HorizontalGroup } from '../Layout/Layout';
|
||||
import Forms from '../Forms';
|
||||
import { MappingType, RangeMap, ValueMap, ValueMapping } from '@grafana/data';
|
||||
import { styleMixins } from '../../themes/mixins';
|
||||
import { useTheme } from '../../themes';
|
||||
import { FieldConfigItemHeaderTitle } from '../FieldConfigs/FieldConfigItemHeaderTitle';
|
||||
|
||||
export interface Props {
|
||||
valueMapping: ValueMapping;
|
||||
updateValueMapping: (valueMapping: ValueMapping) => void;
|
||||
removeValueMapping: () => void;
|
||||
}
|
||||
|
||||
const MAPPING_OPTIONS = [
|
||||
{ value: MappingType.ValueToText, label: 'Value' },
|
||||
{ value: MappingType.RangeToText, label: 'Range' },
|
||||
];
|
||||
|
||||
export const MappingRow: React.FC<Props> = ({ valueMapping, updateValueMapping, removeValueMapping }) => {
|
||||
const theme = useTheme();
|
||||
const { type } = valueMapping;
|
||||
|
||||
const onMappingValueChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
updateValueMapping({ ...valueMapping, value: event.target.value });
|
||||
};
|
||||
|
||||
const onMappingFromChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
updateValueMapping({ ...valueMapping, from: event.target.value });
|
||||
};
|
||||
|
||||
const onMappingToChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
updateValueMapping({ ...valueMapping, to: event.target.value });
|
||||
};
|
||||
|
||||
const onMappingTextChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
updateValueMapping({ ...valueMapping, text: event.target.value });
|
||||
};
|
||||
|
||||
const onMappingTypeChange = (mappingType: MappingType) => {
|
||||
updateValueMapping({ ...valueMapping, type: mappingType });
|
||||
};
|
||||
|
||||
const renderRow = () => {
|
||||
if (type === MappingType.RangeToText) {
|
||||
return (
|
||||
<>
|
||||
<HorizontalGroup>
|
||||
<Forms.Field label="From">
|
||||
<Forms.Input type="number" defaultValue={(valueMapping as RangeMap).from!} onBlur={onMappingFromChange} />
|
||||
</Forms.Field>
|
||||
<Forms.Field label="To">
|
||||
<Forms.Input type="number" defaultValue={(valueMapping as RangeMap).to} onBlur={onMappingToChange} />
|
||||
</Forms.Field>
|
||||
</HorizontalGroup>
|
||||
|
||||
<Forms.Field label="Text">
|
||||
<Forms.Input defaultValue={valueMapping.text} onBlur={onMappingTextChange} />
|
||||
</Forms.Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Forms.Field label="Value">
|
||||
<Forms.Input type="number" defaultValue={(valueMapping as ValueMap).value} onBlur={onMappingValueChange} />
|
||||
</Forms.Field>
|
||||
|
||||
<Forms.Field label="Text">
|
||||
<Forms.Input defaultValue={valueMapping.text} onBlur={onMappingTextChange} />
|
||||
</Forms.Field>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = styleMixins.panelEditorNestedListStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<FieldConfigItemHeaderTitle title="Mapping type" onRemove={removeValueMapping}>
|
||||
<div className={styles.itemContent}>
|
||||
<Forms.Select
|
||||
placeholder="Choose type"
|
||||
isSearchable={false}
|
||||
options={MAPPING_OPTIONS}
|
||||
value={MAPPING_OPTIONS.find(o => o.value === type)}
|
||||
onChange={type => onMappingTypeChange(type.value!)}
|
||||
/>
|
||||
</div>
|
||||
</FieldConfigItemHeaderTitle>
|
||||
<div className={styles.content}>{renderRow()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { ValueMappingsEditor } from './ValueMappingsEditor';
|
||||
|
||||
const ValueMappingsEditorStories = storiesOf('Panel/ValueMappingsEditor', module);
|
||||
|
||||
ValueMappingsEditorStories.add('default', () => {
|
||||
return <ValueMappingsEditor valueMappings={[]} onChange={action('Mapping changed')} />;
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { ValueMappingsEditor, Props } from './ValueMappingsEditor';
|
||||
import { MappingType } from '@grafana/data';
|
||||
|
||||
const setup = (spy?: any, propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
onChange: (mappings: any) => {
|
||||
if (spy) {
|
||||
spy(mappings);
|
||||
}
|
||||
},
|
||||
valueMappings: [
|
||||
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
|
||||
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
|
||||
],
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = mount(<ValueMappingsEditor {...props} />);
|
||||
|
||||
const instance = wrapper.instance() as ValueMappingsEditor;
|
||||
|
||||
return {
|
||||
instance,
|
||||
wrapper,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
expect(setup).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('On remove mapping', () => {
|
||||
it('Should remove mapping at index 0', () => {
|
||||
const onChangeSpy = jest.fn();
|
||||
const { wrapper } = setup(onChangeSpy);
|
||||
const remove = wrapper.find('*[aria-label="FieldConfigItemHeaderTitle remove button"]');
|
||||
remove.at(0).simulate('click');
|
||||
expect(onChangeSpy).toBeCalledWith([
|
||||
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove mapping at index 1', () => {
|
||||
const onChangeSpy = jest.fn();
|
||||
const { wrapper } = setup(onChangeSpy);
|
||||
|
||||
const remove = wrapper.find('*[aria-label="FieldConfigItemHeaderTitle remove button"]');
|
||||
remove.at(1).simulate('click');
|
||||
|
||||
expect(onChangeSpy).toBeCalledWith([
|
||||
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Next id to add', () => {
|
||||
it('should be 3', () => {
|
||||
const onChangeSpy = jest.fn();
|
||||
const { wrapper } = setup(onChangeSpy);
|
||||
|
||||
const add = wrapper.find('*[aria-label="ValueMappingsEditor add mapping button"]');
|
||||
add.at(0).simulate('click');
|
||||
|
||||
expect(onChangeSpy).toBeCalledWith([
|
||||
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
|
||||
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
|
||||
{ id: 3, operator: '', type: MappingType.ValueToText, from: '', to: '', text: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should default to 0', () => {
|
||||
const onChangeSpy = jest.fn();
|
||||
const { wrapper } = setup(onChangeSpy, { valueMappings: [] });
|
||||
const add = wrapper.find('*[aria-label="ValueMappingsEditor add mapping button"]');
|
||||
add.at(0).simulate('click');
|
||||
expect(onChangeSpy).toBeCalledWith([
|
||||
{ id: 0, operator: '', type: MappingType.ValueToText, from: '', to: '', text: '' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { MappingType, ValueMapping } from '@grafana/data';
|
||||
import Forms from '../Forms';
|
||||
import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer';
|
||||
import { MappingRow } from './MappingRow';
|
||||
|
||||
export interface Props {
|
||||
valueMappings?: ValueMapping[];
|
||||
onChange: (valueMappings: ValueMapping[]) => void;
|
||||
}
|
||||
|
||||
export const ValueMappingsEditor: React.FC<Props> = ({ valueMappings, onChange, children }) => {
|
||||
const onAdd = () => {
|
||||
let update = valueMappings;
|
||||
const defaultMapping = {
|
||||
type: MappingType.ValueToText,
|
||||
from: '',
|
||||
to: '',
|
||||
operator: '',
|
||||
text: '',
|
||||
};
|
||||
const id = update && update.length > 0 ? Math.max(...update.map(v => v.id)) + 1 : 0;
|
||||
|
||||
if (update) {
|
||||
update.push({
|
||||
id,
|
||||
...defaultMapping,
|
||||
});
|
||||
} else {
|
||||
update = [
|
||||
{
|
||||
id,
|
||||
...defaultMapping,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
onChange(update);
|
||||
};
|
||||
|
||||
const onRemove = (index: number) => {
|
||||
const update = valueMappings;
|
||||
update!.splice(index, 1);
|
||||
onChange(update!);
|
||||
};
|
||||
|
||||
const onMappingChange = (index: number, value: ValueMapping) => {
|
||||
const update = valueMappings;
|
||||
update![index] = value;
|
||||
onChange(update!);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{valueMappings && valueMappings.length > 0 && (
|
||||
<>
|
||||
{valueMappings.length > 0 &&
|
||||
valueMappings.map((valueMapping, index) => (
|
||||
<MappingRow
|
||||
key={`${valueMapping.text}-${index}`}
|
||||
valueMapping={valueMapping}
|
||||
updateValueMapping={value => onMappingChange(index, value)}
|
||||
removeValueMapping={() => onRemove(index)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<FullWidthButtonContainer>
|
||||
<Forms.Button size="sm" icon="fa fa-plus" onClick={onAdd} aria-label="ValueMappingsEditor add mapping button">
|
||||
Add mapping
|
||||
</Forms.Button>
|
||||
</FullWidthButtonContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -145,6 +145,7 @@ export {
|
||||
SelectOverrideEditor,
|
||||
SelectFieldConfigSettings,
|
||||
} from './FieldConfigs/select';
|
||||
export { FieldConfigItemHeaderTitle } from './FieldConfigs/FieldConfigItemHeaderTitle';
|
||||
|
||||
// Next-gen forms
|
||||
export { default as Forms, ButtonVariant } from './Forms';
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { selectThemeVariant } from './selectThemeVariant';
|
||||
import { css } from 'emotion';
|
||||
import { stylesFactory } from './stylesFactory';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace styleMixins {
|
||||
@@ -45,4 +48,54 @@ export namespace styleMixins {
|
||||
border-radius: ${theme.border.radius.md};
|
||||
`;
|
||||
}
|
||||
|
||||
export const panelEditorNestedListStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
const borderColor = selectThemeVariant(
|
||||
{
|
||||
light: theme.colors.gray85,
|
||||
dark: theme.colors.dark9,
|
||||
},
|
||||
theme.type
|
||||
);
|
||||
const shadow = selectThemeVariant(
|
||||
{
|
||||
light: theme.colors.gray85,
|
||||
dark: theme.colors.black,
|
||||
},
|
||||
theme.type
|
||||
);
|
||||
const headerBg = selectThemeVariant(
|
||||
{
|
||||
light: theme.colors.white,
|
||||
dark: theme.colors.dark1,
|
||||
},
|
||||
theme.type
|
||||
);
|
||||
|
||||
return {
|
||||
wrapper: css`
|
||||
border: 1px dashed ${borderColor};
|
||||
margin-bottom: ${theme.spacing.md};
|
||||
transition: box-shadow 0.5s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
box-shadow: none;
|
||||
&:hover {
|
||||
box-shadow: 0 0 10px ${shadow};
|
||||
}
|
||||
`,
|
||||
headerWrapper: css`
|
||||
background: ${headerBg};
|
||||
padding: ${theme.spacing.xs} 0;
|
||||
`,
|
||||
|
||||
content: css`
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm};
|
||||
border-top: 1px dashed ${borderColor};
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
itemContent: css`
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm};
|
||||
`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { DynamicConfigValue, FieldConfigEditorRegistry, FieldOverrideContext, GrafanaTheme } from '@grafana/data';
|
||||
import { selectThemeVariant, stylesFactory, useTheme } from '@grafana/ui';
|
||||
import { FieldConfigItemHeaderTitle, selectThemeVariant, stylesFactory, useTheme } from '@grafana/ui';
|
||||
|
||||
import { OverrideHeader } from './OverrideHeader';
|
||||
import { css } from 'emotion';
|
||||
interface DynamicConfigValueEditorProps {
|
||||
property: DynamicConfigValue;
|
||||
@@ -29,17 +28,18 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<OverrideHeader onRemove={onRemove} title={item.name} description={item.description} />
|
||||
<div className={styles.property}>
|
||||
<item.override
|
||||
value={property.value}
|
||||
onChange={value => {
|
||||
onChange(value);
|
||||
}}
|
||||
item={item}
|
||||
context={context}
|
||||
/>
|
||||
</div>
|
||||
<FieldConfigItemHeaderTitle onRemove={onRemove} title={item.name} description={item.description} transparent>
|
||||
<div className={styles.property}>
|
||||
<item.override
|
||||
value={property.value}
|
||||
onChange={value => {
|
||||
onChange(value);
|
||||
}}
|
||||
item={item}
|
||||
context={context}
|
||||
/>
|
||||
</div>
|
||||
</FieldConfigItemHeaderTitle>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { fieldMatchersUI, stylesFactory, useTheme, ValuePicker, selectThemeVariant } from '@grafana/ui';
|
||||
import { DynamicConfigValueEditor } from './DynamicConfigValueEditor';
|
||||
import { OverrideHeader } from './OverrideHeader';
|
||||
|
||||
import { getDataLinksVariableSuggestions } from '../../../panel/panellinks/link_srv';
|
||||
import { css } from 'emotion';
|
||||
import { FieldConfigItemHeaderTitle } from '@grafana/ui/src/components/FieldConfigs/FieldConfigItemHeaderTitle';
|
||||
|
||||
interface OverrideEditorProps {
|
||||
data: DataFrame[];
|
||||
@@ -77,8 +78,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
|
||||
const styles = getStyles(theme);
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.headerWrapper}>
|
||||
<OverrideHeader onRemove={onRemove} title={matcherUi.name} description={matcherUi.description} />
|
||||
<FieldConfigItemHeaderTitle onRemove={onRemove} title={matcherUi.name} description={matcherUi.description}>
|
||||
<div className={styles.matcherUi}>
|
||||
<matcherUi.component
|
||||
matcher={matcherUi.matcher}
|
||||
@@ -87,7 +87,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
|
||||
onChange={option => onMatcherConfigChange(option)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FieldConfigItemHeaderTitle>
|
||||
<div>
|
||||
{override.properties.map((p, j) => {
|
||||
const reg = p.custom ? customPropertiesRegistry : standardFieldConfigEditorRegistry;
|
||||
@@ -137,14 +137,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
theme.type
|
||||
);
|
||||
|
||||
const headerBg = selectThemeVariant(
|
||||
{
|
||||
light: theme.colors.white,
|
||||
dark: theme.colors.dark1,
|
||||
},
|
||||
theme.type
|
||||
);
|
||||
|
||||
const shadow = selectThemeVariant(
|
||||
{
|
||||
light: theme.colors.gray85,
|
||||
@@ -163,10 +155,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
box-shadow: 0 0 10px ${shadow};
|
||||
}
|
||||
`,
|
||||
headerWrapper: css`
|
||||
background: ${headerBg};
|
||||
padding: ${theme.spacing.xs} 0;
|
||||
`,
|
||||
matcherUi: css`
|
||||
padding: ${theme.spacing.sm};
|
||||
`,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Forms, Icon, stylesFactory, useTheme } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
|
||||
interface OverrideHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const OverrideHeader: React.FC<OverrideHeaderProps> = ({ title, description, onRemove }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getOverrideHeaderStyles(theme);
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<Forms.Label description={description}>{title}</Forms.Label>
|
||||
<div className={styles.remove} onClick={() => onRemove()}>
|
||||
<Icon name="trash" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getOverrideHeaderStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
header: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.xs} 0 ${theme.spacing.xs};
|
||||
`,
|
||||
remove: css`
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: ${theme.colors.red88};
|
||||
`,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user