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:
Laura Benz
2023-01-12 11:43:40 +01:00
committed by GitHub
parent 321acca59f
commit 9400ccf478
7 changed files with 222 additions and 22 deletions

View File

@ -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);

View File

@ -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();
});
});
});
});

View File

@ -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>