Transformations: Support enum field conversion (#76410)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Nathan Marrs 2023-11-16 10:44:30 -07:00 committed by GitHub
parent 5e50d9b178
commit 7397f975b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 332 additions and 17 deletions

View File

@ -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 {

View File

@ -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>
);

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

View 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;