mirror of
https://github.com/grafana/grafana.git
synced 2025-02-12 00:25:46 -06:00
Transformations: Support enum field conversion (#76410)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
parent
5e50d9b178
commit
7397f975b6
@ -256,25 +256,24 @@ export function ensureTimeField(field: Field, dateFormat?: string): Field {
|
||||
return fieldToTimeField(field, dateFormat);
|
||||
}
|
||||
|
||||
function fieldToEnumField(field: Field, cfg?: EnumFieldConfig): Field {
|
||||
const enumConfig = { ...cfg };
|
||||
function fieldToEnumField(field: Field, config?: EnumFieldConfig): Field {
|
||||
const enumConfig = { ...config };
|
||||
const enumValues = field.values.slice();
|
||||
|
||||
// Create lookup map based on existing enum config text values, if none exist return field as is
|
||||
const lookup = new Map<unknown, number>();
|
||||
if (enumConfig.text) {
|
||||
if (enumConfig.text && enumConfig.text.length > 0) {
|
||||
for (let i = 0; i < enumConfig.text.length; i++) {
|
||||
lookup.set(enumConfig.text[i], i);
|
||||
}
|
||||
} else {
|
||||
enumConfig.text = [];
|
||||
return field;
|
||||
}
|
||||
|
||||
// Convert field values to enum indexes
|
||||
for (let i = 0; i < enumValues.length; i++) {
|
||||
const v = enumValues[i];
|
||||
if (!lookup.has(v)) {
|
||||
enumConfig.text[lookup.size] = v;
|
||||
lookup.set(v, lookup.size);
|
||||
}
|
||||
enumValues[i] = lookup.get(v);
|
||||
const value = enumValues[i];
|
||||
enumValues[i] = lookup.get(value);
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -19,12 +19,13 @@ import {
|
||||
import { Button, InlineField, InlineFieldRow, Input, Select } from '@grafana/ui';
|
||||
import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker';
|
||||
import { allFieldTypeIconOptions } from '@grafana/ui/src/components/MatchersUI/FieldTypeMatcherEditor';
|
||||
import { hasAlphaPanels } from 'app/core/config';
|
||||
import { findField } from 'app/features/dimensions';
|
||||
|
||||
import { getTransformationContent } from '../docs/getTransformationContent';
|
||||
import { getTimezoneOptions } from '../utils';
|
||||
|
||||
import { EnumMappingEditor } from './EnumMappingEditor';
|
||||
|
||||
const fieldNamePickerSettings = {
|
||||
settings: { width: 24, isClearable: false },
|
||||
} as StandardEditorsRegistryItem<string, FieldNamePickerConfigSettings>;
|
||||
@ -175,12 +176,8 @@ export const ConvertFieldTypeTransformerEditor = ({
|
||||
aria-label={'Remove convert field type transformer'}
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
{c.destinationType === FieldType.enum && hasAlphaPanels && (
|
||||
<InlineFieldRow>
|
||||
<InlineField label={''} labelWidth={6}>
|
||||
<div>TODO... show options here (alpha panels enabled)</div>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
{c.destinationType === FieldType.enum && (
|
||||
<EnumMappingEditor input={input} options={options} transformIndex={idx} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
175
public/app/features/transformers/editors/EnumMappingEditor.tsx
Normal file
175
public/app/features/transformers/editors/EnumMappingEditor.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isEqual } from 'lodash';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { DataFrame, EnumFieldConfig, GrafanaTheme2 } from '@grafana/data';
|
||||
import { ConvertFieldTypeTransformerOptions } from '@grafana/data/src/transformations/transformers/convertFieldType';
|
||||
import { Button, HorizontalGroup, InlineFieldRow, useStyles2, VerticalGroup } from '@grafana/ui';
|
||||
|
||||
import EnumMappingRow from './EnumMappingRow';
|
||||
|
||||
type EnumMappingEditorProps = {
|
||||
input: DataFrame[];
|
||||
options: ConvertFieldTypeTransformerOptions;
|
||||
transformIndex: number;
|
||||
onChange: (options: ConvertFieldTypeTransformerOptions) => void;
|
||||
};
|
||||
|
||||
export const EnumMappingEditor = ({ input, options, transformIndex, onChange }: EnumMappingEditorProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [enumRows, updateEnumRows] = useState<string[]>(options.conversions[transformIndex].enumConfig?.text ?? []);
|
||||
|
||||
// Generate enum values from scratch when none exist in save model
|
||||
useEffect(() => {
|
||||
// TODO: consider case when changing target field
|
||||
if (!options.conversions[transformIndex].enumConfig?.text?.length && input.length) {
|
||||
generateEnumValues();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [input]);
|
||||
|
||||
// Apply enum config to save model when enumRows change
|
||||
useEffect(() => {
|
||||
const applyEnumConfig = () => {
|
||||
const textValues = enumRows.map((value) => value);
|
||||
const conversions = options.conversions;
|
||||
const enumConfig: EnumFieldConfig = { text: textValues };
|
||||
conversions[transformIndex] = { ...conversions[transformIndex], enumConfig };
|
||||
|
||||
onChange({
|
||||
...options,
|
||||
conversions: conversions,
|
||||
});
|
||||
};
|
||||
|
||||
applyEnumConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [transformIndex, enumRows]);
|
||||
|
||||
const generateEnumValues = () => {
|
||||
// Loop through all fields in provided data frames to find the target field
|
||||
const targetField = input
|
||||
.flatMap((inputItem) => inputItem?.fields ?? [])
|
||||
.find((field) => field.name === options.conversions[transformIndex].targetField);
|
||||
|
||||
if (!targetField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enumValues = new Set(targetField?.values);
|
||||
|
||||
if (enumRows.length > 0 && !isEqual(enumRows, Array.from(enumValues))) {
|
||||
const confirmed = window.confirm(
|
||||
'This action will overwrite the existing configuration. Are you sure you want to continue?'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updateEnumRows([...enumValues]);
|
||||
};
|
||||
|
||||
const onChangeEnumMapping = (index: number, enumRow: string) => {
|
||||
const newList = [...enumRows];
|
||||
newList.splice(index, 1, enumRow);
|
||||
updateEnumRows(newList);
|
||||
};
|
||||
|
||||
const onRemoveEnumRow = (index: number) => {
|
||||
const newList = [...enumRows];
|
||||
newList.splice(index, 1);
|
||||
updateEnumRows(newList);
|
||||
};
|
||||
|
||||
const onAddEnumRow = () => {
|
||||
updateEnumRows(['', ...enumRows]);
|
||||
};
|
||||
|
||||
const onChangeEnumValue = (index: number, value: string) => {
|
||||
if (enumRows.includes(value)) {
|
||||
// Do not allow duplicate enum values
|
||||
return;
|
||||
}
|
||||
|
||||
onChangeEnumMapping(index, value);
|
||||
};
|
||||
|
||||
const checkIsEnumUniqueValue = (value: string) => {
|
||||
return enumRows.includes(value);
|
||||
};
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Conversion necessary to match the order of enum values to the order shown in the visualization
|
||||
const mappedSourceIndex = enumRows.length - result.source.index - 1;
|
||||
const mappedDestinationIndex = enumRows.length - result.destination.index - 1;
|
||||
|
||||
const copy = [...enumRows];
|
||||
const element = copy[mappedSourceIndex];
|
||||
copy.splice(mappedSourceIndex, 1);
|
||||
copy.splice(mappedDestinationIndex, 0, element);
|
||||
updateEnumRows(copy);
|
||||
};
|
||||
|
||||
return (
|
||||
<InlineFieldRow>
|
||||
<HorizontalGroup>
|
||||
<Button size="sm" icon="plus" onClick={() => generateEnumValues()} className={styles.button}>
|
||||
Generate enum values from data
|
||||
</Button>
|
||||
<Button size="sm" icon="plus" onClick={() => onAddEnumRow()} className={styles.button}>
|
||||
Add enum value
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
|
||||
<VerticalGroup>
|
||||
<table className={styles.compactTable}>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="sortable-enum-config-mappings" direction="vertical">
|
||||
{(provided) => (
|
||||
<tbody ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{[...enumRows].reverse().map((value: string, index: number) => {
|
||||
// Reverse the order of the enum values to match the order of the enum values in the table to the order in the visualization
|
||||
const mappedIndex = enumRows.length - index - 1;
|
||||
return (
|
||||
<EnumMappingRow
|
||||
key={`${transformIndex}/${value}`}
|
||||
transformIndex={transformIndex}
|
||||
value={value}
|
||||
index={index}
|
||||
mappedIndex={mappedIndex}
|
||||
onChangeEnumValue={onChangeEnumValue}
|
||||
onRemoveEnumRow={onRemoveEnumRow}
|
||||
checkIsEnumUniqueValue={checkIsEnumUniqueValue}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</tbody>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</table>
|
||||
</VerticalGroup>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
compactTable: css({
|
||||
'tbody td': {
|
||||
padding: theme.spacing(0.5),
|
||||
},
|
||||
marginTop: theme.spacing(1),
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
button: css({
|
||||
marginTop: theme.spacing(1),
|
||||
}),
|
||||
});
|
144
public/app/features/transformers/editors/EnumMappingRow.tsx
Normal file
144
public/app/features/transformers/editors/EnumMappingRow.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { FormEvent, useState, KeyboardEvent, useRef, useEffect } from 'react';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, Input, IconButton, HorizontalGroup, FieldValidationMessage, useStyles2 } from '@grafana/ui';
|
||||
|
||||
type EnumMappingRowProps = {
|
||||
transformIndex: number;
|
||||
value: string;
|
||||
index: number;
|
||||
mappedIndex: number;
|
||||
onChangeEnumValue: (index: number, value: string) => void;
|
||||
onRemoveEnumRow: (index: number) => void;
|
||||
checkIsEnumUniqueValue: (value: string) => boolean;
|
||||
};
|
||||
|
||||
const EnumMappingRow = ({
|
||||
transformIndex,
|
||||
value,
|
||||
index,
|
||||
mappedIndex,
|
||||
onChangeEnumValue,
|
||||
onRemoveEnumRow,
|
||||
checkIsEnumUniqueValue,
|
||||
}: EnumMappingRowProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [enumValue, setEnumValue] = useState<string>(value);
|
||||
// If the enum value is empty, we assume it is a new row and should be editable
|
||||
const [isEditing, setIsEditing] = useState<boolean>(enumValue === '');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus the input field if it is rendered
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onEnumInputChange = (event: FormEvent<HTMLInputElement>) => {
|
||||
if (
|
||||
event.currentTarget.value !== '' &&
|
||||
checkIsEnumUniqueValue(event.currentTarget.value) &&
|
||||
event.currentTarget.value !== value
|
||||
) {
|
||||
setValidationError('Enum value already exists');
|
||||
} else {
|
||||
setValidationError(null);
|
||||
}
|
||||
|
||||
setEnumValue(event.currentTarget.value);
|
||||
};
|
||||
|
||||
const onEnumInputBlur = () => {
|
||||
setIsEditing(false);
|
||||
setValidationError(null);
|
||||
|
||||
// Do not add empty or duplicate enum values
|
||||
if (enumValue === '' || validationError !== null) {
|
||||
onRemoveEnumRow(mappedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
onChangeEnumValue(mappedIndex, enumValue);
|
||||
};
|
||||
|
||||
const onEnumInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onEnumInputBlur();
|
||||
}
|
||||
};
|
||||
|
||||
const onEnumValueClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const onRemoveButtonClick = () => {
|
||||
onRemoveEnumRow(mappedIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<Draggable key={`${transformIndex}/${value}`} draggableId={`${transformIndex}/${value}`} index={index}>
|
||||
{(provided) => (
|
||||
<tr key={index} ref={provided.innerRef} {...provided.draggableProps}>
|
||||
<td>
|
||||
<div className={styles.dragHandle} {...provided.dragHandleProps}>
|
||||
<Icon name="draggabledots" size="lg" />
|
||||
</div>
|
||||
</td>
|
||||
{isEditing ? (
|
||||
<td>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={enumValue}
|
||||
onChange={onEnumInputChange}
|
||||
onBlur={onEnumInputBlur}
|
||||
onKeyDown={onEnumInputKeyDown}
|
||||
/>
|
||||
{validationError && <FieldValidationMessage>{validationError}</FieldValidationMessage>}
|
||||
</td>
|
||||
) : (
|
||||
<td onClick={onEnumValueClick} className={styles.clickableTableCell}>
|
||||
{value && value !== '' ? value : 'Click to edit'}
|
||||
</td>
|
||||
)}
|
||||
<td className={styles.textAlignCenter}>
|
||||
<HorizontalGroup spacing="sm">
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
onClick={onRemoveButtonClick}
|
||||
data-testid="remove-enum-row"
|
||||
aria-label="Delete enum row"
|
||||
tooltip="Delete"
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
dragHandle: css({
|
||||
cursor: 'grab',
|
||||
}),
|
||||
textAlignCenter: css({
|
||||
textAlign: 'center',
|
||||
}),
|
||||
clickableTableCell: css({
|
||||
cursor: 'pointer',
|
||||
width: '100px',
|
||||
'&:hover': {
|
||||
color: theme.colors.text.maxContrast,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export default EnumMappingRow;
|
Loading…
Reference in New Issue
Block a user