Transformer: Config from Query: set threshold colours (#83366)

* Fix typo

* Create handler arguments

* Create handler arguments editor

* Create link in editor

* Add labels to arguments editor fields

* Create unit tests

* Hide "Additional settings" header when no additional settings possible

* Remove div

* Fix invalid DOM

---------

Co-authored-by: jev forsberg <jev.forsberg@grafana.com>
This commit is contained in:
Lars Stegman 2024-03-22 22:39:01 +01:00 committed by GitHub
parent 5b0b8cb4bf
commit 188aed05f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 186 additions and 11 deletions

View File

@ -85,6 +85,20 @@ describe('config from data', () => {
expect(results[0].fields[1].config.max).toBe(1);
});
it('With threshold', () => {
const options: ConfigFromQueryTransformOptions = {
configRefId: 'A',
mappings: [{ fieldName: 'Max', handlerKey: 'threshold1', handlerArguments: { threshold: { color: 'orange' } } }],
};
const results = extractConfigFromQuery(options, [config, seriesA]);
expect(results.length).toBe(1);
const thresholdConfig = results[0].fields[1].config.thresholds?.steps[1];
expect(thresholdConfig).toBeDefined();
expect(thresholdConfig?.color).toBe('orange');
expect(thresholdConfig?.value).toBe(50);
});
it('With custom matcher and displayName mapping', () => {
const options: ConfigFromQueryTransformOptions = {
configRefId: 'A',
@ -102,6 +116,7 @@ describe('value mapping from data', () => {
const config = toDataFrame({
fields: [
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
{ name: 'threshold', type: FieldType.number, values: [4] },
{ name: 'text', type: FieldType.string, values: ['one', 'two', 'three'] },
{ name: 'color', type: FieldType.string, values: ['red', 'blue', 'green'] },
],

View File

@ -12,7 +12,7 @@ import {
} from '@grafana/data';
import {
evaluteFieldMappings,
evaluateFieldMappings,
FieldToConfigMapping,
getFieldConfigFromFrame,
} from '../fieldToConfigMapping/fieldToConfigMapping';
@ -42,7 +42,7 @@ export function extractConfigFromQuery(options: ConfigFromQueryTransformOptions,
length: 1,
};
const mappingResult = evaluteFieldMappings(configFrame, options.mappings ?? [], false);
const mappingResult = evaluateFieldMappings(configFrame, options.mappings ?? [], false);
// reduce config frame
for (const field of configFrame.fields) {

View File

@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { FieldConfigMappingHandlerArgumentsEditor, Props } from './FieldConfigMappingHandlerArgumentsEditor';
beforeEach(() => {
jest.clearAllMocks();
});
const mockOnChange = jest.fn();
const props: Props = {
handlerKey: null,
handlerArguments: {},
onChange: mockOnChange,
};
const setup = (testProps?: Partial<Props>) => {
const editorProps = { ...props, ...testProps };
return render(<FieldConfigMappingHandlerArgumentsEditor {...editorProps} />);
};
describe('FieldConfigMappingHandlerArgumentsEditor', () => {
it('Should show a color picker when thresholds are selected', async () => {
setup({ handlerKey: 'threshold1' });
expect(await screen.findByDisplayValue('Threshold color')).toBeInTheDocument();
expect(await screen.findByLabelText('red color')).toBeInTheDocument();
});
it('Should show the correct selected color', async () => {
setup({ handlerKey: 'threshold1', handlerArguments: { threshold: { color: 'orange' } } });
expect(await screen.findByDisplayValue('Threshold color')).toBeInTheDocument();
expect(await screen.findByLabelText('orange color')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,57 @@
import React from 'react';
import { ColorPicker, Input } from '@grafana/ui';
import { HandlerArguments } from './fieldToConfigMapping';
export interface Props {
handlerKey: string | null;
handlerArguments: HandlerArguments;
onChange: (args: HandlerArguments) => void;
}
export function createsArgumentsEditor(handlerKey: string | null) {
switch (handlerKey) {
case 'threshold1':
return true;
default:
return false;
}
}
export function FieldConfigMappingHandlerArgumentsEditor({ handlerArguments, handlerKey, onChange }: Props) {
const onChangeThreshold = (color: string | null) => {
if (color) {
onChange({
...handlerArguments,
threshold: {
...handlerArguments.threshold,
color: color,
},
});
} else {
onChange({ ...handlerArguments, threshold: undefined });
}
};
return (
<>
{handlerKey === 'threshold1' && (
<Input
type="text"
value={'Threshold color'}
aria-label={'Threshold color'}
disabled
width={20}
prefix={
<ColorPicker
color={handlerArguments.threshold?.color ?? 'red'}
onChange={onChangeThreshold}
enableNamedColors={true}
/>
}
/>
)}
</>
);
}

View File

@ -89,4 +89,18 @@ describe('FieldToConfigMappingEditor', () => {
expect.arrayContaining([{ fieldName: 'max', handlerKey: 'max', reducerId: 'last' }])
);
});
it('Shows additional settings', async () => {
setup({ mappings: [{ fieldName: 'max', handlerKey: 'threshold1' }] });
const select = await screen.findByText('Additional settings');
expect(select).toBeInTheDocument();
});
it('Does not show additional settings', async () => {
setup();
const select = screen.queryByText('Additional settings');
expect(select).not.toBeInTheDocument();
});
});

View File

@ -7,12 +7,18 @@ import { Select, StatsPicker, useStyles2 } from '@grafana/ui';
import {
configMapHandlers,
evaluteFieldMappings,
evaluateFieldMappings,
FieldToConfigMapHandler,
FieldToConfigMapping,
HandlerArguments,
lookUpConfigHandler as findConfigHandlerFor,
} from '../fieldToConfigMapping/fieldToConfigMapping';
import {
createsArgumentsEditor,
FieldConfigMappingHandlerArgumentsEditor,
} from './FieldConfigMappingHandlerArgumentsEditor';
export interface Props {
frame: DataFrame;
mappings: FieldToConfigMapping[];
@ -27,6 +33,10 @@ export function FieldToConfigMappingEditor({ frame, mappings, onChange, withRedu
const configProps = configMapHandlers.map((def) => configHandlerToSelectOption(def, false)) as Array<
SelectableValue<string>
>;
const hasAdditionalSettings = mappings.reduce(
(prev, mapping) => prev || createsArgumentsEditor(mapping.handlerKey),
false
);
const onChangeConfigProperty = (row: FieldToConfigRowViewModel, value: SelectableValue<string | null>) => {
const existingIdx = mappings.findIndex((x) => x.fieldName === row.fieldName);
@ -60,6 +70,18 @@ export function FieldToConfigMappingEditor({ frame, mappings, onChange, withRedu
}
};
const onChangeHandlerArguments = (row: FieldToConfigRowViewModel, handlerArguments: HandlerArguments) => {
const existingIdx = mappings.findIndex((x) => x.fieldName === row.fieldName);
if (existingIdx !== -1) {
const update = [...mappings];
update.splice(existingIdx, 1, { ...mappings[existingIdx], handlerArguments });
onChange(update);
} else {
onChange([...mappings, { fieldName: row.fieldName, handlerKey: row.handlerKey, handlerArguments }]);
}
};
return (
<table className={styles.table}>
<thead>
@ -67,6 +89,7 @@ export function FieldToConfigMappingEditor({ frame, mappings, onChange, withRedu
<th>Field</th>
<th>Use as</th>
{withReducers && <th>Select</th>}
{hasAdditionalSettings && <th>Additional settings</th>}
</tr>
</thead>
<tbody>
@ -91,6 +114,15 @@ export function FieldToConfigMappingEditor({ frame, mappings, onChange, withRedu
/>
</td>
)}
{hasAdditionalSettings && (
<td data-testid={`${row.fieldName}-handler-arg`} className={styles.selectCell}>
<FieldConfigMappingHandlerArgumentsEditor
handlerKey={row.handlerKey}
handlerArguments={row.handlerArguments}
onChange={(args) => onChangeHandlerArguments(row, args)}
/>
</td>
)}
</tr>
))}
</tbody>
@ -105,6 +137,7 @@ interface FieldToConfigRowViewModel {
placeholder?: string;
missingInFrame?: boolean;
reducerId: string;
handlerArguments: HandlerArguments;
}
function getViewModelRows(
@ -113,7 +146,7 @@ function getViewModelRows(
withNameAndValue?: boolean
): FieldToConfigRowViewModel[] {
const rows: FieldToConfigRowViewModel[] = [];
const mappingResult = evaluteFieldMappings(frame, mappings ?? [], withNameAndValue);
const mappingResult = evaluateFieldMappings(frame, mappings ?? [], withNameAndValue);
for (const field of frame.fields) {
const fieldName = getFieldDisplayName(field, frame);
@ -126,6 +159,7 @@ function getViewModelRows(
placeholder: mapping.automatic ? option?.label : 'Choose',
handlerKey: mapping.handler?.key ?? null,
reducerId: mapping.reducerId,
handlerArguments: mapping.handlerArguments,
});
}
@ -140,6 +174,7 @@ function getViewModelRows(
configOption: configHandlerToSelectOption(handler, false),
missingInFrame: true,
reducerId: mapping.reducerId ?? ReducerID.lastNotNull,
handlerArguments: {},
});
}
}

View File

@ -15,10 +15,19 @@ import {
FieldType,
} from '@grafana/data';
export interface ThresholdArguments {
color: string;
}
export interface HandlerArguments {
threshold?: ThresholdArguments;
}
export interface FieldToConfigMapping {
fieldName: string;
reducerId?: ReducerID;
handlerKey: string | null;
handlerArguments?: HandlerArguments;
}
/**
@ -59,7 +68,7 @@ export function getFieldConfigFromFrame(
continue;
}
const newValue = handler.processor(configValue, config, context);
const newValue = handler.processor(configValue, config, context, mapping.handlerArguments);
if (newValue != null) {
(config as any)[handler.targetProperty ?? handler.key] = newValue;
}
@ -78,7 +87,12 @@ interface FieldToConfigContext {
mappingTexts?: string[];
}
type FieldToConfigMapHandlerProcessor = (value: any, config: FieldConfig, context: FieldToConfigContext) => any;
type FieldToConfigMapHandlerProcessor = (
value: any,
config: FieldConfig,
context: FieldToConfigContext,
handlerArguments: HandlerArguments
) => any;
export interface FieldToConfigMapHandler {
key: string;
@ -143,8 +157,9 @@ export const configMapHandlers: FieldToConfigMapHandler[] = [
},
{
key: 'threshold1',
name: 'Threshold',
targetProperty: 'thresholds',
processor: (value, config) => {
processor: (value, config, _, handlerArguments) => {
const numeric = anyToNumber(value);
if (isNaN(numeric)) {
@ -160,7 +175,7 @@ export const configMapHandlers: FieldToConfigMapHandler[] = [
config.thresholds.steps.push({
value: numeric,
color: 'red',
color: handlerArguments.threshold?.color ?? 'red',
});
return config.thresholds;
@ -278,6 +293,7 @@ export function lookUpConfigHandler(key: string | null): FieldToConfigMapHandler
export interface EvaluatedMapping {
automatic: boolean;
handler: FieldToConfigMapHandler | null;
handlerArguments: HandlerArguments;
reducerId: ReducerID;
}
export interface EvaluatedMappingResult {
@ -286,7 +302,7 @@ export interface EvaluatedMappingResult {
valueField?: Field;
}
export function evaluteFieldMappings(
export function evaluateFieldMappings(
frame: DataFrame,
mappings: FieldToConfigMapping[],
withNameAndValue?: boolean
@ -337,6 +353,7 @@ export function evaluteFieldMappings(
result.index[fieldName] = {
automatic: !mapping,
handler: handler,
handlerArguments: mapping?.handlerArguments ?? {},
reducerId: mapping?.reducerId ?? handler?.defaultReducer ?? ReducerID.lastNotNull,
};
}

View File

@ -4,7 +4,7 @@ import { DataFrame, DataTransformerID, DataTransformerInfo, Field, getFieldDispl
import {
EvaluatedMappingResult,
evaluteFieldMappings,
evaluateFieldMappings,
FieldConfigHandlerKey,
FieldToConfigMapping,
getFieldConfigFromFrame,
@ -35,7 +35,7 @@ export const rowsToFieldsTransformer: DataTransformerInfo<RowToFieldsTransformOp
};
export function rowsToFields(options: RowToFieldsTransformOptions, data: DataFrame): DataFrame {
const mappingResult = evaluteFieldMappings(data, options.mappings ?? [], true);
const mappingResult = evaluateFieldMappings(data, options.mappings ?? [], true);
const { nameField, valueField } = mappingResult;
if (!nameField || !valueField) {