mirror of
https://github.com/grafana/grafana.git
synced 2025-08-06 07:29:26 +08:00
Glue: Validate target query in correlations page (#57245)
* feat: add draft version of validate button * feat: add some styling and basics * temp: intermediate result * refactor: solve TODOs * refactor: replace string in state * refactor: replace error message style * refactor: set validate state on change in ds * refactor: add QueryRunner * refactor: add QueryRunner * temp: temporary status * Emit PanelData to check if the query is valid * refactor: clean up * refactor: improve a11y of error message and adjust test * Remove deprecated property call, change equality * refactor: add changes from code review * refactor: remove memory leak * refactor: replace query runner * refactor: adjust error handling * refactor: move testing to related unit test * refactor: clean up test for QueryEditorField * refactor: clean up test for CorrelationsPage * refactor: repair test * refactor: clean up * refactor: add refId in order avoid errors when running Loki queries * refactor: replace buildQueryTransaction + set query to invalid if query is empty * refactor: add empty query value to test cases * refactor: end handleValidation after setIsValidQuery() * refactor: refactor test * refactor: fix last two tests * refactor: modify validation * refactor: add happy path * refactor: clean up * refactor: clean up tests (not final) * refactor: further clean up * refactor: add condition for failing * refactor: finish clean up * refactor: changes from code review * refactor: add response state to condition Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com> * refactor: fix prettier issue * refactor: remove unused return * refactor: replace change in queryAnalytics.ts * refactor: remove correlations from query analytics * refactor: remove unnecessary test preparation * refactor: revert changes from commit 4997327 Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com> Co-authored-by: Kristina Durivage <kristina.durivage@grafana.com>
This commit is contained in:
@ -304,7 +304,7 @@ describe('CorrelationsPage', () => {
|
||||
expect(screen.getByRole('button', { name: /add$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('correctly adds correlations', async () => {
|
||||
it('correctly adds first correlation', async () => {
|
||||
const CTAButton = screen.getByRole('button', { name: /add correlation/i });
|
||||
expect(CTAButton).toBeInTheDocument();
|
||||
|
||||
@ -440,7 +440,7 @@ describe('CorrelationsPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('correctly adds correlations', async () => {
|
||||
it('correctly adds new correlation', async () => {
|
||||
const addNewButton = screen.getByRole('button', { name: /add new/i });
|
||||
expect(addNewButton).toBeInTheDocument();
|
||||
fireEvent.click(addNewButton);
|
||||
|
@ -1,15 +1,16 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { MockDataSourceApi } from 'test/mocks/datasource_srv';
|
||||
|
||||
import { LoadingState } from '@grafana/data';
|
||||
import { setDataSourceSrv } from '@grafana/runtime';
|
||||
import { MockDataSourceSrv } from 'app/features/alerting/unified/mocks';
|
||||
|
||||
import { QueryEditorField } from './QueryEditorField';
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => {
|
||||
const methods = useForm();
|
||||
const methods = useForm({ defaultValues: { query: {} } });
|
||||
return <FormProvider {...methods}>{children}</FormProvider>;
|
||||
};
|
||||
|
||||
@ -21,7 +22,7 @@ const defaultGetHandler = async (name: string) => {
|
||||
return dsApi;
|
||||
};
|
||||
|
||||
const renderWithContext = async (
|
||||
const renderWithContext = (
|
||||
children: ReactNode,
|
||||
getHandler: (name: string) => Promise<MockDataSourceApi> = defaultGetHandler
|
||||
) => {
|
||||
@ -33,7 +34,24 @@ const renderWithContext = async (
|
||||
render(<Wrapper>{children}</Wrapper>);
|
||||
};
|
||||
|
||||
const initiateDsApi = () => {
|
||||
const dsApi = new MockDataSourceApi('dsApiMock');
|
||||
dsApi.components = {
|
||||
QueryEditor: () => <>query editor</>,
|
||||
};
|
||||
|
||||
renderWithContext(<QueryEditorField name="query" dsUid="randomDsUid" />, async () => {
|
||||
return dsApi;
|
||||
});
|
||||
|
||||
return dsApi;
|
||||
};
|
||||
|
||||
describe('QueryEditorField', () => {
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render the query editor', async () => {
|
||||
renderWithContext(<QueryEditorField name="query" dsUid="test" />);
|
||||
|
||||
@ -63,4 +81,90 @@ describe('QueryEditorField', () => {
|
||||
await screen.findByRole('alert', { name: 'Data source does not export a query editor.' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Query validation', () => {
|
||||
it('should result in succeeded validation if LoadingState.Done and data is available', async () => {
|
||||
const dsApi = initiateDsApi();
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i));
|
||||
|
||||
dsApi.result = {
|
||||
data: [
|
||||
{
|
||||
name: 'test',
|
||||
fields: [],
|
||||
length: 1,
|
||||
},
|
||||
],
|
||||
state: LoadingState.Done,
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Validate query$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('This query is valid.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should result in failed validation if LoadingState.Error and data is not available', async () => {
|
||||
const dsApi = initiateDsApi();
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i));
|
||||
|
||||
dsApi.result = {
|
||||
data: [],
|
||||
state: LoadingState.Error,
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Validate query$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const alertEl = screen.getByRole('alert');
|
||||
expect(alertEl).toBeInTheDocument();
|
||||
expect(alertEl).toHaveTextContent(/this query is not valid/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('should result in failed validation if LoadingState.Error and data is available', async () => {
|
||||
const dsApi = initiateDsApi();
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i));
|
||||
|
||||
dsApi.result = {
|
||||
data: [
|
||||
{
|
||||
name: 'test',
|
||||
fields: [],
|
||||
length: 1,
|
||||
},
|
||||
],
|
||||
state: LoadingState.Error,
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Validate query$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const alertEl = screen.getByRole('alert');
|
||||
expect(alertEl).toBeInTheDocument();
|
||||
expect(alertEl).toHaveTextContent(/this query is not valid/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('should result in failed validation if result with LoadingState.Done and data is not available', async () => {
|
||||
const dsApi = initiateDsApi();
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i));
|
||||
|
||||
dsApi.result = {
|
||||
data: [],
|
||||
state: LoadingState.Done,
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Validate query$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('This query is not valid.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,9 +1,24 @@
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { CoreApp, DataQuery, getDefaultTimeRange, GrafanaTheme2 } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Field, LoadingPlaceholder, Alert } from '@grafana/ui';
|
||||
import {
|
||||
Field,
|
||||
LoadingPlaceholder,
|
||||
Alert,
|
||||
Button,
|
||||
HorizontalGroup,
|
||||
Icon,
|
||||
FieldValidationMessage,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import { generateKey } from '../../../core/utils/explore';
|
||||
import { QueryTransaction } from '../../../types';
|
||||
import { runRequest } from '../../query/state/runRequest';
|
||||
|
||||
interface Props {
|
||||
dsUid?: string;
|
||||
@ -12,7 +27,19 @@ interface Props {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function getStyle(theme: GrafanaTheme2) {
|
||||
return {
|
||||
valid: css`
|
||||
color: ${theme.colors.success.text};
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export const QueryEditorField = ({ dsUid, invalid, error, name }: Props) => {
|
||||
const [isValidQuery, setIsValidQuery] = useState<boolean | undefined>(undefined);
|
||||
|
||||
const style = useStyles2(getStyle);
|
||||
|
||||
const {
|
||||
value: datasource,
|
||||
loading: dsLoading,
|
||||
@ -23,8 +50,56 @@ export const QueryEditorField = ({ dsUid, invalid, error, name }: Props) => {
|
||||
}
|
||||
return getDataSourceSrv().get(dsUid);
|
||||
}, [dsUid]);
|
||||
|
||||
const QueryEditor = datasource?.components?.QueryEditor;
|
||||
|
||||
const handleValidation = (value: DataQuery) => {
|
||||
const interval = '1s';
|
||||
const intervalMs = 1000;
|
||||
const id = generateKey();
|
||||
const queries = [{ ...value, refId: 'A' }];
|
||||
|
||||
const transaction: QueryTransaction = {
|
||||
queries,
|
||||
request: {
|
||||
app: CoreApp.Correlations,
|
||||
timezone: 'utc',
|
||||
startTime: Date.now(),
|
||||
interval,
|
||||
intervalMs,
|
||||
targets: queries,
|
||||
range: getDefaultTimeRange(),
|
||||
requestId: 'correlations_' + id,
|
||||
scopedVars: {
|
||||
__interval: { text: interval, value: interval },
|
||||
__interval_ms: { text: intervalMs, value: intervalMs },
|
||||
},
|
||||
},
|
||||
id,
|
||||
done: false,
|
||||
};
|
||||
|
||||
if (datasource) {
|
||||
runRequest(datasource, transaction.request).subscribe((panelData) => {
|
||||
if (
|
||||
!panelData ||
|
||||
panelData.state === 'Error' ||
|
||||
(panelData.state === 'Done' && panelData.series.length === 0)
|
||||
) {
|
||||
setIsValidQuery(false);
|
||||
} else if (
|
||||
panelData.state === 'Done' &&
|
||||
panelData.series.length > 0 &&
|
||||
Boolean(panelData.series.find((element) => element.length > 0))
|
||||
) {
|
||||
setIsValidQuery(true);
|
||||
} else {
|
||||
setIsValidQuery(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Field label="Query" invalid={invalid} error={error}>
|
||||
<Controller
|
||||
@ -52,8 +127,31 @@ export const QueryEditorField = ({ dsUid, invalid, error, name }: Props) => {
|
||||
if (!QueryEditor) {
|
||||
return <Alert title="Data source does not export a query editor."></Alert>;
|
||||
}
|
||||
|
||||
return <QueryEditor onRunQuery={() => {}} onChange={onChange} datasource={datasource} query={value} />;
|
||||
return (
|
||||
<>
|
||||
<QueryEditor
|
||||
onRunQuery={() => handleValidation(value)}
|
||||
onChange={(value) => {
|
||||
setIsValidQuery(undefined);
|
||||
onChange(value);
|
||||
}}
|
||||
datasource={datasource}
|
||||
query={value}
|
||||
/>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
{isValidQuery ? (
|
||||
<div className={style.valid}>
|
||||
<Icon name="check" /> This query is valid.
|
||||
</div>
|
||||
) : isValidQuery === false ? (
|
||||
<FieldValidationMessage>This query is not valid.</FieldValidationMessage>
|
||||
) : null}
|
||||
<Button variant="secondary" icon={'check'} type="button" onClick={() => handleValidation(value)}>
|
||||
Validate query
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
Reference in New Issue
Block a user