Commit f2742d4a by Dominik Prokop Committed by GitHub

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
parent bf7579d9
import React from 'react'; import React from 'react';
import { Forms, Icon, stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { css } from 'emotion'; import { css } from 'emotion';
import { selectThemeVariant, stylesFactory, useTheme } from '../../themes';
import Forms from '../Forms';
import { Icon } from '../Icon/Icon';
interface OverrideHeaderProps { interface FieldConfigItemHeaderTitleProps {
title: string; title: string;
description?: string; description?: string;
transparent?: boolean;
onRemove: () => void; onRemove: () => void;
} }
export const OverrideHeader: React.FC<OverrideHeaderProps> = ({ title, description, onRemove }) => { export const FieldConfigItemHeaderTitle: React.FC<FieldConfigItemHeaderTitleProps> = ({
title,
description,
onRemove,
children,
transparent,
}) => {
const theme = useTheme(); const theme = useTheme();
const styles = getOverrideHeaderStyles(theme); const styles = getFieldConfigItemHeaderTitleStyles(theme);
return ( return (
<div className={styles.header}> <div className={!transparent ? styles.headerWrapper : ''}>
<Forms.Label description={description}>{title}</Forms.Label> <div className={styles.header}>
<div className={styles.remove} onClick={() => onRemove()}> <Forms.Label description={description}>{title}</Forms.Label>
<Icon name="trash" /> <div className={styles.remove} onClick={() => onRemove()} aria-label="FieldConfigItemHeaderTitle remove button">
<Icon name="trash" />
</div>
</div> </div>
{children}
</div> </div>
); );
}; };
const getOverrideHeaderStyles = stylesFactory((theme: GrafanaTheme) => { const getFieldConfigItemHeaderTitleStyles = stylesFactory((theme: GrafanaTheme) => {
const headerBg = selectThemeVariant(
{
light: theme.colors.white,
dark: theme.colors.dark1,
},
theme.type
);
return { return {
headerWrapper: css`
background: ${headerBg};
padding: ${theme.spacing.xs} 0;
`,
header: css` header: css`
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
......
import React from 'react'; import React from 'react';
import { FieldOverrideContext, FieldOverrideEditorProps, FieldConfigEditorProps, ValueMapping } from '@grafana/data'; import { FieldOverrideContext, FieldOverrideEditorProps, FieldConfigEditorProps, ValueMapping } from '@grafana/data';
import { LegacyValueMappingsEditor } from '..'; import { ValueMappingsEditor } from '../ValueMappingsEditor/ValueMappingsEditor';
export interface ValueMappingFieldConfigSettings {} export interface ValueMappingFieldConfigSettings {}
...@@ -27,7 +27,7 @@ export class ValueMappingsValueEditor extends React.PureComponent< ...@@ -27,7 +27,7 @@ export class ValueMappingsValueEditor extends React.PureComponent<
value = []; value = [];
} }
return <LegacyValueMappingsEditor valueMappings={value} onChange={onChange} />; return <ValueMappingsEditor valueMappings={value} onChange={onChange} />;
} }
} }
...@@ -39,6 +39,12 @@ export class ValueMappingsOverrideEditor extends React.PureComponent< ...@@ -39,6 +39,12 @@ export class ValueMappingsOverrideEditor extends React.PureComponent<
} }
render() { 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} />;
} }
} }
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>
);
};
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')} />;
});
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: '' },
]);
});
});
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 { ...@@ -145,6 +145,7 @@ export {
SelectOverrideEditor, SelectOverrideEditor,
SelectFieldConfigSettings, SelectFieldConfigSettings,
} from './FieldConfigs/select'; } from './FieldConfigs/select';
export { FieldConfigItemHeaderTitle } from './FieldConfigs/FieldConfigItemHeaderTitle';
// Next-gen forms // Next-gen forms
export { default as Forms, ButtonVariant } from './Forms'; export { default as Forms, ButtonVariant } from './Forms';
......
import { GrafanaTheme } from '@grafana/data'; 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 // eslint-disable-next-line @typescript-eslint/no-namespace
export namespace styleMixins { export namespace styleMixins {
...@@ -45,4 +48,54 @@ export namespace styleMixins { ...@@ -45,4 +48,54 @@ export namespace styleMixins {
border-radius: ${theme.border.radius.md}; 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};
`,
};
});
} }
import React from 'react'; import React from 'react';
import { DynamicConfigValue, FieldConfigEditorRegistry, FieldOverrideContext, GrafanaTheme } from '@grafana/data'; 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'; import { css } from 'emotion';
interface DynamicConfigValueEditorProps { interface DynamicConfigValueEditorProps {
property: DynamicConfigValue; property: DynamicConfigValue;
...@@ -29,17 +28,18 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> = ...@@ -29,17 +28,18 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<OverrideHeader onRemove={onRemove} title={item.name} description={item.description} /> <FieldConfigItemHeaderTitle onRemove={onRemove} title={item.name} description={item.description} transparent>
<div className={styles.property}> <div className={styles.property}>
<item.override <item.override
value={property.value} value={property.value}
onChange={value => { onChange={value => {
onChange(value); onChange(value);
}} }}
item={item} item={item}
context={context} context={context}
/> />
</div> </div>
</FieldConfigItemHeaderTitle>
</div> </div>
); );
}; };
......
...@@ -11,9 +11,10 @@ import { ...@@ -11,9 +11,10 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { fieldMatchersUI, stylesFactory, useTheme, ValuePicker, selectThemeVariant } from '@grafana/ui'; import { fieldMatchersUI, stylesFactory, useTheme, ValuePicker, selectThemeVariant } from '@grafana/ui';
import { DynamicConfigValueEditor } from './DynamicConfigValueEditor'; import { DynamicConfigValueEditor } from './DynamicConfigValueEditor';
import { OverrideHeader } from './OverrideHeader';
import { getDataLinksVariableSuggestions } from '../../../panel/panellinks/link_srv'; import { getDataLinksVariableSuggestions } from '../../../panel/panellinks/link_srv';
import { css } from 'emotion'; import { css } from 'emotion';
import { FieldConfigItemHeaderTitle } from '@grafana/ui/src/components/FieldConfigs/FieldConfigItemHeaderTitle';
interface OverrideEditorProps { interface OverrideEditorProps {
data: DataFrame[]; data: DataFrame[];
...@@ -77,8 +78,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({ ...@@ -77,8 +78,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
const styles = getStyles(theme); const styles = getStyles(theme);
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.headerWrapper}> <FieldConfigItemHeaderTitle onRemove={onRemove} title={matcherUi.name} description={matcherUi.description}>
<OverrideHeader onRemove={onRemove} title={matcherUi.name} description={matcherUi.description} />
<div className={styles.matcherUi}> <div className={styles.matcherUi}>
<matcherUi.component <matcherUi.component
matcher={matcherUi.matcher} matcher={matcherUi.matcher}
...@@ -87,7 +87,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({ ...@@ -87,7 +87,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
onChange={option => onMatcherConfigChange(option)} onChange={option => onMatcherConfigChange(option)}
/> />
</div> </div>
</div> </FieldConfigItemHeaderTitle>
<div> <div>
{override.properties.map((p, j) => { {override.properties.map((p, j) => {
const reg = p.custom ? customPropertiesRegistry : standardFieldConfigEditorRegistry; const reg = p.custom ? customPropertiesRegistry : standardFieldConfigEditorRegistry;
...@@ -137,14 +137,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { ...@@ -137,14 +137,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
theme.type theme.type
); );
const headerBg = selectThemeVariant(
{
light: theme.colors.white,
dark: theme.colors.dark1,
},
theme.type
);
const shadow = selectThemeVariant( const shadow = selectThemeVariant(
{ {
light: theme.colors.gray85, light: theme.colors.gray85,
...@@ -163,10 +155,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { ...@@ -163,10 +155,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
box-shadow: 0 0 10px ${shadow}; box-shadow: 0 0 10px ${shadow};
} }
`, `,
headerWrapper: css`
background: ${headerBg};
padding: ${theme.spacing.xs} 0;
`,
matcherUi: css` matcherUi: css`
padding: ${theme.spacing.sm}; padding: ${theme.spacing.sm};
`, `,
......
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