mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
FieldOverrides: UI updates (#23630)
* UI improvements for field overrides * Update tests * Fix missing key * Fix e2e
This commit is contained in:
@@ -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};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
`
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user