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