FieldOverrides: UI updates (#23630)

* UI improvements for field overrides

* Update tests

* Fix missing key

* Fix e2e
This commit is contained in:
Dominik Prokop 2020-04-17 12:52:01 +02:00 committed by GitHub
parent a9e408fecf
commit 416111c5f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 333 additions and 355 deletions

View File

@ -4,8 +4,7 @@ import { css } from 'emotion';
import { Button } from '../../Button/Button';
import cloneDeep from 'lodash/cloneDeep';
import { Modal } from '../../Modal/Modal';
import { FullWidthButtonContainer } from '../../Button/FullWidthButtonContainer';
import { selectThemeVariant, stylesFactory, useTheme } from '../../../themes';
import { stylesFactory, useTheme } from '../../../themes';
import { DataLinksListItem } from './DataLinksListItem';
import { DataLinkEditorModalContent } from './DataLinkEditorModalContent';
@ -99,42 +98,17 @@ export const DataLinksInlineEditor: React.FC<DataLinksInlineEditorProps> = ({ li
</Modal>
)}
<FullWidthButtonContainer>
<Button size="sm" icon="plus" onClick={onDataLinkAdd} variant="secondary">
Add link
</Button>
</FullWidthButtonContainer>
<Button size="sm" icon="plus" onClick={onDataLinkAdd} variant="secondary">
Add data link
</Button>
</>
);
};
const getDataLinksInlineEditorStyles = stylesFactory((theme: GrafanaTheme) => {
const borderColor = selectThemeVariant(
{
light: theme.palette.gray85,
dark: theme.palette.dark9,
},
theme.type
);
const shadow = selectThemeVariant(
{
light: theme.palette.gray85,
dark: theme.palette.black,
},
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};
}
`,
};
});

View File

@ -1,9 +1,9 @@
import React, { FC } from 'react';
import { css, cx } from 'emotion';
import { DataFrame, DataLink, GrafanaTheme, VariableSuggestion } from '@grafana/data';
import { selectThemeVariant, stylesFactory, useTheme } from '../../../themes';
import { HorizontalGroup } from '../../Layout/Layout';
import { Icon } from '../../Icon/Icon';
import { stylesFactory, useTheme } from '../../../themes';
import { HorizontalGroup, VerticalGroup } from '../../Layout/Layout';
import { IconButton } from '../../IconButton/IconButton';
interface DataLinksListItemProps {
index: number;
@ -16,16 +16,7 @@ interface DataLinksListItemProps {
isEditing?: boolean;
}
export const DataLinksListItem: FC<DataLinksListItemProps> = ({
index,
link,
data,
onChange,
suggestions,
isEditing,
onEdit,
onRemove,
}) => {
export const DataLinksListItem: FC<DataLinksListItemProps> = ({ link, onEdit, onRemove }) => {
const theme = useTheme();
const styles = getDataLinkListItemStyles(theme);
@ -34,65 +25,48 @@ export const DataLinksListItem: FC<DataLinksListItemProps> = ({
return (
<div className={styles.wrapper}>
<HorizontalGroup justify="space-between">
<div>
<div className={cx(!hasTitle && styles.notConfigured)}>{hasTitle ? link.title : 'No data link provided'}</div>
<div className={cx(!hasUrl && styles.notConfigured, styles.url)}>{hasUrl ? link.url : 'No url provided'}</div>
</div>
<HorizontalGroup>
<div onClick={onEdit} className={styles.action}>
<Icon name="pen" />
</div>
<div onClick={onRemove} className={cx(styles.action, styles.remove)}>
<Icon name="trash-alt" />
<VerticalGroup spacing="xs">
<HorizontalGroup justify="space-between" align="flex-start" width="100%">
<div className={cx(styles.title, !hasTitle && styles.notConfigured)}>
{hasTitle ? link.title : 'Data link title not provided'}
</div>
<HorizontalGroup>
<IconButton name="pen" onClick={onEdit} />
<IconButton name="times" onClick={onRemove} />
</HorizontalGroup>
</HorizontalGroup>
</HorizontalGroup>
<div className={cx(styles.url, !hasUrl && styles.notConfigured)} title={link.url}>
{hasUrl ? link.url : 'Data link url not provided'}
</div>
</VerticalGroup>
</div>
);
};
const getDataLinkListItemStyles = stylesFactory((theme: GrafanaTheme) => {
const borderColor = selectThemeVariant(
{
light: theme.palette.gray85,
dark: theme.palette.dark9,
},
theme.type
);
const bg = selectThemeVariant(
{
light: theme.palette.white,
dark: theme.palette.dark1,
},
theme.type
);
return {
wrapper: css`
border-bottom: 1px dashed ${borderColor};
padding: ${theme.spacing.sm};
transition: background 0.5s cubic-bezier(0.19, 1, 0.22, 1);
margin-bottom: ${theme.spacing.md};
width: 100%;
&:last-child {
border-bottom: 0;
}
&:hover {
background: ${bg};
margin-bottom: 0;
}
`,
action: css`
cursor: pointer;
`,
notConfigured: css`
font-style: italic;
`,
url: css`
title: css`
color: ${theme.colors.formLabel};
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.semibold};
`,
remove: css`
color: ${theme.palette.red88};
url: css`
color: ${theme.colors.textWeak};
font-size: ${theme.typography.size.sm};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 90%;
`,
};
});

View File

@ -9,7 +9,7 @@ export interface FieldProps {
/** Form input element, i.e Input or Switch */
children: React.ReactElement;
/** Label for the field */
label?: string | JSX.Element;
label?: React.ReactNode;
/** Description of the field */
description?: string;
/** Indicates if field is in invalid state */

View File

@ -6,14 +6,15 @@ import { Icon } from '../Icon/Icon';
import tinycolor from 'tinycolor2';
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
children: string;
description?: string;
children: React.ReactNode;
description?: React.ReactNode;
category?: string[];
}
export const getLabelStyles = stylesFactory((theme: GrafanaTheme) => {
return {
label: css`
label: Label;
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.semibold};
line-height: 1.25;
@ -22,7 +23,12 @@ export const getLabelStyles = stylesFactory((theme: GrafanaTheme) => {
color: ${theme.colors.formLabel};
max-width: 480px;
`,
labelContent: css`
display: flex;
align-items: center;
`,
description: css`
label: Label-description;
color: ${theme.colors.formDescription};
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.regular};
@ -30,6 +36,7 @@ export const getLabelStyles = stylesFactory((theme: GrafanaTheme) => {
display: block;
`,
categories: css`
label: Label-categories;
color: ${theme.isLight
? tinycolor(theme.colors.formLabel)
.lighten(10)
@ -49,9 +56,9 @@ export const getLabelStyles = stylesFactory((theme: GrafanaTheme) => {
export const Label: React.FC<LabelProps> = ({ children, description, className, category, ...labelProps }) => {
const theme = useTheme();
const styles = getLabelStyles(theme);
const categories = category?.map(c => {
const categories = category?.map((c, i) => {
return (
<span className={styles.categories}>
<span className={styles.categories} key={`${c}/${i}`}>
<span>{c}</span>
<Icon name="angle-right" className={styles.chevron} />
</span>
@ -61,8 +68,10 @@ export const Label: React.FC<LabelProps> = ({ children, description, className,
return (
<div className={cx(styles.label, className)}>
<label {...labelProps}>
{categories}
{children}
<div className={styles.labelContent}>
{categories}
{children}
</div>
{description && <span className={styles.description}>{description}</span>}
</label>
</div>

View File

@ -17,6 +17,7 @@ export interface LayoutProps {
spacing?: Spacing;
justify?: Justify;
align?: Align;
width?: string;
}
export interface ContainerProps {
@ -30,14 +31,15 @@ export const Layout: React.FC<LayoutProps> = ({
spacing = 'sm',
justify = 'flex-start',
align = 'normal',
width = 'auto',
}) => {
const theme = useTheme();
const styles = getStyles(theme, orientation, spacing, justify, align);
return (
<div className={styles.layout}>
<div className={styles.layout} style={{ width }}>
{React.Children.map(children, (child, index) => {
return (
<div className={styles.buttonWrapper} key={index}>
<div className={styles.childWrapper} key={index}>
{child}
</div>
);
@ -51,13 +53,14 @@ export const HorizontalGroup: React.FC<Omit<LayoutProps, 'orientation'>> = ({
spacing,
justify,
align = 'center',
width,
}) => (
<Layout spacing={spacing} justify={justify} orientation={Orientation.Horizontal} align={align}>
<Layout spacing={spacing} justify={justify} orientation={Orientation.Horizontal} align={align} width={width}>
{children}
</Layout>
);
export const VerticalGroup: React.FC<Omit<LayoutProps, 'orientation'>> = ({ children, spacing, justify }) => (
<Layout spacing={spacing} justify={justify} orientation={Orientation.Vertical}>
export const VerticalGroup: React.FC<Omit<LayoutProps, 'orientation'>> = ({ children, spacing, justify, width }) => (
<Layout spacing={spacing} justify={justify} orientation={Orientation.Vertical} width={width}>
{children}
</Layout>
);
@ -77,8 +80,9 @@ const getStyles = stylesFactory(
justify-content: ${justify};
align-items: ${align};
height: 100%;
max-width: 100%;
`,
buttonWrapper: css`
childWrapper: css`
margin-bottom: ${orientation === Orientation.Horizontal ? 0 : theme.spacing[spacing]};
margin-right: ${orientation === Orientation.Horizontal ? theme.spacing[spacing] : 0};
display: flex;

View File

@ -15,9 +15,9 @@ import { ColorPicker } from '../ColorPicker/ColorPicker';
import { stylesFactory } from '../../themes';
import { Icon } from '../Icon/Icon';
import { RadioButtonGroup } from '../Forms/RadioButtonGroup/RadioButtonGroup';
import { Field } from '../Forms/Field';
import { Button } from '../Button';
import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer';
import { Label } from '../Forms/Label';
const modes: Array<SelectableValue<ThresholdsMode>> = [
{ value: ThresholdsMode.Absolute, label: 'Absolute', description: 'Pick thresholds based on the absolute values' },
@ -217,11 +217,12 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
})}
</div>
<Field label="Threshold mode" description="Percentage means thresholds relative to min & max">
<div>
<Label description="Percentage means thresholds relative to min & max">Thresholds mode</Label>
<FullWidthButtonContainer>
<RadioButtonGroup size="sm" options={modes} onChange={this.onModeChanged} value={thresholds.mode} />
</FullWidthButtonContainer>
</Field>
</div>
</div>
);
}}
@ -278,7 +279,7 @@ const getStyles = stylesFactory(
wrapper: css`
display: flex;
flex-direction: column;
margin-bottom: -${theme.spacing.formSpacingBase * 2}px;
// margin-bottom: -${theme.spacing.formSpacingBase * 2}px;
`,
thresholds: css`
display: flex;

View File

@ -1,12 +1,9 @@
import React, { ChangeEvent } from 'react';
import { HorizontalGroup } from '../Layout/Layout';
import { Select } from '../index';
import { FullWidthButtonContainer, IconButton, Label, RadioButtonGroup } from '../index';
import { Field } from '../Forms/Field';
import { Input } from '../Input/Input';
import { MappingType, RangeMap, ValueMap, ValueMapping } from '@grafana/data';
import * as styleMixins from '../../themes/mixins';
import { useTheme } from '../../themes';
import { FieldConfigItemHeaderTitle } from '../FieldConfigs/FieldConfigItemHeaderTitle';
import { MappingType, RangeMap, SelectableValue, ValueMap, ValueMapping } from '@grafana/data';
export interface Props {
valueMapping: ValueMapping;
@ -14,13 +11,12 @@ export interface Props {
removeValueMapping: () => void;
}
const MAPPING_OPTIONS = [
const MAPPING_OPTIONS: Array<SelectableValue<MappingType>> = [
{ 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>) => {
@ -76,22 +72,26 @@ export const MappingRow: React.FC<Props> = ({ valueMapping, updateValueMapping,
);
};
const styles = styleMixins.panelEditorNestedListStyles(theme);
const label = (
<HorizontalGroup justify="space-between" align="center">
<Label>Mapping type</Label>
<IconButton name="times" onClick={removeValueMapping} aria-label="ValueMappingsEditor remove button" />
</HorizontalGroup>
);
return (
<div className={styles.wrapper}>
<FieldConfigItemHeaderTitle title="Mapping type" onRemove={removeValueMapping}>
<div className={styles.itemContent}>
<Select
placeholder="Choose type"
isSearchable={false}
<div>
<Field label={label}>
<FullWidthButtonContainer>
<RadioButtonGroup
options={MAPPING_OPTIONS}
value={MAPPING_OPTIONS.find(o => o.value === type)}
onChange={type => onMappingTypeChange(type.value!)}
value={type}
onChange={type => {
onMappingTypeChange(type!);
}}
/>
</div>
</FieldConfigItemHeaderTitle>
<div className={styles.content}>{renderRow()}</div>
</FullWidthButtonContainer>
</Field>
{renderRow()}
</div>
);
};

View File

@ -32,8 +32,9 @@ 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"]');
const remove = wrapper.find('button[aria-label="ValueMappingsEditor remove button"]');
remove.at(0).simulate('click');
expect(onChangeSpy).toBeCalledWith([
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
]);
@ -43,7 +44,7 @@ describe('On remove mapping', () => {
const onChangeSpy = jest.fn();
const wrapper = setup(onChangeSpy);
const remove = wrapper.find('*[aria-label="FieldConfigItemHeaderTitle remove button"]');
const remove = wrapper.find('button[aria-label="ValueMappingsEditor remove button"]');
remove.at(1).simulate('click');
expect(onChangeSpy).toBeCalledWith([

View File

@ -1,7 +1,6 @@
import React from 'react';
import { MappingType, ValueMapping } from '@grafana/data';
import { Button } from '../Button/Button';
import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer';
import { MappingRow } from './MappingRow';
export interface Props {
@ -65,17 +64,15 @@ export const ValueMappingsEditor: React.FC<Props> = ({ valueMappings, onChange,
))}
</>
)}
<FullWidthButtonContainer>
<Button
size="sm"
icon="plus"
onClick={onAdd}
aria-label="ValueMappingsEditor add mapping button"
variant="secondary"
>
Add mapping
</Button>
</FullWidthButtonContainer>
<Button
size="sm"
icon="plus"
onClick={onAdd}
aria-label="ValueMappingsEditor add mapping button"
variant="secondary"
>
Add value mapping
</Button>
</>
);
};

View File

@ -1,7 +1,4 @@
import { GrafanaTheme } from '@grafana/data';
import { selectThemeVariant } from './selectThemeVariant';
import { css } from 'emotion';
import { stylesFactory } from './stylesFactory';
import tinycolor from 'tinycolor2';
export function cardChrome(theme: GrafanaTheme): string {
@ -36,53 +33,3 @@ export function listItemSelected(theme: GrafanaTheme): string {
color: ${theme.colors.textStrong};
`;
}
export const panelEditorNestedListStyles = stylesFactory((theme: GrafanaTheme) => {
const borderColor = selectThemeVariant(
{
light: theme.palette.gray85,
dark: theme.palette.dark9,
},
theme.type
);
const shadow = selectThemeVariant(
{
light: theme.palette.gray85,
dark: theme.palette.black,
},
theme.type
);
const headerBg = selectThemeVariant(
{
light: theme.palette.white,
dark: theme.palette.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};
`,
};
});

View File

@ -1,14 +1,15 @@
import React from 'react';
import { DynamicConfigValue, FieldConfigOptionsRegistry, FieldOverrideContext, GrafanaTheme } from '@grafana/data';
import { FieldConfigItemHeaderTitle, selectThemeVariant, stylesFactory, useTheme } from '@grafana/ui';
import { css } from 'emotion';
import React from 'react';
import { Field, HorizontalGroup, IconButton, IconName, Label, stylesFactory, useTheme } from '@grafana/ui';
import { css, cx } from 'emotion';
import { OptionsGroup } from './OptionsGroup';
interface DynamicConfigValueEditorProps {
property: DynamicConfigValue;
registry: FieldConfigOptionsRegistry;
onChange: (value: DynamicConfigValue) => void;
context: FieldOverrideContext;
onRemove: () => void;
isCollapsible?: boolean;
}
export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> = ({
@ -17,6 +18,7 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
registry,
onChange,
onRemove,
isCollapsible,
}) => {
const theme = useTheme();
const styles = getStyles(theme);
@ -25,11 +27,41 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
if (!item) {
return null;
}
let editor;
const renderLabel = (iconName: IconName, includeDescription = true) => () => (
<HorizontalGroup justify="space-between">
<Label description={includeDescription ? item.description : undefined}>{item.name}</Label>
<div>
<IconButton name={iconName} onClick={onRemove} />
</div>
</HorizontalGroup>
);
return (
<div className={styles.wrapper}>
<FieldConfigItemHeaderTitle onRemove={onRemove} title={item.name} description={item.description} transparent>
<div className={styles.property}>
if (isCollapsible) {
editor = (
<OptionsGroup
renderTitle={renderLabel('trash-alt', false)}
className={css`
padding-left: 0;
padding-right: 0;
`}
nested
defaultToClosed={property.value !== undefined}
>
<item.override
value={property.value}
onChange={value => {
onChange(value);
}}
item={item}
context={context}
/>
</OptionsGroup>
);
} else {
editor = (
<div>
<Field label={renderLabel('times')()} description={item.description}>
<item.override
value={property.value}
onChange={value => {
@ -38,51 +70,30 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
item={item}
context={context}
/>
</div>
</FieldConfigItemHeaderTitle>
</Field>
</div>
);
}
return (
<div
className={cx(
isCollapsible && styles.collapsibleOverrideEditor,
!isCollapsible && 'dynamicConfigValueEditor--nonCollapsible'
)}
>
{editor}
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const borderColor = selectThemeVariant(
{
light: theme.palette.gray85,
dark: theme.palette.dark9,
},
theme.type
);
const highlightColor = selectThemeVariant(
{
light: theme.palette.blue95,
dark: theme.palette.blue77,
},
theme.type
);
return {
wrapper: css`
border-top: 1px dashed ${borderColor};
position: relative;
&:hover {
&:before {
background: ${highlightColor};
}
collapsibleOverrideEditor: css`
label: collapsibleOverrideEditor;
& + .dynamicConfigValueEditor--nonCollapsible {
margin-top: ${theme.spacing.formSpacingBase}px;
}
&:before {
content: '';
position: absolute;
top: 0;
z-index: 1;
left: -1px;
width: 2px;
height: 100%;
transition: background 0.5s cubic-bezier(0.19, 1, 0.22, 1);
}
`,
property: css`
padding: ${theme.spacing.xs} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm};
`,
};
});

View File

@ -8,10 +8,9 @@ import {
PanelPlugin,
SelectableValue,
} from '@grafana/data';
import { fieldMatchersUI, ValuePicker, useTheme, Label, Field } from '@grafana/ui';
import { fieldMatchersUI, ValuePicker, Label, Field, Container } from '@grafana/ui';
import { getDataLinksVariableSuggestions } from '../../../panel/panellinks/link_srv';
import { OverrideEditor } from './OverrideEditor';
import { css } from 'emotion';
import groupBy from 'lodash/groupBy';
import { OptionsGroup } from './OptionsGroup';
@ -27,8 +26,6 @@ interface Props {
* Expects the container div to have size set and will fill it 100%
*/
export const OverrideFieldConfigEditor: React.FC<Props> = props => {
const theme = useTheme();
const onOverrideChange = (index: number, override: any) => {
const { config } = props;
let overrides = cloneDeep(config.overrides);
@ -73,6 +70,7 @@ export const OverrideFieldConfigEditor: React.FC<Props> = props => {
// TODO: apply matcher to retrieve fields
return (
<OverrideEditor
name={`Override ${i + 1}`}
key={`${o.matcher.id}/${i}`}
data={data}
override={o}
@ -88,24 +86,23 @@ export const OverrideFieldConfigEditor: React.FC<Props> = props => {
const renderAddOverride = () => {
return (
<ValuePicker
icon="plus"
label="Add override"
variant="secondary"
options={fieldMatchersUI
.list()
.map<SelectableValue<string>>(i => ({ label: i.name, value: i.id, description: i.description }))}
onChange={value => onOverrideAdd(value)}
/>
<Container padding="md">
<ValuePicker
icon="plus"
label="Add override"
size="md"
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
className={css`
padding: ${theme.spacing.md};
`}
>
<div>
{renderOverrides()}
{renderAddOverride()}
</div>

View File

@ -4,54 +4,96 @@ import { GrafanaTheme } from '@grafana/data';
import { useTheme, Icon, stylesFactory } from '@grafana/ui';
interface Props {
title: string;
title?: React.ReactNode;
renderTitle?: (isExpanded: boolean) => React.ReactNode;
defaultToClosed?: boolean;
className?: string;
nested?: boolean;
}
export const OptionsGroup: FC<Props> = ({ title, children, defaultToClosed }) => {
export const OptionsGroup: FC<Props> = ({
title,
children,
defaultToClosed,
renderTitle,
className,
nested = false,
}) => {
const [isExpanded, toggleExpand] = useState(defaultToClosed ? false : true);
const theme = useTheme();
const styles = getStyles(theme, isExpanded);
const styles = getStyles(theme, isExpanded, nested);
return (
<div className={styles.box}>
<div className={cx(styles.box, className, 'options-group')}>
<div className={styles.header} onClick={() => toggleExpand(!isExpanded)}>
<div className={cx(styles.toggle, 'editor-options-group-toggle')}>
<Icon name={isExpanded ? 'angle-down' : 'angle-right'} />
</div>
{title}
<div style={{ width: '100%' }}>{renderTitle ? renderTitle(isExpanded) : title}</div>
</div>
{isExpanded && <div className={styles.body}>{children}</div>}
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme, isExpanded: boolean) => {
const getStyles = stylesFactory((theme: GrafanaTheme, isExpanded: boolean, isNested: boolean) => {
return {
box: css`
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
`,
box: cx(
!isNested &&
css`
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
`,
isNested &&
isExpanded &&
css`
margin-bottom: ${theme.spacing.formSpacingBase * 2}px;
`
),
toggle: css`
color: ${theme.colors.textWeak};
font-size: ${theme.typography.size.lg};
margin-right: ${theme.spacing.sm};
`,
header: css`
display: flex;
cursor: pointer;
align-items: baseline;
padding: ${theme.spacing.sm} ${theme.spacing.md} ${theme.spacing.sm} ${theme.spacing.sm};
color: ${isExpanded ? theme.colors.text : theme.colors.formLabel};
font-weight: ${theme.typography.weight.semibold};
header: cx(
css`
display: flex;
cursor: pointer;
align-items: baseline;
padding: ${theme.spacing.sm} ${theme.spacing.md} ${theme.spacing.sm} ${theme.spacing.sm};
color: ${isExpanded ? theme.colors.text : theme.colors.formLabel};
font-weight: ${theme.typography.weight.semibold};
&:hover {
.editor-options-group-toggle {
color: ${theme.colors.text};
&:hover {
.editor-options-group-toggle {
color: ${theme.colors.text};
}
}
}
`,
body: css`
padding: 0 ${theme.spacing.md} ${theme.spacing.md} ${theme.spacing.xl};
`,
`,
isNested &&
css`
padding-left: 0;
padding-right: 0;
padding-top: 0;
`
),
body: cx(
css`
padding: 0 ${theme.spacing.md} ${theme.spacing.md} ${theme.spacing.xl};
`,
isNested &&
css`
position: relative;
padding-right: 0;
&:before {
content: '';
position: absolute;
top: 0;
left: 8px;
width: 1px;
height: 100%;
background: ${theme.colors.pageHeaderBorder};
}
`
),
};
});

View File

@ -197,17 +197,6 @@ export const TabsBarContent: React.FC<{
<div className="flex-grow-1" />
</>
)}
{/*
<div className={styles.tabsButton}>
<DashNavButton
icon="search"
iconSize="md"
tooltip="Search all options"
classSuffix="search-options"
onClick={() => setSearchMode(true)}
/>
</div> */}
<div className={styles.tabsButton}>
<DashNavButton
icon="angle-right"

View File

@ -4,17 +4,29 @@ import {
DataFrame,
DynamicConfigValue,
FieldConfigOptionsRegistry,
VariableSuggestionsScope,
FieldConfigProperty,
GrafanaTheme,
VariableSuggestionsScope,
} from '@grafana/data';
import { fieldMatchersUI, stylesFactory, useTheme, ValuePicker, selectThemeVariant } from '@grafana/ui';
import {
Field,
fieldMatchersUI,
HorizontalGroup,
Icon,
IconButton,
Label,
stylesFactory,
useTheme,
ValuePicker,
} from '@grafana/ui';
import { DynamicConfigValueEditor } from './DynamicConfigValueEditor';
import { getDataLinksVariableSuggestions } from '../../../panel/panellinks/link_srv';
import { css } from 'emotion';
import { FieldConfigItemHeaderTitle } from '@grafana/ui/src/components/FieldConfigs/FieldConfigItemHeaderTitle';
import { OptionsGroup } from './OptionsGroup';
interface OverrideEditorProps {
name: string;
data: DataFrame[];
override: ConfigOverrideRule;
onChange: (config: ConfigOverrideRule) => void;
@ -22,8 +34,28 @@ interface OverrideEditorProps {
registry: FieldConfigOptionsRegistry;
}
export const OverrideEditor: React.FC<OverrideEditorProps> = ({ data, override, onChange, onRemove, registry }) => {
const COLLECTION_STANDARD_PROPERTIES = [
FieldConfigProperty.Thresholds,
FieldConfigProperty.Links,
FieldConfigProperty.Mappings,
];
export const OverrideEditor: React.FC<OverrideEditorProps> = ({
name,
data,
override,
onChange,
onRemove,
registry,
}) => {
const theme = useTheme();
const matcherUi = fieldMatchersUI.get(override.matcher.id);
const styles = getStyles(theme);
const matcherLabel = (
<Label category={['Matcher']} description={matcherUi.description}>
{matcherUi.name}
</Label>
);
const onMatcherConfigChange = useCallback(
(matcherConfig: any) => {
override.matcher.options = matcherConfig;
@ -71,20 +103,34 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({ data, override,
};
});
const matcherUi = fieldMatchersUI.get(override.matcher.id);
const styles = getStyles(theme);
const renderOverrideTitle = (isExpanded: boolean) => {
return (
<div>
<HorizontalGroup justify="space-between">
<div>{name}</div>
<IconButton name="trash-alt" onClick={onRemove} />
</HorizontalGroup>
{!isExpanded && (
<div className={styles.overrideDetails}>
Matcher <Icon name="angle-right" /> {matcherUi.name} <br />
{override.properties.length === 0 ? 'No' : override.properties.length} properties overriden
</div>
)}
</div>
);
};
return (
<div className={styles.wrapper}>
<FieldConfigItemHeaderTitle onRemove={onRemove} title={matcherUi.name} description={matcherUi.description}>
<div className={styles.matcherUi}>
<matcherUi.component
matcher={matcherUi.matcher}
data={data}
options={override.matcher.options}
onChange={option => onMatcherConfigChange(option)}
/>
</div>
</FieldConfigItemHeaderTitle>
<OptionsGroup renderTitle={renderOverrideTitle}>
<Field label={matcherLabel} description={matcherUi.description}>
<matcherUi.component
matcher={matcherUi.matcher}
data={data}
options={override.matcher.options}
onChange={option => onMatcherConfigChange(option)}
/>
</Field>
<div>
{override.properties.map((p, j) => {
const item = registry.getIfExists(p.id);
@ -92,70 +138,56 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({ data, override,
if (!item) {
return <div>Unknown property: {p.id}</div>;
}
const isCollapsible =
Array.isArray(p.value) || COLLECTION_STANDARD_PROPERTIES.includes(p.id as FieldConfigProperty);
return (
<div key={`${p.id}/${j}`}>
<DynamicConfigValueEditor
onChange={value => onDynamicConfigValueChange(j, value)}
onRemove={() => onDynamicConfigValueRemove(j)}
property={p}
registry={registry}
context={{
data,
getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(data, scope),
}}
/>
</div>
<DynamicConfigValueEditor
key={`${p.id}/${j}`}
isCollapsible={isCollapsible}
onChange={value => onDynamicConfigValueChange(j, value)}
onRemove={() => onDynamicConfigValueRemove(j)}
property={p}
registry={registry}
context={{
data,
getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(data, scope),
}}
/>
);
})}
<div className={styles.propertyPickerWrapper}>
<ValuePicker
label="Set config property"
icon="plus"
options={configPropertiesOptions}
variant={'link'}
onChange={o => {
onDynamicConfigValueAdd(o.value);
}}
/>
</div>
{override.matcher.options && (
<div className={styles.propertyPickerWrapper}>
<ValuePicker
label="Add override property"
variant="secondary"
size="md"
icon="plus"
options={configPropertiesOptions}
onChange={o => {
onDynamicConfigValueAdd(o.value);
}}
isFullWidth={false}
/>
</div>
)}
</div>
</div>
</OptionsGroup>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const borderColor = selectThemeVariant(
{
light: theme.palette.gray85,
dark: theme.palette.dark9,
},
theme.type
);
const shadow = selectThemeVariant(
{
light: theme.palette.gray85,
dark: theme.palette.black,
},
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};
}
`,
matcherUi: css`
padding: ${theme.spacing.sm};
`,
propertyPickerWrapper: css`
border-top: 1px solid ${borderColor};
margin-top: ${theme.spacing.formSpacingBase * 2}px;
`,
overrideDetails: css`
font-size: ${theme.typography.size.sm};
color: ${theme.colors.textWeak};
font-weight: ${theme.typography.weight.regular};
`,
};
});