Correlations: UX updates (#69313)

* Round 1

* Add comma

* Remove validate query from editor

* Slightly change headings

* Change text
This commit is contained in:
Kristina 2023-06-14 08:34:06 -05:00 committed by GitHub
parent 61dbad6905
commit 3850f7b334
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 29 additions and 202 deletions

View File

@ -15,6 +15,7 @@ import {
type Column,
type CellProps,
type SortByFn,
Icon,
} from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
@ -153,7 +154,14 @@ export default function CorrelationsPage() {
return (
<Page
navModel={navModel}
subTitle="Define how data living in different data sources relates to each other."
subTitle={
<>
Define how data living in different data sources relates to each other. Read more in the{' '}
<a href="https://grafana.com/docs/grafana/next/administration/correlations/" target="_blank" rel="noreferrer">
documentation <Icon name="external-link-alt" />
</a>
</>
}
actions={addButton}
>
<Page.Contents>

View File

@ -25,12 +25,12 @@ export const ConfigureCorrelationBasicInfoForm = () => {
return (
<>
<FieldSet label="Define correlation name (1/3)">
<p>The name of the correlation is used as the label of the link.</p>
<FieldSet label="Define correlation label (Step 1 of 3)">
<p>Define text that will describe the correlation.</p>
<input type="hidden" {...register('config.type')} />
<Field
label="Label"
description="This name is be used as the label of the link button"
description="This name will be used as the label for the correlation. This will show as button text, a menu item, or hover text on a link."
className={styles.label}
invalid={!!formState.errors.label}
error={formState.errors.label?.message}

View File

@ -36,10 +36,13 @@ export const ConfigureCorrelationSourceForm = () => {
);
return (
<>
<FieldSet label="Configure source data source (3/3)">
<FieldSet
label={`Configure the data source that will link to ${
getDatasourceSrv().getInstanceSettings(correlation?.targetUID)?.name
} (Step 3 of 3)`}
>
<p>
Links are displayed with results of the selected origin source data. They show along with the value of the
provided <em>results field</em>.
Define what data source will display the correlation, and what data will replace previously defined variables.
</p>
<Controller
control={control}

View File

@ -16,8 +16,10 @@ export const ConfigureCorrelationTargetForm = () => {
return (
<>
<FieldSet label="Setup target query (2/3)">
<p>Clicking on a link runs a provided target query.</p>
<FieldSet label="Setup the target for the correlation (Step 2 of 3)">
<p>
Define what data source the correlation will link to, and what query will run when the correlation is clicked.
</p>
<Controller
control={control}
name="targetUID"

View File

@ -17,13 +17,13 @@ export const CorrelationFormNavigation = () => {
);
const NextPage = (
<Button variant="secondary" type="submit">
<Button variant="primary" type="submit">
Next
</Button>
);
return (
<HorizontalGroup justify="flex-end">
<HorizontalGroup justify="flex-start">
{currentPage > 0 ? (
<Button variant="secondary" onClick={prevPage}>
Back

View File

@ -1,9 +1,8 @@
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
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 { LoadingState } from '@grafana/data';
import { setDataSourceSrv } from '@grafana/runtime';
import { MockDataSourceSrv } from 'app/features/alerting/unified/mocks';
@ -34,19 +33,6 @@ const renderWithContext = (
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();
@ -81,90 +67,4 @@ 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,24 +1,10 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import React from 'react';
import { Controller } from 'react-hook-form';
import { useAsync } from 'react-use';
import { CoreApp, DataQuery, getDefaultTimeRange, GrafanaTheme2 } from '@grafana/data';
import { CoreApp } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
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';
import { Field, LoadingPlaceholder, Alert } from '@grafana/ui';
interface Props {
dsUid?: string;
@ -27,19 +13,7 @@ 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,
@ -53,53 +27,6 @@ export const QueryEditorField = ({ dsUid, invalid, error, name }: Props) => {
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"
@ -147,27 +74,14 @@ export const QueryEditorField = ({ dsUid, invalid, error, name }: Props) => {
return (
<>
<QueryEditor
onRunQuery={() => {}}
app={CoreApp.Correlations}
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>
</>
);
}}