Value mapping/add icon support (#44503)

Co-authored-by: Ryan McKinley <ryantxu@users.noreply.github.com>
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Nathan Marrs 2022-03-15 08:51:12 -07:00 committed by GitHub
parent e62e9904ee
commit 11aa6a3e8f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 240 additions and 104 deletions

View File

@ -13,7 +13,7 @@ import {
ValueMappingFieldConfigSettings,
valueMappingsOverrideProcessor,
} from '@grafana/data';
import { ValueMappingsValueEditor } from 'app/features/dimensions/editors/ValueMappingsEditor/mappings';
import { ValueMappingsEditor } from 'app/features/dimensions/editors/ValueMappingsEditor/ValueMappingsEditor';
import { ThresholdsValueEditor } from 'app/features/dimensions/editors/ThresholdsEditor/thresholds';
/**
@ -31,7 +31,7 @@ export const getAllOptionEditors = () => {
id: 'mappings',
name: 'Mappings',
description: 'Allows defining value mappings',
editor: ValueMappingsValueEditor as any,
editor: ValueMappingsEditor as any,
};
const thresholds: StandardEditorsRegistryItem<ThresholdsConfig> = {

View File

@ -1,4 +1,4 @@
import { DataFrame, Field, getFieldColorModeForField, getScaleCalculator, GrafanaTheme2 } from '@grafana/data';
import { DataFrame, Field, getDisplayProcessor, getFieldColorModeForField, GrafanaTheme2 } from '@grafana/data';
import { ColorDimensionConfig, DimensionSupplier } from './types';
import { findField, getLastNotNullFieldValue } from './utils';
@ -20,7 +20,7 @@ export function getColorDimensionForField(
theme: GrafanaTheme2
): DimensionSupplier<string> {
if (!field) {
const v = theme.visualization.getColorByName(config.fixed) ?? 'grey';
const v = theme.visualization.getColorByName(config.fixed ?? 'grey');
return {
isAssumed: Boolean(config.field?.length) || !config.fixed,
fixed: v,
@ -28,23 +28,28 @@ export function getColorDimensionForField(
get: (i) => v,
};
}
// Use the expensive color calculation by value
const mode = getFieldColorModeForField(field);
if (!mode.isByValue) {
const fixed = mode.getCalculator(field, theme)(0, 0);
if (mode.isByValue || field.config.mappings?.length) {
const disp = getDisplayProcessor({ field, theme });
const getColor = (value: any): string => {
return disp(value).color ?? '#ccc';
};
return {
fixed,
value: () => fixed,
get: (i) => fixed,
field,
get: (index: number): string => getColor(field.values.get(index)),
value: () => getColor(getLastNotNullFieldValue(field)),
};
}
const scale = getScaleCalculator(field, theme);
// Typically series or fixed color (does not depend on value)
const fixed = mode.getCalculator(field, theme)(0, 0);
return {
get: (i) => {
const val = field.values.get(i);
return scale(val).color;
},
fixed,
value: () => fixed,
get: (i) => fixed,
field,
value: () => scale(getLastNotNullFieldValue(field)).color,
};
}

View File

@ -33,7 +33,7 @@ export const ColorDimensionEditor: FC<StandardEditorProps<ColorDimensionConfig,
field,
});
} else {
const fixed = value.fixed ?? defaultColor;
const fixed = value?.fixed ?? defaultColor;
onChange({
...value,
field: undefined,

View File

@ -3,7 +3,13 @@ import { FieldNamePickerConfigSettings, StandardEditorProps, StandardEditorsRegi
import { InlineField, InlineFieldRow, RadioButtonGroup } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker';
import { MediaType, ResourceDimensionConfig, ResourceDimensionMode, ResourceDimensionOptions } from '../types';
import {
MediaType,
ResourceDimensionConfig,
ResourceDimensionMode,
ResourceDimensionOptions,
ResourcePickerSize,
} from '../types';
import { getPublicOrAbsoluteUrl, ResourceFolderName } from '..';
import { ResourcePicker } from './ResourcePicker';
@ -102,6 +108,7 @@ export const ResourceDimensionEditor: FC<
name={niceName(value?.fixed) ?? ''}
mediaType={mediaType}
folderName={folderName}
size={ResourcePickerSize.NORMAL}
/>
)}
{mode === ResourceDimensionMode.Mapping && (

View File

@ -1,34 +1,83 @@
import React, { createRef } from 'react';
import { css } from '@emotion/css';
import { Button, InlineField, InlineFieldRow, Input, Popover, PopoverController, useStyles2 } from '@grafana/ui';
import {
Button,
InlineField,
InlineFieldRow,
Input,
LinkButton,
Popover,
PopoverController,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import SVG from 'react-inlinesvg';
import { MediaType, ResourceFolderName } from '../types';
import { MediaType, ResourceFolderName, ResourcePickerSize } from '../types';
import { closePopover } from '@grafana/ui/src/utils/closePopover';
import { ResourcePickerPopover } from './ResourcePickerPopover';
import { getPublicOrAbsoluteUrl } from '../resource';
interface Props {
onChange: (value?: string) => void;
mediaType: MediaType;
folderName: ResourceFolderName;
size: ResourcePickerSize;
onClear?: (event: React.MouseEvent) => void;
value?: string; //img/icons/unicons/0-plus.svg
src?: string;
name?: string;
placeholder?: string;
onChange: (value?: string) => void;
onClear: (event: React.MouseEvent) => void;
mediaType: MediaType;
folderName: ResourceFolderName;
color?: string;
}
export const ResourcePicker = (props: Props) => {
const { value, src, name, placeholder, onChange, onClear, mediaType, folderName } = props;
const { value, src, name, placeholder, onChange, onClear, mediaType, folderName, size, color } = props;
const styles = useStyles2(getStyles);
const theme = useTheme2();
const pickerTriggerRef = createRef<any>();
const popoverElement = (
<ResourcePickerPopover onChange={onChange} value={value} mediaType={mediaType} folderName={folderName} />
);
let sanitizedSrc = src;
if (!sanitizedSrc && value) {
sanitizedSrc = getPublicOrAbsoluteUrl(value);
}
const colorStyle = color && {
fill: theme.visualization.getColorByName(color),
};
const renderSmallResourcePicker = () => {
if (value && sanitizedSrc) {
return <SVG src={sanitizedSrc} className={styles.icon} style={{ ...colorStyle }} />;
} else {
return (
<LinkButton variant="primary" fill="text" size="sm">
Set icon
</LinkButton>
);
}
};
const renderNormalResourcePicker = () => (
<InlineFieldRow>
<InlineField label={null} grow>
<Input
value={name}
placeholder={placeholder}
readOnly={true}
prefix={sanitizedSrc && <SVG src={sanitizedSrc} className={styles.icon} style={{ ...colorStyle }} />}
suffix={<Button icon="times" variant="secondary" fill="text" size="sm" onClick={onClear} />}
/>
</InlineField>
</InlineFieldRow>
);
return (
<PopoverController content={popoverElement}>
{(showPopper, hidePopper, popperProps) => {
@ -45,18 +94,9 @@ export const ResourcePicker = (props: Props) => {
/>
)}
<div ref={pickerTriggerRef} onClick={showPopper}>
<InlineFieldRow className={styles.pointer}>
<InlineField label={null} grow>
<Input
value={name}
placeholder={placeholder}
readOnly={true}
prefix={src && <SVG src={src} className={styles.icon} />}
suffix={<Button icon="times" variant="secondary" fill="text" size="sm" onClick={onClear} />}
/>
</InlineField>
</InlineFieldRow>
<div ref={pickerTriggerRef} onClick={showPopper} className={styles.pointer}>
{size === ResourcePickerSize.SMALL && renderSmallResourcePicker()}
{size === ResourcePickerSize.NORMAL && renderNormalResourcePicker()}
</div>
</>
);
@ -76,6 +116,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
vertical-align: middle;
display: inline-block;
fill: currentColor;
max-width: 25px;
width: 25px;
`,
});

View File

@ -3,6 +3,8 @@ import { GrafanaTheme2, MappingType, SpecialValueMatch, SelectableValue, ValueMa
import { Draggable } from 'react-beautiful-dnd';
import { css } from '@emotion/css';
import { useStyles2, Icon, Select, HorizontalGroup, ColorPicker, IconButton, Input, Button } from '@grafana/ui';
import { ResourcePicker } from '../ResourcePicker';
import { ResourcePickerSize, ResourceFolderName, MediaType } from '../../types';
export interface ValueMappingEditRowModel {
type: MappingType;
@ -21,9 +23,10 @@ interface Props {
onChange: (index: number, mapping: ValueMappingEditRowModel) => void;
onRemove: (index: number) => void;
onDuplicate: (index: number) => void;
showIconPicker?: boolean;
}
export function ValueMappingEditRow({ mapping, index, onChange, onRemove, onDuplicate: onDuplicate }: Props) {
export function ValueMappingEditRow({ mapping, index, onChange, onRemove, onDuplicate, showIconPicker }: Props) {
const { key, result } = mapping;
const styles = useStyles2(getStyles);
const inputRef = useRef<HTMLInputElement | null>(null);
@ -63,6 +66,18 @@ export function ValueMappingEditRow({ mapping, index, onChange, onRemove, onDupl
});
};
const onChangeIcon = (icon?: string) => {
update((mapping) => {
mapping.result.icon = icon;
});
};
const onClearIcon = () => {
update((mapping) => {
mapping.result.icon = undefined;
});
};
const onUpdateMatchValue = (event: React.FormEvent<HTMLInputElement>) => {
update((mapping) => {
mapping.key = event.currentTarget.value;
@ -183,6 +198,24 @@ export function ValueMappingEditRow({ mapping, index, onChange, onRemove, onDupl
</ColorPicker>
)}
</td>
{showIconPicker && (
<td className={styles.textAlignCenter}>
<HorizontalGroup spacing="sm" justify="center">
<ResourcePicker
onChange={onChangeIcon}
onClear={onClearIcon}
value={result.icon}
size={ResourcePickerSize.SMALL}
folderName={ResourceFolderName.Icon}
mediaType={MediaType.Icon}
color={result.color}
/>
{result.icon && (
<IconButton name="times" onClick={onClearIcon} tooltip="Remove icon" tooltipPlacement="top" />
)}
</HorizontalGroup>
</td>
)}
<td className={styles.textAlignCenter}>
<HorizontalGroup spacing="sm">
<IconButton name="copy" onClick={() => onDuplicate(index)} data-testid="duplicate-value-mapping" />

View File

@ -26,6 +26,8 @@ const setup = (spy?: any, propOverrides?: object) => {
},
},
],
item: {} as any,
context: {} as any,
};
Object.assign(props, propOverrides);
@ -39,4 +41,23 @@ describe('Render', () => {
const button = screen.getByText('Edit value mappings');
expect(button).toBeInTheDocument();
});
it('should render icon picker when icon exists and icon setting is set to true', () => {
const propOverrides = {
item: { settings: { icon: true } },
value: [
{
type: MappingType.ValueToText,
options: {
'20': { text: 'Ok', icon: 'test' },
},
},
],
};
setup({}, propOverrides);
const iconPicker = screen.getByTestId('iconPicker');
expect(iconPicker).toBeInTheDocument();
});
});

View File

@ -1,16 +1,20 @@
import React, { useCallback, useMemo, useState } from 'react';
import { GrafanaTheme2, MappingType, ValueMapping } from '@grafana/data';
import { GrafanaTheme2, MappingType, StandardEditorProps, ValueMapping } from '@grafana/data';
import { css } from '@emotion/css';
import { buildEditRowModels, editModelToSaveModel, ValueMappingsEditorModal } from './ValueMappingsEditorModal';
import { useStyles2, VerticalGroup, Icon, ColorPicker, Button, Modal } from '@grafana/ui';
import { MediaType, ResourceFolderName, ResourcePickerSize } from '../../types';
import { ResourcePicker } from '../ResourcePicker';
export interface Props {
value: ValueMapping[];
onChange: (valueMappings: ValueMapping[]) => void;
export interface Props extends StandardEditorProps<ValueMapping[], any, any> {
showIcon?: boolean;
}
export const ValueMappingsEditor = React.memo(({ value, onChange }: Props) => {
export const ValueMappingsEditor = React.memo((props: Props) => {
const { value, onChange, item } = props;
const styles = useStyles2(getStyles);
const showIconPicker = item.settings?.icon;
const [isEditorOpen, setIsEditorOpen] = useState(false);
const onCloseEditor = useCallback(() => {
setIsEditorOpen(false);
@ -26,6 +30,14 @@ export const ValueMappingsEditor = React.memo(({ value, onChange }: Props) => {
[rows, onChange]
);
const onChangeIcon = useCallback(
(icon: string | undefined, index: number) => {
rows[index].result.icon = icon;
onChange(editModelToSaveModel(rows));
},
[rows, onChange]
);
return (
<VerticalGroup>
<table className={styles.compactTable}>
@ -46,15 +58,27 @@ export const ValueMappingsEditor = React.memo(({ value, onChange }: Props) => {
<Icon name="arrow-right" />
</td>
<td>{row.result.text}</td>
<td>
{row.result.color && (
{row.result.color && (
<td>
<ColorPicker
color={row.result.color}
onChange={(color) => onChangeColor(color, rowIndex)}
enableNamedColors={true}
/>
)}
</td>
</td>
)}
{showIconPicker && row.result.icon && (
<td data-testid="iconPicker">
<ResourcePicker
onChange={(icon) => onChangeIcon(icon, rowIndex)}
value={row.result.icon}
size={ResourcePickerSize.SMALL}
folderName={ResourceFolderName.Icon}
mediaType={MediaType.Icon}
color={row.result.color}
/>
</td>
)}
</tr>
))}
</tbody>
@ -71,7 +95,12 @@ export const ValueMappingsEditor = React.memo(({ value, onChange }: Props) => {
className={styles.modal}
closeOnBackdropClick={false}
>
<ValueMappingsEditorModal value={value} onChange={onChange} onClose={onCloseEditor} />
<ValueMappingsEditorModal
value={value}
onChange={onChange}
onClose={onCloseEditor}
showIconPicker={showIconPicker}
/>
</Modal>
</VerticalGroup>
);

View File

@ -9,9 +9,10 @@ export interface Props {
value: ValueMapping[];
onChange: (valueMappings: ValueMapping[]) => void;
onClose: () => void;
showIconPicker?: boolean;
}
export function ValueMappingsEditorModal({ value, onChange, onClose }: Props) {
export function ValueMappingsEditorModal({ value, onChange, onClose, showIconPicker }: Props) {
const styles = useStyles2(getStyles);
const [rows, updateRows] = useState<ValueMappingEditRowModel[]>([]);
@ -98,6 +99,7 @@ export function ValueMappingsEditorModal({ value, onChange, onClose }: Props) {
</th>
<th style={{ textAlign: 'left' }}>Display text</th>
<th style={{ width: '10%' }}>Color</th>
{showIconPicker && <th style={{ width: '10%' }}>Icon</th>}
<th style={{ width: '1%' }}></th>
</tr>
</thead>
@ -113,6 +115,7 @@ export function ValueMappingsEditorModal({ value, onChange, onClose }: Props) {
onChange={onChangeMapping}
onRemove={onRemoveRow}
onDuplicate={onDuplicateMapping}
showIconPicker={showIconPicker}
/>
))}
{provided.placeholder}
@ -240,38 +243,40 @@ export function editModelToSaveModel(rows: ValueMappingEditRowModel[]) {
export function buildEditRowModels(value: ValueMapping[]) {
const editRows: ValueMappingEditRowModel[] = [];
for (const mapping of value) {
switch (mapping.type) {
case MappingType.ValueToText:
for (const key of Object.keys(mapping.options)) {
if (value) {
for (const mapping of value) {
switch (mapping.type) {
case MappingType.ValueToText:
for (const key of Object.keys(mapping.options)) {
editRows.push({
type: mapping.type,
result: mapping.options[key],
key,
});
}
break;
case MappingType.RangeToText:
editRows.push({
type: mapping.type,
result: mapping.options[key],
key,
result: mapping.options.result,
from: mapping.options.from ?? 0,
to: mapping.options.to ?? 0,
});
}
break;
case MappingType.RangeToText:
editRows.push({
type: mapping.type,
result: mapping.options.result,
from: mapping.options.from ?? 0,
to: mapping.options.to ?? 0,
});
break;
case MappingType.RegexToText:
editRows.push({
type: mapping.type,
result: mapping.options.result,
pattern: mapping.options.pattern,
});
break;
case MappingType.SpecialValue:
editRows.push({
type: mapping.type,
result: mapping.options.result,
specialMatch: mapping.options.match ?? SpecialValueMatch.Null,
});
break;
case MappingType.RegexToText:
editRows.push({
type: mapping.type,
result: mapping.options.result,
pattern: mapping.options.pattern,
});
break;
case MappingType.SpecialValue:
editRows.push({
type: mapping.type,
result: mapping.options.result,
specialMatch: mapping.options.match ?? SpecialValueMatch.Null,
});
}
}
}

View File

@ -1,22 +0,0 @@
import React from 'react';
import { FieldConfigEditorProps, ValueMapping, ValueMappingFieldConfigSettings } from '@grafana/data';
import { ValueMappingsEditor } from '../ValueMappingsEditor/ValueMappingsEditor';
export class ValueMappingsValueEditor extends React.PureComponent<
FieldConfigEditorProps<ValueMapping[], ValueMappingFieldConfigSettings>
> {
constructor(props: FieldConfigEditorProps<ValueMapping[], ValueMappingFieldConfigSettings>) {
super(props);
}
render() {
const { onChange } = this.props;
let value = this.props.value;
if (!value) {
value = [];
}
return <ValueMappingsEditor value={value} onChange={onChange} />;
}
}

View File

@ -47,9 +47,14 @@ export function getResourceDimension(
};
}
const getIcon = (value: any): string => {
const disp = field.display!;
return getPublicOrAbsoluteUrl(disp(value).icon ?? '');
};
return {
field,
get: field.values.get,
value: () => getLastNotNullFieldValue(field),
get: (index: number): string => getIcon(field.values.get(index)),
value: () => getIcon(getLastNotNullFieldValue(field)),
};
}

View File

@ -127,3 +127,8 @@ export enum PickerTabType {
Folder = 'folder',
URL = 'url',
}
export enum ResourcePickerSize {
SMALL = 'small',
NORMAL = 'normal',
}

View File

@ -1,4 +1,4 @@
import { PanelPlugin } from '@grafana/data';
import { FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { IconPanel } from './IconPanel';
import { defaultPanelOptions, PanelOptions } from './models.gen';
@ -8,7 +8,15 @@ import { CanvasElementOptions } from 'app/features/canvas';
export const plugin = new PanelPlugin<PanelOptions>(IconPanel)
.setNoPadding() // extend to panel edges
.useFieldConfig()
.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Mappings]: {
settings: {
icon: true,
},
},
},
})
.setPanelOptions((builder) => {
builder.addNestedOptions<CanvasElementOptions<IconConfig>>({
category: ['Icon'],