diff --git a/packages/grafana-data/src/transformations/transformers/convertFieldType.ts b/packages/grafana-data/src/transformations/transformers/convertFieldType.ts index f4ef713c709..b076ef4e6ee 100644 --- a/packages/grafana-data/src/transformations/transformers/convertFieldType.ts +++ b/packages/grafana-data/src/transformations/transformers/convertFieldType.ts @@ -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(); - 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 { diff --git a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx index a5fd566f9ae..f1957003191 100644 --- a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx +++ b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx @@ -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; @@ -175,12 +176,8 @@ export const ConvertFieldTypeTransformerEditor = ({ aria-label={'Remove convert field type transformer'} /> - {c.destinationType === FieldType.enum && hasAlphaPanels && ( - - -
TODO... show options here (alpha panels enabled)
-
-
+ {c.destinationType === FieldType.enum && ( + )} ); diff --git a/public/app/features/transformers/editors/EnumMappingEditor.tsx b/public/app/features/transformers/editors/EnumMappingEditor.tsx new file mode 100644 index 00000000000..b8ef06e2d30 --- /dev/null +++ b/public/app/features/transformers/editors/EnumMappingEditor.tsx @@ -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(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 ( + + + + + + + + + + + {(provided) => ( + + {[...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 ( + + ); + })} + {provided.placeholder} + + )} + + +
+
+
+ ); +}; + +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), + }), +}); diff --git a/public/app/features/transformers/editors/EnumMappingRow.tsx b/public/app/features/transformers/editors/EnumMappingRow.tsx new file mode 100644 index 00000000000..52861b7d6ce --- /dev/null +++ b/public/app/features/transformers/editors/EnumMappingRow.tsx @@ -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(value); + // If the enum value is empty, we assume it is a new row and should be editable + const [isEditing, setIsEditing] = useState(enumValue === ''); + const [validationError, setValidationError] = useState(null); + + const inputRef = useRef(null); + + // Focus the input field if it is rendered + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [inputRef]); + + const onEnumInputChange = (event: FormEvent) => { + 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) => { + if (event.key === 'Enter') { + event.preventDefault(); + onEnumInputBlur(); + } + }; + + const onEnumValueClick = () => { + setIsEditing(true); + }; + + const onRemoveButtonClick = () => { + onRemoveEnumRow(mappedIndex); + }; + + return ( + + {(provided) => ( + + +
+ +
+ + {isEditing ? ( + + + {validationError && {validationError}} + + ) : ( + + {value && value !== '' ? value : 'Click to edit'} + + )} + + + + + + + )} +
+ ); +}; + +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;