[Discover] Add support for copying the query from the add rule flyout (#135098)

This commit is contained in:
Davis McPhee 2022-06-30 17:44:50 -03:00 committed by GitHub
parent 2a036506b2
commit 850b8f015b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 317 additions and 22 deletions

View file

@ -18,8 +18,18 @@ import { Subject } from 'rxjs';
import { ISearchSource } from '@kbn/data-plugin/common';
import { IUiSettingsClient } from '@kbn/core/public';
import { findTestSubject } from '@elastic/eui/lib/test';
import { EuiLoadingSpinner } from '@elastic/eui';
import { copyToClipboard, EuiLoadingSpinner } from '@elastic/eui';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { ReactWrapper } from 'enzyme';
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
__esModule: true,
...original,
copyToClipboard: jest.fn(() => true),
};
});
const dataViewPluginMock = dataViewPluginMocks.createStartContract();
const chartsStartMock = chartPluginMock.createStartContract();
@ -83,6 +93,69 @@ const searchSourceMock = {
fetch$: jest.fn(() => {
return mockSearchResult;
}),
getSearchRequestBody: jest.fn(() => ({
fields: [
{
field: '@timestamp',
format: 'date_time',
},
{
field: 'timestamp',
format: 'date_time',
},
{
field: 'utc_time',
format: 'date_time',
},
],
script_fields: {},
stored_fields: ['*'],
runtime_mappings: {
hour_of_day: {
type: 'long',
script: {
source: "emit(doc['timestamp'].value.getHour());",
},
},
},
_source: {
excludes: [],
},
query: {
bool: {
must: [],
filter: [
{
bool: {
must_not: {
bool: {
should: [
{
match: {
response: '200',
},
},
],
minimum_should_match: 1,
},
},
},
},
{
range: {
timestamp: {
format: 'strict_date_optional_time',
gte: '2022-06-19T02:49:51.192Z',
lte: '2022-06-24T02:49:51.192Z',
},
},
},
],
should: [],
must_not: [],
},
},
})),
} as unknown as ISearchSource;
const savedQueryMock = {
@ -179,6 +252,83 @@ describe('SearchSourceAlertTypeExpression', () => {
);
});
it('should call copyToClipboard with the serialized query when the copy query button is clicked', async () => {
let wrapper = null as unknown as ReactWrapper;
await act(async () => {
wrapper = setup(defaultSearchSourceExpressionParams);
});
wrapper.update();
await act(async () => {
findTestSubject(wrapper, 'copyQuery').simulate('click');
});
wrapper.update();
expect(copyToClipboard).toHaveBeenCalledWith(`{
\"fields\": [
{
\"field\": \"@timestamp\",
\"format\": \"date_time\"
},
{
\"field\": \"timestamp\",
\"format\": \"date_time\"
},
{
\"field\": \"utc_time\",
\"format\": \"date_time\"
}
],
\"script_fields\": {},
\"stored_fields\": [
\"*\"
],
\"runtime_mappings\": {
\"hour_of_day\": {
\"type\": \"long\",
\"script\": {
\"source\": \"emit(doc['timestamp'].value.getHour());\"
}
}
},
\"_source\": {
\"excludes\": []
},
\"query\": {
\"bool\": {
\"must\": [],
\"filter\": [
{
\"bool\": {
\"must_not\": {
\"bool\": {
\"should\": [
{
\"match\": {
\"response\": \"200\"
}
}
],
\"minimum_should_match\": 1
}
}
}
},
{
\"range\": {
\"timestamp\": {
\"format\": \"strict_date_optional_time\",
\"gte\": \"2022-06-19T02:49:51.192Z\",
\"lte\": \"2022-06-24T02:49:51.192Z\"
}
}
}
],
\"should\": [],
\"must_not\": []
}
}
}`);
});
test('should render error prompt', async () => {
(dataMock.search.searchSource.create as jest.Mock).mockImplementationOnce(() =>
Promise.reject(new Error('Cant find searchSource'))

View file

@ -172,8 +172,10 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
(updatedValue: number) => dispatch({ type: 'size', payload: updatedValue }),
[]
);
const onTestFetch = useCallback(async () => {
const timeWindow = `${timeWindowSize}${timeWindowUnit}`;
const timeWindow = `${timeWindowSize}${timeWindowUnit}`;
const createTestSearchSource = useCallback(() => {
const testSearchSource = searchSource.createCopy();
const timeFilter = getTime(searchSource.getField('index')!, {
from: `now-${timeWindow}`,
@ -183,9 +185,19 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
'filter',
timeFilter ? [timeFilter, ...ruleConfiguration.filter] : ruleConfiguration.filter
);
return testSearchSource;
}, [searchSource, timeWindow, ruleConfiguration]);
const onCopyQuery = useCallback(() => {
const testSearchSource = createTestSearchSource();
return JSON.stringify(testSearchSource.getSearchRequestBody(), null, 2);
}, [createTestSearchSource]);
const onTestFetch = useCallback(async () => {
const testSearchSource = createTestSearchSource();
const { rawResponse } = await lastValueFrom(testSearchSource.fetch$());
return { nrOfDocs: totalHitsToNumber(rawResponse.hits.total), timeWindow };
}, [searchSource, timeWindowSize, timeWindowUnit, ruleConfiguration]);
}, [timeWindow, createTestSearchSource]);
return (
<Fragment>
@ -289,7 +301,7 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
onChangeSelectedValue={onChangeSizeValue}
/>
<EuiSpacer size="s" />
<TestQueryRow fetch={onTestFetch} hasValidationErrors={false} />
<TestQueryRow fetch={onTestFetch} copyQuery={onCopyQuery} hasValidationErrors={false} />
<EuiSpacer />
</Fragment>
);

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { copyToClipboard } from '@elastic/eui';
import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { TestQueryRow } from './test_query_row';
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
__esModule: true,
...original,
copyToClipboard: jest.fn(() => true),
};
});
const COPIED_QUERY = 'COPIED QUERY';
const onFetch = () => Promise.resolve({ nrOfDocs: 42, timeWindow: '5m' });
const onCopyQuery = () => COPIED_QUERY;
describe('TestQueryRow', () => {
it('should render the copy query button if copyQuery is provided', () => {
const component = mountWithIntl(
<TestQueryRow fetch={onFetch} copyQuery={onCopyQuery} hasValidationErrors={false} />
);
expect(findTestSubject(component, 'copyQuery').exists()).toBe(true);
});
it('should not render the copy query button if copyQuery is not provided', () => {
const component = mountWithIntl(<TestQueryRow fetch={onFetch} hasValidationErrors={false} />);
expect(findTestSubject(component, 'copyQuery').exists()).toBe(false);
});
it('should disable the test query and copy query buttons if hasValidationErrors is true', () => {
const component = mountWithIntl(
<TestQueryRow fetch={onFetch} copyQuery={onCopyQuery} hasValidationErrors={true} />
);
expect(findTestSubject(component, 'testQuery').prop('disabled')).toBe(true);
expect(findTestSubject(component, 'copyQuery').prop('disabled')).toBe(true);
});
it('should not disable the test query and copy query buttons if hasValidationErrors is false', () => {
const component = mountWithIntl(
<TestQueryRow fetch={onFetch} copyQuery={onCopyQuery} hasValidationErrors={false} />
);
expect(findTestSubject(component, 'testQuery').prop('disabled')).toBe(false);
expect(findTestSubject(component, 'copyQuery').prop('disabled')).toBe(false);
});
it('should call the fetch callback when the test query button is clicked', async () => {
const localOnFetch = jest.fn(onFetch);
const component = mountWithIntl(
<TestQueryRow fetch={localOnFetch} hasValidationErrors={false} />
);
await act(async () => {
findTestSubject(component, 'testQuery').simulate('click');
});
expect(localOnFetch).toHaveBeenCalled();
});
it('should call the copyQuery callback and pass the returned value to copyToClipboard when the copy query button is clicked', async () => {
const localOnCopyQuery = jest.fn(onCopyQuery);
const component = mountWithIntl(
<TestQueryRow fetch={onFetch} copyQuery={localOnCopyQuery} hasValidationErrors={false} />
);
await act(async () => {
findTestSubject(component, 'copyQuery').simulate('click');
});
component.update();
expect(localOnCopyQuery).toHaveBeenCalled();
expect(copyToClipboard).toHaveBeenCalledWith(COPIED_QUERY);
});
});

