Transformations: Add error message during datasource or query error (#110969)

* feat: add message for fields during datasource or query error

* chore: remove unused Select import

* chore: i18n

* chore: fix tests

* chore: empty commit to trigger build

* chore: prune suppressions

* chore: feedback

* chore: i18n extract
This commit is contained in:
Alex Spencer
2025-10-06 08:13:17 -07:00
committed by GitHub
parent a5ee212440
commit 92990790ba
5 changed files with 168 additions and 17 deletions
-5
View File
@@ -3383,11 +3383,6 @@
"count": 1
}
},
"public/app/features/transformers/editors/GroupByTransformerEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx": {
"no-restricted-syntax": {
"count": 1
@@ -4,7 +4,6 @@ import { useCallback } from 'react';
import {
DataTransformerID,
ReducerID,
SelectableValue,
standardTransformers,
TransformerRegistryItem,
TransformerUIProps,
@@ -13,12 +12,12 @@ import {
} from '@grafana/data';
import { GroupByFieldOptions, GroupByOperationID, GroupByTransformerOptions } from '@grafana/data/internal';
import { t } from '@grafana/i18n';
import { useTheme2, Select, StatsPicker, InlineField, Stack, Alert } from '@grafana/ui';
import { useTheme2, StatsPicker, InlineField, Stack, Alert, Combobox, ComboboxOption } from '@grafana/ui';
import { getTransformationContent } from '../docs/getTransformationContent';
import darkImage from '../images/dark/groupBy.svg';
import lightImage from '../images/light/groupBy.svg';
import { useAllFieldNamesFromDataFrames } from '../utils';
import { DataFieldsErrorWrapper } from '../utils';
interface FieldProps {
fieldName: string;
@@ -26,9 +25,11 @@ interface FieldProps {
onConfigChange: (config: GroupByFieldOptions) => void;
}
const GroupByTransformerEditor = ({ input, options, onChange }: TransformerUIProps<GroupByTransformerOptions>) => {
const fieldNames = useAllFieldNamesFromDataFrames(input, true);
interface GroupByTransformerEditorProps extends TransformerUIProps<GroupByTransformerOptions> {
fieldNames: string[];
}
export const GroupByTransformerEditorBase = ({ options, onChange, fieldNames }: GroupByTransformerEditorProps) => {
const onConfigChange = useCallback(
(fieldName: string) => (config: GroupByFieldOptions) => {
onChange({
@@ -84,16 +85,20 @@ const GroupByTransformerEditor = ({ input, options, onChange }: TransformerUIPro
);
};
const GroupByTransformerEditor = DataFieldsErrorWrapper(GroupByTransformerEditorBase, {
withBaseFieldNames: true,
});
const GroupByFieldConfiguration = ({ fieldName, config, onConfigChange }: FieldProps) => {
const theme = useTheme2();
const styles = getStyles(theme);
const onChange = useCallback(
(value: SelectableValue<GroupByOperationID | null>) => {
(option: ComboboxOption<GroupByOperationID> | null) => {
onConfigChange({
aggregations: config?.aggregations ?? [],
operation: value?.value ?? null,
operation: option?.value ?? null,
});
},
[config, onConfigChange]
@@ -114,7 +119,7 @@ const GroupByFieldConfiguration = ({ fieldName, config, onConfigChange }: FieldP
<InlineField className={styles.label} label={fieldName} grow shrink>
<Stack gap={0.5} direction="row">
<div className={styles.operation}>
<Select
<Combobox
options={options}
value={config?.operation}
placeholder={t('transformers.group-by-field-configuration.placeholder-ignored', 'Ignored')}
@@ -129,8 +134,8 @@ const GroupByFieldConfiguration = ({ fieldName, config, onConfigChange }: FieldP
placeholder={t('transformers.group-by-field-configuration.placeholder-select-stats', 'Select stats')}
allowMultiple
stats={config.aggregations}
onChange={(stats) => {
onConfigChange({ ...config, aggregations: stats as ReducerID[] });
onChange={(stats: string[]) => {
onConfigChange({ ...config, aggregations: stats.filter((stat): stat is ReducerID => stat in ReducerID) });
}}
filterOptions={(option) =>
config?.operation === GroupByOperationID.groupBy ? option.id === ReducerID.count : true
+95 -1
View File
@@ -1,6 +1,16 @@
import { act, render, screen } from '@testing-library/react';
import { createElement } from 'react';
import { FieldType, toDataFrame } from '@grafana/data';
import { getAllFieldNamesFromDataFrames, numberOrVariableValidator } from './utils';
import { GroupByTransformerEditorBase } from './editors/GroupByTransformerEditor';
import {
DataFieldsErrorWrapper,
detectPartialQueryFailures,
getAllFieldNamesFromDataFrames,
numberOrVariableValidator,
TIMEOUT,
} from './utils';
describe('validator', () => {
it('validates a positive number', () => {
@@ -112,3 +122,87 @@ describe('useAllFieldNamesFromDataFrames', () => {
expect(names).toEqual(['T', 't', 'n', 's', 't2']);
});
});
describe('detectPartialQueryFailures', () => {
it('returns false when all queries are successful', () => {
const frames = [toDataFrame({ fields: [{ name: 'test', type: FieldType.string, values: ['a'] }] })];
expect(detectPartialQueryFailures(frames)).toBe(false);
});
it('returns true when some queries are successful and some are not', () => {
const frames = [
toDataFrame({ fields: [] }),
toDataFrame({ fields: [{ name: 'test', type: FieldType.string, values: ['a'] }] }),
];
expect(detectPartialQueryFailures(frames)).toBe(true);
});
});
describe('DataFieldsErrorWrapper', () => {
const WrappedDashboard = DataFieldsErrorWrapper(GroupByTransformerEditorBase, { withBaseFieldNames: true });
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
act(() => jest.runOnlyPendingTimers());
jest.useRealTimers();
});
test('shows no error message if there are fields', () => {
const mockProps = {
input: [toDataFrame({ fields: [{ name: 'test', type: FieldType.string, values: ['a'] }] })],
options: { fields: {} },
onChange: jest.fn(),
};
act(() => render(createElement(WrappedDashboard, mockProps)));
expect(screen.queryByText(/One or more queries failed/)).not.toBeInTheDocument();
});
test('shows error message after debounce delay when there are no fields (e.g., datasource error or failed query', () => {
const mockProps = {
input: [toDataFrame({ fields: [] })],
options: { fields: {} },
onChange: jest.fn(),
};
act(() => render(createElement(WrappedDashboard, mockProps)));
// It should not show the error message immediately
expect(screen.queryByText(/One or more queries failed/)).not.toBeInTheDocument();
// It should show the error message after the debounce delay
act(() => jest.advanceTimersByTime(TIMEOUT));
expect(screen.getByText(/One or more queries failed/)).toBeInTheDocument();
});
test('shows error message for mixed query results (some successful, some failed)', () => {
const mockProps = {
input: [
toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'temperature', type: FieldType.number, values: [20, 21, 22] },
],
}),
toDataFrame({ refId: 'B', fields: [] }),
],
options: { fields: {} },
onChange: jest.fn(),
};
act(() => render(createElement(WrappedDashboard, mockProps)));
// It should not show the error message immediately
expect(screen.queryByText(/One or more queries failed/)).not.toBeInTheDocument();
// It should show the error message after the debounce delay
act(() => jest.advanceTimersByTime(TIMEOUT));
expect(screen.getByText(/One or more queries failed/)).toBeInTheDocument();
// Should show available field names
expect(screen.getByText('time')).toBeInTheDocument();
});
});
+57 -1
View File
@@ -1,4 +1,6 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import * as React from 'react';
import { useDebounce } from 'react-use';
import {
DataFrame,
@@ -9,9 +11,11 @@ import {
VariableOrigin,
VariableSuggestion,
SpecialValue,
TransformerUIProps,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { getTemplateSrv } from '@grafana/runtime';
import { FieldValidationMessage } from '@grafana/ui';
import { variableRegex } from '../variables/utils';
@@ -45,10 +49,62 @@ export const getAllFieldNamesFromDataFrames = (frames: DataFrame[], withBaseFiel
return names;
};
export const detectPartialQueryFailures = (frames: DataFrame[]) => {
const hasSuccessful = frames.some(({ fields }) => fields.length > 0);
const hasEmpty = frames.some(({ fields }) => !fields.length);
return hasSuccessful && hasEmpty;
};
export function useAllFieldNamesFromDataFrames(frames: DataFrame[], withBaseFieldNames = false): string[] {
return useMemo(() => getAllFieldNamesFromDataFrames(frames, withBaseFieldNames), [frames, withBaseFieldNames]);
}
export const TransformerMissingFieldsMessage = () => {
return React.createElement(
FieldValidationMessage,
null,
t(
'transformers.query-validation-message',
'One or more queries failed to return fields. This transformation can only reference fields from queries with a successful and visible result.'
)
);
};
type ExpandedTransformerUIProps<T> = TransformerUIProps<T> & { fieldNames: string[] };
type DataFieldsErrorWrapperOptions = { withBaseFieldNames?: boolean };
export const TIMEOUT = 300;
export function DataFieldsErrorWrapper<T>(
Component: React.ComponentType<ExpandedTransformerUIProps<T>>,
{ withBaseFieldNames = false }: DataFieldsErrorWrapperOptions = {}
): React.ComponentType<TransformerUIProps<T>> {
function WrappedComponent({ input, ...props }: TransformerUIProps<T>) {
const [showError, setShowError] = useState(false);
const fieldNames = useAllFieldNamesFromDataFrames(input, withBaseFieldNames);
const hasPartialQueryFailures = detectPartialQueryFailures(input);
const hasErrorCondition = fieldNames.length === 0 || hasPartialQueryFailures;
useDebounce(() => setShowError(hasErrorCondition), TIMEOUT, [hasErrorCondition]);
const wrappedComponent = React.createElement(Component, { ...props, input, fieldNames });
if (showError) {
return React.createElement(
React.Fragment,
null,
React.createElement(TransformerMissingFieldsMessage),
wrappedComponent
);
}
return wrappedComponent;
}
return WrappedComponent;
}
export function getDistinctLabels(input: DataFrame[]): Set<string> {
const distinct = new Set<string>();
for (const frame of input) {
+1
View File
@@ -13685,6 +13685,7 @@
}
}
},
"query-validation-message": "One or more queries failed to return fields. This transformation can only reference fields from queries with a successful and visible result.",
"range-matcher-editor": {
"and": "and",
"placeholder-from": "From",