diff --git a/.ci/Jenkinsfile_baseline_capture b/.ci/Jenkinsfile_baseline_capture index 9a49c19b94df2..33ecfcd84fd3e 100644 --- a/.ci/Jenkinsfile_baseline_capture +++ b/.ci/Jenkinsfile_baseline_capture @@ -11,14 +11,14 @@ kibanaPipeline(timeoutMinutes: 120) { 'CI_PARALLEL_PROCESS_NUMBER=1' ]) { parallel([ - 'oss-visualRegression': { - workers.ci(name: 'oss-visualRegression', size: 's-highmem', ramDisk: true) { - kibanaPipeline.functionalTestProcess('oss-visualRegression', './test/scripts/jenkins_visual_regression.sh')() + 'oss-baseline': { + workers.ci(name: 'oss-baseline', size: 's-highmem', ramDisk: true, runErrorReporter: false) { + kibanaPipeline.functionalTestProcess('oss-baseline', './test/scripts/jenkins_baseline.sh')() } }, - 'xpack-visualRegression': { - workers.ci(name: 'xpack-visualRegression', size: 's-highmem', ramDisk: true) { - kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh')() + 'xpack-baseline': { + workers.ci(name: 'xpack-baseline', size: 's-highmem', ramDisk: true, runErrorReporter: false) { + kibanaPipeline.functionalTestProcess('xpack-baseline', './test/scripts/jenkins_xpack_baseline.sh')() } }, ]) diff --git a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx index 7a42ed7fad427..b175066b81c8e 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx +++ b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { act } from 'react-dom/test-utils'; import { mount, ReactWrapper } from 'enzyme'; import sinon from 'sinon'; @@ -111,6 +111,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { requestConfig ); + // Force a re-render of the component to stress-test the useRequest hook and verify its + // state remains unaffected. + const [, setState] = useState(false); + useEffect(() => { + setState(true); + }, []); + hookResult.isInitialRequest = isInitialRequest; hookResult.isLoading = isLoading; hookResult.error = error; diff --git a/src/plugins/es_ui_shared/public/request/use_request.ts b/src/plugins/es_ui_shared/public/request/use_request.ts index e04f84a67b8a3..9d40291423cac 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.ts +++ b/src/plugins/es_ui_shared/public/request/use_request.ts @@ -49,7 +49,7 @@ export const useRequest = ( // Consumers can use isInitialRequest to implement a polling UX. const requestCountRef = useRef(0); - const isInitialRequest = requestCountRef.current === 0; + const isInitialRequestRef = useRef(true); const pollIntervalIdRef = useRef(null); const clearPollInterval = useCallback(() => { @@ -98,6 +98,9 @@ export const useRequest = ( return; } + // Surface to consumers that at least one request has resolved. + isInitialRequestRef.current = false; + setError(responseError); // If there's an error, keep the data from the last request in case it's still useful to the user. if (!responseError) { @@ -146,7 +149,7 @@ export const useRequest = ( }, [clearPollInterval]); return { - isInitialRequest, + isInitialRequest: isInitialRequestRef.current, isLoading, error, data, diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts index f460257caf5e3..333ed0ff64fdb 100644 --- a/src/plugins/vis_type_timeseries/server/index.ts +++ b/src/plugins/vis_type_timeseries/server/index.ts @@ -21,7 +21,7 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/serve import { VisTypeTimeseriesConfig, config as configSchema } from './config'; import { VisTypeTimeseriesPlugin } from './plugin'; -export { VisTypeTimeseriesSetup, Framework } from './plugin'; +export { VisTypeTimeseriesSetup } from './plugin'; export const config: PluginConfigDescriptor = { deprecations: ({ unused, renameFromRoot }) => [ @@ -39,10 +39,10 @@ export const config: PluginConfigDescriptor = { export { ValidationTelemetryServiceSetup } from './validation_telemetry'; -// @ts-ignore -export { AbstractSearchStrategy } from './lib/search_strategies/strategies/abstract_search_strategy'; -// @ts-ignore -export { AbstractSearchRequest } from './lib/search_strategies/search_requests/abstract_request'; +export { + AbstractSearchStrategy, + ReqFacade, +} from './lib/search_strategies/strategies/abstract_search_strategy'; // @ts-ignore export { DefaultSearchCapabilities } from './lib/search_strategies/default_search_capabilities'; diff --git a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts index 0f0d99bff6f1c..777de89672bbe 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts @@ -38,6 +38,7 @@ export async function getFields( // level object passed from here. The layers should be refactored fully at some point, but for now // this works and we are still using the New Platform services for these vis data portions. const reqFacade: ReqFacade = { + requestContext, ...request, framework, payload: {}, @@ -48,22 +49,6 @@ export async function getFields( }, getUiSettingsService: () => requestContext.core.uiSettings.client, getSavedObjectsClient: () => requestContext.core.savedObjects.client, - server: { - plugins: { - elasticsearch: { - getCluster: () => { - return { - callWithRequest: async (req: any, endpoint: string, params: any) => { - return await requestContext.core.elasticsearch.legacy.client.callAsCurrentUser( - endpoint, - params - ); - }, - }; - }, - }, - }, - }, getEsShardTimeout: async () => { return await framework.globalConfig$ .pipe( diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts index f697e754a2e00..5eef2b53e2431 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts @@ -21,7 +21,7 @@ import { FakeRequest, RequestHandlerContext } from 'kibana/server'; import _ from 'lodash'; import { first, map } from 'rxjs/operators'; import { getPanelData } from './vis_data/get_panel_data'; -import { Framework } from '../index'; +import { Framework } from '../plugin'; import { ReqFacade } from './search_strategies/strategies/abstract_search_strategy'; interface GetVisDataResponse { @@ -65,28 +65,13 @@ export function getVisData( // level object passed from here. The layers should be refactored fully at some point, but for now // this works and we are still using the New Platform services for these vis data portions. const reqFacade: ReqFacade = { + requestContext, ...request, framework, pre: {}, payload: request.body, getUiSettingsService: () => requestContext.core.uiSettings.client, getSavedObjectsClient: () => requestContext.core.savedObjects.client, - server: { - plugins: { - elasticsearch: { - getCluster: () => { - return { - callWithRequest: async (req: any, endpoint: string, params: any) => { - return await requestContext.core.elasticsearch.legacy.client.callAsCurrentUser( - endpoint, - params - ); - }, - }; - }, - }, - }, - }, getEsShardTimeout: async () => { return await framework.globalConfig$ .pipe( diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js deleted file mode 100644 index abd2a4c65d35c..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -export class AbstractSearchRequest { - constructor(req, callWithRequest) { - this.req = req; - this.callWithRequest = callWithRequest; - } - - search() { - throw new Error('AbstractSearchRequest: search method should be defined'); - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js deleted file mode 100644 index 6f71aa63728d5..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { AbstractSearchRequest } from './abstract_request'; - -describe('AbstractSearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - - beforeEach(() => { - req = {}; - callWithRequest = jest.fn(); - searchRequest = new AbstractSearchRequest(req, callWithRequest); - }); - - test('should init an AbstractSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should throw an error trying to search', () => { - try { - searchRequest.search(); - } catch (error) { - expect(error instanceof Error).toBe(true); - expect(error.message).toEqual('AbstractSearchRequest: search method should be defined'); - } - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js deleted file mode 100644 index 9ada39e359589..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { AbstractSearchRequest } from './abstract_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -const SEARCH_METHOD = 'msearch'; - -export class MultiSearchRequest extends AbstractSearchRequest { - async search(searches) { - const includeFrozen = await this.req - .getUiSettingsService() - .get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - const multiSearchBody = searches.reduce( - (acc, { body, index }) => [ - ...acc, - { - index, - ignoreUnavailable: true, - }, - body, - ], - [] - ); - - const { responses } = await this.callWithRequest(this.req, SEARCH_METHOD, { - body: multiSearchBody, - rest_total_hits_as_int: true, - ignore_throttled: !includeFrozen, - }); - - return responses; - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js deleted file mode 100644 index c113db76332b7..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { MultiSearchRequest } from './multi_search_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -describe('MultiSearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - let getServiceMock; - let includeFrozen; - - beforeEach(() => { - includeFrozen = false; - getServiceMock = jest.fn().mockResolvedValue(includeFrozen); - req = { - getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }), - }; - callWithRequest = jest.fn().mockReturnValue({ responses: [] }); - searchRequest = new MultiSearchRequest(req, callWithRequest); - }); - - test('should init an MultiSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should get the response from elastic msearch', async () => { - const searches = [ - { body: 'body1', index: 'index' }, - { body: 'body2', index: 'index' }, - ]; - - const responses = await searchRequest.search(searches); - - expect(responses).toEqual([]); - expect(req.getUiSettingsService).toHaveBeenCalled(); - expect(getServiceMock).toHaveBeenCalledWith(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - expect(callWithRequest).toHaveBeenCalledWith(req, 'msearch', { - body: [ - { ignoreUnavailable: true, index: 'index' }, - 'body1', - { ignoreUnavailable: true, index: 'index' }, - 'body2', - ], - rest_total_hits_as_int: true, - ignore_throttled: !includeFrozen, - }); - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js deleted file mode 100644 index e6e3bcb527286..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { AbstractSearchRequest } from './abstract_request'; - -import { MultiSearchRequest } from './multi_search_request'; -import { SingleSearchRequest } from './single_search_request'; - -export class SearchRequest extends AbstractSearchRequest { - getSearchRequestType(searches) { - const isMultiSearch = Array.isArray(searches) && searches.length > 1; - const SearchRequest = isMultiSearch ? MultiSearchRequest : SingleSearchRequest; - - return new SearchRequest(this.req, this.callWithRequest); - } - - async search(options) { - const concreteSearchRequest = this.getSearchRequestType(options); - - return concreteSearchRequest.search(options); - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js deleted file mode 100644 index 3d35a4aa37c5a..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { SearchRequest } from './search_request'; -import { MultiSearchRequest } from './multi_search_request'; -import { SingleSearchRequest } from './single_search_request'; - -describe('SearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - let getServiceMock; - let includeFrozen; - - beforeEach(() => { - includeFrozen = false; - getServiceMock = jest.fn().mockResolvedValue(includeFrozen); - req = { - getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }), - }; - callWithRequest = jest.fn().mockReturnValue({ responses: [] }); - searchRequest = new SearchRequest(req, callWithRequest); - }); - - test('should init an AbstractSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should return search value', async () => { - const concreteSearchRequest = { - search: jest.fn().mockReturnValue('concreteSearchRequest'), - }; - const options = {}; - searchRequest.getSearchRequestType = jest.fn().mockReturnValue(concreteSearchRequest); - - const result = await searchRequest.search(options); - - expect(result).toBe('concreteSearchRequest'); - }); - - test('should return a MultiSearchRequest for multi searches', () => { - const searches = [ - { index: 'index', body: 'body' }, - { index: 'index', body: 'body' }, - ]; - - const result = searchRequest.getSearchRequestType(searches); - - expect(result instanceof MultiSearchRequest).toBe(true); - }); - - test('should return a SingleSearchRequest for single search', () => { - const searches = [{ index: 'index', body: 'body' }]; - - const result = searchRequest.getSearchRequestType(searches); - - expect(result instanceof SingleSearchRequest).toBe(true); - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js deleted file mode 100644 index 7d8b60a7e4595..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { AbstractSearchRequest } from './abstract_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -const SEARCH_METHOD = 'search'; - -export class SingleSearchRequest extends AbstractSearchRequest { - async search([{ body, index }]) { - const includeFrozen = await this.req - .getUiSettingsService() - .get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - const resp = await this.callWithRequest(this.req, SEARCH_METHOD, { - ignore_throttled: !includeFrozen, - body, - index, - }); - - return [resp]; - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js deleted file mode 100644 index b899814f2fe13..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { SingleSearchRequest } from './single_search_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -describe('SingleSearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - let getServiceMock; - let includeFrozen; - - beforeEach(() => { - includeFrozen = false; - getServiceMock = jest.fn().mockResolvedValue(includeFrozen); - req = { - getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }), - }; - callWithRequest = jest.fn().mockReturnValue({}); - searchRequest = new SingleSearchRequest(req, callWithRequest); - }); - - test('should init an SingleSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should get the response from elastic search', async () => { - const searches = [{ body: 'body', index: 'index' }]; - - const responses = await searchRequest.search(searches); - - expect(responses).toEqual([{}]); - expect(req.getUiSettingsService).toHaveBeenCalled(); - expect(getServiceMock).toHaveBeenCalledWith(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - expect(callWithRequest).toHaveBeenCalledWith(req, 'search', { - body: 'body', - index: 'index', - ignore_throttled: !includeFrozen, - }); - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts index ecd09653b3b48..66ea4f017dd90 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts @@ -65,7 +65,7 @@ describe('SearchStrategyRegister', () => { }); test('should add a strategy if it is an instance of AbstractSearchStrategy', () => { - const anotherSearchStrategy = new MockSearchStrategy({}, {} as any, {}); + const anotherSearchStrategy = new MockSearchStrategy('es'); const addedStrategies = registry.addStrategy(anotherSearchStrategy); expect(addedStrategies.length).toEqual(2); @@ -75,7 +75,7 @@ describe('SearchStrategyRegister', () => { test('should return a MockSearchStrategy instance', async () => { const req = {}; const indexPattern = '*'; - const anotherSearchStrategy = new MockSearchStrategy({}, {} as any, {}); + const anotherSearchStrategy = new MockSearchStrategy('es'); registry.addStrategy(anotherSearchStrategy); const { searchStrategy, capabilities } = (await registry.getViableStrategy(req, indexPattern))!; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js index 1fbaffd794c89..6773ee482b098 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js @@ -18,24 +18,13 @@ */ import { AbstractSearchStrategy } from './abstract_search_strategy'; -class SearchRequest { - constructor(req, callWithRequest) { - this.req = req; - this.callWithRequest = callWithRequest; - } -} - describe('AbstractSearchStrategy', () => { let abstractSearchStrategy; - let server; - let callWithRequestFactory; let req; let mockedFields; let indexPattern; beforeEach(() => { - server = {}; - callWithRequestFactory = jest.fn().mockReturnValue('callWithRequest'); mockedFields = {}; req = { pre: { @@ -45,16 +34,11 @@ describe('AbstractSearchStrategy', () => { }, }; - abstractSearchStrategy = new AbstractSearchStrategy( - server, - callWithRequestFactory, - SearchRequest - ); + abstractSearchStrategy = new AbstractSearchStrategy('es'); }); test('should init an AbstractSearchStrategy instance', () => { - expect(abstractSearchStrategy.getCallWithRequestInstance).toBeDefined(); - expect(abstractSearchStrategy.getSearchRequest).toBeDefined(); + expect(abstractSearchStrategy.search).toBeDefined(); expect(abstractSearchStrategy.getFieldsForWildcard).toBeDefined(); expect(abstractSearchStrategy.checkForViability).toBeDefined(); }); @@ -68,17 +52,46 @@ describe('AbstractSearchStrategy', () => { }); }); - test('should invoke callWithRequestFactory with req param passed', () => { - abstractSearchStrategy.getCallWithRequestInstance(req); + test('should return response', async () => { + const searches = [{ body: 'body', index: 'index' }]; + const searchFn = jest.fn().mockReturnValue(Promise.resolve({})); - expect(callWithRequestFactory).toHaveBeenCalledWith(server, req); - }); - - test('should return a search request', () => { - const searchRequest = abstractSearchStrategy.getSearchRequest(req); + const responses = await abstractSearchStrategy.search( + { + requestContext: {}, + framework: { + core: { + getStartServices: jest.fn().mockReturnValue( + Promise.resolve([ + {}, + { + data: { + search: { + search: searchFn, + }, + }, + }, + ]) + ), + }, + }, + }, + searches + ); - expect(searchRequest instanceof SearchRequest).toBe(true); - expect(searchRequest.callWithRequest).toBe('callWithRequest'); - expect(searchRequest.req).toBe(req); + expect(responses).toEqual([{}]); + expect(searchFn).toHaveBeenCalledWith( + {}, + { + params: { + body: 'body', + index: 'index', + }, + indexType: undefined, + }, + { + strategy: 'es', + } + ); }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 0b1c6e6e20414..92b7e6976962e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -18,7 +18,7 @@ */ import { - LegacyAPICaller, + RequestHandlerContext, FakeRequest, IUiSettingsClient, SavedObjectsClientContract, @@ -33,6 +33,7 @@ import { IndexPatternsFetcher } from '../../../../../data/server'; * This will be replaced by standard KibanaRequest and RequestContext objects in a later version. */ export type ReqFacade = FakeRequest & { + requestContext: RequestHandlerContext; framework: Framework; payload: unknown; pre: { @@ -40,34 +41,42 @@ export type ReqFacade = FakeRequest & { }; getUiSettingsService: () => IUiSettingsClient; getSavedObjectsClient: () => SavedObjectsClientContract; - server: { - plugins: { - elasticsearch: { - getCluster: () => { - callWithRequest: (req: ReqFacade, endpoint: string, params: any) => Promise; - }; - }; - }; - }; getEsShardTimeout: () => Promise; }; export class AbstractSearchStrategy { - public getCallWithRequestInstance: (req: ReqFacade) => LegacyAPICaller; - public getSearchRequest: (req: ReqFacade) => any; - - constructor( - server: any, - callWithRequestFactory: (server: any, req: ReqFacade) => LegacyAPICaller, - SearchRequest: any - ) { - this.getCallWithRequestInstance = (req) => callWithRequestFactory(server, req); + public searchStrategyName!: string; + public indexType?: string; + public additionalParams: any; - this.getSearchRequest = (req) => { - const callWithRequest = this.getCallWithRequestInstance(req); + constructor(name: string, type?: string, additionalParams: any = {}) { + this.searchStrategyName = name; + this.indexType = type; + this.additionalParams = additionalParams; + } - return new SearchRequest(req, callWithRequest); - }; + async search(req: ReqFacade, bodies: any[], options = {}) { + const [, deps] = await req.framework.core.getStartServices(); + const requests: any[] = []; + bodies.forEach((body) => { + requests.push( + deps.data.search.search( + req.requestContext, + { + params: { + ...body, + ...this.additionalParams, + }, + indexType: this.indexType, + }, + { + ...options, + strategy: this.searchStrategyName, + } + ) + ); + }); + return Promise.all(requests); } async getFieldsForWildcard(req: ReqFacade, indexPattern: string, capabilities: any) { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js index 63f2911ce1118..7c3609ae3c405 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js @@ -16,21 +16,16 @@ * specific language governing permissions and limitations * under the License. */ + +import { ES_SEARCH_STRATEGY } from '../../../../../data/server'; import { AbstractSearchStrategy } from './abstract_search_strategy'; -import { SearchRequest } from '../search_requests/search_request'; import { DefaultSearchCapabilities } from '../default_search_capabilities'; -const callWithRequestFactory = (server, request) => { - const { callWithRequest } = request.server.plugins.elasticsearch.getCluster('data'); - - return callWithRequest; -}; - export class DefaultSearchStrategy extends AbstractSearchStrategy { name = 'default'; - constructor(server) { - super(server, callWithRequestFactory, SearchRequest); + constructor() { + super(ES_SEARCH_STRATEGY); } checkForViability(req) { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js index 2e3a459bf06fd..a9994ba3e1f75 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js @@ -20,42 +20,20 @@ import { DefaultSearchStrategy } from './default_search_strategy'; describe('DefaultSearchStrategy', () => { let defaultSearchStrategy; - let server; - let callWithRequest; let req; beforeEach(() => { - server = {}; - callWithRequest = jest.fn(); - req = { - server: { - plugins: { - elasticsearch: { - getCluster: jest.fn().mockReturnValue({ - callWithRequest, - }), - }, - }, - }, - }; - defaultSearchStrategy = new DefaultSearchStrategy(server); + req = {}; + defaultSearchStrategy = new DefaultSearchStrategy(); }); test('should init an DefaultSearchStrategy instance', () => { expect(defaultSearchStrategy.name).toBe('default'); expect(defaultSearchStrategy.checkForViability).toBeDefined(); - expect(defaultSearchStrategy.getCallWithRequestInstance).toBeDefined(); - expect(defaultSearchStrategy.getSearchRequest).toBeDefined(); + expect(defaultSearchStrategy.search).toBeDefined(); expect(defaultSearchStrategy.getFieldsForWildcard).toBeDefined(); }); - test('should invoke callWithRequestFactory with passed params', () => { - const value = defaultSearchStrategy.getCallWithRequestInstance(req); - - expect(value).toBe(callWithRequest); - expect(req.server.plugins.elasticsearch.getCluster).toHaveBeenCalledWith('data'); - }); - test('should check a strategy for viability', () => { const value = defaultSearchStrategy.checkForViability(req); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js index b015aaf0ef8db..d8a230dfeef4e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js @@ -39,7 +39,6 @@ export async function getAnnotations({ capabilities, series, }) { - const searchRequest = searchStrategy.getSearchRequest(req); const annotations = panel.annotations.filter(validAnnotation); const lastSeriesTimestamp = getLastSeriesTimestamp(series); const handleAnnotationResponseBy = handleAnnotationResponse(lastSeriesTimestamp); @@ -47,6 +46,7 @@ export async function getAnnotations({ const bodiesPromises = annotations.map((annotation) => getAnnotationRequestParams(req, panel, annotation, esQueryConfig, capabilities) ); + const searches = (await Promise.all(bodiesPromises)).reduce( (acc, items) => acc.concat(items), [] @@ -55,10 +55,10 @@ export async function getAnnotations({ if (!searches.length) return { responses: [] }; try { - const data = await searchRequest.search(searches); + const data = await searchStrategy.search(req.framework.core, req.requestContext, searches); return annotations.reduce((acc, annotation, index) => { - acc[annotation.id] = handleAnnotationResponseBy(data[index], annotation); + acc[annotation.id] = handleAnnotationResponseBy(data[index].rawResponse, annotation); return acc; }, {}); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js index ee48816c6a8af..1eace13c2e336 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js @@ -28,7 +28,6 @@ export async function getSeriesData(req, panel) { searchStrategy, capabilities, } = await req.framework.searchStrategyRegistry.getViableStrategyForPanel(req, panel); - const searchRequest = searchStrategy.getSearchRequest(req); const esQueryConfig = await getEsQueryConfig(req); const meta = { type: panel.type, @@ -45,8 +44,13 @@ export async function getSeriesData(req, panel) { [] ); - const data = await searchRequest.search(searches); - const series = data.map(handleResponseBody(panel)); + const data = await searchStrategy.search(req, searches); + + const handleResponseBodyFn = handleResponseBody(panel); + + const series = data.map((resp) => + handleResponseBodyFn(resp.rawResponse ? resp.rawResponse : resp) + ); let annotations = null; if (panel.annotations && panel.annotations.length) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js index 1d1c245907959..3791eb229db5b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js @@ -30,7 +30,6 @@ export async function getTableData(req, panel) { searchStrategy, capabilities, } = await req.framework.searchStrategyRegistry.getViableStrategy(req, panelIndexPattern); - const searchRequest = searchStrategy.getSearchRequest(req); const esQueryConfig = await getEsQueryConfig(req); const { indexPatternObject } = await getIndexPatternObject(req, panelIndexPattern); @@ -41,13 +40,18 @@ export async function getTableData(req, panel) { try { const body = buildRequestBody(req, panel, esQueryConfig, indexPatternObject, capabilities); - const [resp] = await searchRequest.search([ + const [resp] = await searchStrategy.search(req, [ { body, index: panelIndexPattern, }, ]); - const buckets = get(resp, 'aggregations.pivot.buckets', []); + + const buckets = get( + resp.rawResponse ? resp.rawResponse : resp, + 'aggregations.pivot.buckets', + [] + ); return { ...meta, diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index d863937a4e3dc..678ba2b371978 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -33,6 +33,7 @@ import { VisTypeTimeseriesConfig } from './config'; import { getVisData, GetVisData, GetVisDataOptions } from './lib/get_vis_data'; import { ValidationTelemetryService } from './validation_telemetry'; import { UsageCollectionSetup } from '../../usage_collection/server'; +import { PluginStart } from '../../data/server'; import { visDataRoutes } from './routes/vis'; // @ts-ignore import { fieldsRoutes } from './routes/fields'; @@ -47,6 +48,10 @@ interface VisTypeTimeseriesPluginSetupDependencies { usageCollection?: UsageCollectionSetup; } +interface VisTypeTimeseriesPluginStartDependencies { + data: PluginStart; +} + export interface VisTypeTimeseriesSetup { getVisData: ( requestContext: RequestHandlerContext, @@ -57,7 +62,7 @@ export interface VisTypeTimeseriesSetup { } export interface Framework { - core: CoreSetup; + core: CoreSetup; plugins: any; config$: Observable; globalConfig$: PluginInitializerContext['config']['legacy']['globalConfig$']; @@ -74,7 +79,10 @@ export class VisTypeTimeseriesPlugin implements Plugin { this.validationTelementryService = new ValidationTelemetryService(); } - public setup(core: CoreSetup, plugins: VisTypeTimeseriesPluginSetupDependencies) { + public setup( + core: CoreSetup, + plugins: VisTypeTimeseriesPluginSetupDependencies + ) { const logger = this.initializerContext.logger.get('visTypeTimeseries'); core.uiSettings.register(uiSettings); const config$ = this.initializerContext.config.create(); diff --git a/src/plugins/vis_type_timeseries/server/routes/vis.ts b/src/plugins/vis_type_timeseries/server/routes/vis.ts index 48efd4398e4d4..1ca8b57ab230f 100644 --- a/src/plugins/vis_type_timeseries/server/routes/vis.ts +++ b/src/plugins/vis_type_timeseries/server/routes/vis.ts @@ -21,7 +21,8 @@ import { IRouter, KibanaRequest } from 'kibana/server'; import { schema } from '@kbn/config-schema'; import { getVisData, GetVisDataOptions } from '../lib/get_vis_data'; import { visPayloadSchema } from '../../common/vis_schema'; -import { Framework, ValidationTelemetryServiceSetup } from '../index'; +import { ValidationTelemetryServiceSetup } from '../index'; +import { Framework } from '../plugin'; const escapeHatch = schema.object({}, { unknowns: 'allow' }); diff --git a/test/scripts/jenkins_visual_regression.sh b/test/scripts/jenkins_baseline.sh similarity index 63% rename from test/scripts/jenkins_visual_regression.sh rename to test/scripts/jenkins_baseline.sh index 17345d4301882..e679ac7f31bd1 100755 --- a/test/scripts/jenkins_visual_regression.sh +++ b/test/scripts/jenkins_baseline.sh @@ -9,10 +9,3 @@ linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$PARENT_DIR/install/kibana" mkdir -p "$installDir" tar -xzf "$linuxBuild" -C "$installDir" --strip=1 - -echo " -> running visual regression tests from kibana directory" -yarn percy exec -t 10000 -- -- \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$installDir" \ - --config test/visual_regression/config.ts; diff --git a/test/scripts/jenkins_xpack_visual_regression.sh b/test/scripts/jenkins_xpack_baseline.sh similarity index 64% rename from test/scripts/jenkins_xpack_visual_regression.sh rename to test/scripts/jenkins_xpack_baseline.sh index 55d4a524820c5..7577b6927d166 100755 --- a/test/scripts/jenkins_xpack_visual_regression.sh +++ b/test/scripts/jenkins_xpack_baseline.sh @@ -14,16 +14,5 @@ tar -xzf "$linuxBuild" -C "$installDir" --strip=1 mkdir -p "$WORKSPACE/kibana-build-xpack" cp -pR install/kibana/. $WORKSPACE/kibana-build-xpack/ -# cd "$KIBANA_DIR" -# source "test/scripts/jenkins_xpack_page_load_metrics.sh" - cd "$KIBANA_DIR" source "test/scripts/jenkins_xpack_saved_objects_field_metrics.sh" - -echo " -> running visual regression tests from x-pack directory" -cd "$XPACK_DIR" -yarn percy exec -t 10000 -- -- \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$installDir" \ - --config test/visual_regression/config.ts; diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 33ecefa13e862..3b5f2f55cc4e6 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -1,4 +1,4 @@ -def withPostBuildReporting(Closure closure) { +def withPostBuildReporting(Map params, Closure closure) { try { closure() } finally { @@ -9,8 +9,10 @@ def withPostBuildReporting(Closure closure) { print ex } - catchErrors { - runErrorReporter([pwd()] + parallelWorkspaces) + if (params.runErrorReporter) { + catchErrors { + runErrorReporter([pwd()] + parallelWorkspaces) + } } catchErrors { diff --git a/vars/workers.groovy b/vars/workers.groovy index e582e996a78b5..b6ff5b27667dd 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -118,11 +118,11 @@ def base(Map params, Closure closure) { // Worker for ci processes. Extends the base worker and adds GCS artifact upload, error reporting, junit processing def ci(Map params, Closure closure) { - def config = [ramDisk: true, bootstrapped: true] + params + def config = [ramDisk: true, bootstrapped: true, runErrorReporter: true] + params return base(config) { kibanaPipeline.withGcsArtifactUpload(config.name) { - kibanaPipeline.withPostBuildReporting { + kibanaPipeline.withPostBuildReporting(config) { closure() } } diff --git a/x-pack/plugins/data_enhanced/server/index.ts b/x-pack/plugins/data_enhanced/server/index.ts index fbe1ecc10d632..3c5d5d1e99d13 100644 --- a/x-pack/plugins/data_enhanced/server/index.ts +++ b/x-pack/plugins/data_enhanced/server/index.ts @@ -11,4 +11,6 @@ export function plugin(initializerContext: PluginInitializerContext) { return new EnhancedDataServerPlugin(initializerContext); } +export { ENHANCED_ES_SEARCH_STRATEGY } from '../common'; + export { EnhancedDataServerPlugin as Plugin }; diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md index 31ee304fe2247..ba14be5564be1 100644 --- a/x-pack/plugins/enterprise_search/README.md +++ b/x-pack/plugins/enterprise_search/README.md @@ -13,6 +13,14 @@ This plugin's goal is to provide a Kibana user interface to the Enterprise Searc 2. Update `config/kibana.dev.yml` with `enterpriseSearch.host: 'http://localhost:3002'` 3. For faster QA/development, run Enterprise Search on [elasticsearch-native auth](https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm) and log in as the `elastic` superuser on Kibana. +### Kea + +Enterprise Search uses [Kea.js](https://github.com/keajs/kea) to manage our React/Redux state for us. Kea state is handled in our `*Logic` files and exposes [values](https://kea.js.org/docs/guide/concepts#values) and [actions](https://kea.js.org/docs/guide/concepts#actions) for our components to get and set state with. + +#### Debugging Kea + +To debug Kea state in-browser, Kea recommends [Redux Devtools](https://kea.js.org/docs/guide/debugging). To facilitate debugging, we use the [path](https://kea.js.org/docs/guide/debugging/#setting-the-path-manually) key with `snake_case`d paths. The path key should always end with the logic filename (e.g. `['enterprise_search', 'some_logic']`) to make it easy for devs to quickly find/jump to files via IDE tooling. + ## Testing ### Unit tests diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts index 3f71759390879..9388d61041b13 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts @@ -16,6 +16,7 @@ export interface IAppActions { } export const AppLogic = kea>({ + path: ['enterprise_search', 'app_search', 'app_logic'], actions: { initializeAppData: (props) => props, }, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts index 3ae48f352b2c1..37a8f16acad6d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts @@ -32,6 +32,7 @@ const convertToArray = (messages: IFlashMessage | IFlashMessage[]) => !Array.isArray(messages) ? [messages] : messages; export const FlashMessagesLogic = kea>({ + path: ['enterprise_search', 'flash_messages_logic'], actions: { setFlashMessages: (messages) => ({ messages: convertToArray(messages) }), clearFlashMessages: () => null, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts index 5e2b5a9ed6b06..72380142fe399 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts @@ -26,6 +26,7 @@ export interface IHttpActions { } export const HttpLogic = kea>({ + path: ['enterprise_search', 'http_logic'], actions: { initializeHttp: (props) => props, initializeHttpInterceptors: () => null, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts index f88a00f63f487..94bd1d529b65f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts @@ -22,6 +22,7 @@ export interface IAppActions { } export const AppLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'app_logic'], actions: { initializeAppData: ({ workplaceSearch, isFederatedAuth }) => ({ workplaceSearch, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts index 787d5295db1cf..a156b8a8009f9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts @@ -31,6 +31,7 @@ export interface IOverviewValues extends IOverviewServerData { } export const OverviewLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'overview_logic'], actions: { setServerData: (serverData) => serverData, initializeOverview: () => null, diff --git a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx index 98f5878ec927e..07baf29fdd32a 100644 --- a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx @@ -56,6 +56,7 @@ export const ToolbarPopover: React.FunctionComponent = ({ onClick={() => { setOpen(!open); }} + title={title} hasArrow={false} isDisabled={isDisabled} groupPosition={groupPosition} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 7e2e8f0453588..2114d63fcfacd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; import { EuiButtonGroupProps, EuiSuperSelect, EuiButtonGroup } from '@elastic/eui'; -import { LayerContextMenu, XyToolbar } from './xy_config_panel'; +import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; import { ToolbarPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; import { FramePublicAPI } from '../types'; @@ -171,4 +171,48 @@ describe('XY Config panels', () => { expect(component.find(AxisSettingsPopover).length).toEqual(3); }); }); + + describe('Dimension Editor', () => { + test('shows the correct axis side options when in horizontal mode', () => { + const state = testState(); + const component = mount( + + ); + + const options = component + .find(EuiButtonGroup) + .first() + .prop('options') as EuiButtonGroupProps['options']; + + expect(options!.map(({ label }) => label)).toEqual(['Auto', 'Bottom', 'Top']); + }); + + test('shows the default axis side options when not in horizontal mode', () => { + const state = testState(); + const component = mount( + + ); + + const options = component + .find(EuiButtonGroup) + .first() + .prop('options') as EuiButtonGroupProps['options']; + + expect(options!.map(({ label }) => label)).toEqual(['Auto', 'Left', 'Right']); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index bc98bf53d9f12..4aa5bd62c05a5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -274,9 +274,15 @@ export function XyToolbar(props: VisualizationToolbarProps) { group.groupId === 'left') || {}).length === 0 } @@ -310,9 +316,15 @@ export function XyToolbar(props: VisualizationToolbarProps) { toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange} /> group.groupId === 'right') || {}).length === 0 } @@ -345,6 +357,7 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps) const { state, setState, layerId, accessor } = props; const index = state.layers.findIndex((l) => l.layerId === layerId); const layer = state.layers[index]; + const isHorizontal = isHorizontalChart(state.layers); const axisMode = (layer.yConfig && layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) || @@ -377,15 +390,23 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps) }, { id: `${idPrefix}left`, - label: i18n.translate('xpack.lens.xyChart.axisSide.left', { - defaultMessage: 'Left', - }), + label: isHorizontal + ? i18n.translate('xpack.lens.xyChart.axisSide.bottom', { + defaultMessage: 'Bottom', + }) + : i18n.translate('xpack.lens.xyChart.axisSide.left', { + defaultMessage: 'Left', + }), }, { id: `${idPrefix}right`, - label: i18n.translate('xpack.lens.xyChart.axisSide.right', { - defaultMessage: 'Right', - }), + label: isHorizontal + ? i18n.translate('xpack.lens.xyChart.axisSide.top', { + defaultMessage: 'Top', + }) + : i18n.translate('xpack.lens.xyChart.axisSide.right', { + defaultMessage: 'Right', + }), }, ]} idSelected={`${idPrefix}${axisMode}`} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json index 245b7e0819c7d..bb0323ed9ae78 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for compiler activity by a user context which does not normally run compilers. This can be ad-hoc software changes or unauthorized software deployment. This can also be due to local privliege elevation via locally run exploits or malware activity.", + "description": "Security: Auditbeat - Looks for compiler activity by a user context which does not normally run compilers. This can be ad-hoc software changes or unauthorized software deployment. This can also be due to local privilege elevation via locally run exploits or malware activity.", "groups": [ "security", "auditbeat", diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js index d466ebd69737e..8672a8b8f6849 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js +++ b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js @@ -6,21 +6,16 @@ import { registerRollupSearchStrategy } from './register_rollup_search_strategy'; describe('Register Rollup Search Strategy', () => { - let routeDependencies; let addSearchStrategy; + let getRollupService; beforeEach(() => { - routeDependencies = { - router: jest.fn().mockName('router'), - elasticsearchService: jest.fn().mockName('elasticsearchService'), - elasticsearch: jest.fn().mockName('elasticsearch'), - }; - addSearchStrategy = jest.fn().mockName('addSearchStrategy'); + getRollupService = jest.fn().mockName('getRollupService'); }); test('should run initialization', () => { - registerRollupSearchStrategy(routeDependencies, addSearchStrategy); + registerRollupSearchStrategy(addSearchStrategy, getRollupService); expect(addSearchStrategy).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts index 333863979ba95..22dafbb71d802 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts +++ b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts @@ -4,27 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ILegacyScopedClusterClient } from 'src/core/server'; import { - AbstractSearchRequest, DefaultSearchCapabilities, AbstractSearchStrategy, + ReqFacade, } from '../../../../../../src/plugins/vis_type_timeseries/server'; -import { CallWithRequestFactoryShim } from '../../types'; import { getRollupSearchStrategy } from './rollup_search_strategy'; -import { getRollupSearchRequest } from './rollup_search_request'; import { getRollupSearchCapabilities } from './rollup_search_capabilities'; export const registerRollupSearchStrategy = ( - callWithRequestFactory: CallWithRequestFactoryShim, - addSearchStrategy: (searchStrategy: any) => void + addSearchStrategy: (searchStrategy: any) => void, + getRollupService: (reg: ReqFacade) => Promise ) => { - const RollupSearchRequest = getRollupSearchRequest(AbstractSearchRequest); const RollupSearchCapabilities = getRollupSearchCapabilities(DefaultSearchCapabilities); const RollupSearchStrategy = getRollupSearchStrategy( AbstractSearchStrategy, - RollupSearchRequest, RollupSearchCapabilities, - callWithRequestFactory + getRollupService ); addSearchStrategy(new RollupSearchStrategy()); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js deleted file mode 100644 index 2ea0612140946..0000000000000 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { getRollupSearchRequest } from './rollup_search_request'; - -class AbstractSearchRequest { - indexPattern = 'indexPattern'; - callWithRequest = jest.fn(({ body }) => Promise.resolve(body)); -} - -describe('Rollup search request', () => { - let RollupSearchRequest; - - beforeEach(() => { - RollupSearchRequest = getRollupSearchRequest(AbstractSearchRequest); - }); - - test('should create instance of RollupSearchRequest', () => { - const rollupSearchRequest = new RollupSearchRequest(); - - expect(rollupSearchRequest).toBeInstanceOf(AbstractSearchRequest); - expect(rollupSearchRequest.search).toBeDefined(); - expect(rollupSearchRequest.callWithRequest).toBeDefined(); - }); - - test('should send one request for single search', async () => { - const rollupSearchRequest = new RollupSearchRequest(); - const searches = [{ body: 'body', index: 'index' }]; - - await rollupSearchRequest.search(searches); - - expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledTimes(1); - expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledWith('rollup.search', { - body: 'body', - index: 'index', - rest_total_hits_as_int: true, - }); - }); - - test('should send multiple request for multi search', async () => { - const rollupSearchRequest = new RollupSearchRequest(); - const searches = [ - { body: 'body', index: 'index' }, - { body: 'body1', index: 'index' }, - ]; - - await rollupSearchRequest.search(searches); - - expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledTimes(2); - }); -}); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts deleted file mode 100644 index 7e12d5286f34c..0000000000000 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -const SEARCH_METHOD = 'rollup.search'; - -interface Search { - index: string; - body: { - [key: string]: any; - }; -} - -export const getRollupSearchRequest = (AbstractSearchRequest: any) => - class RollupSearchRequest extends AbstractSearchRequest { - async search(searches: Search[]) { - const requests = searches.map(({ body, index }) => - this.callWithRequest(SEARCH_METHOD, { - body, - index, - rest_total_hits_as_int: true, - }) - ); - - return await Promise.all(requests); - } - }; diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js index 63f4628e36bfe..f3da7ed3fdd17 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js +++ b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js @@ -7,13 +7,32 @@ import { getRollupSearchStrategy } from './rollup_search_strategy'; describe('Rollup Search Strategy', () => { let RollupSearchStrategy; - let RollupSearchRequest; let RollupSearchCapabilities; let callWithRequest; let rollupResolvedData; - const server = 'server'; - const request = 'request'; + const request = { + requestContext: { + core: { + elasticsearch: { + client: { + asCurrentUser: { + rollup: { + getRollupIndexCaps: jest.fn().mockImplementation(() => rollupResolvedData), + }, + }, + }, + }, + }, + }, + }; + const getRollupService = jest.fn().mockImplementation(() => { + return { + callAsCurrentUser: async () => { + return rollupResolvedData; + }, + }; + }); const indexPattern = 'indexPattern'; beforeEach(() => { @@ -33,19 +52,17 @@ describe('Rollup Search Strategy', () => { } } - RollupSearchRequest = jest.fn(); RollupSearchCapabilities = jest.fn(() => 'capabilities'); - callWithRequest = jest.fn().mockImplementation(() => rollupResolvedData); RollupSearchStrategy = getRollupSearchStrategy( AbstractSearchStrategy, - RollupSearchRequest, - RollupSearchCapabilities + RollupSearchCapabilities, + getRollupService ); }); test('should create instance of RollupSearchRequest', () => { - const rollupSearchStrategy = new RollupSearchStrategy(server); + const rollupSearchStrategy = new RollupSearchStrategy(); expect(rollupSearchStrategy.name).toBe('rollup'); }); @@ -55,7 +72,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(server); + rollupSearchStrategy = new RollupSearchStrategy(); rollupSearchStrategy.getRollupData = jest.fn(() => ({ [rollupIndex]: { rollup_jobs: [ @@ -104,7 +121,7 @@ describe('Rollup Search Strategy', () => { let rollupSearchStrategy; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(server); + rollupSearchStrategy = new RollupSearchStrategy(); }); test('should return rollup data', async () => { @@ -112,10 +129,7 @@ describe('Rollup Search Strategy', () => { const rollupData = await rollupSearchStrategy.getRollupData(request, indexPattern); - expect(callWithRequest).toHaveBeenCalledWith('rollup.rollupIndexCapabilities', { - indexPattern, - }); - expect(rollupSearchStrategy.getCallWithRequestInstance).toHaveBeenCalledWith(request); + expect(getRollupService).toHaveBeenCalled(); expect(rollupData).toBe('data'); }); @@ -135,7 +149,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(server); + rollupSearchStrategy = new RollupSearchStrategy(); fieldsCapabilities = { [rollupIndex]: { aggs: { diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts index 885836780f1a9..e7794caf8697b 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts +++ b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import { keyBy, isString } from 'lodash'; -import { KibanaRequest } from 'src/core/server'; - -import { CallWithRequestFactoryShim } from '../../types'; +import { ILegacyScopedClusterClient } from 'src/core/server'; +import { ReqFacade } from '../../../../../../src/plugins/vis_type_timeseries/server'; +import { ENHANCED_ES_SEARCH_STRATEGY } from '../../../../data_enhanced/server'; import { mergeCapabilitiesWithFields } from '../merge_capabilities_with_fields'; import { getCapabilitiesForRollupIndices } from '../map_capabilities'; -const ROLLUP_INDEX_CAPABILITIES_METHOD = 'rollup.rollupIndexCapabilities'; - -const getRollupIndices = (rollupData: { [key: string]: any[] }) => Object.keys(rollupData); +const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); const isIndexPatternValid = (indexPattern: string) => @@ -20,28 +18,40 @@ const isIndexPatternValid = (indexPattern: string) => export const getRollupSearchStrategy = ( AbstractSearchStrategy: any, - RollupSearchRequest: any, RollupSearchCapabilities: any, - callWithRequestFactory: CallWithRequestFactoryShim + getRollupService: (reg: ReqFacade) => Promise ) => class RollupSearchStrategy extends AbstractSearchStrategy { name = 'rollup'; constructor() { - // TODO: When vis_type_timeseries and AbstractSearchStrategy are migrated to the NP, it - // shouldn't require elasticsearchService to be injected, and we can remove this null argument. - super(null, callWithRequestFactory, RollupSearchRequest); + super(ENHANCED_ES_SEARCH_STRATEGY, 'rollup', { rest_total_hits_as_int: true }); } - getRollupData(req: KibanaRequest, indexPattern: string) { - const callWithRequest = this.getCallWithRequestInstance(req); + async search(req: ReqFacade, bodies: any[], options = {}) { + const rollupService = await getRollupService(req); + const requests: any[] = []; + bodies.forEach((body) => { + requests.push( + rollupService.callAsCurrentUser('rollup.search', { + ...body, + rest_total_hits_as_int: true, + }) + ); + }); + return Promise.all(requests); + } - return callWithRequest(ROLLUP_INDEX_CAPABILITIES_METHOD, { - indexPattern, - }).catch(() => Promise.resolve({})); + async getRollupData(req: ReqFacade, indexPattern: string) { + const rollupService = await getRollupService(req); + return rollupService + .callAsCurrentUser('rollup.rollupIndexCapabilities', { + indexPattern, + }) + .catch(() => Promise.resolve({})); } - async checkForViability(req: KibanaRequest, indexPattern: string) { + async checkForViability(req: ReqFacade, indexPattern: string) { let isViable = false; let capabilities = null; @@ -66,7 +76,7 @@ export const getRollupSearchStrategy = ( } async getFieldsForWildcard( - req: KibanaRequest, + req: ReqFacade, indexPattern: string, { fieldsCapabilities, diff --git a/x-pack/plugins/rollup/server/plugin.ts b/x-pack/plugins/rollup/server/plugin.ts index 8b3a6355f950d..fe193150fc1ca 100644 --- a/x-pack/plugins/rollup/server/plugin.ts +++ b/x-pack/plugins/rollup/server/plugin.ts @@ -17,17 +17,16 @@ import { ILegacyCustomClusterClient, Plugin, Logger, - KibanaRequest, PluginInitializerContext, ILegacyScopedClusterClient, - LegacyAPICaller, SharedGlobalConfig, } from 'src/core/server'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; +import { ReqFacade } from '../../../../src/plugins/vis_type_timeseries/server'; import { PLUGIN, CONFIG_ROLLUPS } from '../common'; -import { Dependencies, CallWithRequestFactoryShim } from './types'; +import { Dependencies } from './types'; import { registerApiRoutes } from './routes'; import { License } from './services'; import { registerRollupUsageCollector } from './collectors'; @@ -132,19 +131,12 @@ export class RollupPlugin implements Plugin { }); if (visTypeTimeseries) { - // TODO: When vis_type_timeseries is fully migrated to the NP, it shouldn't require this shim. - const callWithRequestFactoryShim = ( - elasticsearchServiceShim: CallWithRequestFactoryShim, - request: KibanaRequest - ): LegacyAPICaller => { - return async (...args: Parameters) => { - this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices)); - return await this.rollupEsClient.asScoped(request).callAsCurrentUser(...args); - }; + const getRollupService = async (request: ReqFacade) => { + this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices)); + return this.rollupEsClient.asScoped(request); }; - const { addSearchStrategy } = visTypeTimeseries; - registerRollupSearchStrategy(callWithRequestFactoryShim, addSearchStrategy); + registerRollupSearchStrategy(addSearchStrategy, getRollupService); } if (usageCollection) { diff --git a/x-pack/plugins/rollup/server/types.ts b/x-pack/plugins/rollup/server/types.ts index 290d2df050099..b167806cf8d5d 100644 --- a/x-pack/plugins/rollup/server/types.ts +++ b/x-pack/plugins/rollup/server/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter, LegacyAPICaller, KibanaRequest } from 'src/core/server'; +import { IRouter } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server'; @@ -39,9 +39,3 @@ export interface RouteDependencies { IndexPatternsFetcher: typeof IndexPatternsFetcher; }; } - -// TODO: When vis_type_timeseries is fully migrated to the NP, it shouldn't require this shim. -export type CallWithRequestFactoryShim = ( - elasticsearchServiceShim: CallWithRequestFactoryShim, - request: KibanaRequest -) => LegacyAPICaller; diff --git a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts index 6194d6892d799..a45b1fd18a4b6 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts @@ -24,17 +24,16 @@ import { ALL_CASES_TAGS_COUNT, } from '../screens/all_cases'; import { - ACTION, CASE_DETAILS_DESCRIPTION, CASE_DETAILS_PAGE_TITLE, CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN, CASE_DETAILS_STATUS, CASE_DETAILS_TAGS, - CASE_DETAILS_USER_ACTION, + CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME, + CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT, CASE_DETAILS_USERNAMES, PARTICIPANTS, REPORTER, - USER, } from '../screens/case_details'; import { TIMELINE_DESCRIPTION, TIMELINE_QUERY, TIMELINE_TITLE } from '../screens/timeline'; @@ -84,8 +83,8 @@ describe('Cases', () => { const expectedTags = case1.tags.join(''); cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', case1.name); cy.get(CASE_DETAILS_STATUS).should('have.text', 'open'); - cy.get(CASE_DETAILS_USER_ACTION).eq(USER).should('have.text', case1.reporter); - cy.get(CASE_DETAILS_USER_ACTION).eq(ACTION).should('have.text', 'added description'); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', case1.reporter); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description'); cy.get(CASE_DETAILS_DESCRIPTION).should( 'have.text', `${case1.description} ${case1.timeline.title}` diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts index 6af4d174b9583..3862a89a7d833 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts @@ -11,7 +11,7 @@ import { addNewCase, selectCase, } from '../tasks/timeline'; -import { DESCRIPTION_INPUT } from '../screens/create_new_case'; +import { DESCRIPTION_INPUT, ADD_COMMENT_INPUT } from '../screens/create_new_case'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { caseTimeline, TIMELINE_CASE_ID } from '../objects/case'; @@ -34,7 +34,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(DESCRIPTION_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); @@ -46,7 +46,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(DESCRIPTION_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); @@ -66,9 +66,9 @@ describe('attach timeline to case', () => { selectCase(TIMELINE_CASE_ID); cy.location('origin').then((origin) => { - cy.get(DESCRIPTION_INPUT).should( + cy.get(ADD_COMMENT_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts index f2cdaa6994356..7b995f5395543 100644 --- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ACTION = 2; - -export const CASE_DETAILS_DESCRIPTION = '[data-test-subj="markdown-root"]'; +export const CASE_DETAILS_DESCRIPTION = + '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]'; export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; @@ -17,14 +16,17 @@ export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]'; export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]'; -export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = '[data-test-subj="markdown-timeline-link"]'; +export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = + '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"] button'; + +export const CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT = + '[data-test-subj="description-action"] .euiCommentEvent__headerEvent'; -export const CASE_DETAILS_USER_ACTION = '[data-test-subj="user-action-title"] .euiFlexItem'; +export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME = + '[data-test-subj="description-action"] .euiCommentEvent__headerUsername'; export const CASE_DETAILS_USERNAMES = '[data-test-subj="case-view-username"]'; export const PARTICIPANTS = 1; export const REPORTER = 0; - -export const USER = 1; diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts index 9431c054d96a4..4f348b4dcdbd1 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +export const ADD_COMMENT_INPUT = '[data-test-subj="add-comment"] textarea'; + export const BACK_TO_CASES_BTN = '[data-test-subj="backToCases"]'; -export const DESCRIPTION_INPUT = '[data-test-subj="textAreaInput"]'; +export const DESCRIPTION_INPUT = '[data-test-subj="caseDescription"] textarea'; -export const INSERT_TIMELINE_BTN = '[data-test-subj="insert-timeline-button"]'; +export const INSERT_TIMELINE_BTN = '.euiMarkdownEditorToolbar [aria-label="Insert timeline link"]'; export const LOADING_SPINNER = '[data-test-subj="create-case-loading-spinner"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts index 1d5d240c5c53d..f5013eed07d29 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts @@ -13,7 +13,6 @@ import { INSERT_TIMELINE_BTN, LOADING_SPINNER, TAGS_INPUT, - TIMELINE, TIMELINE_SEARCHBOX, TITLE_INPUT, } from '../screens/create_new_case'; @@ -43,9 +42,6 @@ export const createNewCaseWithTimeline = (newCase: TestCase) => { cy.get(INSERT_TIMELINE_BTN).click({ force: true }); cy.get(TIMELINE_SEARCHBOX).type(`${newCase.timeline.title}{enter}`); - cy.get(TIMELINE).should('be.visible'); - cy.wait(300); - cy.get(TIMELINE).eq(0).click({ force: true }); cy.get(SUBMIT_BTN).click({ force: true }); cy.get(LOADING_SPINNER).should('exist'); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index ef13c87a92dbb..14c42697dcbb4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -11,14 +11,14 @@ import styled from 'styled-components'; import { CommentRequest } from '../../../../../case/common/api'; import { usePostComment } from '../../containers/use_post_comment'; import { Case } from '../../containers/types'; -import { MarkdownEditorForm } from '../../../common/components/markdown_editor/form'; +import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import { Form, useForm, UseField, useFormData } from '../../../shared_imports'; import * as i18n from './translations'; import { schema } from './schema'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click'; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index e1d7d98ba8c51..246df1c94b817 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -114,34 +114,41 @@ describe('CaseView ', () => { expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual( data.title ); + expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual( data.status ); + expect( wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-coke"]`) + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`) .first() .text() ).toEqual(data.tags[0]); + expect( wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-pepsi"]`) + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`) .first() .text() ).toEqual(data.tags[1]); + expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual( data.createdBy.username ); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); + expect(wrapper.find(`[data-test-subj="case-view-createdAt"]`).first().prop('value')).toEqual( data.createdAt ); + expect( wrapper .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`) .first() - .prop('raw') - ).toEqual(data.description); + .text() + ).toBe(data.description); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 3c3cc95218b03..a8babe729fde0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -31,10 +31,10 @@ import { schema } from './schema'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import * as i18n from '../../translations'; -import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; +import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; import { useGetTags } from '../../containers/use_get_tags'; import { getCaseDetailsUrl } from '../../../common/components/link_to'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click'; export const CommonUseField = getUseField({ component: Field }); diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx index 7c3fcde687033..a60167a18762f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx @@ -58,6 +58,7 @@ describe('TagList ', () => { fetchTags, })); }); + it('Renders no tags, and then edit', () => { const wrapper = mount( @@ -69,6 +70,7 @@ describe('TagList ', () => { expect(wrapper.find(`[data-test-subj="no-tags"]`).last().exists()).toBeFalsy(); expect(wrapper.find(`[data-test-subj="edit-tags"]`).last().exists()).toBeTruthy(); }); + it('Edit tag on submit', async () => { const wrapper = mount( @@ -81,6 +83,7 @@ describe('TagList ', () => { await waitFor(() => expect(onSubmit).toBeCalledWith(sampleTags)); }); }); + it('Tag options render with new tags added', () => { const wrapper = mount( @@ -92,6 +95,7 @@ describe('TagList ', () => { wrapper.find(`[data-test-subj="caseTags"] [data-test-subj="input"]`).first().prop('options') ).toEqual([{ label: 'coke' }, { label: 'pepsi' }, { label: 'rad' }, { label: 'dude' }]); }); + it('Cancels on cancel', async () => { const props = { ...defaultProps, @@ -102,17 +106,19 @@ describe('TagList ', () => { ); - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy(); + + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().simulate('click'); await act(async () => { - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeFalsy(); wrapper.find(`[data-test-subj="edit-tags-cancel"]`).last().simulate('click'); await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); }); }); }); + it('Renders disabled button', () => { const props = { ...defaultProps, disabled: true }; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx index eeb7c49eceab5..4af781e3c31f4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx @@ -10,8 +10,6 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, - EuiBadgeGroup, - EuiBadge, EuiButton, EuiButtonEmpty, EuiButtonIcon, @@ -25,6 +23,8 @@ import { schema } from './schema'; import { CommonUseField } from '../create'; import { useGetTags } from '../../containers/use_get_tags'; +import { Tags } from './tags'; + interface TagListProps { disabled?: boolean; isLoading: boolean; @@ -99,15 +99,7 @@ export const TagList = React.memo( {tags.length === 0 && !isEditTags &&

{i18n.NO_TAGS}

} - - {tags.length > 0 && - !isEditTags && - tags.map((tag) => ( - - {tag} - - ))} - + {!isEditTags && } {isEditTags && ( diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx new file mode 100644 index 0000000000000..e257563ce751e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiBadgeGroup, EuiBadge, EuiBadgeGroupProps } from '@elastic/eui'; + +interface TagsProps { + tags: string[]; + color?: string; + gutterSize?: EuiBadgeGroupProps['gutterSize']; +} + +const TagsComponent: React.FC = ({ tags, color = 'default', gutterSize }) => { + return ( + <> + {tags.length > 0 && ( + + {tags.map((tag) => ( + + {tag} + + ))} + + )} + + ); +}; + +export const Tags = memo(TagsComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx index b5be84db59920..4e5c05f2f1404 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx @@ -14,7 +14,7 @@ import { connectorsMock } from '../../containers/configure/mock'; describe('User action tree helpers', () => { const connectors = connectorsMock; it('label title generated for update tags', () => { - const action = getUserAction(['title'], 'update'); + const action = getUserAction(['tags'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, connectors, @@ -27,8 +27,11 @@ describe('User action tree helpers', () => { ` ${i18n.TAGS.toLowerCase()}` ); - expect(wrapper.find(`[data-test-subj="ua-tag"]`).first().text()).toEqual(action.newValue); + expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual( + action.newValue + ); }); + it('label title generated for update title', () => { const action = getUserAction(['title'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -44,6 +47,7 @@ describe('User action tree helpers', () => { }"` ); }); + it('label title generated for update description', () => { const action = getUserAction(['description'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -55,6 +59,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); }); + it('label title generated for update status to open', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'open' }; const result: string | JSX.Element = getLabelTitle({ @@ -66,6 +71,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`); }); + it('label title generated for update status to closed', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' }; const result: string | JSX.Element = getLabelTitle({ @@ -77,6 +83,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`); }); + it('label title generated for update comment', () => { const action = getUserAction(['comment'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -88,6 +95,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`); }); + it('label title generated for pushed incident', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ @@ -105,6 +113,7 @@ describe('User action tree helpers', () => { JSON.parse(action.newValue).external_url ); }); + it('label title generated for needs update incident', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ @@ -122,6 +131,7 @@ describe('User action tree helpers', () => { JSON.parse(action.newValue).external_url ); }); + it('label title generated for update connector', () => { const action = getUserAction(['connector_id'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -136,6 +146,8 @@ describe('User action tree helpers', () => { ` ${i18n.TAGS.toLowerCase()}` ); - expect(wrapper.find(`[data-test-subj="ua-tag"]`).first().text()).toEqual(action.newValue); + expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual( + action.newValue + ); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index e343c3da6cc8b..4d8bb9ba078e5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiBadgeGroup, EuiBadge, EuiLink } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import React from 'react'; import { CaseFullExternalService, Connector } from '../../../../../case/common/api'; import { CaseUserActions } from '../../containers/types'; +import { CaseServices } from '../../containers/use_get_case_user_actions'; import * as i18n from '../case_view/translations'; +import { Tags } from '../tag_list/tags'; interface LabelTitle { action: CaseUserActions; @@ -44,22 +46,21 @@ export const getLabelTitle = ({ action, connectors, field, firstPush }: LabelTit return ''; }; -const getTagsLabelTitle = (action: CaseUserActions) => ( - - - {action.action === 'add' && i18n.ADDED_FIELD} - {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} - - - {action.newValue != null && - action.newValue.split(',').map((tag) => ( - - {tag} - - ))} - - -); +const getTagsLabelTitle = (action: CaseUserActions) => { + const tags = action.newValue != null ? action.newValue.split(',') : []; + + return ( + + + {action.action === 'add' && i18n.ADDED_FIELD} + {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + + + + + ); +}; const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => { const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; @@ -78,3 +79,20 @@ const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) ); }; + +export const getPushInfo = ( + caseServices: CaseServices, + parsedValue: { connector_id: string; connector_name: string }, + index: number +) => + parsedValue != null + ? { + firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, + parsedConnectorId: parsedValue.connector_id, + parsedConnectorName: parsedValue.connector_name, + } + : { + firstPush: false, + parsedConnectorId: 'none', + parsedConnectorName: 'none', + }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx index d67c364bbda10..d2bb2fb243458 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx @@ -6,6 +6,9 @@ import React from 'react'; import { mount } from 'enzyme'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; import { getFormMock, useFormMock, useFormDataMock } from '../__mock__/form'; @@ -13,9 +16,6 @@ import { useUpdateComment } from '../../containers/use_update_comment'; import { basicCase, basicPush, getUserAction } from '../../containers/mock'; import { UserActionTree } from '.'; import { TestProviders } from '../../../common/mock'; -// we don't have the types for waitFor just yet, so using "as waitFor" until when we do -import { wait as waitFor } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; const fetchUserActions = jest.fn(); const onUpdateField = jest.fn(); @@ -66,9 +66,10 @@ describe('UserActionTree ', () => { expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().prop('name')).toEqual( defaultProps.data.createdBy.fullName ); - expect(wrapper.find(`[data-test-subj="user-action-title"] strong`).first().text()).toEqual( - defaultProps.data.createdBy.username - ); + + expect( + wrapper.find(`[data-test-subj="description-action"] figcaption strong`).first().text() + ).toEqual(defaultProps.data.createdBy.username); }); it('Renders service now update line with top and bottom when push is required', async () => { @@ -76,6 +77,7 @@ describe('UserActionTree ', () => { getUserAction(['pushed'], 'push-to-service'), getUserAction(['comment'], 'update'), ]; + const props = { ...defaultProps, caseServices: { @@ -90,20 +92,18 @@ describe('UserActionTree ', () => { caseUserActions: ourActions, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeTruthy(); }); - - expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeTruthy(); }); it('Renders service now update line with top only when push is up to date', async () => { @@ -122,20 +122,17 @@ describe('UserActionTree ', () => { }, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); + const wrapper = mount( + + + + + + ); + expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeFalsy(); }); - - expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeFalsy(); }); it('Outlines comment when update move to link is clicked', async () => { @@ -145,89 +142,104 @@ describe('UserActionTree ', () => { caseUserActions: ourActions, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); - }); + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) + .first() + .hasClass('outlined') + ).toBeFalsy(); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(''); - wrapper - .find(`[data-test-subj="comment-update-action"] [data-test-subj="move-to-link"]`) - .first() - .simulate('click'); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(ourActions[0].commentId); + wrapper + .find( + `[data-test-subj="comment-update-action-${ourActions[1].actionId}"] [data-test-subj="move-to-link-${props.data.comments[0].id}"]` + ) + .first() + .simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) + .first() + .hasClass('outlined') + ).toBeTruthy(); + }); + }); }); it('Switches to markdown when edit is clicked and back to panel when canceled', async () => { - const ourActions = [getUserAction(['comment'], 'create')]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - - const wrapper = mount( - - - - - - ); - - await act(async () => { - wrapper.update(); - }); + await waitFor(() => { + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` ) - .exists() - ).toEqual(false); + .first() + .simulate('click'); - wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`) - .first() - .simulate('click'); - - wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`) - .first() - .simulate('click'); + wrapper.update(); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` ) - .exists() - ).toEqual(true); + .first() + .simulate('click'); - wrapper - .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` - ) - .first() - .simulate('click'); + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(true); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` ) - .exists() - ).toEqual(false); + .first() + .simulate('click'); + + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + }); }); it('calls update comment when comment markdown is saved', async () => { @@ -236,6 +248,7 @@ describe('UserActionTree ', () => { ...defaultProps, caseUserActions: ourActions, }; + const wrapper = mount( @@ -243,27 +256,35 @@ describe('UserActionTree ', () => { ); + wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`) + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` + ) .first() .simulate('click'); + wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`) + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` + ) .first() .simulate('click'); + wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]` ) .first() .simulate('click'); + await act(async () => { await waitFor(() => { wrapper.update(); expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` ) .exists() ).toEqual(false); @@ -288,93 +309,101 @@ describe('UserActionTree ', () => {
); + wrapper .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) .first() .simulate('click'); + wrapper .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-pencil"]`) .first() .simulate('click'); - wrapper - .find( - `[data-test-subj="user-action-description"] [data-test-subj="user-action-save-markdown"]` - ) - .first() - .simulate('click'); + await act(async () => { - await waitFor(() => { - expect( - wrapper - .find( - `[data-test-subj="user-action-${props.data.id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(false); - expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); - }); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-save-markdown"]`) + .first() + .simulate('click'); }); + + wrapper.update(); + + expect( + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown-form"]`) + .exists() + ).toEqual(false); + + expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); }); it('quotes', async () => { - const commentData = { - comment: '', - }; - const formHookMock = getFormMock(commentData); - const setFieldValue = jest.fn(); - useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } })); - const props = defaultProps; - const wrapper = mount( - - - - - - ); - await act(async () => { + const commentData = { + comment: '', + }; + const setFieldValue = jest.fn(); + + const formHookMock = getFormMock(commentData); + useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } })); + + const props = defaultProps; + const wrapper = mount( + + + + + + ); + + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) + .first() + .simulate('click'); + await waitFor(() => { - wrapper - .find( - `[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]` - ) - .first() - .simulate('click'); wrapper.update(); }); - }); - wrapper - .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) - .first() - .simulate('click'); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) + .first() + .simulate('click'); - expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); + expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); + }); }); it('Outlines comment when url param is provided', async () => { - const commentId = 'neat-comment-id'; - const ourActions = [getUserAction(['comment'], 'create')]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - + const commentId = 'basic-comment-id'; jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId }); - const wrapper = mount( - - - - - - ); await act(async () => { - wrapper.update(); - }); + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + + const wrapper = mount( + + + + + + ); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(commentId); + await waitFor(() => { + wrapper.update(); + }); + + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${commentId}"]`) + .first() + .hasClass('outlined') + ).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index d1263ab13f41b..bada15294de09 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -3,25 +3,38 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import classNames from 'classnames'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiCommentList, + EuiCommentProps, +} from '@elastic/eui'; import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; -import * as i18n from '../case_view/translations'; +import * as i18n from './translations'; import { Case, CaseUserActions } from '../../containers/types'; import { useUpdateComment } from '../../containers/use_update_comment'; import { useCurrentUser } from '../../../common/lib/kibana'; import { AddComment, AddCommentRefObject } from '../add_comment'; -import { getLabelTitle } from './helpers'; -import { UserActionItem } from './user_action_item'; -import { UserActionMarkdown } from './user_action_markdown'; import { Connector } from '../../../../../case/common/api/cases'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; import { OnUpdateFields } from '../case_view'; +import { getLabelTitle, getPushInfo } from './helpers'; +import { UserActionAvatar } from './user_action_avatar'; +import { UserActionMarkdown } from './user_action_markdown'; +import { UserActionTimestamp } from './user_action_timestamp'; +import { UserActionCopyLink } from './user_action_copy_link'; +import { UserActionMoveToReference } from './user_action_move_to_reference'; +import { UserActionUsername } from './user_action_username'; +import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; +import { UserActionContentToolbar } from './user_action_content_toolbar'; export interface UserActionTreeProps { caseServices: CaseServices; @@ -40,6 +53,31 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` margin-bottom: 8px; `; +const MyEuiCommentList = styled(EuiCommentList)` + ${({ theme }) => ` + & .userAction__comment.outlined .euiCommentEvent { + outline: solid 5px ${theme.eui.euiColorVis1_behindText}; + margin: 0.5em; + transition: 0.8s; + } + + & .euiComment.isEdit { + & .euiCommentEvent { + border: none; + box-shadow: none; + } + + & .euiCommentEvent__body { + padding: 0; + } + + & .euiCommentEvent__header { + display: none; + } + } + `} +`; + const DESCRIPTION_ID = 'description'; const NEW_ID = 'newComment'; @@ -86,8 +124,7 @@ export const UserActionTree = React.memo( updateCase, }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [caseData, handleManageMarkdownEditId, patchComment, updateCase] + [caseData.id, fetchUserActions, patchComment, updateCase] ); const handleOutlineComment = useCallback( @@ -172,117 +209,246 @@ export const UserActionTree = React.memo( } } }, [commentId, initLoading, isLoadingUserActions, isLoadingIds, handleOutlineComment]); - return ( - <> - {i18n.ADDED_DESCRIPTION}} - fullName={caseData.createdBy.fullName ?? caseData.createdBy.username ?? ''} - markdown={MarkdownDescription} - onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} - onQuote={handleManageQuote.bind(null, caseData.description)} - username={caseData.createdBy.username ?? i18n.UNKNOWN} - /> - {caseUserActions.map((action, index) => { - if (action.commentId != null && action.action === 'create') { - const comment = caseData.comments.find((c) => c.id === action.commentId); - if (comment != null) { - return ( - {i18n.ADDED_COMMENT}} - fullName={comment.createdBy.fullName ?? comment.createdBy.username ?? ''} - markdown={ - - } - onEdit={handleManageMarkdownEditId.bind(null, comment.id)} - onQuote={handleManageQuote.bind(null, comment.comment)} - outlineComment={handleOutlineComment} - username={comment.createdBy.username ?? ''} - updatedAt={comment.updatedAt} - /> + const descriptionCommentListObj: EuiCommentProps = useMemo( + () => ({ + username: ( + + ), + event: i18n.ADDED_DESCRIPTION, + 'data-test-subj': 'description-action', + timestamp: , + children: MarkdownDescription, + timelineIcon: ( + + ), + className: classNames({ + isEdit: manageMarkdownEditIds.includes(DESCRIPTION_ID), + }), + actions: ( + + ), + }), + [ + MarkdownDescription, + caseData, + handleManageMarkdownEditId, + handleManageQuote, + isLoadingDescription, + userCanCrud, + manageMarkdownEditIds, + ] + ); + + const userActions: EuiCommentProps[] = useMemo( + () => + caseUserActions.reduce( + (comments, action, index) => { + if (action.commentId != null && action.action === 'create') { + const comment = caseData.comments.find((c) => c.id === action.commentId); + if (comment != null) { + return [ + ...comments, + { + username: ( + + ), + 'data-test-subj': `comment-create-action-${comment.id}`, + timestamp: ( + + ), + className: classNames('userAction__comment', { + outlined: comment.id === selectedOutlineCommentId, + isEdit: manageMarkdownEditIds.includes(comment.id), + }), + children: ( + + ), + timelineIcon: ( + + ), + actions: ( + + ), + }, + ]; + } + } + + if (action.actionField.length === 1) { + const myField = action.actionField[0]; + const parsedValue = parseString(`${action.newValue}`); + const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo( + caseServices, + parsedValue, + index ); + + const labelTitle: string | JSX.Element = getLabelTitle({ + action, + field: myField, + firstPush, + connectors, + }); + + const showTopFooter = + action.action === 'push-to-service' && + index === caseServices[parsedConnectorId].lastPushIndex; + + const showBottomFooter = + action.action === 'push-to-service' && + index === caseServices[parsedConnectorId].lastPushIndex && + caseServices[parsedConnectorId].hasDataToPush; + + let footers: EuiCommentProps[] = []; + + if (showTopFooter) { + footers = [ + ...footers, + { + username: '', + type: 'update', + event: i18n.ALREADY_PUSHED_TO_SERVICE(`${parsedConnectorName}`), + timelineIcon: 'sortUp', + 'data-test-subj': 'top-footer', + }, + ]; + } + + if (showBottomFooter) { + footers = [ + ...footers, + { + username: '', + type: 'update', + event: i18n.REQUIRED_UPDATE_TO_SERVICE(`${parsedConnectorName}`), + timelineIcon: 'sortDown', + 'data-test-subj': 'bottom-footer', + }, + ]; + } + + return [ + ...comments, + { + username: ( + + ), + type: 'update', + event: labelTitle, + 'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`, + timestamp: , + timelineIcon: + action.action === 'add' || action.action === 'delete' ? 'tag' : 'dot', + actions: ( + + + + + {action.action === 'update' && action.commentId != null && ( + + + + )} + + ), + }, + ...footers, + ]; } - } - if (action.actionField.length === 1) { - const myField = action.actionField[0]; - const parsedValue = parseString(`${action.newValue}`); - const { firstPush, parsedConnectorId, parsedConnectorName } = - parsedValue != null - ? { - firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, - parsedConnectorId: parsedValue.connector_id, - parsedConnectorName: parsedValue.connector_name, - } - : { - firstPush: false, - parsedConnectorId: 'none', - parsedConnectorName: 'none', - }; - const labelTitle: string | JSX.Element = getLabelTitle({ - action, - field: myField, - firstPush, - connectors, - }); - - return ( - {labelTitle}} - linkId={ - action.action === 'update' && action.commentId != null ? action.commentId : null - } - fullName={action.actionBy.fullName ?? action.actionBy.username ?? ''} - outlineComment={handleOutlineComment} - showTopFooter={ - action.action === 'push-to-service' && - index === caseServices[parsedConnectorId].lastPushIndex - } - showBottomFooter={ - action.action === 'push-to-service' && - index === caseServices[parsedConnectorId].lastPushIndex && - caseServices[parsedConnectorId].hasDataToPush - } - username={action.actionBy.username ?? ''} - /> - ); - } - return null; - })} + + return comments; + }, + [descriptionCommentListObj] + ), + [ + caseData, + caseServices, + caseUserActions, + connectors, + handleOutlineComment, + descriptionCommentListObj, + handleManageMarkdownEditId, + handleManageQuote, + handleSaveComment, + isLoadingIds, + manageMarkdownEditIds, + selectedOutlineCommentId, + userCanCrud, + ] + ); + + const bottomActions = [ + { + username: ( + + ), + 'data-test-subj': 'add-comment', + timelineIcon: ( + + ), + className: 'isEdit', + children: MarkdownNewComment, + }, + ]; + + const comments = [...userActions, ...bottomActions]; + + return ( + <> + {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( @@ -290,17 +456,6 @@ export const UserActionTree = React.memo( )} - ); } diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx new file mode 100644 index 0000000000000..df5c51394b88a --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionAvatar } from './user_action_avatar'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionAvatar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('E'); + }); + + it('it shows the username if the fullName is undefined', async () => { + wrapper = mount(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('e'); + }); + + it('shows the loading spinner when the username AND the fullName are undefined', async () => { + wrapper = mount(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeFalsy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx index f3276bd50e72c..8339d9bedd123 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx @@ -4,15 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiAvatar } from '@elastic/eui'; -import React from 'react'; +import React, { memo } from 'react'; +import { EuiAvatar, EuiLoadingSpinner } from '@elastic/eui'; interface UserActionAvatarProps { - name: string; + username?: string | null; + fullName?: string | null; } -export const UserActionAvatar = ({ name }: UserActionAvatarProps) => { +const UserActionAvatarComponent = ({ username, fullName }: UserActionAvatarProps) => { + const avatarName = fullName && fullName.length > 0 ? fullName : username ?? null; + return ( - + <> + {avatarName ? ( + + ) : ( + + )} + ); }; + +export const UserActionAvatar = memo(UserActionAvatarComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx new file mode 100644 index 0000000000000..1f4c858e9581e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionContentToolbar } from './user_action_content_toolbar'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn().mockReturnValue({ detailName: 'case-1' }), + }; +}); + +jest.mock('../../../common/components/navigation/use_get_url_search'); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: () => ({ + services: { + application: { + getUrlForApp: jest.fn(), + }, + }, + }), + }; +}); + +const props = { + id: '1', + editLabel: 'edit', + quoteLabel: 'quote', + disabled: false, + isLoading: false, + onEdit: jest.fn(), + onQuote: jest.fn(), +}; + +describe('UserActionContentToolbar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx new file mode 100644 index 0000000000000..89239c9e8392c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { UserActionCopyLink } from './user_action_copy_link'; +import { UserActionPropertyActions } from './user_action_property_actions'; + +interface UserActionContentToolbarProps { + id: string; + editLabel: string; + quoteLabel: string; + disabled: boolean; + isLoading: boolean; + onEdit: (id: string) => void; + onQuote: (id: string) => void; +} + +const UserActionContentToolbarComponent = ({ + id, + editLabel, + quoteLabel, + disabled, + isLoading, + onEdit, + onQuote, +}: UserActionContentToolbarProps) => { + return ( + + + + + + + + + ); +}; + +export const UserActionContentToolbar = memo(UserActionContentToolbarComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx new file mode 100644 index 0000000000000..0566281dac130 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { useParams } from 'react-router-dom'; +import copy from 'copy-to-clipboard'; + +import { TestProviders } from '../../../common/mock'; +import { UserActionCopyLink } from './user_action_copy_link'; +import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; + +const searchURL = + '?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn(), + }; +}); + +jest.mock('copy-to-clipboard', () => { + return jest.fn(); +}); + +jest.mock('../../../common/components/navigation/use_get_url_search'); + +const mockGetUrlForApp = jest.fn( + (appId: string, options?: { path?: string; absolute?: boolean }) => + `${appId}${options?.path ?? ''}` +); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: () => ({ + services: { + application: { + getUrlForApp: mockGetUrlForApp, + }, + }, + }), + }; +}); + +const props = { + id: 'comment-id', +}; + +describe('UserActionCopyLink ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ detailName: 'case-1' }); + (useGetUrlSearch as jest.Mock).mockReturnValue(searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().exists()).toBeTruthy(); + }); + + it('calls copy clipboard correctly', async () => { + wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().simulate('click'); + expect(copy).toHaveBeenCalledWith( + 'securitySolution:case/case-1/comment-id?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx new file mode 100644 index 0000000000000..98de2ab3288a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { useParams } from 'react-router-dom'; +import copy from 'copy-to-clipboard'; + +import { useFormatUrl, getCaseDetailsUrlWithCommentId } from '../../../common/components/link_to'; +import { SecurityPageName } from '../../../app/types'; +import * as i18n from './translations'; + +interface UserActionCopyLinkProps { + id: string; +} + +const UserActionCopyLinkComponent = ({ id }: UserActionCopyLinkProps) => { + const { detailName: caseId } = useParams<{ detailName: string }>(); + const { formatUrl } = useFormatUrl(SecurityPageName.case); + + const handleAnchorLink = useCallback(() => { + copy( + formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId: id }), { absolute: true }) + ); + }, [caseId, formatUrl, id]); + + return ( + {i18n.COPY_REFERENCE_LINK}

}> + +
+ ); +}; + +export const UserActionCopyLink = memo(UserActionCopyLinkComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx deleted file mode 100644 index eeb728aa7d1df..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiPanel, - EuiHorizontalRule, - EuiText, -} from '@elastic/eui'; -import React from 'react'; -import styled, { css } from 'styled-components'; - -import { UserActionAvatar } from './user_action_avatar'; -import { UserActionTitle } from './user_action_title'; -import * as i18n from './translations'; - -interface UserActionItemProps { - caseConnectorName?: string; - createdAt: string; - 'data-test-subj'?: string; - disabled: boolean; - id: string; - isEditable: boolean; - isLoading: boolean; - labelEditAction?: string; - labelQuoteAction?: string; - labelTitle?: JSX.Element; - linkId?: string | null; - fullName?: string | null; - markdown?: React.ReactNode; - onEdit?: (id: string) => void; - onQuote?: (id: string) => void; - username: string; - updatedAt?: string | null; - outlineComment?: (id: string) => void; - showBottomFooter?: boolean; - showTopFooter?: boolean; - idToOutline?: string | null; -} - -export const UserActionItemContainer = styled(EuiFlexGroup)` - ${({ theme }) => css` - & { - background-image: linear-gradient( - to right, - transparent 0, - transparent 15px, - ${theme.eui.euiBorderColor} 15px, - ${theme.eui.euiBorderColor} 17px, - transparent 17px, - transparent 100% - ); - background-repeat: no-repeat; - background-position: left ${theme.eui.euiSizeXXL}; - margin-bottom: ${theme.eui.euiSizeS}; - } - .userAction__panel { - margin-bottom: ${theme.eui.euiSize}; - } - .userAction__circle { - flex-shrink: 0; - margin-right: ${theme.eui.euiSize}; - vertical-align: top; - } - .userAction_loadingAvatar { - position: relative; - margin-right: ${theme.eui.euiSizeXL}; - top: ${theme.eui.euiSizeM}; - left: ${theme.eui.euiSizeS}; - } - .userAction__title { - padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; - background: ${theme.eui.euiColorLightestShade}; - border-bottom: ${theme.eui.euiBorderThin}; - border-radius: ${theme.eui.euiBorderRadius} ${theme.eui.euiBorderRadius} 0 0; - } - .euiText--small * { - margin-bottom: 0; - } - `} -`; - -const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` - flex-grow: 0; - ${({ theme, showoutline }) => - showoutline === 'true' - ? ` - outline: solid 5px ${theme.eui.euiColorVis1_behindText}; - margin: 0.5em; - transition: 0.8s; - ` - : ''} -`; - -const PushedContainer = styled(EuiFlexItem)` - ${({ theme }) => ` - margin-top: ${theme.eui.euiSizeS}; - margin-bottom: ${theme.eui.euiSizeXL}; - hr { - margin: 5px; - height: ${theme.eui.euiBorderWidthThick}; - } - `} -`; - -const PushedInfoContainer = styled.div` - margin-left: 48px; -`; - -export const UserActionItem = ({ - caseConnectorName, - createdAt, - disabled, - 'data-test-subj': dataTestSubj, - id, - idToOutline, - isEditable, - isLoading, - labelEditAction, - labelQuoteAction, - labelTitle, - linkId, - fullName, - markdown, - onEdit, - onQuote, - outlineComment, - showBottomFooter, - showTopFooter, - username, - updatedAt, -}: UserActionItemProps) => ( - - - - - {(fullName && fullName.length > 0) || (username && username.length > 0) ? ( - 0 ? fullName : username ?? ''} /> - ) : ( - - )} - - - {isEditable && markdown} - {!isEditable && ( - - } - linkId={linkId} - onEdit={onEdit} - onQuote={onQuote} - outlineComment={outlineComment} - updatedAt={updatedAt} - username={username} - /> - {markdown} - - )} - - - - {showTopFooter && ( - - - - {i18n.ALREADY_PUSHED_TO_SERVICE(`${caseConnectorName}`)} - - - - {showBottomFooter && ( - - - {i18n.REQUIRED_UPDATE_TO_SERVICE(`${caseConnectorName}`)} - - - )} - - )} - -); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx index 6cf827ea55f1f..f1f7d40009045 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx @@ -17,8 +17,9 @@ const onChangeEditable = jest.fn(); const onSaveContent = jest.fn(); const timelineId = '1e10f150-949b-11ea-b63c-2bc51864784c'; +const timelineMarkdown = `[timeline](http://localhost:5601/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t))`; const defaultProps = { - content: `A link to a timeline [timeline](http://localhost:5601/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t))`, + content: `A link to a timeline ${timelineMarkdown}`, id: 'markdown-id', isEditable: false, onChangeEditable, @@ -40,7 +41,11 @@ describe('UserActionMarkdown ', () => {
); - wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click'); + + wrapper + .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`) + .first() + .simulate('click'); expect(queryTimelineByIdSpy).toBeCalledWith({ apolloClient: mockUseApolloClient(), @@ -59,8 +64,19 @@ describe('UserActionMarkdown ', () => { ); - wrapper.find(`[data-test-subj="preview-tab"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click'); + + // Preview button of Markdown editor + wrapper + .find( + `[data-test-subj="user-action-markdown-form"] .euiMarkdownEditorToolbar .euiButtonEmpty` + ) + .first() + .simulate('click'); + + wrapper + .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`) + .first() + .simulate('click'); expect(queryTimelineByIdSpy).toBeCalledWith({ apolloClient: mockUseApolloClient(), graphEventId: '', diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx index ac2ad179ec60c..45e46b2d7d2db 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx @@ -4,18 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiMarkdownFormat, +} from '@elastic/eui'; import React, { useCallback } from 'react'; import styled from 'styled-components'; import * as i18n from '../case_view/translations'; -import { Markdown } from '../../../common/components/markdown'; -import { Form, useForm, UseField, useFormData } from '../../../shared_imports'; +import { Form, useForm, UseField } from '../../../shared_imports'; import { schema, Content } from './schema'; -import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; -import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; -import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { + MarkdownEditorForm, + parsingPlugins, + processingPlugins, +} from '../../../common/components/markdown_editor/eui_form'; const ContentWrapper = styled.div` padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; @@ -43,24 +49,12 @@ export const UserActionMarkdown = ({ }); const fieldName = 'content'; - const { submit, setFieldValue } = form; - const [{ content: contentFormValue }] = useFormData({ form, watch: [fieldName] }); - - const onContentChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [ - setFieldValue, - ]); - - const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( - contentFormValue, - onContentChange - ); + const { submit } = form; const handleCancelAction = useCallback(() => { onChangeEditable(id); }, [id, onChangeEditable]); - const handleTimelineClick = useTimelineClick(); - const handleSaveAction = useCallback(async () => { const { isValid, data } = await submit(); if (isValid) { @@ -105,29 +99,24 @@ export const UserActionMarkdown = ({ path={fieldName} component={MarkdownEditorForm} componentProps={{ + 'aria-label': 'Cases markdown editor', + value: content, + id, bottomRightContent: renderButtons({ cancelAction: handleCancelAction, saveAction: handleSaveAction, }), - onClickTimeline: handleTimelineClick, - onCursorPositionUpdate: handleCursorChange, - topRightContent: ( - - ), }} /> ) : ( - - + + + {content} + ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx new file mode 100644 index 0000000000000..5bb0f50ce25e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionMoveToReference } from './user_action_move_to_reference'; + +const outlineComment = jest.fn(); +const props = { + id: 'move-to-ref-id', + outlineComment, +}; + +describe('UserActionMoveToReference ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find(`[data-test-subj="move-to-link-${props.id}"]`).first().exists() + ).toBeTruthy(); + }); + + it('calls outlineComment correctly', async () => { + wrapper.find(`[data-test-subj="move-to-link-${props.id}"]`).first().simulate('click'); + expect(outlineComment).toHaveBeenCalledWith(props.id); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx new file mode 100644 index 0000000000000..39d016dd69520 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; + +import * as i18n from './translations'; + +interface UserActionMoveToReferenceProps { + id: string; + outlineComment: (id: string) => void; +} + +const UserActionMoveToReferenceComponent = ({ + id, + outlineComment, +}: UserActionMoveToReferenceProps) => { + const handleMoveToLink = useCallback(() => { + outlineComment(id); + }, [id, outlineComment]); + + return ( + {i18n.MOVE_TO_ORIGINAL_COMMENT}

}> + +
+ ); +}; + +export const UserActionMoveToReference = memo(UserActionMoveToReferenceComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx new file mode 100644 index 0000000000000..bd5da8aca7d4f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionPropertyActions } from './user_action_property_actions'; + +const props = { + id: 'property-actions-id', + editLabel: 'edit', + quoteLabel: 'quote', + disabled: false, + isLoading: false, + onEdit: jest.fn(), + onQuote: jest.fn(), +}; + +describe('UserActionPropertyActions ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists() + ).toBeFalsy(); + + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeTruthy(); + }); + + it('it shows the edit and quote buttons', async () => { + wrapper.find('[data-test-subj="property-actions-ellipses"]').first().simulate('click'); + wrapper.find('[data-test-subj="property-actions-pencil"]').exists(); + wrapper.find('[data-test-subj="property-actions-quote"]').exists(); + }); + + it('it shows the spinner when loading', async () => { + wrapper = mount(); + expect( + wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists() + ).toBeTruthy(); + + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx new file mode 100644 index 0000000000000..454880e93a27f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useMemo, useCallback } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +import { PropertyActions } from '../property_actions'; + +interface UserActionPropertyActionsProps { + id: string; + editLabel: string; + quoteLabel: string; + disabled: boolean; + isLoading: boolean; + onEdit: (id: string) => void; + onQuote: (id: string) => void; +} + +const UserActionPropertyActionsComponent = ({ + id, + editLabel, + quoteLabel, + disabled, + isLoading, + onEdit, + onQuote, +}: UserActionPropertyActionsProps) => { + const onEditClick = useCallback(() => onEdit(id), [id, onEdit]); + const onQuoteClick = useCallback(() => onQuote(id), [id, onQuote]); + + const propertyActions = useMemo(() => { + return [ + { + disabled, + iconType: 'pencil', + label: editLabel, + onClick: onEditClick, + }, + { + disabled, + iconType: 'quote', + label: quoteLabel, + onClick: onQuoteClick, + }, + ]; + }, [disabled, editLabel, quoteLabel, onEditClick, onQuoteClick]); + return ( + <> + {isLoading && } + {!isLoading && } + + ); +}; + +export const UserActionPropertyActions = memo(UserActionPropertyActionsComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx new file mode 100644 index 0000000000000..a65806520c854 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { TestProviders } from '../../../common/mock'; +import { UserActionTimestamp } from './user_action_timestamp'; + +jest.mock('@kbn/i18n/react', () => { + const originalModule = jest.requireActual('@kbn/i18n/react'); + const FormattedRelative = jest.fn(); + FormattedRelative.mockImplementationOnce(() => '2 days ago'); + FormattedRelative.mockImplementation(() => '20 hours ago'); + + return { + ...originalModule, + FormattedRelative, + }; +}); + +const props = { + createdAt: '2020-09-06T14:40:59.889Z', + updatedAt: '2020-09-07T14:40:59.889Z', +}; + +describe('UserActionTimestamp ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-title-creation-relative-time"]').first().exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="user-action-title-edited-relative-time"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows only the created time when the updated time is missing', async () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect( + newWrapper + .find('[data-test-subj="user-action-title-creation-relative-time"]') + .first() + .exists() + ).toBeTruthy(); + expect( + newWrapper.find('[data-test-subj="user-action-title-edited-relative-time"]').first().exists() + ).toBeFalsy(); + }); + + it('it shows the timestamp correctly', async () => { + const createdText = wrapper + .find('[data-test-subj="user-action-title-creation-relative-time"]') + .first() + .text(); + + const updatedText = wrapper + .find('[data-test-subj="user-action-title-edited-relative-time"]') + .first() + .text(); + + expect(`${createdText} (${updatedText})`).toBe('2 days ago (20 hours ago)'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx new file mode 100644 index 0000000000000..72dc5de9cdb3b --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiTextColor } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; + +import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip'; +import * as i18n from './translations'; + +interface UserActionAvatarProps { + createdAt: string; + updatedAt?: string | null; +} + +const UserActionTimestampComponent = ({ createdAt, updatedAt }: UserActionAvatarProps) => { + return ( + <> + + + + {updatedAt && ( + + {/* be careful of the extra space at the beginning of the parenthesis */} + {' ('} + {i18n.EDITED_FIELD}{' '} + + + + {')'} + + )} + + ); +}; + +export const UserActionTimestamp = memo(UserActionTimestampComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx deleted file mode 100644 index 0bb02ce69a544..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { mount } from 'enzyme'; -import copy from 'copy-to-clipboard'; -import { Router, routeData, mockHistory } from '../__mock__/router'; -import { caseUserActions as basicUserActions } from '../../containers/mock'; -import { UserActionTitle } from './user_action_title'; -import { TestProviders } from '../../../common/mock'; - -const outlineComment = jest.fn(); -const onEdit = jest.fn(); -const onQuote = jest.fn(); - -jest.mock('copy-to-clipboard'); -const defaultProps = { - createdAt: basicUserActions[0].actionAt, - disabled: false, - fullName: basicUserActions[0].actionBy.fullName, - id: basicUserActions[0].actionId, - isLoading: false, - labelEditAction: 'labelEditAction', - labelQuoteAction: 'labelQuoteAction', - labelTitle: <>{'cool'}, - linkId: basicUserActions[0].commentId, - onEdit, - onQuote, - outlineComment, - updatedAt: basicUserActions[0].actionAt, - username: basicUserActions[0].actionBy.username, -}; - -describe('UserActionTitle ', () => { - beforeEach(() => { - jest.resetAllMocks(); - jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId: '123' }); - }); - - it('Calls copy when copy link is clicked', async () => { - const wrapper = mount( - - - - - - ); - wrapper.find(`[data-test-subj="copy-link"]`).first().simulate('click'); - expect(copy).toBeCalledTimes(1); - }); -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx deleted file mode 100644 index 9477299e563a8..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiButtonIcon, - EuiToolTip, -} from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n/react'; -import copy from 'copy-to-clipboard'; -import { isEmpty } from 'lodash/fp'; -import React, { useMemo, useCallback } from 'react'; -import styled from 'styled-components'; -import { useParams } from 'react-router-dom'; - -import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip'; -import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; -import { navTabs } from '../../../app/home/home_navigations'; -import { PropertyActions } from '../property_actions'; -import { SecurityPageName } from '../../../app/types'; -import * as i18n from './translations'; - -const MySpinner = styled(EuiLoadingSpinner)` - .euiLoadingSpinner { - margin-top: 1px; // yes it matters! - } -`; - -interface UserActionTitleProps { - createdAt: string; - disabled: boolean; - id: string; - isLoading: boolean; - labelEditAction?: string; - labelQuoteAction?: string; - labelTitle: JSX.Element; - linkId?: string | null; - fullName?: string | null; - updatedAt?: string | null; - username?: string | null; - onEdit?: (id: string) => void; - onQuote?: (id: string) => void; - outlineComment?: (id: string) => void; -} - -export const UserActionTitle = ({ - createdAt, - disabled, - fullName, - id, - isLoading, - labelEditAction, - labelQuoteAction, - labelTitle, - linkId, - onEdit, - onQuote, - outlineComment, - updatedAt, - username = i18n.UNKNOWN, -}: UserActionTitleProps) => { - const { detailName: caseId } = useParams<{ detailName: string }>(); - const urlSearch = useGetUrlSearch(navTabs.case); - const propertyActions = useMemo(() => { - return [ - ...(labelEditAction != null && onEdit != null - ? [ - { - disabled, - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ] - : []), - ...(labelQuoteAction != null && onQuote != null - ? [ - { - disabled, - iconType: 'quote', - label: labelQuoteAction, - onClick: () => onQuote(id), - }, - ] - : []), - ]; - }, [disabled, id, labelEditAction, onEdit, labelQuoteAction, onQuote]); - - const handleAnchorLink = useCallback(() => { - copy( - `${window.location.origin}${window.location.pathname}#${SecurityPageName.case}/${caseId}/${id}${urlSearch}` - ); - }, [caseId, id, urlSearch]); - - const handleMoveToLink = useCallback(() => { - if (outlineComment != null && linkId != null) { - outlineComment(linkId); - } - }, [linkId, outlineComment]); - return ( - - - - - - {fullName ?? username}

}> - {username} -
-
- {labelTitle} - - - - - - {updatedAt != null && ( - - - {'('} - {i18n.EDITED_FIELD}{' '} - - - - {')'} - - - )} -
-
- - - {!isEmpty(linkId) && ( - - {i18n.MOVE_TO_ORIGINAL_COMMENT}

}> - -
-
- )} - - {i18n.COPY_REFERENCE_LINK}

}> - -
-
- {propertyActions.length > 0 && ( - - {isLoading && } - {!isLoading && } - - )} -
-
-
-
- ); -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx new file mode 100644 index 0000000000000..008eb18aef074 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionUsername } from './user_action_username'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionUsername ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-username-tooltip"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows the username', async () => { + expect(wrapper.find('[data-test-subj="user-action-username-tooltip"]').text()).toBe('elastic'); + }); + + test('it shows the fullname when hovering the username', () => { + // Use fake timers so we don't have to wait for the EuiToolTip timeout + jest.useFakeTimers(); + + wrapper.find('[data-test-subj="user-action-username-tooltip"]').first().simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + wrapper.update(); + expect(wrapper.find('.euiToolTipPopover').text()).toBe('Elastic'); + + // Clearing all mocks will also reset fake timers. + jest.clearAllMocks(); + }); + + test('it shows the username when hovering the username and the fullname is missing', () => { + // Use fake timers so we don't have to wait for the EuiToolTip timeout + jest.useFakeTimers(); + + const newWrapper = mount(); + newWrapper + .find('[data-test-subj="user-action-username-tooltip"]') + .first() + .simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + newWrapper.update(); + expect(newWrapper.find('.euiToolTipPopover').text()).toBe('elastic'); + + // Clearing all mocks will also reset fake timers. + jest.clearAllMocks(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx new file mode 100644 index 0000000000000..dbc153ddbe577 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiToolTip } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + +interface UserActionUsernameProps { + username: string; + fullName?: string; +} + +const UserActionUsernameComponent = ({ username, fullName }: UserActionUsernameProps) => { + return ( + {isEmpty(fullName) ? username : fullName}

} + data-test-subj="user-action-username-tooltip" + > + {username} +
+ ); +}; + +export const UserActionUsername = memo(UserActionUsernameComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx new file mode 100644 index 0000000000000..f8403738c24ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionUsernameWithAvatar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-username-with-avatar"]').first().exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="user-action-username-avatar"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows the avatar', async () => { + expect(wrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe('E'); + }); + + it('it shows the avatar without fullName', async () => { + const newWrapper = mount(); + expect(newWrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe( + 'e' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx new file mode 100644 index 0000000000000..e2326a3580e6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiAvatar } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + +import { UserActionUsername } from './user_action_username'; + +interface UserActionUsernameWithAvatarProps { + username: string; + fullName?: string; +} + +const UserActionUsernameWithAvatarComponent = ({ + username, + fullName, +}: UserActionUsernameWithAvatarProps) => { + return ( + + + + + + + + + ); +}; + +export const UserActionUsernameWithAvatar = memo(UserActionUsernameWithAvatarComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts index 403c8d838fa44..89fcc67bcd15f 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts @@ -16,25 +16,40 @@ export { getDetectionEngineUrl } from './redirect_to_detection_engine'; export { getAppOverviewUrl } from './redirect_to_overview'; export { getHostDetailsUrl, getHostsUrl } from './redirect_to_hosts'; export { getNetworkUrl, getNetworkDetailsUrl } from './redirect_to_network'; -export { getTimelinesUrl, getTimelineTabsUrl } from './redirect_to_timelines'; +export { getTimelinesUrl, getTimelineTabsUrl, getTimelineUrl } from './redirect_to_timelines'; export { getCaseDetailsUrl, getCaseUrl, getCreateCaseUrl, getConfigureCasesUrl, + getCaseDetailsUrlWithCommentId, } from './redirect_to_case'; +interface FormatUrlOptions { + absolute: boolean; + skipSearch: boolean; +} + +type FormatUrl = (path: string, options?: Partial) => string; + export const useFormatUrl = (page: SecurityPageName) => { const { getUrlForApp } = useKibana().services.application; const search = useGetUrlSearch(navTabs[page]); - const formatUrl = useCallback( - (path: string) => { + const formatUrl = useCallback( + (path: string, { absolute = false, skipSearch = false } = {}) => { const pathArr = path.split('?'); const formattedPath = `${pathArr[0]}${ - isEmpty(pathArr[1]) ? search : `${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}` + !skipSearch + ? isEmpty(pathArr[1]) + ? search + : `?${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}` + : isEmpty(pathArr[1]) + ? '' + : `?${pathArr[1]}` }`; return getUrlForApp(`${APP_ID}:${page}`, { path: formattedPath, + absolute, }); }, [getUrlForApp, page, search] diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx index 7005460999fc7..3ef00635844f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx @@ -11,6 +11,17 @@ export const getCaseUrl = (search: string | null) => `${appendSearch(search ?? u export const getCaseDetailsUrl = ({ id, search }: { id: string; search?: string | null }) => `/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`; +export const getCaseDetailsUrlWithCommentId = ({ + id, + commentId, + search, +}: { + id: string; + commentId: string; + search?: string | null; +}) => + `/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}${appendSearch(search ?? undefined)}`; + export const getCreateCaseUrl = (search?: string | null) => `/create${appendSearch(search ?? undefined)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx index 75a2fa1efa414..58b9f940ceaa6 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import { TimelineTypeLiteral } from '../../../../common/types/timeline'; import { appendSearch } from './helpers'; @@ -11,3 +12,8 @@ export const getTimelinesUrl = (search?: string) => `${appendSearch(search)}`; export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral, search?: string) => `/${tabName}${appendSearch(search)}`; + +export const getTimelineUrl = (id: string, graphEventId?: string) => + `?timeline=(id:'${id}',isOpen:!t${ + isEmpty(graphEventId) ? ')' : `,graphEventId:'${graphEventId}')` + }`; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx new file mode 100644 index 0000000000000..481ed7892a8be --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useCallback } from 'react'; +import styled from 'styled-components'; +import { + EuiMarkdownEditor, + EuiMarkdownEditorProps, + EuiFormRow, + EuiFlexItem, + EuiFlexGroup, + getDefaultEuiMarkdownParsingPlugins, + getDefaultEuiMarkdownProcessingPlugins, +} from '@elastic/eui'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; + +import * as timelineMarkdownPlugin from './plugins/timeline'; + +type MarkdownEditorFormProps = EuiMarkdownEditorProps & { + id: string; + field: FieldHook; + dataTestSubj: string; + idAria: string; + isDisabled?: boolean; + bottomRightContent?: React.ReactNode; +}; + +const BottomContentWrapper = styled(EuiFlexGroup)` + ${({ theme }) => ` + padding: ${theme.eui.ruleMargins.marginSmall} 0; + `} +`; + +export const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); +parsingPlugins.push(timelineMarkdownPlugin.parser); + +export const processingPlugins = getDefaultEuiMarkdownProcessingPlugins(); +processingPlugins[1][1].components.timeline = timelineMarkdownPlugin.renderer; + +export const MarkdownEditorForm: React.FC = ({ + id, + field, + dataTestSubj, + idAria, + bottomRightContent, +}) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + + return ( + + <> + + {bottomRightContent && ( + + {bottomRightContent} + + )} + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx deleted file mode 100644 index 2cc3fe05a2215..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFormRow } from '@elastic/eui'; -import React, { useCallback } from 'react'; - -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; -import { CursorPosition, MarkdownEditor } from '.'; - -interface IMarkdownEditorForm { - bottomRightContent?: React.ReactNode; - dataTestSubj: string; - field: FieldHook; - idAria: string; - isDisabled: boolean; - onClickTimeline?: (timelineId: string, graphEventId?: string) => void; - onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void; - placeholder?: string; - topRightContent?: React.ReactNode; -} -export const MarkdownEditorForm = ({ - bottomRightContent, - dataTestSubj, - field, - idAria, - isDisabled = false, - onClickTimeline, - onCursorPositionUpdate, - placeholder, - topRightContent, -}: IMarkdownEditorForm) => { - const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - - const handleContentChange = useCallback( - (newContent: string) => { - field.setValue(newContent); - }, - [field] - ); - - return ( - - - - ); -}; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx deleted file mode 100644 index b5e5b01189418..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount } from 'enzyme'; -import React from 'react'; - -import { MarkdownEditor } from '.'; -import { TestProviders } from '../../mock'; - -describe('Markdown Editor', () => { - const onChange = jest.fn(); - const onCursorPositionUpdate = jest.fn(); - const defaultProps = { - content: 'hello world', - onChange, - onCursorPositionUpdate, - }; - beforeEach(() => { - jest.clearAllMocks(); - }); - test('it calls onChange with correct value', () => { - const wrapper = mount( - - - - ); - const newValue = 'a new string'; - wrapper - .find(`[data-test-subj="textAreaInput"]`) - .first() - .simulate('change', { target: { value: newValue } }); - expect(onChange).toBeCalledWith(newValue); - }); - test('it calls onCursorPositionUpdate with correct args', () => { - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="textAreaInput"]`).first().simulate('blur'); - expect(onCursorPositionUpdate).toBeCalledWith({ - start: 0, - end: 0, - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx index d4ad4a11b60a3..9f4141dbcae7d 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx @@ -4,167 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiPanel, - EuiTabbedContent, - EuiTextArea, -} from '@elastic/eui'; -import React, { useMemo, useCallback, ChangeEvent } from 'react'; -import styled, { css } from 'styled-components'; - -import { Markdown } from '../markdown'; -import * as i18n from './translations'; -import { MARKDOWN_HELP_LINK } from './constants'; - -const TextArea = styled(EuiTextArea)` - width: 100%; -`; - -const Container = styled(EuiPanel)` - ${({ theme }) => css` - padding: 0; - background: ${theme.eui.euiColorLightestShade}; - position: relative; - .markdown-tabs-header { - position: absolute; - top: ${theme.eui.euiSizeS}; - right: ${theme.eui.euiSizeS}; - z-index: ${theme.eui.euiZContentMenu}; - } - .euiTab { - padding: 10px; - } - .markdown-tabs { - width: 100%; - } - .markdown-tabs-footer { - height: 41px; - padding: 0 ${theme.eui.euiSizeM}; - .euiLink { - font-size: ${theme.eui.euiSizeM}; - } - } - .euiFormRow__labelWrapper { - position: absolute; - top: -${theme.eui.euiSizeL}; - } - .euiFormErrorText { - padding: 0 ${theme.eui.euiSizeM}; - } - `} -`; - -const MarkdownContainer = styled(EuiPanel)` - min-height: 150px; - overflow: auto; -`; - -export interface CursorPosition { - start: number; - end: number; -} - -/** An input for entering a new case description */ -export const MarkdownEditor = React.memo<{ - bottomRightContent?: React.ReactNode; - topRightContent?: React.ReactNode; - content: string; - isDisabled?: boolean; - onChange: (description: string) => void; - onClickTimeline?: (timelineId: string, graphEventId?: string) => void; - onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void; - placeholder?: string; -}>( - ({ - bottomRightContent, - topRightContent, - content, - isDisabled = false, - onChange, - onClickTimeline, - placeholder, - onCursorPositionUpdate, - }) => { - const handleOnChange = useCallback( - (evt: ChangeEvent) => { - onChange(evt.target.value); - }, - [onChange] - ); - - const setCursorPosition = useCallback( - (e: React.ChangeEvent) => { - if (onCursorPositionUpdate) { - onCursorPositionUpdate({ - start: e!.target!.selectionStart ?? 0, - end: e!.target!.selectionEnd ?? 0, - }); - } - }, - [onCursorPositionUpdate] - ); - - const tabs = useMemo( - () => [ - { - id: 'comment', - name: i18n.MARKDOWN, - content: ( -