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
15 changed files with 333 additions and 355 deletions

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};
`,
};
});