mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 08:05:43 -06:00
Correlations: Add query editor and target field to settings page (#55567)
* Fix: use type=button in editor * Grafana-UI: TextArea: make ctextare a block element * WIP: add field & target query to correlations * add table query helpers & test ordering * refactor some tests for disappearance * chore: move QueryEditorField & add tests * cleanup & fix typo * revert textarea changes * update form to support new config * move defaults
This commit is contained in:
parent
db68fa358f
commit
da9d8fe14f
@ -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(
|
||||
<Provider store={configureStore({})}>
|
||||
<GrafanaContext.Provider value={grafanaContext}>
|
||||
<CorrelationsPage />
|
||||
</GrafanaContext.Provider>
|
||||
</Provider>
|
||||
</Provider>,
|
||||
{
|
||||
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<HTMLTableRowElement>('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<HTMLTableCellElement>('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<HTMLTableCellElement>('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();
|
||||
|
@ -148,7 +148,7 @@ export default function CorrelationsPage() {
|
||||
<Table
|
||||
renderExpandedRow={({ target, source, ...correlation }) => (
|
||||
<EditCorrelationForm
|
||||
defaultValues={{ sourceUID: source.uid, ...correlation }}
|
||||
correlation={{ ...correlation, sourceUID: source.uid, targetUID: target.uid }}
|
||||
onUpdated={fetchCorrelations}
|
||||
readOnly={isSourceReadOnly({ source }) || !canWriteCorrelations}
|
||||
/>
|
||||
|
@ -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<FormDTO>({ onSubmit: execute });
|
||||
const methods = useForm<FormDTO>({ defaultValues: { config: { type: 'query', target: {} } } });
|
||||
|
||||
return (
|
||||
<PanelContainer className={styles.panelContainer}>
|
||||
<CloseButton onClick={onClose} />
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={styles.horizontalGroup}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="sourceUID"
|
||||
rules={{
|
||||
required: { value: true, message: 'This field is required.' },
|
||||
validate: {
|
||||
writable: (uid: string) =>
|
||||
!getDatasourceSrv().getInstanceSettings(uid)?.readOnly || "Source can't be a read-only data source.",
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Field label="Source" htmlFor="source" invalid={!!errors.sourceUID} error={errors.sourceUID?.message}>
|
||||
<DataSourcePicker
|
||||
onChange={withDsUID(onChange)}
|
||||
noDefault
|
||||
current={value}
|
||||
inputId="source"
|
||||
width={32}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<div className={styles.linksToContainer}>Links to</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="targetUID"
|
||||
rules={{ required: { value: true, message: 'This field is required.' } }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Field label="Target" htmlFor="target" invalid={!!errors.targetUID} error={errors.targetUID?.message}>
|
||||
<DataSourcePicker
|
||||
onChange={withDsUID(onChange)}
|
||||
noDefault
|
||||
current={value}
|
||||
inputId="target"
|
||||
width={32}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(execute)}>
|
||||
<div className={styles.horizontalGroup}>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="sourceUID"
|
||||
rules={{
|
||||
required: { value: true, message: 'This field is required.' },
|
||||
validate: {
|
||||
writable: (uid: string) =>
|
||||
!getDatasourceSrv().getInstanceSettings(uid)?.readOnly ||
|
||||
"Source can't be a read-only data source.",
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Field
|
||||
label="Source"
|
||||
htmlFor="source"
|
||||
invalid={!!methods.formState.errors.sourceUID}
|
||||
error={methods.formState.errors.sourceUID?.message}
|
||||
>
|
||||
<DataSourcePicker
|
||||
onChange={withDsUID(onChange)}
|
||||
noDefault
|
||||
current={value}
|
||||
inputId="source"
|
||||
width={32}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<div className={styles.linksToContainer}>Links to</div>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="targetUID"
|
||||
rules={{ required: { value: true, message: 'This field is required.' } }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Field
|
||||
label="Target"
|
||||
htmlFor="target"
|
||||
invalid={!!methods.formState.errors.targetUID}
|
||||
error={methods.formState.errors.targetUID?.message}
|
||||
>
|
||||
<DataSourcePicker
|
||||
onChange={withDsUID(onChange)}
|
||||
noDefault
|
||||
current={value}
|
||||
inputId="target"
|
||||
width={32}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CorrelationDetailsFormPart register={register} />
|
||||
<CorrelationDetailsFormPart />
|
||||
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="primary" icon={loading ? 'fa fa-spinner' : 'plus'} type="submit" disabled={loading}>
|
||||
Add
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</form>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="primary" icon={loading ? 'fa fa-spinner' : 'plus'} type="submit" disabled={loading}>
|
||||
Add
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</PanelContainer>
|
||||
);
|
||||
};
|
||||
|
@ -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<Correlation, 'uid' | 'sourceUID' | 'targetUID'>;
|
||||
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<FormDTO>();
|
||||
const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || correlation?.targetUID;
|
||||
|
||||
return (
|
||||
<>
|
||||
<input type="hidden" {...register('config.type')} />
|
||||
|
||||
<Field label="Label" className={styles.label}>
|
||||
<Input
|
||||
id={getInputId('label', correlation)}
|
||||
@ -50,10 +57,31 @@ export function CorrelationDetailsFormPart({ register, readOnly = false, correla
|
||||
<Field
|
||||
label="Description"
|
||||
// the Field component automatically adds margin to itself, so we are forced to workaround it by overriding its styles
|
||||
className={cx(readOnly && styles.marginless, styles.description)}
|
||||
className={cx(styles.description)}
|
||||
>
|
||||
<TextArea id={getInputId('description', correlation)} {...register('description')} readOnly={readOnly} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Target field"
|
||||
className={styles.label}
|
||||
invalid={!!errors?.config?.field}
|
||||
error={errors?.config?.field?.message}
|
||||
>
|
||||
<Input
|
||||
id={getInputId('field', correlation)}
|
||||
{...register('config.field', { required: 'This field is required.' })}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<QueryEditorField
|
||||
name="config.target"
|
||||
dsUid={targetUID}
|
||||
invalid={!!errors?.config?.target}
|
||||
// @ts-expect-error react-hook-form's errors do not work well with object types
|
||||
error={errors?.config?.target?.message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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<EditFormDTO>({ onSubmit: execute, defaultValues });
|
||||
const { uid, sourceUID, targetUID, ...otherCorrelation } = correlation;
|
||||
|
||||
const methods = useForm<EditFormDTO>({ defaultValues: otherCorrelation });
|
||||
|
||||
return (
|
||||
<form onSubmit={readOnly ? (e) => e.preventDefault() : handleSubmit}>
|
||||
<input type="hidden" {...register('uid')} />
|
||||
<input type="hidden" {...register('sourceUID')} />
|
||||
<CorrelationDetailsFormPart register={register} readOnly={readOnly} correlation={defaultValues} />
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={readOnly ? (e) => e.preventDefault() : methods.handleSubmit(onSubmit)}>
|
||||
<CorrelationDetailsFormPart readOnly={readOnly} correlation={correlation} />
|
||||
|
||||
{!readOnly && (
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="primary" icon={loading ? 'fa fa-spinner' : 'save'} type="submit" disabled={loading}>
|
||||
Save
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
</form>
|
||||
{!readOnly && (
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="primary" icon={loading ? 'fa fa-spinner' : 'save'} type="submit" disabled={loading}>
|
||||
Save
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
@ -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 <FormProvider {...methods}>{children}</FormProvider>;
|
||||
};
|
||||
|
||||
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<MockDataSourceApi> = defaultGetHandler
|
||||
) => {
|
||||
const dsServer = new MockDataSourceSrv({});
|
||||
dsServer.get = getHandler;
|
||||
|
||||
setDataSourceSrv(dsServer);
|
||||
|
||||
render(<Wrapper>{children}</Wrapper>);
|
||||
};
|
||||
|
||||
describe('QueryEditorField', () => {
|
||||
it('should render the query editor', async () => {
|
||||
renderWithContext(<QueryEditorField name="query" dsUid="test" />);
|
||||
|
||||
expect(await screen.findByText('test query editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error alert when datasource can't be loaded", async () => {
|
||||
renderWithContext(<QueryEditorField name="query" dsUid="something" />, () => {
|
||||
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(<QueryEditorField name="query" />);
|
||||
|
||||
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(<QueryEditorField name="query" dsUid="something" />, async (name) => {
|
||||
return new MockDataSourceApi(name);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole('alert', { name: 'Data source does not export a query editor.' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
61
public/app/features/correlations/Forms/QueryEditorField.tsx
Normal file
61
public/app/features/correlations/Forms/QueryEditorField.tsx
Normal file
@ -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 (
|
||||
<Field label="Query" invalid={invalid} error={error}>
|
||||
<Controller
|
||||
name={name}
|
||||
rules={{
|
||||
validate: {
|
||||
hasQueryEditor: () =>
|
||||
QueryEditor !== undefined || 'The selected target data source must export a query editor.',
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (dsLoading) {
|
||||
return <LoadingPlaceholder text="Loading query editor..." />;
|
||||
}
|
||||
if (dsError) {
|
||||
return <Alert title="Error loading data source">The selected data source could not be loaded.</Alert>;
|
||||
}
|
||||
if (!datasource) {
|
||||
return (
|
||||
<Alert title="No data source selected" severity="info">
|
||||
Please select a target data source first.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (!QueryEditor) {
|
||||
return <Alert title="Data source does not export a query editor."></Alert>;
|
||||
}
|
||||
|
||||
return <QueryEditor onRunQuery={() => {}} onChange={onChange} datasource={datasource} query={value} />;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
};
|
@ -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<FormDTO, 'targetUID'>;
|
||||
export type EditFormDTO = Partial<FormDTOWithoutTarget> & Pick<FormDTO, 'sourceUID'> & { uid: Correlation['uid'] };
|
||||
export type EditFormDTO = Omit<FormDTO, 'targetUID' | 'sourceUID'>;
|
||||
|
@ -1,21 +0,0 @@
|
||||
import { DeepPartial, FieldValues, SubmitHandler, UnpackNestedValue, useForm } from 'react-hook-form';
|
||||
|
||||
interface UseCorrelationFormOptions<T extends FieldValues> {
|
||||
onSubmit: SubmitHandler<T>;
|
||||
defaultValues?: UnpackNestedValue<DeepPartial<T>>;
|
||||
}
|
||||
export const useCorrelationForm = <T extends FieldValues>({
|
||||
onSubmit,
|
||||
defaultValues,
|
||||
}: UseCorrelationFormOptions<T>) => {
|
||||
const {
|
||||
handleSubmit: submit,
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<T>({ defaultValues });
|
||||
|
||||
const handleSubmit = submit(onSubmit);
|
||||
|
||||
return { control, handleSubmit, register, errors };
|
||||
};
|
@ -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<Correlation, 'sourceUID' | 'uid'>;
|
||||
|
Loading…
Reference in New Issue
Block a user