From 8c571888cf657763974880e4c47a569b4b0e936f Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 17 Sep 2020 17:24:27 -0400 Subject: [PATCH 1/9] Fix isInitialRequest (#76984) (#77809) --- .../public/request/use_request.test.helpers.tsx | 9 ++++++++- src/plugins/es_ui_shared/public/request/use_request.ts | 7 +++++-- 2 files changed, 13 insertions(+), 3 deletions(-) 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 7a42ed7fad4274..b175066b81c8ee 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 e04f84a67b8a3c..9d40291423cac2 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, From 962bc173772d7fe7f25843049084454635465de0 Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 17 Sep 2020 14:58:34 -0700 Subject: [PATCH 2/9] [Enterprise Search][tech debt] Add Kea logic paths for easier debugging/defaults (#77698) (#77820) * Add Kea logic paths for easier debugging/defaults * PR feedback: prefer snake_case to match file names, use exact file name for last path key * Document Kea + casing logic --- x-pack/plugins/enterprise_search/README.md | 8 ++++++++ .../public/applications/app_search/app_logic.ts | 1 + .../shared/flash_messages/flash_messages_logic.ts | 1 + .../public/applications/shared/http/http_logic.ts | 1 + .../public/applications/workplace_search/app_logic.ts | 1 + .../workplace_search/views/overview/overview_logic.ts | 1 + 6 files changed, 13 insertions(+) diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md index 31ee304fe22477..ba14be5564be17 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 3f71759390879a..9388d61041b13e 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 3ae48f352b2c18..37a8f16acad6df 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 5e2b5a9ed6b06b..72380142fe3998 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 f88a00f63f4873..94bd1d529b65ff 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 787d5295db1cf2..a156b8a8009f9f 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, From d7c65f94a05c0d17c857d59f065080a495bfb12b Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Thu, 17 Sep 2020 18:39:51 -0400 Subject: [PATCH 3/9] [7.x] [Security Solution] [Detections] Remove file validation on import route (#77770) (#77821) * utlize schema.any() for validation on file in body of import rules request, adds new functional tests and unit tests to make sure we can reach and don't go past bounds. These tests would have helped uncover performance issues io-ts gave us with validating the import rules file object * fix type check failure * updates getSimpleRule and getSimpleRuleAsNdjson to accept an enabled param defaulted to false * updates comments in e2e tests for import rules route * fix tests after adding enabled boolean in test utils --- .../routes/rules/import_rules_route.test.ts | 25 ++++++++ .../routes/rules/import_rules_route.ts | 10 +-- .../basic/tests/import_rules.ts | 62 +++++++++++++++++-- .../security_and_spaces/tests/import_rules.ts | 12 ++-- .../detection_engine_api_integration/utils.ts | 8 ++- 5 files changed, 99 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index 81c9387c6f39c0..a033c16cd5e997 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -55,6 +55,18 @@ describe('import_rules_route', () => { expect(response.status).toEqual(200); }); + test('returns 500 if more than 10,000 rules are imported', async () => { + const ruleIds = new Array(10001).fill(undefined).map((_, index) => `rule-${index}`); + const multiRequest = getImportRulesRequest(buildHapiStream(ruleIdsToNdJsonString(ruleIds))); + const response = await server.inject(multiRequest, context); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: "Can't import more than 10000 rules", + status_code: 500, + }); + }); + test('returns 404 if alertClient is not available on the route', async () => { context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(request, context); @@ -229,6 +241,19 @@ describe('import_rules_route', () => { }); }); + test('returns 200 if many rules are imported successfully', async () => { + const ruleIds = new Array(9999).fill(undefined).map((_, index) => `rule-${index}`); + const multiRequest = getImportRulesRequest(buildHapiStream(ruleIdsToNdJsonString(ruleIds))); + const response = await server.inject(multiRequest, context); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + errors: [], + success: true, + success_count: 9999, + }); + }); + test('returns 200 with errors if all rules are missing rule_ids and import fails on validation', async () => { const rulesWithoutRuleIds = ['rule-1', 'rule-2'].map((ruleId) => getImportRulesWithIdSchemaMock(ruleId) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 60bb8c79243d7c..0f44b50d4bc747 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -6,13 +6,12 @@ import { chunk } from 'lodash/fp'; import { extname } from 'path'; +import { schema } from '@kbn/config-schema'; import { validate } from '../../../../../common/validate'; import { importRulesQuerySchema, ImportRulesQuerySchemaDecoded, - importRulesPayloadSchema, - ImportRulesPayloadSchemaDecoded, ImportRulesSchemaDecoded, } from '../../../../../common/detection_engine/schemas/request/import_rules_schema'; import { @@ -48,7 +47,7 @@ import { PartialFilter } from '../../types'; type PromiseFromStreams = ImportRulesSchemaDecoded | Error; -const CHUNK_PARSED_OBJECT_SIZE = 10; +const CHUNK_PARSED_OBJECT_SIZE = 50; export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupPlugins['ml']) => { router.post( @@ -58,10 +57,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP query: buildRouteValidation( importRulesQuerySchema ), - body: buildRouteValidation< - typeof importRulesPayloadSchema, - ImportRulesPayloadSchemaDecoded - >(importRulesPayloadSchema), + body: schema.any(), // validation on file object is accomplished later in the handler. }, options: { tags: ['access:securitySolution'], diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts index 108ca365bc14ff..c6294cfe6ec28b 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts @@ -129,7 +129,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1'], true), 'rules.ndjson') .expect(200); const { body } = await supertest @@ -192,6 +192,56 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + // import is very slow in 7.10+ due to the alerts client find api + // when importing 100 rules it takes about 30 seconds for this + // test to complete so at 10 rules completing in about 10 seconds + // I figured this is enough to make sure the import route is doing its job. + it('should be able to import 10 rules', async () => { + const ruleIds = new Array(10).fill(undefined).map((_, index) => `rule-${index}`); + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(ruleIds, false), 'rules.ndjson') + .expect(200); + + expect(body).to.eql({ + errors: [], + success: true, + success_count: 10, + }); + }); + + // uncomment the below test once we speed up the alerts client find api + // in another PR. + // it('should be able to import 10000 rules', async () => { + // const ruleIds = new Array(10000).fill(undefined).map((_, index) => `rule-${index}`); + // const { body } = await supertest + // .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + // .set('kbn-xsrf', 'true') + // .attach('file', getSimpleRuleAsNdjson(ruleIds, false), 'rules.ndjson') + // .expect(200); + + // expect(body).to.eql({ + // errors: [], + // success: true, + // success_count: 10000, + // }); + // }); + + it('should NOT be able to import more than 10,000 rules', async () => { + const ruleIds = new Array(10001).fill(undefined).map((_, index) => `rule-${index}`); + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(ruleIds, false), 'rules.ndjson') + .expect(500); + + expect(body).to.eql({ + status_code: 500, + message: "Can't import more than 10000 rules", + }); + }); + it('should report a conflict if there is an attempt to import two rules with the same rule_id', async () => { const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) @@ -280,7 +330,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1'], true), 'rules.ndjson') .expect(200); const simpleRule = getSimpleRule('rule-1'); @@ -372,13 +422,17 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2'], true), 'rules.ndjson') .expect(200); await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') + .attach( + 'file', + getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3'], true), + 'rules.ndjson' + ) .expect(200); const { body: bodyOfRule1 } = await supertest diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts index e0b60ae1fbeebb..664077d5a4fab9 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts @@ -129,7 +129,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1'], true), 'rules.ndjson') .expect(200); const { body } = await supertest @@ -243,7 +243,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1'], true), 'rules.ndjson') .expect(200); const simpleRule = getSimpleRule('rule-1'); @@ -335,13 +335,17 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2'], true), 'rules.ndjson') .expect(200); await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') + .attach( + 'file', + getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3'], true), + 'rules.ndjson' + ) .expect(200); const { body: bodyOfRule1 } = await supertest diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 4cbbc142edd40f..1dba1a154373b3 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -56,10 +56,12 @@ export const removeServerGeneratedPropertiesIncludingRuleId = ( /** * This is a typical simple rule for testing that is easy for most basic testing * @param ruleId + * @param enabled Enables the rule on creation or not. Defaulted to false to enable it on import */ -export const getSimpleRule = (ruleId = 'rule-1'): CreateRulesSchema => ({ +export const getSimpleRule = (ruleId = 'rule-1', enabled = true): CreateRulesSchema => ({ name: 'Simple Rule Query', description: 'Simple Rule Query', + enabled, risk_score: 1, rule_id: ruleId, severity: 'high', @@ -360,9 +362,9 @@ export const deleteSignalsIndex = async ( * for testing uploads. * @param ruleIds Array of strings of rule_ids */ -export const getSimpleRuleAsNdjson = (ruleIds: string[]): Buffer => { +export const getSimpleRuleAsNdjson = (ruleIds: string[], enabled = false): Buffer => { const stringOfRules = ruleIds.map((ruleId) => { - const simpleRule = getSimpleRule(ruleId); + const simpleRule = getSimpleRule(ruleId, enabled); return JSON.stringify(simpleRule); }); return Buffer.from(stringOfRules.join('\n')); From b6be6e9dfb15f822fd13acc099f45e3f4f86885e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 18 Sep 2020 02:21:23 +0300 Subject: [PATCH 4/9] [7.x] [Security Solutions][Cases] Cases Redesign (#73247) (#77771) Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Yara Tercero Co-authored-by: Elastic Machine --- .../cypress/integration/cases.spec.ts | 9 +- .../timeline_attach_to_case.spec.ts | 10 +- .../cypress/screens/case_details.ts | 16 +- .../cypress/screens/create_new_case.ts | 6 +- .../cypress/tasks/create_new_case.ts | 4 - .../cases/components/add_comment/index.tsx | 4 +- .../cases/components/case_view/index.test.tsx | 15 +- .../public/cases/components/create/index.tsx | 4 +- .../cases/components/tag_list/index.test.tsx | 12 +- .../cases/components/tag_list/index.tsx | 14 +- .../public/cases/components/tag_list/tags.tsx | 32 ++ .../user_action_tree/helpers.test.tsx | 18 +- .../components/user_action_tree/helpers.tsx | 52 ++- .../user_action_tree/index.test.tsx | 349 ++++++++------- .../components/user_action_tree/index.tsx | 407 ++++++++++++------ .../user_action_avatar.test.tsx | 47 ++ .../user_action_tree/user_action_avatar.tsx | 21 +- .../user_action_content_toolbar.test.tsx | 55 +++ .../user_action_content_toolbar.tsx | 52 +++ .../user_action_copy_link.test.tsx | 74 ++++ .../user_action_copy_link.tsx | 43 ++ .../user_action_tree/user_action_item.tsx | 197 --------- .../user_action_markdown.test.tsx | 24 +- .../user_action_tree/user_action_markdown.tsx | 59 ++- .../user_action_move_to_reference.test.tsx | 34 ++ .../user_action_move_to_reference.tsx | 37 ++ .../user_action_property_actions.test.tsx | 50 +++ .../user_action_property_actions.tsx | 58 +++ .../user_action_timestamp.test.tsx | 74 ++++ .../user_action_timestamp.tsx | 46 ++ .../user_action_title.test.tsx | 54 --- .../user_action_tree/user_action_title.tsx | 183 -------- .../user_action_username.test.tsx | 68 +++ .../user_action_tree/user_action_username.tsx | 28 ++ .../user_action_username_with_avatar.test.tsx | 42 ++ .../user_action_username_with_avatar.tsx | 43 ++ .../public/common/components/link_to/index.ts | 23 +- .../components/link_to/redirect_to_case.tsx | 11 + .../link_to/redirect_to_timelines.tsx | 6 + .../components/markdown_editor/eui_form.tsx | 87 ++++ .../components/markdown_editor/form.tsx | 67 --- .../components/markdown_editor/index.test.tsx | 49 --- .../components/markdown_editor/index.tsx | 165 +------ .../plugins/timeline/constants.ts | 8 + .../markdown_editor/plugins/timeline/index.ts | 11 + .../plugins/timeline/parser.ts | 119 +++++ .../plugins/timeline/plugin.tsx | 87 ++++ .../plugins/timeline/processor.tsx | 34 ++ .../plugins/timeline/translations.ts | 54 +++ .../markdown_editor/plugins/timeline/types.ts | 18 + .../components/markdown_editor/types.ts | 10 + .../utils/timeline}/use_timeline_click.tsx | 0 .../rules/step_about_rule/index.tsx | 2 +- .../use_insert_timeline.tsx | 19 +- 54 files changed, 1888 insertions(+), 1123 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx delete mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx delete mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/constants.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/parser.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/plugin.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/types.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/types.ts rename x-pack/plugins/security_solution/public/{cases/components/utils => common/utils/timeline}/use_timeline_click.tsx (100%) 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 6194d6892d799c..a45b1fd18a4b6d 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 6af4d174b9583c..3862a89a7d8339 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 f2cdaa69943565..7b995f53955438 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 9431c054d96a49..4f348b4dcdbd12 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 1d5d240c5c53d6..f5013eed07d295 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 ef13c87a92dbb8..14c42697dcbb46 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 e1d7d98ba8c515..246df1c94b8171 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 3c3cc95218b039..a8babe729fde08 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 7c3fcde6870338..a60167a18762f9 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 eeb7c49eceab5f..4af781e3c31f47 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 00000000000000..e257563ce751ef --- /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 b5be84db599205..4e5c05f2f1404e 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 e343c3da6cc8bf..4d8bb9ba078e5d 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 d67c364bbda10a..d2bb2fb243458b 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 d1263ab13f41b4..bada15294de09b 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 00000000000000..df5c51394b88a2 --- /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 f3276bd50e72c9..8339d9bedd123d 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 00000000000000..1f4c858e9581e5 --- /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 00000000000000..89239c9e8392ce --- /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 00000000000000..0566281dac130e --- /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 00000000000000..98de2ab3288a87 --- /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 eeb728aa7d1df3..00000000000000 --- 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 6cf827ea55f1f7..f1f7d400090450 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 ac2ad179ec60c5..45e46b2d7d2db8 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 00000000000000..5bb0f50ce25e53 --- /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 00000000000000..39d016dd69520d --- /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 00000000000000..bd5da8aca7d4fd --- /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 00000000000000..454880e93a27fc --- /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 00000000000000..a65806520c8543 --- /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 00000000000000..72dc5de9cdb3b5 --- /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 0bb02ce69a5448..00000000000000 --- 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 9477299e563a83..00000000000000 --- 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 00000000000000..008eb18aef0740 --- /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 00000000000000..dbc153ddbe577d --- /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 00000000000000..f8403738c24ea7 --- /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 00000000000000..e2326a3580e6ff --- /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 403c8d838fa443..89fcc67bcd15fd 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 7005460999fc77..3ef00635844f65 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 75a2fa1efa414f..58b9f940ceaa60 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 00000000000000..481ed7892a8be0 --- /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 2cc3fe05a2215c..00000000000000 --- 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 b5e5b01189418b..00000000000000 --- 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 d4ad4a11b60a32..9f4141dbcae7df 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: ( -