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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user