From a2771a39af52c1700b230e31cb828337b3f08e2a Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Sun, 13 Dec 2020 08:03:16 -0500 Subject: [PATCH] [Security Solution] Refactor Timeline Notes to use EuiCommentList (#85256) (#85716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Security Solution] Refactor Timeline Notes to use EuiCommentList * notes * fix types * unit tests * selector * uncomment Pinned tab * note event details * cleanup * cleanup * transparent background * don't display elastic as an owner when note is created * review + bugs fixed found Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Patryk KopyciƄski --- .../timeline_actions/add_to_case_action.tsx | 2 +- .../common/components/charts/barchart.tsx | 10 +- .../__snapshots__/json_view.test.tsx.snap | 26 +- .../event_details/event_fields_browser.tsx | 2 +- .../components/event_details/json_view.tsx | 36 +- .../components/event_details/summary_view.tsx | 15 +- .../events_viewer/event_details_flyout.tsx | 6 +- .../events_viewer/events_viewer.tsx | 12 +- .../public/common/components/links/index.tsx | 28 +- .../public/common/components/top_n/top_n.tsx | 8 +- .../hooks/use_timeline_events_count.tsx | 27 + .../public/common/lib/note/index.ts | 2 + .../public/common/store/app/selectors.ts | 16 +- .../public/common/store/inputs/selectors.ts | 16 +- .../common/store/sourcerer/selectors.ts | 15 +- .../alerts_histogram_panel/helpers.tsx | 4 +- .../investigate_in_timeline_action.tsx | 1 - .../alerts_by_category/index.test.tsx | 4 + .../components/alerts_by_category/index.tsx | 10 +- .../components/event_counts/index.test.tsx | 5 + .../components/event_counts/index.tsx | 51 +- .../components/events_by_dataset/index.tsx | 10 +- .../components/overview_host/index.tsx | 51 +- .../components/overview_network/index.tsx | 51 +- .../components/signals_by_category/index.tsx | 10 +- .../public/overview/pages/overview.tsx | 11 +- .../flyout/header/active_timelines.tsx | 19 +- .../components/flyout/header/index.tsx | 4 +- .../components/flyout/header/translations.ts | 4 + .../timelines/components/notes/columns.tsx | 45 -- .../timelines/components/notes/index.tsx | 119 --- .../note_card_body.test.tsx.snap | 759 ------------------ .../components/notes/note_card/index.test.tsx | 40 - .../components/notes/note_card/index.tsx | 29 - .../notes/note_card/note_card_body.test.tsx | 38 - .../notes/note_card/note_card_body.tsx | 41 - .../notes/note_card/note_card_header.test.tsx | 53 -- .../notes/note_card/note_card_header.tsx | 51 -- .../notes/note_card/note_created.test.tsx | 30 - .../notes/note_card/note_created.tsx | 27 - .../notes/note_cards/index.test.tsx | 11 +- .../components/notes/note_cards/index.tsx | 48 +- .../components/notes/translations.ts | 4 + .../components/open_timeline/helpers.test.ts | 2 + .../components/open_timeline/helpers.ts | 2 + .../components/open_timeline/index.test.tsx | 12 +- .../note_previews/index.test.tsx | 6 +- .../open_timeline/note_previews/index.tsx | 103 ++- .../note_previews/note_preview.test.tsx | 154 ---- .../note_previews/note_preview.tsx | 69 -- .../note_previews/translations.ts | 14 + .../components/open_timeline/types.ts | 2 + .../body/actions/action_icon_item.tsx | 4 +- .../body/actions/add_note_icon_item.tsx | 16 +- .../timeline/body/column_headers/index.tsx | 1 + .../body/events/event_column_view.test.tsx | 21 +- .../body/events/event_column_view.tsx | 18 +- .../timeline/body/events/stateful_event.tsx | 1 - .../components/timeline/body/helpers.tsx | 3 +- .../timeline/date_picker_lock/translations.ts | 10 +- .../timeline/expandable_event/index.tsx | 4 +- .../timeline/notes_tab_content/index.tsx | 158 +++- .../timeline/properties/helpers.tsx | 124 +-- .../components/timeline/properties/styles.tsx | 12 +- .../properties/use_create_timeline.test.tsx | 3 + .../properties/use_create_timeline.tsx | 2 + .../timeline/query_tab_content/index.tsx | 24 +- .../timelines/components/timeline/styles.tsx | 2 +- .../timeline/tabs_content/index.tsx | 54 +- .../timeline/tabs_content/selectors.ts | 3 + .../public/timelines/store/timeline/model.ts | 2 + 71 files changed, 648 insertions(+), 1929 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/columns.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index 7466d34a9938f..8ec5133ef48b0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -134,7 +134,7 @@ const AddToCaseActionComponent: React.FC = ({ ecsRowData, return ( <> - + = ({ ); }; -export const BarChart = React.memo(BarChartComponent); +export const BarChart = React.memo( + BarChartComponent, + (prevProps, nextProps) => + prevProps.stackByField === nextProps.stackByField && + prevProps.timelineId === nextProps.timelineId && + deepEqual(prevProps.configs, nextProps.configs) && + deepEqual(prevProps.barChart, nextProps.barChart) +); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap index 15f00bbf72cf1..2b681870e92fe 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap @@ -1,17 +1,18 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`JSON View rendering should match snapshot 1`] = ` - + + width="100%" + /> + `; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx index 315d8b88d15e2..b494960f12fac 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx @@ -98,7 +98,7 @@ export const EventFieldsBrowser = React.memo( } const linkFieldData = (data ?? []).find((d) => d.field === linkField); const linkFieldValue = getOr(null, 'originalValue', linkFieldData); - return linkFieldValue; + return Array.isArray(linkFieldValue) ? linkFieldValue[0] : linkFieldValue; }, [data, columnHeaders] ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx index bf548d04e780b..2944a15cbeb93 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx @@ -16,8 +16,10 @@ interface Props { data: TimelineEventsDetailsItem[]; } -const StyledEuiCodeEditor = styled(EuiCodeEditor)` - flex: 1; +const EuiCodeEditorContainer = styled.div` + .euiCodeEditorWrapper { + position: absolute; + } `; const EDITOR_SET_OPTIONS = { fontSize: '12px' }; @@ -34,19 +36,29 @@ export const JsonView = React.memo(({ data }) => { ); return ( - + + + ); }); JsonView.displayName = 'JsonView'; export const buildJsonView = (data: TimelineEventsDetailsItem[]) => - data.reduce((accumulator, item) => set(item.field, item.originalValue, accumulator), {}); + data.reduce( + (accumulator, item) => + set( + item.field, + Array.isArray(item.originalValue) ? item.originalValue.join() : item.originalValue, + accumulator + ), + {} + ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx index 13d734657acce..860bf13908855 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx @@ -167,15 +167,12 @@ export const SummaryViewComponent: React.FC<{ eventId: string; timelineId: string; }> = ({ data, eventId, timelineId, browserFields }) => { - const ruleId = useMemo( - () => - getOr( - null, - 'originalValue', - data.find((d) => d.field === 'signal.rule.id') - ), - [data] - ); + const ruleId = useMemo(() => { + const item = data.find((d) => d.field === 'signal.rule.id'); + return Array.isArray(item?.originalValue) + ? item?.originalValue[0] + : item?.originalValue ?? null; + }, [data]); const { rule: maybeRule } = useRuleAsync(ruleId); const summaryList = useMemo(() => getSummary({ browserFields, data, eventId, timelineId }), [ browserFields, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx index 6fecf0d739d1a..48bdebbc0aa4f 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx @@ -39,7 +39,7 @@ const EventDetailsFlyoutComponent: React.FC = ({ const dispatch = useDispatch(); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const expandedEvent = useDeepEqualSelector( - (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent + (state) => (getTimeline(state, timelineId) ?? timelineDefaults)?.expandedEvent ?? {} ); const handleClearSelection = useCallback(() => { @@ -48,8 +48,8 @@ const EventDetailsFlyoutComponent: React.FC = ({ const [loading, detailsData] = useTimelineEventsDetails({ docValueFields, - indexName: expandedEvent.indexName!, - eventId: expandedEvent.eventId!, + indexName: expandedEvent?.indexName ?? '', + eventId: expandedEvent?.eventId ?? '', skip: !expandedEvent.eventId, }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 69c75bfbea56a..d6b2efbe43053 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -5,8 +5,8 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; -import { isEmpty, some } from 'lodash/fp'; -import React, { useEffect, useMemo, useState } from 'react'; +import { isEmpty } from 'lodash/fp'; +import React, { useEffect, useMemo, useState, useRef } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { useDispatch } from 'react-redux'; @@ -180,6 +180,9 @@ const EventsViewerComponent: React.FC = ({ [justTitle] ); + const prevCombinedQueries = useRef<{ + filterQuery: string; + } | null>(null); const combinedQueries = combineQueries({ config: esQuery.getEsQueryConfig(kibana.services.uiSettings), dataProviders, @@ -232,10 +235,11 @@ const EventsViewerComponent: React.FC = ({ }); useEffect(() => { - if (!events || (expandedEvent.eventId && !some(['_id', expandedEvent.eventId], events))) { + if (!deepEqual(prevCombinedQueries.current, combinedQueries)) { + prevCombinedQueries.current = combinedQueries; dispatch(timelineActions.toggleExpandedEvent({ timelineId: id })); } - }, [dispatch, events, expandedEvent, id]); + }, [combinedQueries, dispatch, id]); const totalCountMinusDeleted = useMemo( () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index d6cbd31e86ddb..3964acbc9b766 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -327,6 +327,20 @@ const ReputationLinkComponent: React.FC<{ [ipReputationLinksSetting, domain, defaultNameMapping, allItemsLimit] ); + const renderCallback = useCallback( + (rowItem) => + isReputationLink(rowItem) && ( + + <>{rowItem.name ?? domain} + + ), + [allItemsLimit, domain, overflowIndexStart] + ); + return ipReputationLinks?.length > 0 ? (
{ - return ( - isReputationLink(rowItem) && ( - - <>{rowItem.name ?? domain} - - ) - ); - }} + render={renderCallback} moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT} overflowIndexStart={overflowIndexStart} /> diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx index ac03e6c5c0018..fb4cd95ae36f2 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx @@ -58,20 +58,17 @@ export interface Props extends Pick = ({ combinedQueries, defaultView, deleteQuery, - filters = NO_FILTERS, + filters, field, from, indexPattern, indexNames, options, - query = DEFAULT_QUERY, + query, setAbsoluteRangeDatePickerTarget, setQuery, timelineId, @@ -132,7 +129,6 @@ const TopNComponent: React.FC = ({ filters={filters} from={from} headerChildren={headerChildren} - indexPattern={indexPattern} onlyField={field} query={query} setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget} diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx new file mode 100644 index 0000000000000..393c844bf5098 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx @@ -0,0 +1,27 @@ +/* + * 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 } from 'react'; +import { createPortalNode, OutPortal } from 'react-reverse-portal'; + +/** + * A singleton portal for rendering content in the global header + */ +const timelineEventsCountPortalNodeSingleton = createPortalNode(); + +export const useTimelineEventsCountPortal = () => { + const [timelineEventsCountPortalNode] = useState(timelineEventsCountPortalNodeSingleton); + + return { timelineEventsCountPortalNode }; +}; + +export const TimelineEventsCountBadge = React.memo(() => { + const { timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); + + return ; +}); + +TimelineEventsCountBadge.displayName = 'TimelineEventsCountBadge'; diff --git a/x-pack/plugins/security_solution/public/common/lib/note/index.ts b/x-pack/plugins/security_solution/public/common/lib/note/index.ts index b803cade326ad..19821753a6cdc 100644 --- a/x-pack/plugins/security_solution/public/common/lib/note/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/note/index.ts @@ -8,6 +8,7 @@ export interface Note { /** When the note was created */ created: Date; + eventId?: string | null; /** Uniquely identifies the note */ id: string; /** When not `null`, this represents the last edit */ @@ -18,5 +19,6 @@ export interface Note { user: string; /** SaveObjectID for note */ saveObjectId: string | null | undefined; + timelineId?: string | null; version: string | null | undefined; } diff --git a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts index 59d783107e587..9808bbb1faed3 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { keys } from 'lodash/fp'; +import { keys, values } from 'lodash/fp'; import memoizeOne from 'memoize-one'; import { createSelector } from 'reselect'; import { Note } from '../../lib/note'; import { ErrorModel, NotesById } from './model'; import { State } from '../types'; +import { TimelineResultNote } from '../../../timelines/components/open_timeline/types'; const selectNotesById = (state: State): NotesById => state.app.notesById; @@ -25,6 +26,16 @@ export const getNotes = memoizeOne((notesById: NotesById, noteIds: string[]): No }, []) ); +export const getNotesAsCommentsList = (notesById: NotesById): TimelineResultNote[] => + values(notesById).map((note) => ({ + eventId: note.eventId, + savedObjectId: note.saveObjectId, + note: note.note, + noteId: note.id, + updated: (note.lastEdit ?? note.created).getTime(), + updatedBy: note.user, + })); + export const selectNotesByIdSelector = createSelector( selectNotesById, (notesById: NotesById) => notesById @@ -33,4 +44,7 @@ export const selectNotesByIdSelector = createSelector( export const notesByIdsSelector = () => createSelector(selectNotesById, (notesById: NotesById) => notesById); +export const selectNotesAsCommentsListSelector = () => + createSelector(selectNotesById, getNotesAsCommentsList); + export const errorsSelector = () => createSelector(getErrors, (errors) => errors); diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts b/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts index 9feb2f87d7e08..47a63ec843073 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts @@ -6,6 +6,7 @@ import { createSelector } from 'reselect'; +import { Filter, Query } from '../../../../../../../src/plugins/data/public'; import { State } from '../types'; import { InputsModel, InputsRange, GlobalQuery } from './model'; @@ -64,21 +65,18 @@ export const timelineQueryByIdSelector = () => export const globalSelector = () => createSelector(selectGlobal, (global) => global); +const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; + export const globalQuerySelector = () => - createSelector( - selectGlobal, - (global) => - global.query || { - query: '', - language: 'kuery', - } - ); + createSelector(selectGlobal, (global) => global.query || DEFAULT_QUERY); export const globalSavedQuerySelector = () => createSelector(selectGlobal, (global) => global.savedQuery || null); +const NO_FILTERS: Filter[] = []; + export const globalFiltersQuerySelector = () => - createSelector(selectGlobal, (global) => global.filters || []); + createSelector(selectGlobal, (global) => global.filters || NO_FILTERS); export const getTimelineSelector = () => createSelector(selectTimeline, (timeline) => timeline); diff --git a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts index 599cddb605148..88694c66bf960 100644 --- a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts @@ -91,16 +91,23 @@ export const getSourcererScopeSelector = () => { : selectedPatterns; }); + const getIndexPattern = memoizeOne( + (indexPattern, title) => ({ + ...indexPattern, + title, + }), + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] && newArgs[1].length === lastArgs[1].length + ); + const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => { const scope = getScopeIdSelector(state, scopeId); const selectedPatterns = getSelectedPatterns(scope.selectedPatterns.sort().join()); + const indexPattern = getIndexPattern(scope.indexPattern, selectedPatterns.join()); + return { ...scope, selectedPatterns, - indexPattern: { - ...scope.indexPattern, - title: selectedPatterns.join(), - }, + indexPattern, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx index bb8cc2267249f..e2ab339fbaa83 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx @@ -10,6 +10,8 @@ import { HistogramData, AlertsAggregation, AlertsBucket, AlertsGroupBucket } fro import { AlertSearchResponse } from '../../containers/detection_engine/alerts/types'; import * as i18n from './translations'; +const EMPTY_ALERTS_DATA: HistogramData[] = []; + export const formatAlertsData = (alertsData: AlertSearchResponse<{}, AlertsAggregation> | null) => { const groupBuckets: AlertsGroupBucket[] = alertsData?.aggregations?.alertsByGrouping?.buckets ?? []; @@ -25,7 +27,7 @@ export const formatAlertsData = (alertsData: AlertSearchResponse<{}, AlertsAggre g: group, })), ]; - }, []); + }, EMPTY_ALERTS_DATA); }; export const getAlertsHistogramQuery = ( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx index 8960b7a76660b..d7306e26d3cfe 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx @@ -89,7 +89,6 @@ const InvestigateInTimelineActionComponent: React.FC diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx index 704506d9813d9..50b5ae9388fe5 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx @@ -37,6 +37,10 @@ describe('Alerts by category', () => { indexPattern: mockIndexPattern, setQuery: jest.fn(), to, + query: { + query: '', + language: 'kuery', + }, }; describe('before loading data', () => { beforeAll(async () => { diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx index 4ab72afc3fb45..a58b5cf315ec1 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx @@ -35,26 +35,24 @@ import { LinkButton } from '../../../common/components/links'; const ID = 'alertsByCategoryOverview'; -const NO_FILTERS: Filter[] = []; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; const DEFAULT_STACK_BY = 'event.module'; interface Props extends Pick { - filters?: Filter[]; + filters: Filter[]; hideHeaderChildren?: boolean; indexPattern: IIndexPattern; indexNames: string[]; - query?: Query; + query: Query; } const AlertsByCategoryComponent: React.FC = ({ deleteQuery, - filters = NO_FILTERS, + filters, from, hideHeaderChildren = false, indexPattern, indexNames, - query = DEFAULT_QUERY, + query, setQuery, to, }) => { diff --git a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx index 44cb7a65dbc5e..7e96ab8779304 100644 --- a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx @@ -21,11 +21,16 @@ describe('EventCounts', () => { const to = '2020-01-21T20:49:57.080Z'; const testProps = { + filters: [], from, indexNames: [], indexPattern: mockIndexPattern, setQuery: jest.fn(), to, + query: { + query: '', + language: 'kuery', + }, }; test('it filters the `Host events` widget with a `host.name` `exists` filter', () => { diff --git a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx index 6e47de68221c7..af3c7ecf1f36d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx @@ -5,7 +5,7 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import { OverviewHost } from '../overview_host'; @@ -26,38 +26,52 @@ const HorizontalSpacer = styled(EuiFlexItem)` width: 24px; `; -const NO_FILTERS: Filter[] = []; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; - interface Props extends Pick { - filters?: Filter[]; + filters: Filter[]; indexNames: string[]; indexPattern: IIndexPattern; - query?: Query; + query: Query; } const EventCountsComponent: React.FC = ({ - filters = NO_FILTERS, + filters, from, indexNames, indexPattern, - query = DEFAULT_QUERY, + query, setQuery, to, }) => { - const kibana = useKibana(); + const { uiSettings } = useKibana().services; + + const hostFilterQuery = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters: [...filters, ...filterHostData], + }), + [filters, indexPattern, query, uiSettings] + ); + + const networkFilterQuery = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters: [...filters, ...filterNetworkData], + }), + [filters, indexPattern, uiSettings, query] + ); return ( = ({ { combinedQueries?: string; - filters?: Filter[]; + filters: Filter[]; headerChildren?: React.ReactNode; indexPattern: IIndexPattern; indexNames: string[]; onlyField?: string; - query?: Query; + query: Query; setAbsoluteRangeDatePickerTarget?: InputsModelId; showSpacer?: boolean; timelineId?: string; @@ -63,13 +61,13 @@ const getHistogramOption = (fieldName: string): MatrixHistogramOption => ({ const EventsByDatasetComponent: React.FC = ({ combinedQueries, deleteQuery, - filters = NO_FILTERS, + filters, from, headerChildren, indexPattern, indexNames, onlyField, - query = DEFAULT_QUERY, + query, setAbsoluteRangeDatePickerTarget, setQuery, showSpacer = true, diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx index f92f004bd2448..a74d7af7140b7 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx @@ -84,33 +84,38 @@ const OverviewHostComponent: React.FC = ({ [goToHost, formatUrl] ); + const title = useMemo( + () => ( + + ), + [] + ); + + const subtitle = useMemo( + () => + !isEmpty(overviewHost) ? ( + + ) : ( + <>{''} + ), + [formattedHostEventsCount, hostEventsCount, overviewHost] + ); + return ( - - ) : ( - <>{''} - ) - } - title={ - - } - > + <>{hostPageButton} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx index 178a752d1286f..fd4b7bbd386ba 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx @@ -89,34 +89,39 @@ const OverviewNetworkComponent: React.FC = ({ [goToNetwork, formatUrl] ); + const title = useMemo( + () => ( + + ), + [] + ); + + const subtitle = useMemo( + () => + !isEmpty(overviewNetwork) ? ( + + ) : ( + <>{''} + ), + [formattedNetworkEventsCount, networkEventsCount, overviewNetwork] + ); + return ( <> - - ) : ( - <>{''} - ) - } - title={ - - } - > + {networkPageButton} diff --git a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx index 34722fd147a99..432ad0642be9d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx @@ -11,19 +11,15 @@ import { AlertsHistogramPanel } from '../../../detections/components/alerts_hist import { alertsHistogramOptions } from '../../../detections/components/alerts_histogram_panel/config'; import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; -import { Filter, IIndexPattern, Query } from '../../../../../../../src/plugins/data/public'; +import { Filter, Query } from '../../../../../../../src/plugins/data/public'; import { InputsModelId } from '../../../common/store/inputs/constants'; import * as i18n from '../../pages/translations'; import { UpdateDateRange } from '../../../common/components/charts/common'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; -const NO_FILTERS: Filter[] = []; - interface Props extends Pick { filters?: Filter[]; headerChildren?: React.ReactNode; - indexPattern: IIndexPattern; /** Override all defaults, and only display this field */ onlyField?: string; query?: Query; @@ -33,11 +29,11 @@ interface Props extends Pick = ({ deleteQuery, - filters = NO_FILTERS, + filters, from, headerChildren, onlyField, - query = DEFAULT_QUERY, + query, setAbsoluteRangeDatePickerTarget = 'global', setQuery, timelineId, diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 0f34734ebf861..2e1a8d3a6e376 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -6,7 +6,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useState, useMemo } from 'react'; -import { Query, Filter } from 'src/plugins/data/public'; import styled from 'styled-components'; import { AlertsByCategory } from '../components/alerts_by_category'; @@ -33,9 +32,6 @@ import { Sourcerer } from '../../common/components/sourcerer'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; -const NO_FILTERS: Filter[] = []; - const SidebarFlexItem = styled(EuiFlexItem)` margin-right: 24px; `; @@ -46,10 +42,8 @@ const OverviewComponent = () => { [] ); const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); - const query = useDeepEqualSelector((state) => getGlobalQuerySelector(state) ?? DEFAULT_QUERY); - const filters = useDeepEqualSelector( - (state) => getGlobalFiltersQuerySelector(state) ?? NO_FILTERS - ); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { from, deleteQuery, setQuery, to } = useGlobalTime(); const { indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); @@ -97,7 +91,6 @@ const OverviewComponent = () => { span { + padding: 0; + + > span { + display: flex; + flex-direction: row; + } + } +`; + const ActiveTimelinesComponent: React.FC = ({ timelineId, timelineType, @@ -46,16 +58,17 @@ const ActiveTimelinesComponent: React.FC = ({ : UNTITLED_TIMELINE; return ( - + - {title} - + {!isOpen && } + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index e09eedcd34dd1..368cb53eccc34 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -175,7 +175,7 @@ const TimelineStatusInfoComponent: React.FC = ({ timelineId } return ( - {'Unsaved'} + {i18n.UNSAVED} ); @@ -198,7 +198,7 @@ const TimelineStatusInfoComponent: React.FC = ({ timelineId } const TimelineStatusInfo = React.memo(TimelineStatusInfoComponent); const FlyoutHeaderComponent: React.FC = ({ timelineId }) => ( - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts index ef9b88d65c551..2633faf4e3e43 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts @@ -13,6 +13,10 @@ export const CLOSE_TIMELINE = i18n.translate( } ); +export const UNSAVED = i18n.translate('xpack.securitySolution.timeline.properties.unsavedLabel', { + defaultMessage: 'Unsaved', +}); + export const AUTOSAVED = i18n.translate( 'xpack.securitySolution.timeline.properties.autosavedLabel', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/columns.tsx deleted file mode 100644 index fb43dd7cf6fc0..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/columns.tsx +++ /dev/null @@ -1,45 +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. - */ - -/* eslint-disable react/display-name */ - -import React from 'react'; - -import { EuiTableDataType } from '@elastic/eui'; - -import { NoteCard } from './note_card'; -import * as i18n from './translations'; - -const Column = React.memo<{ text: string }>(({ text }) => {text}); -Column.displayName = 'Column'; - -interface Item { - created: Date; - note: string; - user: string; -} - -interface Column { - field: string; - dataType?: EuiTableDataType; - name: string; - sortable: boolean; - truncateText: boolean; - render: (value: string, item: Item) => JSX.Element; -} - -export const columns: Column[] = [ - { - field: 'note', - dataType: 'string', - name: i18n.NOTE, - sortable: true, - truncateText: false, - render: (_, { created, note, user }) => ( - - ), - }, -]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx deleted file mode 100644 index 1ba573c0ac6c3..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx +++ /dev/null @@ -1,119 +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 { - EuiInMemoryTable, - EuiInMemoryTableProps, - EuiModalBody, - EuiModalHeader, - EuiSpacer, -} from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; -import styled from 'styled-components'; -import { useDispatch } from 'react-redux'; - -import { Note } from '../../../common/lib/note'; - -import { AddNote } from './add_note'; -import { columns } from './columns'; -import { AssociateNote, NotesCount, search } from './helpers'; -import { TimelineStatusLiteral, TimelineStatus } from '../../../../common/types/timeline'; -import { timelineActions } from '../../store/timeline'; -import { appSelectors } from '../../../common/store/app'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; - -interface Props { - associateNote: AssociateNote; - noteIds: string[]; - status: TimelineStatusLiteral; -} - -export const InMemoryTable: typeof EuiInMemoryTable & { displayName: string } = styled( - EuiInMemoryTable as React.ComponentType> -)` - & thead { - display: none; - } -` as any; // eslint-disable-line @typescript-eslint/no-explicit-any - -InMemoryTable.displayName = 'InMemoryTable'; - -/** A view for entering and reviewing notes */ -export const Notes = React.memo(({ associateNote, noteIds, status }) => { - const getNotesByIds = appSelectors.notesByIdsSelector(); - const [newNote, setNewNote] = useState(''); - const isImmutable = status === TimelineStatus.immutable; - - const notesById = useDeepEqualSelector(getNotesByIds); - - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); - - return ( - <> - - - - - - {!isImmutable && ( - - )} - - - - - ); -}); - -Notes.displayName = 'Notes'; - -interface NotesTabContentPros { - noteIds: string[]; - timelineId: string; - timelineStatus: TimelineStatusLiteral; -} - -/** A view for entering and reviewing notes */ -export const NotesTabContent = React.memo( - ({ noteIds, timelineStatus, timelineId }) => { - const dispatch = useDispatch(); - const getNotesByIds = appSelectors.notesByIdsSelector(); - const [newNote, setNewNote] = useState(''); - const isImmutable = timelineStatus === TimelineStatus.immutable; - const notesById = useDeepEqualSelector(getNotesByIds); - - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); - - const associateNote = useCallback( - (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), - [dispatch, timelineId] - ); - - return ( - <> - - - {!isImmutable && ( - - )} - - ); - } -); - -NotesTabContent.displayName = 'NotesTabContent'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap deleted file mode 100644 index 58cf0ae1e9f8f..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap +++ /dev/null @@ -1,759 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NoteCardBody renders correctly against snapshot 1`] = ` - - - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.test.tsx deleted file mode 100644 index 161671ed730f3..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.test.tsx +++ /dev/null @@ -1,40 +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 { mountWithIntl } from '@kbn/test/jest'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import '../../../../common/mock/formatted_relative'; - -import { NoteCard } from '.'; - -describe('NoteCard', () => { - const created = new Date(); - const rawNote = 'noteworthy'; - const user = 'elastic'; - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - - test('it renders a note card header', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="note-card-header"]').exists()).toEqual(true); - }); - - test('it renders a note card body', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="note-card-body"]').exists()).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.tsx deleted file mode 100644 index e02ebc2a25fd0..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.tsx +++ /dev/null @@ -1,29 +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 { EuiPanel } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import { NoteCardBody } from './note_card_body'; -import { NoteCardHeader } from './note_card_header'; - -const NoteCardContainer = styled(EuiPanel)` - width: 100%; -`; - -NoteCardContainer.displayName = 'NoteCardContainer'; - -export const NoteCard = React.memo<{ created: Date; rawNote: string; user: string }>( - ({ created, rawNote, user }) => ( - - - - - ) -); - -NoteCard.displayName = 'NoteCard'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.test.tsx deleted file mode 100644 index 77f1375b7a3c0..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.test.tsx +++ /dev/null @@ -1,38 +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, shallow } from 'enzyme'; -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; - -import { NoteCardBody } from './note_card_body'; - -describe('NoteCardBody', () => { - const markdownHeaderPrefix = '# '; // translates to an h1 in markdown - const noteText = 'This is a note'; - const rawNote = `${markdownHeaderPrefix} ${noteText}`; - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - - test('renders correctly against snapshot', () => { - const wrapper = shallow( - - - - ); - expect(wrapper).toMatchSnapshot(); - }); - - test('it renders the text of the note in an h1', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('h1').first().text()).toEqual(noteText); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.tsx deleted file mode 100644 index efda3737cd177..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.tsx +++ /dev/null @@ -1,41 +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 { EuiPanel, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; - -import { WithCopyToClipboard } from '../../../../common/lib/clipboard/with_copy_to_clipboard'; -import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; -import { WithHoverActions } from '../../../../common/components/with_hover_actions'; -import * as i18n from '../translations'; - -const BodyContainer = styled(EuiPanel)` - border: none; -`; - -BodyContainer.displayName = 'BodyContainer'; - -export const NoteCardBody = React.memo<{ rawNote: string }>(({ rawNote }) => { - const hoverContent = useMemo( - () => ( - - - - ), - [rawNote] - ); - - const render = useCallback(() => {rawNote}, [rawNote]); - - return ( - - - - ); -}); - -NoteCardBody.displayName = 'NoteCardBody'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.test.tsx deleted file mode 100644 index 4fbb7ce3f46eb..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import moment from 'moment-timezone'; -import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import '../../../../common/mock/formatted_relative'; - -import * as i18n from '../translations'; - -import { NoteCardHeader } from './note_card_header'; - -describe('NoteCardHeader', () => { - beforeEach(() => { - moment.tz.setDefault('UTC'); - }); - afterEach(() => { - moment.tz.setDefault('Browser'); - }); - - moment.locale('en'); - - const date = moment('2019-02-19 06:21:00'); - - const user = 'elastic'; - - test('it renders an avatar containing the first letter of the username', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual(user[0]); - }); - - test('it renders the username', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="user"]').first().text()).toEqual(user); - }); - - test('it renders the expected action', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="action"]').first().text()).toEqual(i18n.ADDED_A_NOTE); - }); - - test('it renders a humanized date when the note was created', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.exists()).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.tsx deleted file mode 100644 index e6aa0542df4b3..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.tsx +++ /dev/null @@ -1,51 +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 { EuiAvatar, EuiPanel } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import * as i18n from '../translations'; - -import { NoteCreated } from './note_created'; - -const Action = styled.span` - margin-right: 5px; -`; - -Action.displayName = 'Action'; - -const Avatar = styled(EuiAvatar)` - margin-right: 5px; -`; - -Avatar.displayName = 'Avatar'; - -const HeaderContainer = styled.div` - align-items: center; - display: flex; - user-select: none; -`; - -HeaderContainer.displayName = 'HeaderContainer'; - -const User = styled.span` - font-weight: 700; - margin: 5px; -`; - -export const NoteCardHeader = React.memo<{ created: Date; user: string }>(({ created, user }) => ( - - - - {user} - {i18n.ADDED_A_NOTE} - - - -)); - -NoteCardHeader.displayName = 'NoteCardHeader'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.test.tsx deleted file mode 100644 index 92d334a059ae9..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.test.tsx +++ /dev/null @@ -1,30 +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 moment from 'moment-timezone'; -import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import '../../../../common/mock/formatted_relative'; - -import { NoteCreated } from './note_created'; - -describe('NoteCreated', () => { - beforeEach(() => { - moment.tz.setDefault('UTC'); - }); - afterEach(() => { - moment.tz.setDefault('Browser'); - }); - - moment.locale('en'); - const date = moment('2019-02-19 06:21:00'); - - test('it renders a humanized date when the note was created', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="note-created"]').first().exists()).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.tsx deleted file mode 100644 index dc97373660bd1..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.tsx +++ /dev/null @@ -1,27 +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 { FormattedRelative } from '@kbn/i18n/react'; -import React from 'react'; -import styled from 'styled-components'; - -import { LocalizedDateTooltip } from '../../../../common/components/localized_date_tooltip'; - -const NoteCreatedContainer = styled.span` - user-select: none; -`; - -NoteCreatedContainer.displayName = 'NoteCreatedContainer'; - -export const NoteCreated = React.memo<{ created: Date }>(({ created }) => ( - - - - - -)); - -NoteCreated.displayName = 'NoteCreated'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx index 8fd95feba6031..724f49e9bd481 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx @@ -77,14 +77,9 @@ describe('NoteCards', () => { ); - expect( - wrapper - .find('[data-test-subj="note-card"]') - .find('[data-test-subj="note-card-body"]') - .find('.euiMarkdownFormat') - .first() - .text() - ).toEqual(getNotesByIds().abc.note); + expect(wrapper.find('.euiCommentEvent__body .euiMarkdownFormat').first().text()).toEqual( + getNotesByIds().abc.note + ); }); test('it shows controls for adding notes when showAddNote is true', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx index 4ce4de1851863..6c3fd2b50ae6a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx @@ -12,7 +12,8 @@ import { appSelectors } from '../../../../common/store'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { AddNote } from '../add_note'; import { AssociateNote } from '../helpers'; -import { NoteCard } from '../note_card'; +import { NotePreviews, NotePreviewsContainer } from '../../open_timeline/note_previews'; +import { TimelineResultNote } from '../../open_timeline/types'; const AddNoteContainer = styled.div``; AddNoteContainer.displayName = 'AddNoteContainer'; @@ -22,23 +23,17 @@ const NoteContainer = styled.div` `; NoteContainer.displayName = 'NoteContainer'; -interface NoteCardsCompProps { - children: React.ReactNode; -} const NoteCardsCompContainer = styled(EuiPanel)` border: none; background-color: transparent; box-shadow: none; + + &.euiPanel--plain { + background-color: transparent; + } `; NoteCardsCompContainer.displayName = 'NoteCardsCompContainer'; -const NoteCardsComp = React.memo(({ children }) => ( - - {children} - -)); -NoteCardsComp.displayName = 'NoteCardsComp'; - const NotesContainer = styled(EuiFlexGroup)` margin-bottom: 5px; `; @@ -56,7 +51,6 @@ export const NoteCards = React.memo( ({ associateNote, noteIds, showAddNote, toggleShowAddNote }) => { const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); const notesById = useDeepEqualSelector(getNotesByIds); - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); const [newNote, setNewNote] = useState(''); const associateNoteAndToggleShow = useCallback( @@ -67,16 +61,26 @@ export const NoteCards = React.memo( [associateNote, toggleShowAddNote] ); + const notes: TimelineResultNote[] = useMemo( + () => + appSelectors.getNotes(notesById, noteIds).map((note) => ({ + savedObjectId: note.saveObjectId, + note: note.note, + noteId: note.id, + updated: (note.lastEdit ?? note.created).getTime(), + updatedBy: note.user, + })), + [notesById, noteIds] + ); + return ( - - {noteIds.length ? ( - - {items.map((note) => ( - - - - ))} - + + {notes.length ? ( + + + + + ) : null} {showAddNote ? ( @@ -89,7 +93,7 @@ export const NoteCards = React.memo( /> ) : null} - + ); } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts index 4827481c7c5f3..e92b918a525d0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts @@ -50,3 +50,7 @@ export const COPY_TO_CLIPBOARD = i18n.translate( defaultMessage: 'Copy to Clipboard', } ); + +export const CREATED_BY = i18n.translate('xpack.securitySolution.notes.createdByLabel', { + defaultMessage: 'Created by', +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 6c76da44c8557..61b0c004dcb9d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -1502,11 +1502,13 @@ describe('helpers', () => { notes: [ { created: new Date('2020-03-26T14:35:56.356Z'), + eventId: null, id: 'note-id', lastEdit: new Date('2020-03-26T14:35:56.356Z'), note: 'I am a note', user: 'unknown', saveObjectId: 'note-id', + timelineId: null, version: undefined, }, ], diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 76eb9196e8c5c..df12194e264de 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -462,6 +462,8 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli user: note.updatedBy || 'unknown', saveObjectId: note.noteId, version: note.version, + eventId: note.eventId ?? null, + timelineId: note.timelineId ?? null, })) : [], }) diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx index 9ca5d0c7b438a..ffff6af3f1351 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx @@ -568,16 +568,8 @@ describe('StatefulOpenTimeline', () => { wrapper.update(); wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); - expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toEqual(true); - expect(wrapper.find('[data-test-subj="updated-by"]').exists()).toEqual(true); - - expect( - wrapper - .find('[data-test-subj="note-previews-container"]') - .find('[data-test-subj="updated-by"]') - .first() - .text() - ).toEqual('elastic'); + expect(wrapper.find('.euiCommentEvent__headerUsername').exists()).toEqual(true); + expect(wrapper.find('.euiCommentEvent__headerUsername').first().text()).toEqual('elastic'); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx index d791e6ebe4366..18e276a0914b4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx @@ -104,7 +104,7 @@ describe('NotePreviews', () => { ); - expect(wrapper.find(`[data-test-subj="updated-by"]`).at(2).text()).toEqual('bob'); + expect(wrapper.find('.euiCommentEvent__headerUsername').at(1).text()).toEqual('bob'); }); test('it filters-out null savedObjectIds', () => { @@ -135,7 +135,7 @@ describe('NotePreviews', () => { ); - expect(wrapper.find(`[data-test-subj="updated-by"]`).at(2).text()).toEqual('bob'); + expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); test('it filters-out undefined savedObjectIds', () => { @@ -165,6 +165,6 @@ describe('NotePreviews', () => { ); - expect(wrapper.find(`[data-test-subj="updated-by"]`).at(2).text()).toEqual('bob'); + expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx index 8c804dbe4b70d..7efa16d8168e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx @@ -5,46 +5,101 @@ */ import { uniqBy } from 'lodash/fp'; -import React from 'react'; +import { EuiAvatar, EuiButtonIcon, EuiCommentList } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; -import { NotePreview } from './note_preview'; import { TimelineResultNote } from '../types'; +import { getEmptyValue, defaultToEmptyTag } from '../../../../common/components/empty_value'; +import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; +import { timelineActions } from '../../../store/timeline'; +import * as i18n from './translations'; -const NotePreviewsContainer = styled.section` - padding: ${(props) => - `${props.theme.eui.euiSizeS} 0 ${props.theme.eui.euiSizeS} ${props.theme.eui.euiSizeXXL}`}; +export const NotePreviewsContainer = styled.section` + padding-top: ${({ theme }) => `${theme.eui.euiSizeS}`}; `; NotePreviewsContainer.displayName = 'NotePreviewsContainer'; +interface ToggleEventDetailsButtonProps { + eventId: string; + timelineId: string; +} + +const ToggleEventDetailsButtonComponent: React.FC = ({ + eventId, + timelineId, +}) => { + const dispatch = useDispatch(); + const handleClick = useCallback(() => { + dispatch( + timelineActions.toggleExpandedEvent({ + timelineId, + event: { + eventId, + // we don't store yet info about event index name in note + indexName: '', + }, + }) + ); + }, [dispatch, eventId, timelineId]); + + return ( + + ); +}; + +const ToggleEventDetailsButton = React.memo(ToggleEventDetailsButtonComponent); /** * Renders a preview of a note in the All / Open Timelines table */ -export const NotePreviews = React.memo<{ + +interface NotePreviewsProps { notes?: TimelineResultNote[] | null; -}>(({ notes }) => { + timelineId?: string; +} + +export const NotePreviews = React.memo(({ notes, timelineId }) => { + const notesList = useMemo( + () => + uniqBy('savedObjectId', notes).map((note) => ({ + 'data-test-subj': `note-preview-${note.savedObjectId}`, + username: defaultToEmptyTag(note.updatedBy), + event: 'added a comment', + timestamp: note.updated ? ( + + ) : ( + getEmptyValue() + ), + children: {note.note ?? ''}, + actions: + note.eventId && timelineId ? ( + + ) : null, + timelineIcon: ( + + ), + })), + [notes, timelineId] + ); + if (notes == null || notes.length === 0) { return null; } - const uniqueNotes = uniqBy('savedObjectId', notes); - - return ( - - {uniqueNotes.map(({ note, savedObjectId, updated, updatedBy }) => - savedObjectId != null ? ( - - ) : null - )} - - ); + return ; }); NotePreviews.displayName = 'NotePreviews'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.test.tsx deleted file mode 100644 index 484b3e5a60015..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.test.tsx +++ /dev/null @@ -1,154 +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 euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { mountWithIntl } from '@kbn/test/jest'; -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import '../../../../common/mock/formatted_relative'; - -import { getEmptyValue } from '../../../../common/components/empty_value'; -import { NotePreview } from './note_preview'; - -import * as i18n from '../translations'; - -describe('NotePreview', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - - describe('Avatar', () => { - test('it renders an avatar with the expected initials when updatedBy is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual('a'); - }); - - test('it renders an avatar with a "?" when updatedBy is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual('?'); - }); - - test('it renders an avatar with a "?" when updatedBy is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual('?'); - }); - }); - - describe('UpdatedBy', () => { - test('it renders the username when updatedBy is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated-by"]').first().text()).toEqual('admin'); - }); - - test('it renders placeholder text when updatedBy is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated-by"]').first().text()).toEqual(getEmptyValue()); - }); - - test('it renders placeholder text when updatedBy is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated-by"]').first().text()).toEqual(getEmptyValue()); - }); - }); - - describe('Updated', () => { - const updated = 1553300753 * 1000; - - test('it is always prefixed by "Posted:"', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="posted"]').first().text().startsWith(i18n.POSTED)).toBe( - true - ); - }); - - test('it renders the relative date when updated is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated"]').first().exists()).toBe(true); - }); - - test('it does NOT render the relative date when updated is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated"]').first().exists()).toBe(false); - }); - - test('it does NOT render the relative date when updated is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated"]').first().exists()).toBe(false); - }); - - test('it renders placeholder text when updated is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="posted"]').first().text()).toEqual( - `Posted: ${getEmptyValue()}` - ); - }); - - test('it renders placeholder text when updated is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="posted"]').first().text()).toEqual( - `Posted: ${getEmptyValue()}` - ); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.tsx deleted file mode 100644 index a8e7a2c465e0c..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.tsx +++ /dev/null @@ -1,69 +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 { EuiAvatar, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle, EuiToolTip } from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n/react'; -import React from 'react'; -import styled from 'styled-components'; - -import { getEmptyValue, defaultToEmptyTag } from '../../../../common/components/empty_value'; -import { FormattedDate } from '../../../../common/components/formatted_date'; -import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; -import * as i18n from '../translations'; -import { TimelineResultNote } from '../types'; - -const NotePreviewGroup = styled.article` - & + & { - margin-top: ${(props) => props.theme.eui.euiSizeL}; - } -`; - -NotePreviewGroup.displayName = 'NotePreviewGroup'; - -const NotePreviewHeader = styled.header` - margin-bottom: ${(props) => props.theme.eui.euiSizeS}; -`; - -NotePreviewHeader.displayName = 'NotePreviewHeader'; - -/** - * Renders a preview of a note in the All / Open Timelines table - */ -export const NotePreview = React.memo>( - ({ note, updated, updatedBy }) => ( - - - - - - - - - -
{defaultToEmptyTag(updatedBy)}
-
- - -

- {i18n.POSTED}{' '} - {updated != null ? ( - }> - - - ) : ( - getEmptyValue() - )} -

-
-
- {note ?? ''} -
-
-
- ) -); - -NotePreview.displayName = 'NotePreview'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts new file mode 100644 index 0000000000000..9857e55e36570 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts @@ -0,0 +1,14 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const TOGGLE_EXPAND_EVENT_DETAILS = i18n.translate( + 'xpack.securitySolution.timeline.toggleEventDetailsTitle', + { + defaultMessage: 'Expand event details', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index 62f3f700b111a..49c7f3c7f374f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -25,9 +25,11 @@ export interface FavoriteTimelineResult { } export interface TimelineResultNote { + eventId?: string | null; savedObjectId?: string | null; note?: string | null; noteId?: string | null; + timelineId?: string | null; updated?: number | null; updatedBy?: string | null; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx index 12cfbbc04222f..fd4a7e91ddb79 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx @@ -12,7 +12,6 @@ import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; interface ActionIconItemProps { ariaLabel?: string; - id: string; width?: number; dataTestSubj?: string; content?: string; @@ -23,7 +22,6 @@ interface ActionIconItemProps { } const ActionIconItemComponent: React.FC = ({ - id, width = DEFAULT_ICON_BUTTON_WIDTH, dataTestSubj, content, @@ -33,7 +31,7 @@ const ActionIconItemComponent: React.FC = ({ onClick, children, }) => ( - + {children ?? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx index af8045bf624c3..3f9f680ee1913 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx @@ -6,38 +6,26 @@ import React from 'react'; -import { TimelineType, TimelineStatus } from '../../../../../../common/types/timeline'; -import { AssociateNote } from '../../../notes/helpers'; +import { TimelineType } from '../../../../../../common/types/timeline'; import * as i18n from '../translations'; import { NotesButton } from '../../properties/helpers'; import { ActionIconItem } from './action_icon_item'; interface AddEventNoteActionProps { - associateNote: AssociateNote; - noteIds: string[]; showNotes: boolean; - status: TimelineStatus; timelineType: TimelineType; toggleShowNotes: () => void; } const AddEventNoteActionComponent: React.FC = ({ - associateNote, - noteIds, showNotes, - status, timelineType, toggleShowNotes, }) => ( - + + { - const origin = jest.requireActual('react-redux'); - return { - ...origin, - useSelector: jest.fn(), - }; -}); +jest.mock('../../../../../common/hooks/use_selector'); describe('EventColumnView', () => { - (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); + (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.default); const props = { id: 'event-id', @@ -82,17 +76,14 @@ describe('EventColumnView', () => { }); test('it renders correct tooltip for NotesButton - timeline template', () => { - (useSelector as jest.Mock).mockReturnValue({ - ...mockTimelineModel, - timelineType: TimelineType.template, - }); + (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.template); const wrapper = mount(, { wrappingComponent: TestProviders }); expect(wrapper.find('[data-test-subj="add-note"]').prop('toolTip')).toEqual( i18n.NOTES_DISABLE_TOOLTIP ); - (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); + (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.default); }); test('it does NOT render a pin button when isEventViewer is true', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index 3297d4d613a2b..cbb7bb9d0c6a1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -4,15 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pick } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; -import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; +import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { timelineSelectors } from '../../../../store/timeline'; -import { AssociateNote } from '../../../notes/helpers'; import { OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; import { EventsTrData } from '../../styles'; import { Actions } from '../actions'; @@ -30,12 +27,13 @@ import { AddEventNoteAction } from '../actions/add_note_icon_item'; import { PinEventAction } from '../actions/pin_event_action'; import { inputsModel } from '../../../../../common/store'; import { TimelineId } from '../../../../../../common/types/timeline'; +import { timelineSelectors } from '../../../../store/timeline'; +import { timelineDefaults } from '../../../../store/timeline/defaults'; import { AddToCaseAction } from '../../../../../cases/components/timeline_actions/add_to_case_action'; interface Props { id: string; actionsColumnWidth: number; - associateNote: AssociateNote; columnHeaders: ColumnHeaderOptions[]; columnRenderers: ColumnRenderer[]; data: TimelineNonEcsData[]; @@ -64,7 +62,6 @@ export const EventColumnView = React.memo( ({ id, actionsColumnWidth, - associateNote, columnHeaders, columnRenderers, data, @@ -87,8 +84,8 @@ export const EventColumnView = React.memo( toggleShowNotes, }) => { const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { timelineType, status } = useDeepEqualSelector((state) => - pick(['timelineType', 'status'], getTimeline(state, timelineId)) + const timelineType = useShallowEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).timelineType ); const handlePinClicked = useCallback( @@ -125,11 +122,8 @@ export const EventColumnView = React.memo( ? [ , ( />, ], [ - associateNote, data, ecsData, eventIdToNoteIds, @@ -176,7 +169,6 @@ export const EventColumnView = React.memo( refetch, onRuleChange, showNotes, - status, timelineId, timelineType, toggleShowNotes, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index baaf9aa867d90..917b4a4e7a762 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -156,7 +156,6 @@ const StatefulEventComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts index 58729f69402e1..ecd06faed7253 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts @@ -10,7 +10,7 @@ export const LOCK_SYNC_MAIN_DATE_PICKER_TOOL_TIP = i18n.translate( 'xpack.securitySolution.timeline.properties.lockDatePickerTooltip', { defaultMessage: - 'Disable syncing of date/time range between the currently viewed page and your timeline', + 'Disable syncing of date/time range bteween the currently viewed page and your timeline', } ); @@ -25,27 +25,27 @@ export const UNLOCK_SYNC_MAIN_DATE_PICKER_TOOL_TIP = i18n.translate( export const LOCK_SYNC_MAIN_DATE_PICKER_LABEL = i18n.translate( 'xpack.securitySolution.timeline.properties.lockedDatePickerLabel', { - defaultMessage: 'Date picker is locked to global date picker', + defaultMessage: 'Global date picker is locked to timeline date picker', } ); export const UNLOCK_SYNC_MAIN_DATE_PICKER_LABEL = i18n.translate( 'xpack.securitySolution.timeline.properties.unlockedDatePickerLabel', { - defaultMessage: 'Date picker is NOT locked to global date picker', + defaultMessage: 'Global date picker NOT locked to timeline date picker', } ); export const LOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( 'xpack.securitySolution.timeline.properties.lockDatePickerDescription', { - defaultMessage: 'Lock date picker to global date picker', + defaultMessage: 'Lock global date picker to timeline date picker', } ); export const UNLOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( 'xpack.securitySolution.timeline.properties.unlockDatePickerDescription', { - defaultMessage: 'Unlock date picker to global date picker', + defaultMessage: 'Unlock global date picker from timeline date picker', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx index 5c6bcbccc8e0a..df8e84b4e2a78 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx @@ -81,7 +81,9 @@ export const ExpandableEvent = React.memo( | undefined; if (messageField?.originalValue) { - return messageField?.originalValue; + return Array.isArray(messageField?.originalValue) + ? messageField?.originalValue.join() + : messageField?.originalValue; } } return null; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx index 9855a0124b8f5..20c528c701890 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx @@ -4,21 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pick } from 'lodash/fp'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiPanel } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import { filter, pick, uniqBy } from 'lodash/fp'; +import { + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTitle, + EuiPanel, + EuiHorizontalRule, +} from '@elastic/eui'; +import React, { Fragment, useCallback, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { TimelineStatus } from '../../../../../common/types/timeline'; import { appSelectors } from '../../../../common/store/app'; import { timelineDefaults } from '../../../store/timeline/defaults'; import { AddNote } from '../../notes/add_note'; -import { InMemoryTable } from '../../notes'; -import { columns } from '../../notes/columns'; -import { search } from '../../notes/helpers'; +import { CREATED_BY, NOTES } from '../../notes/translations'; +import { PARTICIPANTS } from '../../../../cases/translations'; +import { NotePreviews } from '../../open_timeline/note_previews'; +import { TimelineResultNote } from '../../open_timeline/types'; +import { EventDetails } from '../event_details'; const FullWidthFlexGroup = styled(EuiFlexGroup)` width: 100%; @@ -27,7 +40,8 @@ const FullWidthFlexGroup = styled(EuiFlexGroup)` `; const ScrollableFlexItem = styled(EuiFlexItem)` - overflow: auto; + overflow-x: hidden; + overflow-y: auto; `; const VerticalRule = styled.div` @@ -41,6 +55,66 @@ const StyledPanel = styled(EuiPanel)` box-shadow: none; `; +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +const Username = styled(EuiText)` + font-weight: bold; +`; + +interface UsernameWithAvatar { + username: string; +} + +const UsernameWithAvatarComponent: React.FC = ({ username }) => ( + + + + + + {username} + + +); + +const UsernameWithAvatar = React.memo(UsernameWithAvatarComponent); + +interface ParticipantsProps { + users: TimelineResultNote[]; +} + +const ParticipantsComponent: React.FC = ({ users }) => { + const List = useMemo( + () => + users.map((user) => ( + + + + + )), + [users] + ); + + if (!users.length) { + return null; + } + + return ( + <> + +

{PARTICIPANTS}

+
+ + {List} + + ); +}; + +ParticipantsComponent.displayName = 'ParticipantsComponent'; + +const Participants = React.memo(ParticipantsComponent); + interface NotesTabContentProps { timelineId: string; } @@ -48,37 +122,77 @@ interface NotesTabContentProps { const NotesTabContentComponent: React.FC = ({ timelineId }) => { const dispatch = useDispatch(); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { status: timelineStatus, noteIds } = useDeepEqualSelector((state) => - pick(['noteIds', 'status'], getTimeline(state, timelineId) ?? timelineDefaults) + const { createdBy, expandedEvent, status: timelineStatus } = useDeepEqualSelector((state) => + pick( + ['createdBy', 'expandedEvent', 'status'], + getTimeline(state, timelineId) ?? timelineDefaults + ) ); - const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); + const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.timeline); + + const getNotesAsCommentsList = useMemo( + () => appSelectors.selectNotesAsCommentsListSelector(), + [] + ); const [newNote, setNewNote] = useState(''); const isImmutable = timelineStatus === TimelineStatus.immutable; - const notesById = useDeepEqualSelector(getNotesByIds); + const notes: TimelineResultNote[] = useDeepEqualSelector(getNotesAsCommentsList); - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); + // filter for savedObjectId to make sure we don't display `elastic` user while saving the note + const participants = useMemo(() => uniqBy('updatedBy', filter('savedObjectId', notes)), [notes]); const associateNote = useCallback( (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), [dispatch, timelineId] ); + const handleOnEventClosed = useCallback(() => { + dispatch(timelineActions.toggleExpandedEvent({ timelineId })); + }, [dispatch, timelineId]); + + const EventDetailsContent = useMemo( + () => + expandedEvent.eventId ? ( + + ) : null, + [browserFields, docValueFields, expandedEvent.eventId, handleOnEventClosed, timelineId] + ); + + const SidebarContent = useMemo( + () => ( + <> + {createdBy && ( + <> + + +

{CREATED_BY}

+
+ + + + + )} + + + ), + [createdBy, participants] + ); + return ( - + -

{'Notes'}

+

{NOTES}

- + {!isImmutable && ( @@ -86,7 +200,7 @@ const NotesTabContentComponent: React.FC = ({ timelineId }
- {/* SIDEBAR PLACEHOLDER */} + {EventDetailsContent ?? SidebarContent}
); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 494b3cefba6f1..673efa1857cb8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -9,11 +9,6 @@ import { EuiButton, EuiButtonIcon, EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiModal, - EuiOverlayMask, EuiToolTip, EuiTextArea, } from '@elastic/eui'; @@ -22,22 +17,14 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; -import { - TimelineTypeLiteral, - TimelineType, - TimelineStatusLiteral, -} from '../../../../../common/types/timeline'; +import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline'; import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; import { useDeepEqualSelector, useShallowEqualSelector, } from '../../../../common/hooks/use_selector'; -import { Notes } from '../../notes'; -import { AssociateNote } from '../../notes/helpers'; - -import { NOTES_PANEL_WIDTH } from './notes_size'; -import { ButtonContainer, DescriptionContainer, LabelText, NameField, NameWrapper } from './styles'; +import { DescriptionContainer, NameField, NameWrapper } from './styles'; import * as i18n from './translations'; import { TimelineInput } from '../../../store/timeline/actions'; import { useCreateTimelineButton } from './use_create_timeline'; @@ -259,43 +246,12 @@ export const NewTimeline = React.memo( NewTimeline.displayName = 'NewTimeline'; interface NotesButtonProps { - animate?: boolean; - associateNote: AssociateNote; - noteIds: string[]; - size: 's' | 'l'; - status: TimelineStatusLiteral; showNotes: boolean; toggleShowNotes: () => void; - text?: string; toolTip?: string; timelineType: TimelineTypeLiteral; } -interface LargeNotesButtonProps { - noteIds: string[]; - text?: string; - toggleShowNotes: () => void; -} - -const LargeNotesButton = React.memo(({ noteIds, text, toggleShowNotes }) => ( - - - - - - - {text && text.length ? {text} : null} - - - - {noteIds.length} - - - - -)); -LargeNotesButton.displayName = 'LargeNotesButton'; - interface SmallNotesButtonProps { toggleShowNotes: () => void; timelineType: TimelineTypeLiteral; @@ -316,83 +272,13 @@ const SmallNotesButton = React.memo(({ toggleShowNotes, t }); SmallNotesButton.displayName = 'SmallNotesButton'; -/** - * The internal implementation of the `NotesButton` - */ -const NotesButtonComponent = React.memo( - ({ - animate = true, - associateNote, - noteIds, - showNotes, - size, - status, - toggleShowNotes, - text, - timelineType, - }) => ( - - <> - {size === 'l' ? ( - - ) : ( - - )} - {size === 'l' && showNotes ? ( - - - - - - ) : null} - - - ) -); -NotesButtonComponent.displayName = 'NotesButtonComponent'; - export const NotesButton = React.memo( - ({ - animate = true, - associateNote, - noteIds, - showNotes, - size, - status, - timelineType, - toggleShowNotes, - toolTip, - text, - }) => + ({ showNotes, timelineType, toggleShowNotes, toolTip }) => showNotes ? ( - + ) : ( - + ) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx index 7dc5b8601955a..c1f9b18f05c60 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx @@ -5,12 +5,7 @@ */ import { EuiFieldText } from '@elastic/eui'; -import styled, { keyframes } from 'styled-components'; - -const fadeInEffect = keyframes` - from { opacity: 0; } - to { opacity: 1; } -`; +import styled from 'styled-components'; export const NameField = styled(EuiFieldText)` .euiToolTipAnchor { @@ -33,11 +28,6 @@ export const DescriptionContainer = styled.div` `; DescriptionContainer.displayName = 'DescriptionContainer'; -export const ButtonContainer = styled.div<{ animate: boolean }>` - animation: ${fadeInEffect} ${({ animate }) => (animate ? '0.3s' : '0s')}; -`; -ButtonContainer.displayName = 'ButtonContainer'; - export const LabelText = styled.div` margin-left: 10px; `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx index 10b505da5c76f..ddf180f7d2286 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx @@ -145,6 +145,9 @@ describe('useCreateTimelineButton', () => { 'x-pack/security_solution/local/inputs/ADD_TIMELINE_LINK_TO' ); expect(mockDispatch.mock.calls[4][0].type).toEqual( + 'x-pack/security_solution/local/app/ADD_NOTE' + ); + expect(mockDispatch.mock.calls[5][0].type).toEqual( 'x-pack/security_solution/local/inputs/SET_RELATIVE_RANGE_DATE_PICKER' ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx index 4043ceeb85b7e..7fab0374d791d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx @@ -19,6 +19,7 @@ import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { inputsActions, inputsSelectors } from '../../../../common/store/inputs'; import { sourcererActions, sourcererSelectors } from '../../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { appActions } from '../../../../common/store/app'; interface Props { timelineId?: string; @@ -57,6 +58,7 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P ); dispatch(inputsActions.addGlobalLinkTo({ linkToId: 'timeline' })); dispatch(inputsActions.addTimelineLinkTo({ linkToId: 'global' })); + dispatch(appActions.addNotes({ notes: [] })); if (globalTimeRange.kind === 'absolute') { dispatch( inputsActions.setAbsoluteRangeDatePicker({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index aa3970bba5884..8da3c257a5db8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -12,13 +12,15 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiSpacer, + EuiBadge, } from '@elastic/eui'; -import { isEmpty, some } from 'lodash/fp'; -import React, { useState, useMemo, useEffect, useCallback } from 'react'; +import { isEmpty } from 'lodash/fp'; +import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import styled from 'styled-components'; import { Dispatch } from 'redux'; import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; +import { InPortal } from 'react-reverse-portal'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { Direction } from '../../../../../common/search_strategy'; @@ -42,6 +44,7 @@ import { sourcererActions } from '../../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count'; import { TimelineModel } from '../../../../timelines/store/timeline/model'; import { EventDetails } from '../event_details'; import { TimelineDatePickerLock } from '../date_picker_lock'; @@ -127,6 +130,10 @@ const StyledEuiTabbedContent = styled(EuiTabbedContent)` StyledEuiTabbedContent.displayName = 'StyledEuiTabbedContent'; +const EventsCountBadge = styled(EuiBadge)` + margin-left: ${({ theme }) => theme.eui.paddingSizes.s}; +`; + const isTimerangeSame = (prevProps: Props, nextProps: Props) => prevProps.end === nextProps.end && prevProps.start === nextProps.start && @@ -160,6 +167,7 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, updateEventTypeAndIndexesName, }) => { + const { timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); const { browserFields, docValueFields, @@ -174,6 +182,10 @@ export const QueryTabContentComponent: React.FC = ({ const kqlQuery = useMemo(() => ({ query: kqlQueryExpression, language: 'kuery' }), [ kqlQueryExpression, ]); + + const prevCombinedQueries = useRef<{ + filterQuery: string; + } | null>(null); const combinedQueries = useMemo( () => combineQueries({ @@ -255,13 +267,17 @@ export const QueryTabContentComponent: React.FC = ({ }, [loadingSourcerer, timelineId, isQueryLoading, setIsTimelineLoading]); useEffect(() => { - if (!events || (expandedEvent.eventId && !some(['_id', expandedEvent.eventId], events))) { + if (!deepEqual(prevCombinedQueries.current, combinedQueries)) { + prevCombinedQueries.current = combinedQueries; handleOnEventClosed(); } - }, [expandedEvent, handleOnEventClosed, events, combinedQueries]); + }, [combinedQueries, handleOnEventClosed]); return ( <> + + {totalCount >= 0 ? {totalCount} : null} + ({ }))<{ className: string }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; line-height: ${({ theme }) => theme.eui.euiLineHeight}; - padding: 0 ${({ theme }) => theme.eui.paddingSizes.m}; + padding-left: ${({ theme }) => theme.eui.paddingSizes.m}; .euiAccordion + div { background-color: ${({ theme }) => theme.eui.euiColorEmptyShade}; padding: 0 ${({ theme }) => theme.eui.paddingSizes.s}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index c9c2b1b1c2af9..7ffe661e78517 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -10,9 +10,10 @@ import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count'; import { timelineActions } from '../../../store/timeline'; import { TimelineTabs } from '../../../store/timeline/model'; -import { getActiveTabSelector } from './selectors'; +import { getActiveTabSelector, getShowTimelineSelector } from './selectors'; import * as i18n from './translations'; const HideShowContainer = styled.div.attrs<{ $isVisible: boolean }>(({ $isVisible = false }) => ({ @@ -99,18 +100,35 @@ const ActiveTimelineTab = memo(({ activeTimelineTab, tim ActiveTimelineTab.displayName = 'ActiveTimelineTab'; +const StyledEuiTab = styled(EuiTab)` + > span { + display: flex; + flex-direction: row; + white-space: pre; + } + + :focus { + text-decoration: none; + + > span > span { + text-decoration: underline; + } + } +`; + const TabsContentComponent: React.FC = ({ timelineId, graphEventId }) => { const dispatch = useDispatch(); const getActiveTab = useMemo(() => getActiveTabSelector(), []); + const getShowTimeline = useMemo(() => getShowTimelineSelector(), []); const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId)); + const showTimeline = useShallowEqualSelector((state) => getShowTimeline(state, timelineId)); - const setQueryAsActiveTab = useCallback( - () => - dispatch( - timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.query }) - ), - [dispatch, timelineId] - ); + const setQueryAsActiveTab = useCallback(() => { + dispatch(timelineActions.toggleExpandedEvent({ timelineId })); + dispatch( + timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.query }) + ); + }, [dispatch, timelineId]); const setGraphAsActiveTab = useCallback( () => @@ -120,13 +138,12 @@ const TabsContentComponent: React.FC = ({ timelineId, graphEve [dispatch, timelineId] ); - const setNotesAsActiveTab = useCallback( - () => - dispatch( - timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.notes }) - ), - [dispatch, timelineId] - ); + const setNotesAsActiveTab = useCallback(() => { + dispatch(timelineActions.toggleExpandedEvent({ timelineId })); + dispatch( + timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.notes }) + ); + }, [dispatch, timelineId]); const setPinnedAsActiveTab = useCallback( () => @@ -145,15 +162,16 @@ const TabsContentComponent: React.FC = ({ timelineId, graphEve return ( <> - - {i18n.QUERY_TAB} - + {i18n.QUERY_TAB} + {showTimeline && } + createSelector(selectTimeline, (timeline) => timeline?.activeTab ?? TimelineTabs.query); + +export const getShowTimelineSelector = () => + createSelector(selectTimeline, (timeline) => timeline?.show ?? false); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index 9c71fabfffac5..79d7460c7e117 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -55,6 +55,8 @@ export interface TimelineModel { activeTab: TimelineTabs; /** The columns displayed in the timeline */ columns: ColumnHeaderOptions[]; + /** Timeline saved object owner */ + createdBy?: string; /** The sources of the event data shown in the timeline */ dataProviders: DataProvider[]; /** Events to not be rendered **/