View file

@ -4,38 +4,92 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButton, EuiFormRow, EuiText } from '@elastic/eui';
import React, { ReactNode, useState } from 'react';
import {
copyToClipboard,
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useTestQuery } from './use_test_query';
export function TestQueryRow({
fetch,
copyQuery,
hasValidationErrors,
}: {
fetch: () => Promise<{ nrOfDocs: number; timeWindow: string }>;
copyQuery?: () => string;
hasValidationErrors: boolean;
}) {
const { onTestQuery, testQueryResult, testQueryError, testQueryLoading } = useTestQuery(fetch);
const [copiedMessage, setCopiedMessage] = useState<ReactNode | null>(null);
return (
<>
<EuiFormRow>
<EuiButton
data-test-subj="testQuery"
color="primary"
iconSide="left"
iconType="playFilled"
onClick={onTestQuery}
disabled={hasValidationErrors}
isLoading={testQueryLoading}
size="s"
>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.testQuery"
defaultMessage="Test query"
/>
</EuiButton>
<EuiFlexGroup alignItems="center" responsive={false} gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="testQuery"
color="primary"
iconSide="left"
iconType="playFilled"
onClick={() => {
onTestQuery();
}}
disabled={hasValidationErrors}
isLoading={testQueryLoading}
size="s"
>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.testQuery"
defaultMessage="Test query"
/>
</EuiButton>
</EuiFlexItem>
{copyQuery && (
<EuiFlexItem grow={false}>
<EuiToolTip
content={copiedMessage}
onMouseOut={() => {
setCopiedMessage(null);
}}
>
<EuiButtonEmpty
data-test-subj="copyQuery"
color="primary"
iconSide="left"
iconType="copyClipboard"
onClick={() => {
const copied = copyToClipboard(copyQuery());
if (copied) {
setCopiedMessage(
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.queryCopiedToClipboard"
defaultMessage="Copied"
/>
);
}
}}
disabled={hasValidationErrors}
isLoading={testQueryLoading}
size="s"
>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.copyQuery"
defaultMessage="Copy query"
/>
</EuiButtonEmpty>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFormRow>
{testQueryLoading && (
<EuiFormRow>