diff --git a/public/app/features/correlations/CorrelationsPage.test.tsx b/public/app/features/correlations/CorrelationsPage.test.tsx
index b3ac6243f94..81827aee913 100644
--- a/public/app/features/correlations/CorrelationsPage.test.tsx
+++ b/public/app/features/correlations/CorrelationsPage.test.tsx
@@ -1,9 +1,10 @@
-import { render, waitFor, screen, fireEvent } from '@testing-library/react';
+import { render, waitFor, screen, fireEvent, waitForElementToBeRemoved, within, Matcher } from '@testing-library/react';
import { merge, uniqueId } from 'lodash';
import React from 'react';
import { DeepPartial } from 'react-hook-form';
import { Provider } from 'react-redux';
import { Observable } from 'rxjs';
+import { MockDataSourceApi } from 'test/mocks/datasource_srv';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { DataSourcePluginMeta } from '@grafana/data';
@@ -127,19 +128,93 @@ const renderWithContext = async (
} as unknown as BackendSrv;
const grafanaContext = getGrafanaContextMock({ backend });
- setDataSourceSrv(new MockDataSourceSrv(datasources));
+ const dsServer = new MockDataSourceSrv(datasources);
+ dsServer.get = (name: string) => {
+ const dsApi = new MockDataSourceApi(name);
+ dsApi.components = {
+ QueryEditor: () => <>{name} query editor>,
+ };
+ return Promise.resolve(dsApi);
+ };
- render(
+ setDataSourceSrv(dsServer);
+
+ const renderResult = render(
-
+ ,
+ {
+ queries: {
+ /**
+ * Gets all the rows in the table having the given text in the given column
+ */
+ queryRowsByCellValue: (
+ container: HTMLElement,
+ columnName: Matcher,
+ textValue: Matcher
+ ): HTMLTableRowElement[] => {
+ const table = within(container).getByRole('table');
+ const headers = within(table).getAllByRole('columnheader');
+ const headerIndex = headers.findIndex((h) => {
+ return within(h).queryByText(columnName);
+ });
+
+ // the first rowgroup is the header
+ const tableBody = within(table).getAllByRole('rowgroup')[1];
+
+ return within(tableBody)
+ .getAllByRole('row')
+ .filter((row) => {
+ const rowCells = within(row).getAllByRole('cell');
+ const cell = rowCells[headerIndex];
+ return within(cell).queryByText(textValue);
+ });
+ },
+ /**
+ * Gets all the cells in the table for the given column name
+ */
+ queryCellsByColumnName: (container: HTMLElement, columnName: Matcher) => {
+ const table = within(container).getByRole('table');
+ const headers = within(table).getAllByRole('columnheader');
+ const headerIndex = headers.findIndex((h) => {
+ return within(h).queryByText(columnName);
+ });
+ const tbody = table.querySelector('tbody');
+ if (!tbody) {
+ return [];
+ }
+ return within(tbody)
+ .getAllByRole('row')
+ .map((r) => {
+ const cells = within(r).getAllByRole('cell');
+ return cells[headerIndex];
+ });
+ },
+ /**
+ * Gets the table header cell matching the given name
+ */
+ getHeaderByName: (container: HTMLElement, columnName: Matcher): HTMLTableCellElement => {
+ const table = within(container).getByRole('table');
+ const headers = within(table).getAllByRole('columnheader');
+ const header = headers.find((h) => {
+ return within(h).queryByText(columnName);
+ });
+ if (!header) {
+ throw new Error(`Could not find header with name ${columnName}`);
+ }
+ return header;
+ },
+ },
+ }
);
await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});
+
+ return renderResult;
};
beforeAll(() => {
@@ -194,6 +269,9 @@ describe('CorrelationsPage', () => {
fireEvent.click(CTAButton);
+ // wait for the form to be rendered and query editor to be mounted
+ await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i));
+
// form's submit button
expect(screen.getByRole('button', { name: /add$/i })).toBeInTheDocument();
});
@@ -218,12 +296,14 @@ describe('CorrelationsPage', () => {
fireEvent.keyDown(screen.getByLabelText(/^target$/i), { keyCode: 40 });
fireEvent.click(screen.getByText('prometheus'));
+ fireEvent.change(screen.getByRole('textbox', { name: /target field/i }), { target: { value: 'Line' } });
+
+ await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i));
+
fireEvent.click(screen.getByRole('button', { name: /add$/i }));
// Waits for the form to be removed, meaning the correlation got successfully saved
- await waitFor(() => {
- expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
- });
+ await waitForElementToBeRemoved(() => screen.queryByRole('button', { name: /add$/i }));
// the table showing correlations should have appeared
expect(screen.getByRole('table')).toBeInTheDocument();
@@ -231,8 +311,12 @@ describe('CorrelationsPage', () => {
});
describe('With correlations', () => {
+ let queryRowsByCellValue: (columnName: Matcher, textValue: Matcher) => HTMLTableRowElement[];
+ let getHeaderByName: (columnName: Matcher) => HTMLTableCellElement;
+ let queryCellsByColumnName: (columnName: Matcher) => HTMLTableCellElement[];
+
beforeEach(async () => {
- await renderWithContext(
+ const renderResult = await renderWithContext(
{
loki: mockDataSource(
{
@@ -275,16 +359,53 @@ describe('CorrelationsPage', () => {
}
),
},
- [{ sourceUID: 'loki', targetUID: 'loki', uid: '1', label: 'Some label' }]
+ [
+ {
+ sourceUID: 'loki',
+ targetUID: 'loki',
+ uid: '1',
+ label: 'Some label',
+ config: { field: 'line', target: {}, type: 'query' },
+ },
+ {
+ sourceUID: 'prometheus',
+ targetUID: 'loki',
+ uid: '2',
+ label: 'Prometheus to Loki',
+ config: { field: 'label', target: {}, type: 'query' },
+ },
+ ]
);
+ queryRowsByCellValue = renderResult.queryRowsByCellValue;
+ queryCellsByColumnName = renderResult.queryCellsByColumnName;
+ getHeaderByName = renderResult.getHeaderByName;
});
it('shows a table with correlations', async () => {
- await renderWithContext();
-
expect(screen.getByRole('table')).toBeInTheDocument();
});
+ it('correctly sorts by source', async () => {
+ const sourceHeader = getHeaderByName('Source');
+ fireEvent.click(sourceHeader);
+ let cells = queryCellsByColumnName('Source');
+ cells.forEach((cell, i, allCells) => {
+ const prevCell = allCells[i - 1];
+ if (prevCell && prevCell.textContent) {
+ expect(cell.textContent?.localeCompare(prevCell.textContent)).toBeGreaterThanOrEqual(0);
+ }
+ });
+
+ fireEvent.click(sourceHeader);
+ cells = queryCellsByColumnName('Source');
+ cells.forEach((cell, i, allCells) => {
+ const prevCell = allCells[i - 1];
+ if (prevCell && prevCell.textContent) {
+ expect(cell.textContent?.localeCompare(prevCell.textContent)).toBeLessThanOrEqual(0);
+ }
+ });
+ });
+
it('correctly adds correlations', async () => {
const addNewButton = screen.getByRole('button', { name: /add new/i });
expect(addNewButton).toBeInTheDocument();
@@ -295,18 +416,20 @@ describe('CorrelationsPage', () => {
// set source datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^source$/i), { keyCode: 40 });
- fireEvent.click(screen.getByText('prometheus'));
+ fireEvent.click(within(screen.getByLabelText('Select options menu')).getByText('prometheus'));
// set target datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^target$/i), { keyCode: 40 });
fireEvent.click(screen.getByText('elastic'));
+ fireEvent.change(screen.getByRole('textbox', { name: /target field/i }), { target: { value: 'Line' } });
+
+ await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i));
+
fireEvent.click(screen.getByRole('button', { name: /add$/i }));
// the form should get removed after successful submissions
- await waitFor(() => {
- expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
- });
+ await waitForElementToBeRemoved(() => screen.queryByRole('button', { name: /add$/i }));
});
it('correctly closes the form when clicking on the close icon', async () => {
@@ -316,35 +439,37 @@ describe('CorrelationsPage', () => {
fireEvent.click(screen.getByRole('button', { name: /close$/i }));
- await waitFor(() => {
- expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
- });
+ expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
});
it('correctly deletes correlations', async () => {
// A row with the correlation should exist
expect(screen.getByRole('cell', { name: /some label/i })).toBeInTheDocument();
- const deleteButton = screen.getByRole('button', { name: /delete correlation/i });
+ const tableRows = queryRowsByCellValue('Source', 'loki');
+
+ const deleteButton = within(tableRows[0]).getByRole('button', { name: /delete correlation/i });
expect(deleteButton).toBeInTheDocument();
fireEvent.click(deleteButton);
- const confirmButton = screen.getByRole('button', { name: /delete$/i });
+ const confirmButton = within(tableRows[0]).getByRole('button', { name: /delete$/i });
expect(confirmButton).toBeInTheDocument();
fireEvent.click(confirmButton);
- await waitFor(() => {
- expect(screen.queryByRole('cell', { name: /some label/i })).not.toBeInTheDocument();
- });
+ await waitForElementToBeRemoved(() => screen.queryByRole('cell', { name: /some label$/i }));
});
it('correctly edits correlations', async () => {
- const rowExpanderButton = screen.getByRole('button', { name: /toggle row expanded/i });
+ const tableRows = queryRowsByCellValue('Source', 'loki');
+
+ const rowExpanderButton = within(tableRows[0]).getByRole('button', { name: /toggle row expanded/i });
fireEvent.click(rowExpanderButton);
+ await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i));
+
fireEvent.change(screen.getByRole('textbox', { name: /label/i }), { target: { value: 'edited label' } });
fireEvent.change(screen.getByRole('textbox', { name: /description/i }), {
target: { value: 'edited description' },
@@ -361,7 +486,15 @@ describe('CorrelationsPage', () => {
});
describe('Read only correlations', () => {
- const correlations = [{ sourceUID: 'loki', targetUID: 'loki', uid: '1', label: 'Some label' }];
+ const correlations: Correlation[] = [
+ {
+ sourceUID: 'loki',
+ targetUID: 'loki',
+ uid: '1',
+ label: 'Some label',
+ config: { field: 'line', target: {}, type: 'query' },
+ },
+ ];
beforeEach(async () => {
await renderWithContext(
@@ -393,6 +526,9 @@ describe('CorrelationsPage', () => {
fireEvent.click(rowExpanderButton);
+ // wait for the form to be rendered and query editor to be mounted
+ await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i));
+
// form elements should be readonly
const labelInput = screen.getByRole('textbox', { name: /label/i });
expect(labelInput).toBeInTheDocument();
diff --git a/public/app/features/correlations/CorrelationsPage.tsx b/public/app/features/correlations/CorrelationsPage.tsx
index d1b8824c13d..5ee331be916 100644
--- a/public/app/features/correlations/CorrelationsPage.tsx
+++ b/public/app/features/correlations/CorrelationsPage.tsx
@@ -148,7 +148,7 @@ export default function CorrelationsPage() {
(
diff --git a/public/app/features/correlations/Forms/AddCorrelationForm.tsx b/public/app/features/correlations/Forms/AddCorrelationForm.tsx
index 48c60350362..3be1bae60ed 100644
--- a/public/app/features/correlations/Forms/AddCorrelationForm.tsx
+++ b/public/app/features/correlations/Forms/AddCorrelationForm.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
-import { Controller } from 'react-hook-form';
+import { Controller, FormProvider, useForm } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
@@ -12,7 +12,6 @@ import { useCorrelations } from '../useCorrelations';
import { CorrelationDetailsFormPart } from './CorrelationDetailsFormPart';
import { FormDTO } from './types';
-import { useCorrelationForm } from './useCorrelationForm';
const getStyles = (theme: GrafanaTheme2) => ({
panelContainer: css`
@@ -57,62 +56,75 @@ export const AddCorrelationForm = ({ onClose, onCreated }: Props) => {
}
}, [error, loading, value, onCreated]);
- const { control, handleSubmit, register, errors } = useCorrelationForm({ onSubmit: execute });
+ const methods = useForm({ defaultValues: { config: { type: 'query', target: {} } } });
return (
-
+
);
};
diff --git a/public/app/features/correlations/Forms/CorrelationDetailsFormPart.tsx b/public/app/features/correlations/Forms/CorrelationDetailsFormPart.tsx
index dccb18a4148..58de7a44c13 100644
--- a/public/app/features/correlations/Forms/CorrelationDetailsFormPart.tsx
+++ b/public/app/features/correlations/Forms/CorrelationDetailsFormPart.tsx
@@ -1,13 +1,16 @@
import { css, cx } from '@emotion/css';
import React from 'react';
-import { RegisterOptions, UseFormRegisterReturn } from 'react-hook-form';
+import { useFormContext, useWatch } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Field, Input, TextArea, useStyles2 } from '@grafana/ui';
-import { EditFormDTO } from './types';
+import { Correlation } from '../types';
-const getInputId = (inputName: string, correlation?: EditFormDTO) => {
+import { QueryEditorField } from './QueryEditorField';
+import { FormDTO } from './types';
+
+const getInputId = (inputName: string, correlation?: CorrelationBaseData) => {
if (!correlation) {
return inputName;
}
@@ -16,9 +19,6 @@ const getInputId = (inputName: string, correlation?: EditFormDTO) => {
};
const getStyles = (theme: GrafanaTheme2) => ({
- marginless: css`
- margin: 0;
- `,
label: css`
max-width: ${theme.spacing(32)};
`,
@@ -27,17 +27,24 @@ const getStyles = (theme: GrafanaTheme2) => ({
`,
});
+type CorrelationBaseData = Pick;
interface Props {
- register: (path: 'label' | 'description', options?: RegisterOptions) => UseFormRegisterReturn;
readOnly?: boolean;
- correlation?: EditFormDTO;
+ correlation?: CorrelationBaseData;
}
-export function CorrelationDetailsFormPart({ register, readOnly = false, correlation }: Props) {
+export function CorrelationDetailsFormPart({ readOnly = false, correlation }: Props) {
const styles = useStyles2(getStyles);
+ const {
+ register,
+ formState: { errors },
+ } = useFormContext();
+ const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || correlation?.targetUID;
return (
<>
+
+
+
+
+
+
+
+
>
);
}
diff --git a/public/app/features/correlations/Forms/EditCorrelationForm.tsx b/public/app/features/correlations/Forms/EditCorrelationForm.tsx
index 1290b61177e..3e7014841ea 100644
--- a/public/app/features/correlations/Forms/EditCorrelationForm.tsx
+++ b/public/app/features/correlations/Forms/EditCorrelationForm.tsx
@@ -1,45 +1,52 @@
import React, { useEffect } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
import { Button, HorizontalGroup } from '@grafana/ui';
+import { Correlation } from '../types';
import { useCorrelations } from '../useCorrelations';
import { CorrelationDetailsFormPart } from './CorrelationDetailsFormPart';
import { EditFormDTO } from './types';
-import { useCorrelationForm } from './useCorrelationForm';
interface Props {
onUpdated: () => void;
- defaultValues: EditFormDTO;
+ correlation: Correlation;
readOnly?: boolean;
}
-export const EditCorrelationForm = ({ onUpdated, defaultValues, readOnly = false }: Props) => {
+export const EditCorrelationForm = ({ onUpdated, correlation, readOnly = false }: Props) => {
const {
update: { execute, loading, error, value },
} = useCorrelations();
+ const onSubmit = (data: EditFormDTO) => {
+ return execute({ ...data, sourceUID: correlation.sourceUID, uid: correlation.uid });
+ };
+
useEffect(() => {
if (!error && !loading && value) {
onUpdated();
}
}, [error, loading, value, onUpdated]);
- const { handleSubmit, register } = useCorrelationForm({ onSubmit: execute, defaultValues });
+ const { uid, sourceUID, targetUID, ...otherCorrelation } = correlation;
+
+ const methods = useForm({ defaultValues: otherCorrelation });
return (
-
+
);
};
diff --git a/public/app/features/correlations/Forms/QueryEditorField.test.tsx b/public/app/features/correlations/Forms/QueryEditorField.test.tsx
new file mode 100644
index 00000000000..fdae41700ec
--- /dev/null
+++ b/public/app/features/correlations/Forms/QueryEditorField.test.tsx
@@ -0,0 +1,66 @@
+import { render, screen } from '@testing-library/react';
+import React, { ReactNode } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { MockDataSourceApi } from 'test/mocks/datasource_srv';
+
+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();
+ return {children};
+};
+
+const defaultGetHandler = async (name: string) => {
+ const dsApi = new MockDataSourceApi(name);
+ dsApi.components = {
+ QueryEditor: () => <>{name} query editor>,
+ };
+ return dsApi;
+};
+
+const renderWithContext = async (
+ children: ReactNode,
+ getHandler: (name: string) => Promise = defaultGetHandler
+) => {
+ const dsServer = new MockDataSourceSrv({});
+ dsServer.get = getHandler;
+
+ setDataSourceSrv(dsServer);
+
+ render({children});
+};
+
+describe('QueryEditorField', () => {
+ it('should render the query editor', async () => {
+ renderWithContext();
+
+ expect(await screen.findByText('test query editor')).toBeInTheDocument();
+ });
+
+ it("shows an error alert when datasource can't be loaded", async () => {
+ renderWithContext(, () => {
+ throw new Error('Unable to load datasource');
+ });
+
+ expect(await screen.findByRole('alert', { name: 'Error loading data source' })).toBeInTheDocument();
+ });
+
+ it('shows an info alert when no datasource is selected', async () => {
+ renderWithContext();
+
+ expect(await screen.findByRole('alert', { name: 'No data source selected' })).toBeInTheDocument();
+ });
+
+ it('shows an info alert when datasaource does not export a query editor', async () => {
+ renderWithContext(, async (name) => {
+ return new MockDataSourceApi(name);
+ });
+
+ expect(
+ await screen.findByRole('alert', { name: 'Data source does not export a query editor.' })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/public/app/features/correlations/Forms/QueryEditorField.tsx b/public/app/features/correlations/Forms/QueryEditorField.tsx
new file mode 100644
index 00000000000..ffcc21f7c77
--- /dev/null
+++ b/public/app/features/correlations/Forms/QueryEditorField.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { Controller } from 'react-hook-form';
+import { useAsync } from 'react-use';
+
+import { getDataSourceSrv } from '@grafana/runtime';
+import { Field, LoadingPlaceholder, Alert } from '@grafana/ui';
+
+interface Props {
+ dsUid?: string;
+ name: string;
+ invalid?: boolean;
+ error?: string;
+}
+
+export const QueryEditorField = ({ dsUid, invalid, error, name }: Props) => {
+ const {
+ value: datasource,
+ loading: dsLoading,
+ error: dsError,
+ } = useAsync(async () => {
+ if (!dsUid) {
+ return;
+ }
+ return getDataSourceSrv().get(dsUid);
+ }, [dsUid]);
+ const QueryEditor = datasource?.components?.QueryEditor;
+
+ return (
+
+
+ QueryEditor !== undefined || 'The selected target data source must export a query editor.',
+ },
+ }}
+ render={({ field: { value, onChange } }) => {
+ if (dsLoading) {
+ return ;
+ }
+ if (dsError) {
+ return The selected data source could not be loaded.;
+ }
+ if (!datasource) {
+ return (
+
+ Please select a target data source first.
+
+ );
+ }
+ if (!QueryEditor) {
+ return ;
+ }
+
+ return {}} onChange={onChange} datasource={datasource} query={value} />;
+ }}
+ />
+
+ );
+};
diff --git a/public/app/features/correlations/Forms/types.ts b/public/app/features/correlations/Forms/types.ts
index e56c565055f..368c83f6cee 100644
--- a/public/app/features/correlations/Forms/types.ts
+++ b/public/app/features/correlations/Forms/types.ts
@@ -1,11 +1,11 @@
-import { Correlation } from '../types';
+import { CorrelationConfig } from '../types';
export interface FormDTO {
sourceUID: string;
targetUID: string;
label: string;
description: string;
+ config: CorrelationConfig;
}
-type FormDTOWithoutTarget = Omit;
-export type EditFormDTO = Partial & Pick & { uid: Correlation['uid'] };
+export type EditFormDTO = Omit;
diff --git a/public/app/features/correlations/Forms/useCorrelationForm.ts b/public/app/features/correlations/Forms/useCorrelationForm.ts
deleted file mode 100644
index 99cce49c84e..00000000000
--- a/public/app/features/correlations/Forms/useCorrelationForm.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { DeepPartial, FieldValues, SubmitHandler, UnpackNestedValue, useForm } from 'react-hook-form';
-
-interface UseCorrelationFormOptions {
- onSubmit: SubmitHandler;
- defaultValues?: UnpackNestedValue>;
-}
-export const useCorrelationForm = ({
- onSubmit,
- defaultValues,
-}: UseCorrelationFormOptions) => {
- const {
- handleSubmit: submit,
- control,
- register,
- formState: { errors },
- } = useForm({ defaultValues });
-
- const handleSubmit = submit(onSubmit);
-
- return { control, handleSubmit, register, errors };
-};
diff --git a/public/app/features/correlations/types.ts b/public/app/features/correlations/types.ts
index 405daac4e78..03789340cbd 100644
--- a/public/app/features/correlations/types.ts
+++ b/public/app/features/correlations/types.ts
@@ -4,12 +4,20 @@ export interface AddCorrelationResponse {
export type GetCorrelationsResponse = Correlation[];
+type CorrelationConfigType = 'query';
+export interface CorrelationConfig {
+ field: string;
+ target: object;
+ type: CorrelationConfigType;
+}
+
export interface Correlation {
uid: string;
sourceUID: string;
targetUID: string;
label?: string;
description?: string;
+ config: CorrelationConfig;
}
export type RemoveCorrelationParams = Pick;