mirror of https://github.com/grafana/grafana
Transformations: Support enum field conversion (#76410)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>pull/78285/head
parent
5e50d9b178
commit
7397f975b6
@ -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), |
||||
}), |
||||
}); |
@ -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